Use structured custom fields in entry editor
This commit is contained in:
@@ -106,6 +106,8 @@ type ui struct {
|
||||
entryTags widget.Editor
|
||||
entryPath widget.Editor
|
||||
entryFields widget.Editor
|
||||
customFieldKeys []widget.Editor
|
||||
customFieldValues []widget.Editor
|
||||
historyIndex widget.Editor
|
||||
groupName widget.Editor
|
||||
passwordProfile widget.Editor
|
||||
@@ -149,6 +151,7 @@ type ui struct {
|
||||
deleteGroup widget.Clickable
|
||||
confirmDeleteGroup widget.Clickable
|
||||
cancelDeleteGroup widget.Clickable
|
||||
addCustomField widget.Clickable
|
||||
togglePasswordInline widget.Clickable
|
||||
showEntries widget.Clickable
|
||||
showTemplates widget.Clickable
|
||||
@@ -161,6 +164,7 @@ type ui struct {
|
||||
breadcrumbs []widget.Clickable
|
||||
groupClicks []widget.Clickable
|
||||
recentVaultClicks []widget.Clickable
|
||||
removeCustomFields []widget.Clickable
|
||||
state appstate.State
|
||||
visible []entry
|
||||
currentPath []string
|
||||
@@ -278,6 +282,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
||||
u.passwordProfile.SetText("strong")
|
||||
u.keyboardFocus = focusSearch
|
||||
u.setCustomFieldRows(nil)
|
||||
u.loadRecentVaults()
|
||||
u.filter()
|
||||
return u
|
||||
|
||||
+40
-2
@@ -908,7 +908,10 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
|
||||
u.entryNotes.SetText("Registrar account")
|
||||
u.entryTags.SetText("dns, registrar")
|
||||
u.entryPath.SetText("Root / Internet")
|
||||
u.entryFields.SetText("Environment=prod\nAccount ID=12345")
|
||||
u.setCustomFieldRows(map[string]string{
|
||||
"Environment": "prod",
|
||||
"Account ID": "12345",
|
||||
})
|
||||
|
||||
if err := u.saveEntryAction(); err != nil {
|
||||
t.Fatalf("saveEntryAction() create error = %v", err)
|
||||
@@ -937,6 +940,41 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
u := newUIWithModel("desktop", vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "gitlab",
|
||||
Title: "Gitlab",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Fields: map[string]string{
|
||||
"AndroidApp1": "androidapp://com.gitlab.android",
|
||||
"OTP": "123456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
u.showEntriesSection()
|
||||
u.state.NavigateToPath([]string{"Root", "Internet"})
|
||||
u.filter()
|
||||
u.state.SelectedEntryID = "gitlab"
|
||||
u.loadSelectedEntryIntoEditor()
|
||||
|
||||
if len(u.customFieldKeys) != 2 || len(u.customFieldValues) != 2 {
|
||||
t.Fatalf("custom field rows = %d/%d, want 2 rows", len(u.customFieldKeys), len(u.customFieldValues))
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for i := range u.customFieldKeys {
|
||||
got[u.customFieldKeys[i].Text()] = u.customFieldValues[i].Text()
|
||||
}
|
||||
if got["AndroidApp1"] != "androidapp://com.gitlab.android" || got["OTP"] != "123456" {
|
||||
t.Fatalf("custom field rows = %#v, want AndroidApp1 and OTP values", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1145,7 +1183,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T
|
||||
u.state.SelectedEntryID = "tpl-web"
|
||||
u.loadSelectedEntryIntoEditor()
|
||||
u.entryTitle.SetText("Website Login Updated")
|
||||
u.entryFields.SetText("Environment=prod")
|
||||
u.setCustomFieldRows(map[string]string{"Environment": "prod"})
|
||||
if err := u.saveTemplateAction(); err != nil {
|
||||
t.Fatalf("saveTemplateAction(edit) error = %v", err)
|
||||
}
|
||||
|
||||
+67
-1
@@ -3,9 +3,11 @@ 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"
|
||||
@@ -52,6 +54,7 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
||||
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("")
|
||||
@@ -67,11 +70,74 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
||||
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 {
|
||||
@@ -341,7 +407,7 @@ func (u *ui) editorEntry() (vault.Entry, error) {
|
||||
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
|
||||
path = append([]string{root}, path...)
|
||||
}
|
||||
fields, err := parseFields(u.entryFields.Text())
|
||||
fields, err := u.currentCustomFields()
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
+96
-2
@@ -126,6 +126,66 @@ func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||
}()...)
|
||||
}
|
||||
|
||||
func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.customFieldKeys) == 0 {
|
||||
u.setCustomFieldRows(nil)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "CUSTOM FIELDS")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Add key/value pairs. Changes are only saved when you save the entry.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
|
||||
children := make([]layout.FlexChild, 0, len(u.customFieldKeys)*2)
|
||||
for i := range u.customFieldKeys {
|
||||
index := i
|
||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
for u.removeCustomFields[index].Clicked(gtx) {
|
||||
u.removeCustomFieldRow(index)
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(0.38, func(gtx layout.Context) layout.Dimensions {
|
||||
return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Flexed(0.52, func(gtx layout.Context) layout.Dimensions {
|
||||
return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "-")
|
||||
}),
|
||||
)
|
||||
}))
|
||||
if i < len(u.customFieldKeys)-1 {
|
||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||
}
|
||||
}
|
||||
return children
|
||||
}()...)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
for u.addCustomField.Clicked(gtx) {
|
||||
u.appendCustomFieldRow("", "")
|
||||
}
|
||||
return tonedButton(gtx, u.theme, &u.addCustomField, "+")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
@@ -218,9 +278,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
|
||||
layout.Rigid(labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(120))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(labeledEditorHelpFocus(u.theme, "Custom Fields", "One key=value pair per line. These fields are only saved when you save the entry.", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))),
|
||||
layout.Rigid(u.customFieldEditorPanel),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
@@ -385,3 +445,37 @@ func labeledEditorWithFocus(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func labeledMultilineEditorWithFocus(
|
||||
th *material.Theme,
|
||||
label string,
|
||||
editor *widget.Editor,
|
||||
sensitive bool,
|
||||
focused bool,
|
||||
minHeight unit.Dp,
|
||||
) layout.Widget {
|
||||
return func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
|
||||
mask := editor.Mask
|
||||
if sensitive {
|
||||
editor.Mask = '•'
|
||||
}
|
||||
defer func() { editor.Mask = mask }()
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
if min := gtx.Dp(minHeight); gtx.Constraints.Min.Y < min {
|
||||
gtx.Constraints.Min.Y = min
|
||||
}
|
||||
ed := material.Editor(th, editor, label)
|
||||
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user