package vault import ( "errors" "slices" "strings" ) var ErrEntryNotFound = errors.New("entry not found") var ErrGroupNotEmpty = errors.New("group is not empty") type Entry struct { ID string Title string Username string Password string URL string Notes string Tags []string Fields map[string]string Attachments map[string][]byte History []Entry Path []string } type SearchResult struct { Entry Entry Path string } type Model struct { Entries []Entry Templates []Entry RecycleBin []Entry Groups [][]string } func (m Model) ChildGroups(path []string) []string { seen := map[string]bool{} var groups []string for _, entry := range m.Entries { if len(path) > len(entry.Path) { continue } if !slices.Equal(entry.Path[:len(path)], path) { continue } if len(entry.Path) == len(path) { continue } group := entry.Path[len(path)] if seen[group] { continue } seen[group] = true groups = append(groups, group) } for _, groupPath := range m.Groups { 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 } func (m Model) EntriesInPath(path []string) []Entry { var entries []Entry for _, entry := range m.Entries { if slices.Equal(entry.Path, path) { entries = append(entries, entry) } } slices.SortFunc(entries, func(a, b Entry) int { switch { case a.Title < b.Title: return -1 case a.Title > b.Title: return 1 default: return 0 } }) return entries } func (m Model) EntriesUnderPath(path []string) []Entry { var entries []Entry for _, entry := range m.Entries { if !hasPathPrefix(entry.Path, path) { continue } entries = append(entries, entry) } slices.SortFunc(entries, func(a, b Entry) int { switch { case a.Title < b.Title: return -1 case a.Title > b.Title: return 1 default: return 0 } }) return entries } func (m Model) Search(query string) []SearchResult { query = strings.TrimSpace(strings.ToLower(query)) if query == "" { return nil } var results []SearchResult for _, entry := range m.Entries { haystack := strings.ToLower( entry.Title + " " + entry.Username + " " + entry.URL + " " + strings.Join(entry.Path, " "), ) if !strings.Contains(haystack, query) { continue } results = append(results, SearchResult{ Entry: entry, Path: strings.Join(entry.Path, " / "), }) } slices.SortFunc(results, func(a, b SearchResult) int { switch { case a.Entry.Title < b.Entry.Title: return -1 case a.Entry.Title > b.Entry.Title: return 1 default: return 0 } }) return results } func (m *Model) UpsertEntry(entry Entry) { for i := range m.Entries { if m.Entries[i].ID != entry.ID { continue } previous := cloneEntry(m.Entries[i]) entry.History = append([]Entry{previous}, cloneHistory(m.Entries[i].History)...) m.Entries[i] = cloneEntry(entry) return } m.Entries = append(m.Entries, cloneEntry(entry)) } func (m *Model) UpsertTemplate(entry Entry) { for i := range m.Templates { if m.Templates[i].ID != entry.ID { continue } m.Templates[i] = cloneEntry(entry) return } m.Templates = append(m.Templates, cloneEntry(entry)) } func (m *Model) DeleteTemplate(id string) error { for i := range m.Templates { if m.Templates[i].ID != id { continue } m.Templates = append(m.Templates[:i], m.Templates[i+1:]...) return nil } return ErrEntryNotFound } func (m *Model) DeleteEntry(id string) error { for i := range m.Entries { if m.Entries[i].ID != id { continue } m.RecycleBin = append(m.RecycleBin, cloneEntry(m.Entries[i])) m.Entries = append(m.Entries[:i], m.Entries[i+1:]...) return nil } return ErrEntryNotFound } func (m *Model) RestoreEntry(id string) error { for i := range m.RecycleBin { if m.RecycleBin[i].ID != id { continue } m.Entries = append(m.Entries, cloneEntry(m.RecycleBin[i])) m.RecycleBin = append(m.RecycleBin[:i], m.RecycleBin[i+1:]...) return nil } return ErrEntryNotFound } func (m *Model) InstantiateTemplate(templateID string, overrides Entry) (Entry, error) { for i := range m.Templates { if m.Templates[i].ID != templateID { continue } entry := mergeEntryTemplate(m.Templates[i], overrides) m.UpsertEntry(entry) return cloneEntry(entry), nil } return Entry{}, ErrEntryNotFound } func (m *Model) DuplicateEntry(id, duplicateID string) (Entry, error) { for i := range m.Entries { if m.Entries[i].ID != id { continue } duplicate := cloneEntry(m.Entries[i]) duplicate.ID = duplicateID duplicate.Title = duplicate.Title + " (Copy)" duplicate.History = nil m.Entries = append(m.Entries, duplicate) return cloneEntry(duplicate), nil } return Entry{}, ErrEntryNotFound } func (m *Model) RestoreEntryVersion(id string, historyIndex int) error { for i := range m.Entries { if m.Entries[i].ID != id { continue } if historyIndex < 0 || historyIndex >= len(m.Entries[i].History) { return ErrEntryNotFound } current := cloneEntry(m.Entries[i]) restored := cloneEntry(m.Entries[i].History[historyIndex]) restored.ID = current.ID restored.History = append([]Entry{current}, append( cloneHistory(m.Entries[i].History[:historyIndex]), cloneHistory(m.Entries[i].History[historyIndex+1:])..., )...) m.Entries[i] = restored return nil } return ErrEntryNotFound } func (m *Model) CreateGroup(parent []string, name string) { groupPath := append([]string(nil), parent...) for _, part := range splitGroupPath(name) { groupPath = append(groupPath, part) if groupPathExists(m.Groups, groupPath) { continue } m.Groups = append(m.Groups, append([]string(nil), groupPath...)) } } func (m *Model) RenameGroup(path []string, newName string) error { if len(path) == 0 { return ErrEntryNotFound } renamed := false newPath := append(append([]string(nil), path[:len(path)-1]...), newName) for i := range m.Entries { if !hasPathPrefix(m.Entries[i].Path, path) { continue } m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...) renamed = true } for i := range m.Templates { if !hasPathPrefix(m.Templates[i].Path, path) { continue } m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...) renamed = true } for i := range m.Groups { if !hasPathPrefix(m.Groups[i], path) { continue } m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...) renamed = true } if !renamed { return ErrEntryNotFound } return nil } func (m *Model) MoveEntry(id string, path []string) error { for i := range m.Entries { if m.Entries[i].ID != id { continue } m.Entries[i].Path = append([]string(nil), path...) return nil } return ErrEntryNotFound } func (m *Model) MoveGroup(path, parent []string) error { if len(path) == 0 { return ErrEntryNotFound } if hasPathPrefix(parent, path) { return ErrEntryNotFound } groupName := path[len(path)-1] newPath := append(append([]string(nil), parent...), groupName) moved := false for i := range m.Entries { if !hasPathPrefix(m.Entries[i].Path, path) { continue } m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...) moved = true } for i := range m.Templates { if !hasPathPrefix(m.Templates[i].Path, path) { continue } m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...) moved = true } for i := range m.Groups { if !hasPathPrefix(m.Groups[i], path) { continue } m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...) moved = true } if !moved { return ErrEntryNotFound } if !groupPathExists(m.Groups, newPath) { m.Groups = append(m.Groups, append([]string(nil), newPath...)) } return nil } func (m *Model) MoveTemplate(id string, path []string) error { for i := range m.Templates { if m.Templates[i].ID != id { continue } m.Templates[i].Path = append([]string(nil), path...) return nil } return ErrEntryNotFound } func (m *Model) DeleteGroup(path []string) error { for _, entry := range m.Entries { if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { return ErrGroupNotEmpty } } for _, entry := range m.Templates { if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { return ErrGroupNotEmpty } } for i := range m.Groups { if slices.Equal(m.Groups[i], path) { m.Groups = append(m.Groups[:i], m.Groups[i+1:]...) return nil } } return ErrEntryNotFound } func hasPathPrefix(path, prefix []string) bool { if len(prefix) > len(path) { return false } return slices.Equal(path[:len(prefix)], prefix) } func splitGroupPath(name string) []string { var parts []string for _, part := range strings.Split(name, "/") { part = strings.TrimSpace(part) if part == "" { continue } parts = append(parts, part) } return parts } func groupPathExists(groups [][]string, path []string) bool { for _, existing := range groups { if slices.Equal(existing, path) { return true } } return false } func mergeEntryTemplate(template, overrides Entry) Entry { entry := cloneEntry(template) if overrides.ID != "" { entry.ID = overrides.ID } if overrides.Title != "" { entry.Title = overrides.Title } if overrides.Username != "" { entry.Username = overrides.Username } if overrides.Password != "" { entry.Password = overrides.Password } if overrides.URL != "" { entry.URL = overrides.URL } if overrides.Notes != "" { entry.Notes = overrides.Notes } if len(overrides.Tags) > 0 { entry.Tags = slices.Clone(overrides.Tags) } if len(overrides.Path) > 0 { entry.Path = slices.Clone(overrides.Path) } entry.Fields = mergeStringMaps(template.Fields, overrides.Fields) entry.Attachments = mergeBinaryMaps(template.Attachments, overrides.Attachments) entry.History = nil return entry } func mergeStringMaps(base, overrides map[string]string) map[string]string { if len(base) == 0 && len(overrides) == 0 { return nil } out := make(map[string]string, len(base)+len(overrides)) for key, value := range base { out[key] = value } for key, value := range overrides { out[key] = value } return out } func mergeBinaryMaps(base, overrides map[string][]byte) map[string][]byte { if len(base) == 0 && len(overrides) == 0 { return nil } out := make(map[string][]byte, len(base)+len(overrides)) for key, value := range base { out[key] = slices.Clone(value) } for key, value := range overrides { out[key] = slices.Clone(value) } return out } func cloneEntry(entry Entry) Entry { entry.Tags = slices.Clone(entry.Tags) entry.Path = slices.Clone(entry.Path) entry.History = cloneHistory(entry.History) if entry.Fields != nil { fields := make(map[string]string, len(entry.Fields)) for key, value := range entry.Fields { fields[key] = value } entry.Fields = fields } if entry.Attachments != nil { attachments := make(map[string][]byte, len(entry.Attachments)) for key, value := range entry.Attachments { attachments[key] = slices.Clone(value) } entry.Attachments = attachments } return entry } func cloneHistory(history []Entry) []Entry { if len(history) == 0 { return nil } out := make([]Entry, len(history)) for i := range history { out[i] = cloneEntry(history[i]) } return out }