package main import ( "fmt" "os" "slices" "strconv" "strings" "gioui.org/widget" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/vault" ) func (u *ui) attachmentInput() (string, []byte, error) { name := strings.TrimSpace(u.attachmentName.Text()) if name == "" { return "", nil, fmt.Errorf("attachment name is required") } path := strings.TrimSpace(u.attachmentPath.Text()) if path == "" { return "", nil, fmt.Errorf("attachment path is required") } info, err := os.Stat(path) if err != nil { return "", nil, fmt.Errorf("stat attachment: %w", err) } if info.Size() > maxAttachmentBytes { return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes) } content, err := os.ReadFile(path) if err != nil { return "", nil, fmt.Errorf("read attachment: %w", err) } return name, content, nil } func (u *ui) loadSelectedEntryIntoEditor() { u.resetPasswordPeek() u.clearGeneratedPasswordDraft() u.selectedHistoryIndex = -1 u.historyIndex.SetText("") item, ok := u.selectedEntry() if !ok { u.entryID.SetText("") u.entryTitle.SetText("") u.entryUsername.SetText("") u.entryPassword.SetText("") u.entryURL.SetText("") u.entryNotes.SetText("") u.entryTags.SetText("") u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.entryFields.SetText("") u.setCustomFieldRows(nil) u.attachmentName.SetText("") u.attachmentPath.SetText("") u.exportAttachmentPath.SetText("") return } u.entryID.SetText(item.ID) u.entryTitle.SetText(item.Title) u.entryUsername.SetText(item.Username) u.entryPassword.SetText(item.Password) u.entryURL.SetText(item.URL) u.entryNotes.SetText(item.Notes) u.entryTags.SetText(strings.Join(item.Tags, ", ")) u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / ")) u.entryFields.SetText(marshalFields(item.Fields)) u.setCustomFieldRows(item.Fields) u.attachmentName.SetText("") u.attachmentPath.SetText("") u.exportAttachmentPath.SetText("") } func (u *ui) setCustomFieldRows(fields map[string]string) { u.customFieldKeys = nil u.customFieldValues = nil u.removeCustomFields = nil if len(fields) == 0 { u.appendCustomFieldRow("", "") return } keys := make([]string, 0, len(fields)) for key := range fields { keys = append(keys, key) } slices.Sort(keys) for _, key := range keys { u.appendCustomFieldRow(key, fields[key]) } } func (u *ui) appendCustomFieldRow(key, value string) { keyEditor := widget.Editor{SingleLine: true, Submit: false} keyEditor.SetText(key) valueEditor := widget.Editor{SingleLine: true, Submit: false} valueEditor.SetText(value) u.customFieldKeys = append(u.customFieldKeys, keyEditor) u.customFieldValues = append(u.customFieldValues, valueEditor) u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{}) } func (u *ui) removeCustomFieldRow(index int) { if index < 0 || index >= len(u.customFieldKeys) { return } u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...) u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...) u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...) if len(u.customFieldKeys) == 0 { u.appendCustomFieldRow("", "") } } func (u *ui) currentCustomFields() (map[string]string, error) { fields := map[string]string{} for i := range u.customFieldKeys { key := strings.TrimSpace(u.customFieldKeys[i].Text()) value := strings.TrimSpace(u.customFieldValues[i].Text()) if key == "" && value == "" { continue } if key == "" { return nil, fmt.Errorf("custom field name is required") } fields[key] = value } if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" { return parseFields(u.entryFields.Text()) } if len(fields) == 0 { return nil, nil } return fields, nil } func (u *ui) visibleHistory() []vault.Entry { item, ok := u.selectedEntry() if !ok || len(item.History) == 0 { return nil } return append([]vault.Entry(nil), item.History...) } func (u *ui) selectedHistoryEntry() (vault.Entry, bool) { history := u.visibleHistory() if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) { return vault.Entry{}, false } return history[u.selectedHistoryIndex], true } func (u *ui) selectHistoryVersion(index int) error { history := u.visibleHistory() if index < 0 || index >= len(history) { return fmt.Errorf("history index %d out of range", index) } u.selectedHistoryIndex = index u.historyIndex.SetText(strconv.Itoa(index)) return nil } func (u *ui) saveEntryAction() error { entry, err := u.editorEntry() if err != nil { return err } if err := u.state.UpsertEntry(entry); err != nil { return err } u.editingEntry = false u.clearGeneratedPasswordDraft() u.filter() return nil } func (u *ui) duplicateSelectedEntryAction() error { baseID := strings.TrimSpace(u.state.SelectedEntryID) if baseID == "" { return fmt.Errorf("no entry selected") } duplicateID := baseID + "-copy" if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil { return err } u.loadSelectedEntryIntoEditor() u.filter() return nil } func (u *ui) deleteSelectedEntryAction() error { if err := u.state.DeleteSelectedEntry(); err != nil { return err } u.loadSelectedEntryIntoEditor() u.filter() return nil } func (u *ui) restoreSelectedRecycleEntryAction() error { id := strings.TrimSpace(u.state.SelectedEntryID) if id == "" { return fmt.Errorf("no recycle-bin entry selected") } if err := u.state.RestoreEntry(id); err != nil { return err } u.filter() return nil } func (u *ui) saveTemplateAction() error { entry, err := u.editorEntry() if err != nil { return err } if len(entry.Path) == 0 { entry.Path = []string{"Templates"} } if err := u.state.UpsertTemplate(entry); err != nil { return err } u.editingEntry = false u.clearGeneratedPasswordDraft() u.filter() return nil } func (u *ui) deleteSelectedTemplateAction() error { id := strings.TrimSpace(u.state.SelectedEntryID) if id == "" { return fmt.Errorf("no template selected") } if err := u.state.DeleteTemplate(id); err != nil { return err } u.loadSelectedEntryIntoEditor() u.filter() return nil } func (u *ui) createGroupAction() error { u.clearDeleteGroupConfirmation() return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text())) } func (u *ui) moveCurrentGroupAction() error { u.clearDeleteGroupConfirmation() if len(u.displayPath()) == 0 { return fmt.Errorf("no current group selected") } return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text())) } func (u *ui) renameGroupAction() error { u.clearDeleteGroupConfirmation() return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text())) } func (u *ui) deleteCurrentGroupAction() error { if !u.deleteGroupPendingConfirmation() { return fmt.Errorf("confirm deleting the empty group first") } if err := u.state.DeleteCurrentGroup(); err != nil { return err } u.clearDeleteGroupConfirmation() u.currentPath = append([]string(nil), u.state.CurrentPath...) u.syncedPath = append([]string(nil), u.state.CurrentPath...) u.filter() return nil } func (u *ui) instantiateSelectedTemplateAction() error { templateID := strings.TrimSpace(u.state.SelectedEntryID) if templateID == "" { return fmt.Errorf("no template selected") } entry, err := u.editorEntry() if err != nil { return err } if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil { return err } u.filter() return nil } func (u *ui) addAttachmentAction() error { name, content, err := u.attachmentInput() if err != nil { return err } if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil { return err } u.loadSelectedEntryIntoEditor() u.attachmentName.SetText(name) u.filter() return nil } func (u *ui) replaceAttachmentAction() error { name, content, err := u.attachmentInput() if err != nil { return err } if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil { return err } u.loadSelectedEntryIntoEditor() u.attachmentName.SetText(name) u.filter() return nil } func (u *ui) exportAttachmentAction() error { item, ok := u.selectedEntry() if !ok { return fmt.Errorf("no entry selected") } name := strings.TrimSpace(u.attachmentName.Text()) content, ok := item.Attachments[name] if !ok { return fmt.Errorf("attachment not found") } exportPath := strings.TrimSpace(u.exportAttachmentPath.Text()) if exportPath == "" { return fmt.Errorf("export attachment path is required") } if err := os.WriteFile(exportPath, content, 0o600); err != nil { return fmt.Errorf("write attachment export: %w", err) } return nil } func (u *ui) removeAttachmentAction() error { name := strings.TrimSpace(u.attachmentName.Text()) if name == "" { return fmt.Errorf("attachment name is required") } if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil { return err } u.loadSelectedEntryIntoEditor() u.filter() return nil } func (u *ui) restoreSelectedHistoryAction() error { index, err := u.selectedHistoryVersionIndex() if err != nil { return err } if err := u.state.RestoreSelectedEntryVersion(index); err != nil { return err } u.loadSelectedEntryIntoEditor() u.filter() return nil } func (u *ui) selectedHistoryVersionIndex() (int, error) { text := strings.TrimSpace(u.historyIndex.Text()) if text != "" { index, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("invalid history index: %w", err) } return index, nil } if u.selectedHistoryIndex >= 0 { return u.selectedHistoryIndex, nil } return 0, fmt.Errorf("no history version selected") } func (u *ui) copySelectedFieldAction(target clipboard.Target) error { model, err := u.state.Session.Current() if err != nil { return err } service := clipboard.Service{Writer: u.clipboardWriter} return service.Copy(model, u.state.SelectedEntryID, target) } func (u *ui) generatePasswordAction() error { profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text()) if err != nil { return err } password, err := passwords.Generate(profile) if err != nil { return err } u.entryPassword.SetText(password) u.generatedPasswordDraft = true return nil } func (u *ui) editorEntry() (vault.Entry, error) { path := parsePath(u.entryPath.Text()) if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) { path = append([]string{root}, path...) } fields, err := u.currentCustomFields() if err != nil { return vault.Entry{}, err } return vault.Entry{ ID: strings.TrimSpace(u.entryID.Text()), Title: strings.TrimSpace(u.entryTitle.Text()), Username: strings.TrimSpace(u.entryUsername.Text()), Password: u.entryPassword.Text(), URL: strings.TrimSpace(u.entryURL.Text()), Notes: strings.TrimSpace(u.entryNotes.Text()), Tags: parseTags(u.entryTags.Text()), Path: path, Fields: fields, }, nil } func parsePath(text string) []string { var out []string for _, part := range strings.Split(text, "/") { part = strings.TrimSpace(part) if part == "" { continue } out = append(out, part) } return out } func parseTags(text string) []string { var out []string for _, part := range strings.Split(text, ",") { part = strings.TrimSpace(part) if part == "" { continue } out = append(out, part) } return out } func parseFields(text string) (map[string]string, error) { if strings.TrimSpace(text) == "" { return nil, nil } fields := map[string]string{} for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } key, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("invalid field line %q", line) } fields[strings.TrimSpace(key)] = strings.TrimSpace(value) } return fields, nil } func marshalFields(fields map[string]string) string { if len(fields) == 0 { return "" } lines := make([]string, 0, len(fields)) for key, value := range fields { lines = append(lines, key+"="+value) } return strings.Join(lines, "\n") } func (u *ui) clearGeneratedPasswordDraft() { u.generatedPasswordDraft = false } func (u *ui) attachmentActionSummary() string { items := u.selectedAttachmentItems() if len(items) == 0 { return "No attachments yet. Add one below to store a file with this entry." } name := strings.TrimSpace(u.attachmentName.Text()) if name == "" { return "Select an attachment above, then replace it, export it, or remove it below." } for _, item := range items { if item.Name == name { return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name) } } return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name) }