Files
keepassgo/ui_editor.go
T
2026-04-01 10:24:57 -07:00

492 lines
12 KiB
Go

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.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.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.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)
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")
}