563 lines
14 KiB
Go
563 lines
14 KiB
Go
package appui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gioui.org/widget"
|
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
"git.julianfamily.org/keepassgo/internal/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
|
|
u.copyCustomFields = nil
|
|
u.toggleCustomFields = 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{})
|
|
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
|
|
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
|
|
}
|
|
|
|
func (u *ui) removeCustomFieldRow(index int) {
|
|
u.ensureCustomFieldRowControls()
|
|
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:]...)
|
|
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
|
|
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
|
|
if len(u.customFieldKeys) == 0 {
|
|
u.appendCustomFieldRow("", "")
|
|
}
|
|
}
|
|
|
|
func (u *ui) ensureCustomFieldRowControls() {
|
|
if len(u.customFieldValues) < len(u.customFieldKeys) {
|
|
values := make([]widget.Editor, len(u.customFieldKeys))
|
|
copy(values, u.customFieldValues)
|
|
for i := len(u.customFieldValues); i < len(values); i++ {
|
|
values[i] = widget.Editor{SingleLine: true, Submit: false}
|
|
}
|
|
u.customFieldValues = values
|
|
}
|
|
if len(u.removeCustomFields) < len(u.customFieldKeys) {
|
|
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
|
copy(clicks, u.removeCustomFields)
|
|
u.removeCustomFields = clicks
|
|
}
|
|
if len(u.copyCustomFields) < len(u.customFieldKeys) {
|
|
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
|
copy(clicks, u.copyCustomFields)
|
|
u.copyCustomFields = clicks
|
|
}
|
|
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
|
|
clicks := make([]widget.Clickable, len(u.customFieldKeys))
|
|
copy(clicks, u.toggleCustomFields)
|
|
u.toggleCustomFields = clicks
|
|
}
|
|
}
|
|
|
|
func (u *ui) currentCustomFields() (map[string]string, error) {
|
|
u.ensureCustomFieldRowControls()
|
|
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.adoptStateCurrentPath()
|
|
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) copySelectedCustomFieldAction(key string) error {
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
service := clipboard.Service{Writer: u.clipboardWriter}
|
|
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
|
|
}
|
|
|
|
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)
|
|
}
|