Add local-first remote binding model
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncModeManual SyncMode = "manual"
|
||||||
|
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteBinding struct {
|
||||||
|
LocalVaultPath string `json:"localVaultPath"`
|
||||||
|
RemoteProfileID string `json:"remoteProfileId"`
|
||||||
|
CredentialEntryID string `json:"credentialEntryId"`
|
||||||
|
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedRemoteBinding struct {
|
||||||
|
Profile vault.RemoteProfile
|
||||||
|
Credentials vault.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||||
|
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||||
|
}
|
||||||
|
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||||
|
}
|
||||||
|
return ResolvedRemoteBinding{
|
||||||
|
Profile: profile,
|
||||||
|
Credentials: credentials,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "tjulian-webdav",
|
||||||
|
Title: "WebDAV Sign-In",
|
||||||
|
Username: "tjulian",
|
||||||
|
Password: "token-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
binding := RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "tjulian-webdav",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := binding.Resolve(model)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" {
|
||||||
|
t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Username; got != "tjulian" {
|
||||||
|
t.Fatalf("resolved credentials username = %q, want tjulian", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Password; got != "token-1" {
|
||||||
|
t.Fatalf("resolved credentials password = %q, want token-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "tjulian-webdav", Title: "WebDAV Sign-In"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err = (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content, err := json.Marshal(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(content)
|
||||||
|
for _, disallowed := range []string{"token-1", "password", "username", "baseUrl"} {
|
||||||
|
if strings.Contains(text, disallowed) {
|
||||||
|
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Local-First Remote Sync Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||||
|
|
||||||
|
- every vault is a local KDBX file first
|
||||||
|
- remote sync is an optional binding on top of that local file
|
||||||
|
- shared remote configuration lives in the vault
|
||||||
|
- user-specific remote credentials live in the vault
|
||||||
|
- app-local state stores only non-secret binding metadata
|
||||||
|
|
||||||
|
Android adds only one platform-specific capability on top of that model:
|
||||||
|
|
||||||
|
- share/import the initial local KDBX file between devices
|
||||||
|
|
||||||
|
## Product Rules
|
||||||
|
|
||||||
|
1. A remote-backed vault must always have a local cache KDBX file.
|
||||||
|
2. Opening a remote-backed vault should open the local KDBX first.
|
||||||
|
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||||
|
4. Remote credentials must not be stored in plaintext app-local state.
|
||||||
|
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||||
|
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||||
|
7. Sync must support both manual and automatic modes.
|
||||||
|
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||||
|
|
||||||
|
## Target Data Model
|
||||||
|
|
||||||
|
### In-Vault Shared Remote Profile
|
||||||
|
|
||||||
|
Store a reusable remote profile in the vault with fields such as:
|
||||||
|
|
||||||
|
- profile ID
|
||||||
|
- profile name
|
||||||
|
- backend type, initially WebDAV
|
||||||
|
- base URL
|
||||||
|
- remote object path
|
||||||
|
- optional notes or labels
|
||||||
|
- default sync policy, if shared defaults are desirable
|
||||||
|
|
||||||
|
### In-Vault User Credential Binding
|
||||||
|
|
||||||
|
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||||
|
|
||||||
|
- remote profile ID
|
||||||
|
- credential entry UUID, or another stable internal reference
|
||||||
|
- optional username field override if needed
|
||||||
|
|
||||||
|
The credential entry should contain the actual username/password or token.
|
||||||
|
|
||||||
|
### Local App State
|
||||||
|
|
||||||
|
Persist only non-secret binding state such as:
|
||||||
|
|
||||||
|
- local vault path
|
||||||
|
- selected remote profile ID
|
||||||
|
- selected credential entry reference
|
||||||
|
- sync policy override
|
||||||
|
- last sync metadata
|
||||||
|
- conflict or recovery markers
|
||||||
|
|
||||||
|
## Core Flows
|
||||||
|
|
||||||
|
### Create Or Configure Remote Sync
|
||||||
|
|
||||||
|
1. Open or create a local vault.
|
||||||
|
2. Create or edit a shared remote profile in that vault.
|
||||||
|
3. Create or select a credential entry in that vault.
|
||||||
|
4. Bind the local vault to the selected remote profile and credential reference.
|
||||||
|
5. Choose manual or automatic sync behavior.
|
||||||
|
|
||||||
|
### Reopen Existing Remote-Backed Vault
|
||||||
|
|
||||||
|
1. Open the local vault file from app state.
|
||||||
|
2. Resolve the selected remote profile from vault contents.
|
||||||
|
3. Resolve the credential entry from vault contents.
|
||||||
|
4. Offer or perform sync based on the binding policy.
|
||||||
|
|
||||||
|
### Bootstrap A New Android Device
|
||||||
|
|
||||||
|
1. Share the local KDBX file through Android Sharesheet.
|
||||||
|
2. Import and open that KDBX locally on the new device.
|
||||||
|
3. Select a remote profile stored in the vault.
|
||||||
|
4. Select or create that user’s credential entry in the vault.
|
||||||
|
5. Bind the local vault as the cache for the remote-backed setup.
|
||||||
|
|
||||||
|
## Migration Requirements
|
||||||
|
|
||||||
|
1. Migrate existing remote connections that save credentials in app state.
|
||||||
|
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||||
|
3. Replace saved plaintext credential state with a vault credential reference.
|
||||||
|
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||||
|
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Domain Model
|
||||||
|
|
||||||
|
- define remote profile structures independent of Gio UI
|
||||||
|
- define credential reference structures independent of Gio UI
|
||||||
|
- define sync binding state independent of Gio UI
|
||||||
|
- add behavior tests for local-first remote-backed vaults
|
||||||
|
|
||||||
|
### Phase 2: Vault Storage
|
||||||
|
|
||||||
|
- persist remote profiles in the vault
|
||||||
|
- persist credential references in the vault
|
||||||
|
- resolve credentials from normal vault entries
|
||||||
|
- add behavior tests for read/write and lookup semantics
|
||||||
|
|
||||||
|
### Phase 3: State And Open Flow
|
||||||
|
|
||||||
|
- shrink app state to non-secret metadata only
|
||||||
|
- update open flows to always prefer the local cache vault
|
||||||
|
- update reopen behavior on all platforms to use the same model
|
||||||
|
- add migration coverage for old remote state
|
||||||
|
|
||||||
|
### Phase 4: Sync Binding
|
||||||
|
|
||||||
|
- bind a local vault to a selected remote profile
|
||||||
|
- support manual sync
|
||||||
|
- support automatic sync on open/save
|
||||||
|
- define conflict and remote-failure handling for the local cache model
|
||||||
|
|
||||||
|
### Phase 5: Android Bootstrap
|
||||||
|
|
||||||
|
- add Android Sharesheet export of the current local KDBX
|
||||||
|
- add Android import flow for a shared KDBX
|
||||||
|
- keep the remote pivot flow consistent with desktop after local open
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||||
|
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||||
|
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||||
|
4. How should multiple remote profiles per vault be presented in the UI?
|
||||||
|
5. What should happen when the credential entry reference no longer resolves?
|
||||||
|
|
||||||
|
## Recommended First Slice
|
||||||
|
|
||||||
|
Implement the shared domain model and tests first:
|
||||||
|
|
||||||
|
- model a local vault plus optional remote binding
|
||||||
|
- define in-vault remote profile and credential reference semantics
|
||||||
|
- add tests proving app state no longer needs plaintext remote credentials
|
||||||
|
|
||||||
|
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||||
+40
-3
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
|||||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
|
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
db.Credentials = credentials
|
db.Credentials = credentials
|
||||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||||
|
db.Content.Meta.CustomData = customDataForModel(model)
|
||||||
db.Content.Root = &gokeepasslib.RootData{}
|
db.Content.Root = &gokeepasslib.RootData{}
|
||||||
if config != nil && config.Header != nil {
|
if config != nil && config.Header != nil {
|
||||||
db.Header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
for _, group := range db.Content.Root.Groups {
|
for _, group := range db.Content.Root.Groups {
|
||||||
appendGroupEntries(&model, db, group, nil)
|
appendGroupEntries(&model, db, group, nil)
|
||||||
}
|
}
|
||||||
|
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||||
|
|
||||||
return model, &KDBXConfig{
|
return model, &KDBXConfig{
|
||||||
Header: cloneHeader(db.Header),
|
Header: cloneHeader(db.Header),
|
||||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||||
|
if len(model.RemoteProfiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := json.Marshal(model.RemoteProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []gokeepasslib.CustomData{{
|
||||||
|
Key: remoteProfilesKey,
|
||||||
|
Value: string(content),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, item := range meta.CustomData {
|
||||||
|
if item.Key != remoteProfilesKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var profiles []RemoteProfile
|
||||||
|
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||||
switch {
|
switch {
|
||||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||||
|
|||||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := Model{
|
||||||
|
RemoteProfiles: []RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loaded.RemoteProfiles) != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := loaded.RemoteProfiles[0]
|
||||||
|
if got.ID != "family-webdav" || got.Name != "Family Vault" {
|
||||||
|
t.Fatalf("loaded remote profile = %#v, want family-webdav Family Vault", got)
|
||||||
|
}
|
||||||
|
if got.Backend != RemoteBackendWebDAV {
|
||||||
|
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||||
|
}
|
||||||
|
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||||
|
}
|
||||||
|
if got.Path != "files/family/keepass.kdbx" {
|
||||||
|
t.Fatalf("remote path = %q, want files/family/keepass.kdbx", got.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+49
-4
@@ -8,6 +8,21 @@ import (
|
|||||||
|
|
||||||
var ErrEntryNotFound = errors.New("entry not found")
|
var ErrEntryNotFound = errors.New("entry not found")
|
||||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||||
|
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||||
|
|
||||||
|
type RemoteBackend string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteProfile struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Backend RemoteBackend
|
||||||
|
BaseURL string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Entries []Entry
|
Entries []Entry
|
||||||
Templates []Entry
|
Templates []Entry
|
||||||
RecycleBin []Entry
|
RecycleBin []Entry
|
||||||
Groups [][]string
|
Groups [][]string
|
||||||
|
RemoteProfiles []RemoteProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ChildGroups(path []string) []string {
|
func (m Model) ChildGroups(path []string) []string {
|
||||||
@@ -168,6 +184,35 @@ func (m *Model) UpsertEntry(entry Entry) {
|
|||||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||||
|
for _, entry := range m.Entries {
|
||||||
|
if entry.ID == id {
|
||||||
|
return cloneEntry(entry), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Entry{}, ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||||
|
for i := range m.RemoteProfiles {
|
||||||
|
if m.RemoteProfiles[i].ID != profile.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.RemoteProfiles[i] = profile
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||||
|
for _, profile := range m.RemoteProfiles {
|
||||||
|
if profile.ID == id {
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) UpsertTemplate(entry Entry) {
|
func (m *Model) UpsertTemplate(entry Entry) {
|
||||||
for i := range m.Templates {
|
for i := range m.Templates {
|
||||||
if m.Templates[i].ID != entry.ID {
|
if m.Templates[i].ID != entry.ID {
|
||||||
|
|||||||
Reference in New Issue
Block a user