Use structured custom fields in entry editor
This commit is contained in:
@@ -106,6 +106,8 @@ type ui struct {
|
|||||||
entryTags widget.Editor
|
entryTags widget.Editor
|
||||||
entryPath widget.Editor
|
entryPath widget.Editor
|
||||||
entryFields widget.Editor
|
entryFields widget.Editor
|
||||||
|
customFieldKeys []widget.Editor
|
||||||
|
customFieldValues []widget.Editor
|
||||||
historyIndex widget.Editor
|
historyIndex widget.Editor
|
||||||
groupName widget.Editor
|
groupName widget.Editor
|
||||||
passwordProfile widget.Editor
|
passwordProfile widget.Editor
|
||||||
@@ -149,6 +151,7 @@ type ui struct {
|
|||||||
deleteGroup widget.Clickable
|
deleteGroup widget.Clickable
|
||||||
confirmDeleteGroup widget.Clickable
|
confirmDeleteGroup widget.Clickable
|
||||||
cancelDeleteGroup widget.Clickable
|
cancelDeleteGroup widget.Clickable
|
||||||
|
addCustomField widget.Clickable
|
||||||
togglePasswordInline widget.Clickable
|
togglePasswordInline widget.Clickable
|
||||||
showEntries widget.Clickable
|
showEntries widget.Clickable
|
||||||
showTemplates widget.Clickable
|
showTemplates widget.Clickable
|
||||||
@@ -161,6 +164,7 @@ type ui struct {
|
|||||||
breadcrumbs []widget.Clickable
|
breadcrumbs []widget.Clickable
|
||||||
groupClicks []widget.Clickable
|
groupClicks []widget.Clickable
|
||||||
recentVaultClicks []widget.Clickable
|
recentVaultClicks []widget.Clickable
|
||||||
|
removeCustomFields []widget.Clickable
|
||||||
state appstate.State
|
state appstate.State
|
||||||
visible []entry
|
visible []entry
|
||||||
currentPath []string
|
currentPath []string
|
||||||
@@ -278,6 +282,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
||||||
u.passwordProfile.SetText("strong")
|
u.passwordProfile.SetText("strong")
|
||||||
u.keyboardFocus = focusSearch
|
u.keyboardFocus = focusSearch
|
||||||
|
u.setCustomFieldRows(nil)
|
||||||
u.loadRecentVaults()
|
u.loadRecentVaults()
|
||||||
u.filter()
|
u.filter()
|
||||||
return u
|
return u
|
||||||
|
|||||||
+40
-2
@@ -908,7 +908,10 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
|
|||||||
u.entryNotes.SetText("Registrar account")
|
u.entryNotes.SetText("Registrar account")
|
||||||
u.entryTags.SetText("dns, registrar")
|
u.entryTags.SetText("dns, registrar")
|
||||||
u.entryPath.SetText("Root / Internet")
|
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 {
|
if err := u.saveEntryAction(); err != nil {
|
||||||
t.Fatalf("saveEntryAction() create error = %v", err)
|
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) {
|
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1145,7 +1183,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T
|
|||||||
u.state.SelectedEntryID = "tpl-web"
|
u.state.SelectedEntryID = "tpl-web"
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
u.entryTitle.SetText("Website Login Updated")
|
u.entryTitle.SetText("Website Login Updated")
|
||||||
u.entryFields.SetText("Environment=prod")
|
u.setCustomFieldRows(map[string]string{"Environment": "prod"})
|
||||||
if err := u.saveTemplateAction(); err != nil {
|
if err := u.saveTemplateAction(); err != nil {
|
||||||
t.Fatalf("saveTemplateAction(edit) error = %v", err)
|
t.Fatalf("saveTemplateAction(edit) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-1
@@ -3,9 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/widget"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
@@ -52,6 +54,7 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
|||||||
u.entryTags.SetText("")
|
u.entryTags.SetText("")
|
||||||
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
|
u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
|
||||||
u.entryFields.SetText("")
|
u.entryFields.SetText("")
|
||||||
|
u.setCustomFieldRows(nil)
|
||||||
u.attachmentName.SetText("")
|
u.attachmentName.SetText("")
|
||||||
u.attachmentPath.SetText("")
|
u.attachmentPath.SetText("")
|
||||||
u.exportAttachmentPath.SetText("")
|
u.exportAttachmentPath.SetText("")
|
||||||
@@ -67,11 +70,74 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
|||||||
u.entryTags.SetText(strings.Join(item.Tags, ", "))
|
u.entryTags.SetText(strings.Join(item.Tags, ", "))
|
||||||
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
|
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
|
||||||
u.entryFields.SetText(marshalFields(item.Fields))
|
u.entryFields.SetText(marshalFields(item.Fields))
|
||||||
|
u.setCustomFieldRows(item.Fields)
|
||||||
u.attachmentName.SetText("")
|
u.attachmentName.SetText("")
|
||||||
u.attachmentPath.SetText("")
|
u.attachmentPath.SetText("")
|
||||||
u.exportAttachmentPath.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 {
|
func (u *ui) visibleHistory() []vault.Entry {
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok || len(item.History) == 0 {
|
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) {
|
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
|
||||||
path = append([]string{root}, path...)
|
path = append([]string{root}, path...)
|
||||||
}
|
}
|
||||||
fields, err := parseFields(u.entryFields.Text())
|
fields, err := u.currentCustomFields()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vault.Entry{}, err
|
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 {
|
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
||||||
if u.state.Section != appstate.SectionEntries {
|
if u.state.Section != appstate.SectionEntries {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
@@ -218,9 +278,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
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(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(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
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