From a88b8a824b89d90b630d9eb1603e2c8adfca6b3e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 08:50:33 -0700 Subject: [PATCH] Add explicit templates vault view --- internal/appstate/state.go | 107 ++++++++++++++++--- internal/vaultview/view.go | 179 +++++++++++++++++++++++++++----- internal/vaultview/view_test.go | 38 +++++++ 3 files changed, 284 insertions(+), 40 deletions(-) diff --git a/internal/appstate/state.go b/internal/appstate/state.go index 9c886ad..9f31153 100644 --- a/internal/appstate/state.go +++ b/internal/appstate/state.go @@ -32,6 +32,7 @@ const ( ) const entriesRootLabel = "Root" +const templatesRootLabel = "Templates" type CurrentSession interface { Current() (vault.Model, error) @@ -402,8 +403,8 @@ func (s *State) ChildGroups() ([]string, error) { } if s.Section != SectionEntries { - if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { - return childGroups(s.entriesForSection(model), []string{"Templates"}), nil + if s.Section == SectionTemplates { + return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil } return childGroups(s.entriesForSection(model), s.CurrentPath), nil } @@ -452,7 +453,7 @@ func (s *State) currentModel() (vault.Model, error) { func (s *State) entriesForSection(model vault.Model) []vault.Entry { switch s.Section { case SectionTemplates: - return slices.Clone(model.Templates) + return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil)) case SectionRecycleBin: return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil)) case SectionAPITokens, SectionAPIAudit, SectionAbout: @@ -466,9 +467,7 @@ func (s State) SearchPathContext(entry vault.Entry) string { path := slices.Clone(entry.Path) switch s.Section { case SectionTemplates: - if len(path) == 0 || path[0] != "Templates" { - path = append([]string{"Templates"}, path...) - } + path = logicalTemplatePath(path) case SectionRecycleBin: path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...) case SectionEntries: @@ -555,6 +554,26 @@ func logicalEntriesPath(path []string) []string { return append([]string{entriesRootLabel}, append([]string(nil), path...)...) } +func logicalTemplatePath(path []string) []string { + if len(path) == 0 { + return []string{templatesRootLabel} + } + if path[0] == templatesRootLabel { + return append([]string(nil), path...) + } + return append([]string{templatesRootLabel}, append([]string(nil), path...)...) +} + +func templatesViewPath(path []string) []string { + if len(path) == 0 { + return nil + } + if path[0] == templatesRootLabel { + return append([]string(nil), path[1:]...) + } + return append([]string(nil), path...) +} + func entriesViewPathForModel(model vault.Model, path []string) []string { if len(path) == 0 { return nil @@ -590,6 +609,25 @@ func logicalEntries(entries []vault.Entry) []vault.Entry { return out } +func logicalTemplateEntry(entry vault.Entry) vault.Entry { + entry.Path = logicalTemplatePath(entry.Path) + for i := range entry.History { + entry.History[i] = logicalTemplateEntry(entry.History[i]) + } + return entry +} + +func logicalTemplateEntries(entries []vault.Entry) []vault.Entry { + if len(entries) == 0 { + return nil + } + out := make([]vault.Entry, len(entries)) + for i := range entries { + out[i] = logicalTemplateEntry(entries[i]) + } + return out +} + func entryForModel(model vault.Model, entry vault.Entry) vault.Entry { entry.Path = entriesViewPathForModel(model, entry.Path) for i := range entry.History { @@ -598,6 +636,14 @@ func entryForModel(model vault.Model, entry vault.Entry) vault.Entry { return entry } +func templateEntryForModel(entry vault.Entry) vault.Entry { + entry.Path = templatesViewPath(entry.Path) + for i := range entry.History { + entry.History[i] = templateEntryForModel(entry.History[i]) + } + return entry +} + func usesPhysicalEntriesRoot(model vault.Model) bool { if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 { return true @@ -663,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string { return groups } +func sectionGroupView(model vault.Model, section Section) vaultview.View { + switch section { + case SectionTemplates: + return vaultview.VaultTemplates(model) + default: + return vaultview.VaultRoot(model) + } +} + +func sectionGroupViewPath(model vault.Model, section Section, path []string) []string { + switch section { + case SectionTemplates: + return templatesViewPath(path) + default: + return entriesViewPathForModel(model, path) + } +} + +func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string { + switch section { + case SectionTemplates: + return logicalTemplatePath(path) + default: + return logicalEntriesPathForModel(model, path) + } +} + func (s *State) DeleteSelectedEntry() error { session, ok := s.Session.(MutableSession) if !ok { @@ -730,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error { return err } - model.UpsertTemplate(entry) + model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry))) session.Replace(model) s.SelectedEntryID = entry.ID return s.markDirtyAndAutoSave() @@ -1124,7 +1197,8 @@ func (s *State) CreateGroup(name string) error { return err } - model.CreateGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), name) + view := sectionGroupView(model, s.Section) + model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name) session.Replace(model) return s.markDirtyAndAutoSave() } @@ -1138,15 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error { if err != nil { return err } - current := logicalEntriesPathForModel(model, s.CurrentPath) - currentViewPath := entriesViewPathForModel(model, current) - parentViewPath := entriesViewPathForModel(model, parent) - if err := model.MoveGroup(vaultview.VaultRoot(model).ToPhysicalPath(currentViewPath), vaultview.VaultRoot(model).ToPhysicalPath(parentViewPath)); err != nil { + view := sectionGroupView(model, s.Section) + current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath) + currentViewPath := sectionGroupViewPath(model, s.Section, current) + parentViewPath := sectionGroupViewPath(model, s.Section, parent) + if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil { return err } session.Replace(model) if len(currentViewPath) > 0 { - s.CurrentPath = logicalEntriesPathForModel(model, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1])) + s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1])) } return s.markDirtyAndAutoSave() } @@ -1162,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error { return err } - if err := model.RenameGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), newName); err != nil { + view := sectionGroupView(model, s.Section) + if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil { return err } @@ -1203,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error { return err } - if err := model.DeleteGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath))); err != nil { + view := sectionGroupView(model, s.Section) + if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil { return err } diff --git a/internal/vaultview/view.go b/internal/vaultview/view.go index b9500d1..260ff1e 100644 --- a/internal/vaultview/view.go +++ b/internal/vaultview/view.go @@ -7,6 +7,7 @@ import ( ) const KeepassRoot = "keepass" +const TemplatesRoot = "Templates" // View projects the physical vault model into a logical tree for a specific // product surface. @@ -28,7 +29,13 @@ func Vault(model vault.Model) View { // VaultRoot returns the logical main-vault view rooted at the physical // keepass storage group. func VaultRoot(model vault.Model) View { - return rootView{model: model, rooted: usesKeepassRoot(model)} + return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)} +} + +// VaultTemplates returns the logical templates view rooted at the physical +// Templates storage group. +func VaultTemplates(model vault.Model) View { + return templatesView{model: model} } // VaultRecycleBin returns the logical recycle-bin view. @@ -68,47 +75,48 @@ func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry { return cloneEntry(entry) } -type rootView struct { +type prefixedView struct { model vault.Model + root string rooted bool } -func (v rootView) ChildGroups(path []string) []string { +func (v prefixedView) ChildGroups(path []string) []string { return v.model.ChildGroups(v.ToPhysicalPath(path)) } -func (v rootView) EntriesInPath(path []string) []vault.Entry { +func (v prefixedView) EntriesInPath(path []string) []vault.Entry { return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path))) } -func (v rootView) EntriesUnderPath(path []string) []vault.Entry { +func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry { return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path))) } -func (v rootView) ToPhysicalPath(path []string) []string { +func (v prefixedView) ToPhysicalPath(path []string) []string { if !v.rooted { return clonePath(path) } if len(path) == 0 { - return []string{KeepassRoot} + return []string{v.root} } - return append([]string{KeepassRoot}, clonePath(path)...) + return append([]string{v.root}, clonePath(path)...) } -func (v rootView) FromPhysicalPath(path []string) []string { +func (v prefixedView) FromPhysicalPath(path []string) []string { if !v.rooted { return clonePath(path) } if len(path) == 0 { return nil } - if path[0] != KeepassRoot { + if path[0] != v.root { return clonePath(path) } return clonePath(path[1:]) } -func (v rootView) ToPhysicalEntry(entry vault.Entry) vault.Entry { +func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry { entry = cloneEntry(entry) entry.Path = v.ToPhysicalPath(entry.Path) for i := range entry.History { @@ -117,7 +125,7 @@ func (v rootView) ToPhysicalEntry(entry vault.Entry) vault.Entry { return entry } -func (v rootView) FromPhysicalEntry(entry vault.Entry) vault.Entry { +func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry { entry = cloneEntry(entry) entry.Path = v.FromPhysicalPath(entry.Path) for i := range entry.History { @@ -126,7 +134,7 @@ func (v rootView) FromPhysicalEntry(entry vault.Entry) vault.Entry { return entry } -func (v rootView) mapEntries(entries []vault.Entry) []vault.Entry { +func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry { out := make([]vault.Entry, 0, len(entries)) for _, entry := range entries { out = append(out, v.FromPhysicalEntry(entry)) @@ -138,6 +146,89 @@ type recycleBinView struct { model vault.Model } +type templatesView struct { + model vault.Model +} + +func (v templatesView) ChildGroups(path []string) []string { + return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path) +} + +func (v templatesView) EntriesInPath(path []string) []vault.Entry { + return entriesInPath(v.EntriesUnderPath(nil), path) +} + +func (v templatesView) EntriesUnderPath(path []string) []vault.Entry { + var out []vault.Entry + for _, entry := range v.model.Templates { + if len(path) > len(entry.Path) { + continue + } + physical := entry.Path + if len(physical) > 0 && physical[0] == TemplatesRoot { + physical = physical[1:] + } + if len(path) > len(physical) { + continue + } + if !slices.Equal(physical[:len(path)], path) { + continue + } + item := cloneEntry(entry) + item.Path = clonePath(physical) + for i := range item.History { + item.History[i].Path = v.FromPhysicalPath(item.History[i].Path) + } + out = append(out, item) + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + +func (v templatesView) ToPhysicalPath(path []string) []string { + if len(path) == 0 { + return []string{TemplatesRoot} + } + return append([]string{TemplatesRoot}, clonePath(path)...) +} + +func (v templatesView) FromPhysicalPath(path []string) []string { + if len(path) == 0 { + return nil + } + if path[0] != TemplatesRoot { + return clonePath(path) + } + return clonePath(path[1:]) +} + +func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry { + entry = cloneEntry(entry) + entry.Path = v.ToPhysicalPath(entry.Path) + for i := range entry.History { + entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path) + } + return entry +} + +func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry { + entry = cloneEntry(entry) + entry.Path = v.FromPhysicalPath(entry.Path) + for i := range entry.History { + entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path) + } + return entry +} + func (v recycleBinView) ChildGroups(path []string) []string { return childGroups(v.model.RecycleBin, path) } @@ -187,6 +278,10 @@ func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry { } func childGroups(entries []vault.Entry, path []string) []string { + return groupChildren(nil, entries, path) +} + +func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string { seen := map[string]bool{} var groups []string for _, entry := range entries { @@ -206,6 +301,23 @@ func childGroups(entries []vault.Entry, path []string) []string { seen[group] = true groups = append(groups, group) } + for _, groupPath := range groupPaths { + if len(path) > len(groupPath) { + continue + } + if !slices.Equal(groupPath[:len(path)], path) { + continue + } + if len(groupPath) == len(path) { + continue + } + group := groupPath[len(path)] + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } slices.Sort(groups) return groups } @@ -275,22 +387,39 @@ func clonePath(path []string) []string { return slices.Clone(path) } -func usesKeepassRoot(model vault.Model) bool { - if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 { - return true - } +func templateGroupPaths(model vault.Model) [][]string { + var out [][]string for _, group := range model.Groups { - if len(group) > 0 && group[0] == KeepassRoot { - return true + if len(group) == 0 || group[0] != TemplatesRoot { + continue } + out = append(out, clonePath(group[1:])) } - for _, entry := range model.Entries { - if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot { - return true - } + return out +} + +func usesTopLevelRoot(model vault.Model, root string) bool { + if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 { + return root == KeepassRoot } - for _, entry := range model.RecycleBin { - if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot { + return groupsUseRoot(model.Groups, root) || + entriesUseRoot(model.Entries, root) || + entriesUseRoot(model.Templates, root) || + entriesUseRoot(model.RecycleBin, root) +} + +func groupsUseRoot(groups [][]string, root string) bool { + for _, group := range groups { + if len(group) > 0 && group[0] == root { + return true + } + } + return false +} + +func entriesUseRoot(entries []vault.Entry, root string) bool { + for _, entry := range entries { + if len(entry.Path) > 0 && entry.Path[0] == root { return true } } diff --git a/internal/vaultview/view_test.go b/internal/vaultview/view_test.go index ef0904f..66a6b14 100644 --- a/internal/vaultview/view_test.go +++ b/internal/vaultview/view_test.go @@ -78,6 +78,44 @@ func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) { } } +func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Templates: []vault.Entry{ + {ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + Groups: [][]string{ + {"Templates"}, + {"Templates", "Infra"}, + {"Templates", "Web"}, + {"keepass"}, + }, + } + + view := VaultTemplates(model) + + if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) { + t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got) + } + + gotEntries := view.EntriesInPath([]string{"Web"}) + if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) { + t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries) + } + + if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) { + t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got) + } + if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) { + t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got) + } + if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) { + t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got) + } +} + func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) { t.Parallel()