From e7cfa32b3bff7049ab4258aa228978cac46ed744 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 10 Jan 2026 16:36:59 -0300 Subject: [PATCH] build: variadic fields and other small things. --- build.go | 397 ++++++++++++++++++++++++++++++++++++++++++------------- go.mod | 6 +- go.sum | 6 + 3 files changed, 317 insertions(+), 92 deletions(-) diff --git a/build.go b/build.go index 915f18e..007e213 100644 --- a/build.go +++ b/build.go @@ -5,15 +5,18 @@ import ( "encoding/json" "fmt" "net/url" + "os" "regexp" "slices" "strconv" "strings" + "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/schema" qt "github.com/mappu/miqt/qt6" "github.com/mappu/miqt/qt6/mainthread" + "github.com/sahilm/fuzzy" ) type buildVars struct { @@ -33,6 +36,11 @@ type buildVars struct { contentLabel *qt.QLabel } +type listFuzzySource struct{ *qt.QListWidget } + +func (l listFuzzySource) String(i int) string { return strings.ToLower(l.Item(i).Text()) } +func (l listFuzzySource) Len() int { return l.Count() } + type kindItem struct { kind uint16 schema.KindSchema @@ -48,6 +56,7 @@ type tagSection struct { rowsBox *qt.QVBoxLayout rows []*qt.QHBoxLayout edits [][]*qt.QLineEdit + bool *qt.QCheckBox } var build = &buildVars{} @@ -75,11 +84,23 @@ func setupBuildTab() *qt.QWidget { // filter list items as user types searchInput.OnTextChanged(func(text string) { filterText := strings.ToLower(text) - for i := 0; i < build.kindsList.Count(); i++ { - item := build.kindsList.Item(i) - itemText := strings.ToLower(item.Text()) - // TODO: instead of strings.Contains() use levenshtein distance fuzz matching - build.kindsList.SetRowHidden(i, !strings.Contains(itemText, filterText)) + matches := fuzzy.FindFrom(filterText, listFuzzySource{build.kindsList}) + + if filterText == "" { + // show all + for i := 0; i < build.kindsList.Count(); i++ { + build.kindsList.SetRowHidden(i, false) + } + } else { + // hide all + for i := 0; i < build.kindsList.Count(); i++ { + build.kindsList.SetRowHidden(i, true) + } + + // except the matched + for _, match := range matches { + build.kindsList.SetRowHidden(match.Index, false) + } } }) @@ -115,16 +136,64 @@ func setupBuildTab() *qt.QWidget { continueButton.OnClicked(func() { eventBuilt := build.buildEvent() - // TODO: check for missing tags in event + // check for missing required tags missing := []string{} - if len(missing) > 0 { - setStatus(tabs.build, "missing required tags: %s", strings.Join(missing, ", ")) - return + for _, ts := range build.tagSections { + if ts.isRequired { + tagName := strings.TrimSuffix( + strings.SplitN(ts.label.Text(), " ", 2)[0], + ":", + ) + + hasValidTag := false + if ts.isBoolean { + hasValidTag = ts.bool != nil && ts.bool.CheckState() == qt.Checked + } else { + for _, edits := range ts.edits { + hasValidValue := false + for _, edit := range edits { + if strings.TrimSpace(edit.Text()) != "" { + hasValidValue = true + break + } + } + if hasValidValue { + hasValidTag = true + break + } + } + } + + if !hasValidTag { + missing = append(missing, tagName) + } + } } - // TODO: check each tag to see if it has all the necessary items and they match each of the types - // ('constrained', 'url', 'relay', 'id', 'addr', 'pubkey', 'timestamp', 'kind', 'giturl'. 'json', 'hex') - // ... + if len(missing) > 0 { + // show validation failure alert + msgBox := qt.NewQMessageBox2() + msgBox.SetWindowTitle("validation failed") + msgBox.SetText(fmt.Sprintf("missing required tags: %s", strings.Join(missing, ", "))) + msgBox.SetInformativeText("you can continue to manual event editing or cancel to fix the issues.") + msgBox.SetStandardButtons(qt.QMessageBox__Ok | qt.QMessageBox__Cancel) + if msgBox.Exec() == int(qt.QMessageBox__Cancel) { + return + } + } + + // check each tag to see if it has all the necessary items and they match each of the types + validationErrors := build.validateTags() + if len(validationErrors) > 0 { + msgBox := qt.NewQMessageBox2() + msgBox.SetWindowTitle("tag validation errors") + msgBox.SetText(strings.Join(validationErrors, "\n")) + msgBox.SetInformativeText("you can continue to manual event editing or cancel to fix the issues") + msgBox.SetStandardButtons(qt.QMessageBox__Ok | qt.QMessageBox__Cancel) + if msgBox.Exec() == int(qt.QMessageBox__Cancel) { + return + } + } event.populate(eventBuilt) tabWidget.SetCurrentIndex(tabs.event) @@ -137,7 +206,7 @@ func setupBuildTab() *qt.QWidget { go func() { sch, err := schema.FetchSchemaFromURL("https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml") if err != nil { - fmt.Println("error loading schema:", err) + fmt.Fprintf(os.Stderr, "error loading schema:", err) mainthread.Wait(func() { setStatus(tabs.build, "error loading schema: %s", err) }) @@ -173,6 +242,17 @@ func setupBuildTab() *qt.QWidget { // show description build.descLabel.SetText(item.Description) + // all addressable events need a 'd' tag + if nostr.Kind(kindItem.kind).IsAddressable() { + build.addTag(&schema.TagSpec{ + Name: "d", + Next: &schema.ContentSpec{ + Type: "free", + Required: true, + }, + }, true, false) + } + // generate tag inputs based on schema for _, tagSpec := range item.Tags { isMultiple := slices.Contains(item.Multiple, tagSpec.Name) @@ -227,23 +307,27 @@ func (b *buildVars) addTag(tagSpec *schema.TagSpec, required bool, isMultiple bo vbox := qt.NewQVBoxLayout2() ts.rowsBox = vbox ts.sectionBox.AddLayout(vbox.Layout()) - build.addTagRow(0, ts, tagSpec) + build.addTagRow(ts, tagSpec, 0) + } else { + // boolean tag - add checkbox + ts.bool = qt.NewQCheckBox2() + ts.sectionBox.AddWidget(ts.bool.QWidget) } } var ( - gitURLRegex = regexp.MustCompile(`^(git@[\w.-]+:[\w./-]+(\.git)?|https?://[\w.-]+/[\w./-]+(\.git)?)$`) + gitURLRegex = regexp.MustCompile(`^(git@[\w.-]+:[\w./-]+(\.git)?|(https?|git|ssh)://[\w.-]+/[\w./-]+(\.git)?)$`) addrRegex = regexp.MustCompile(`^\d+:[0-9a-fA-F]{64}:.+$`) dateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) -func validateTagInput(text string, inputType string, either []string) bool { +func validateTagInput(text string, spec *schema.ContentSpec) bool { text = strings.TrimSpace(text) if text == "" { return true // empty is always valid (required check is separate) } - switch inputType { + switch spec.Type { case "free": return true @@ -252,7 +336,7 @@ func validateTagInput(text string, inputType string, either []string) bool { return err == nil && n >= 0 case "constrained": - return slices.Contains(either, text) + return slices.Contains(spec.Either, text) case "hex": _, err := hex.DecodeString(text) @@ -297,70 +381,97 @@ func validateTagInput(text string, inputType string, either []string) bool { } } -func (b *buildVars) addTagRow(i int, ts *tagSection, tagSpec *schema.TagSpec) { +func (b *buildVars) addTagRow(ts *tagSection, tagSpec *schema.TagSpec, rowIdx int) { hbox := qt.NewQHBoxLayout2() ts.rowsBox.AddLayout(hbox.QLayout) ts.rows = append(ts.rows, hbox) ts.edits = append(ts.edits, make([]*qt.QLineEdit, 0, 3)) - for curr := tagSpec.Next; curr != nil; curr = curr.Next { - // input for the tag value - edit := qt.NewQLineEdit(b.tab) - switch curr.Type { - case "constrained": - edit.SetPlaceholderText("\"" + strings.Join(curr.Either, "\" or \"") + "\"") - default: - edit.SetPlaceholderText(curr.Type) + if ts.isBoolean { + // TODO: in this case only add one field: a checkbox, save it to ts.bool + } else { + itemIdx := 0 + for curr := tagSpec.Next; curr != nil; curr = curr.Next { + b.addInput(ts, tagSpec, rowIdx, hbox, curr, itemIdx) + itemIdx++ } - hbox.AddWidget(edit.QWidget) - ts.edits[i] = append(ts.edits[i], edit) + } +} - inputType := curr.Type - eitherValues := curr.Either +func (b *buildVars) addInput( + ts *tagSection, + tagSpec *schema.TagSpec, + rowIdx int, + row *qt.QHBoxLayout, + itemSpec *schema.ContentSpec, + itemIdx int, +) { + // input for the tag value + edit := qt.NewQLineEdit(b.tab) + configureInputForType(edit, itemSpec) - edit.OnTextChanged(func(text string) { - // validation - if validateTagInput(text, inputType, eitherValues) { - edit.SetStyleSheet("") - } else { - edit.SetStyleSheet("border: 1px solid red;") + row.AddWidget(edit.QWidget) + ts.edits[rowIdx] = append(ts.edits[rowIdx], edit) + + edit.OnTextChanged(func(text string) { + if !ts.isMultiple { + return + } + + if strings.TrimSpace(text) != "" { + // if this is the last row and it has content and this tag accepts multiple values and add a new row + if rowIdx == ts.rowsBox.Count()-1 { + b.addTagRow(ts, tagSpec, rowIdx+1) } - - if !ts.isMultiple { - return - } - - if strings.TrimSpace(text) != "" { - // if this is the last row and it has content and this tag accepts multiple values and add a new row - if i == len(ts.rowsBox.Children())-1 { - b.addTagRow(i+1, ts, tagSpec) - } - } else { - // now if this is the second to last row and the last row and it is fully empty we remove the last - if len(ts.rows) >= 2 && i == len(ts.rows)-2 { - shouldDelete := true - // if all inputs are empty in the last two rows we will delete - deletecheck: - for r := len(ts.rows) - 2; r < len(ts.edits); r++ { - for _, edit := range ts.edits[r] { - if strings.TrimSpace(edit.Text()) != "" { - shouldDelete = false - break deletecheck - } + } else { + // now if this is the second to last row and the last row and it is fully empty we remove the last + if len(ts.rows) >= 2 && rowIdx == len(ts.rows)-2 { + shouldDelete := true + // if all inputs are empty in the last two rows we will delete + deletecheck: + for r := len(ts.rows) - 2; r < len(ts.edits); r++ { + for _, edit := range ts.edits[r] { + if strings.TrimSpace(edit.Text()) != "" { + shouldDelete = false + break deletecheck } } + } - if shouldDelete { - last := ts.rows[len(ts.rows)-1] - for _, edit := range ts.edits[len(ts.rows)-1] { - last.RemoveWidget(edit.QWidget) - edit.DeleteLater() - } - ts.edits = ts.edits[0 : len(ts.edits)-1] + if shouldDelete { + last := ts.rows[len(ts.rows)-1] + for _, edit := range ts.edits[len(ts.rows)-1] { + last.RemoveWidget(edit.QWidget) + edit.DeleteLater() + } + ts.edits = ts.edits[0 : len(ts.edits)-1] - ts.rowsBox.RemoveItem(last.QLayoutItem) - last.DeleteLater() - ts.rows = ts.rows[0 : len(ts.rows)-1] + ts.rowsBox.RemoveItem(last.QLayoutItem) + last.DeleteLater() + ts.rows = ts.rows[0 : len(ts.rows)-1] + } + } + } + }) + + if itemSpec.Variadic { + // variadic field - add dynamic add/remove functionality + edit.OnTextChanged(func(text string) { + if strings.TrimSpace(text) != "" { + // if this is the last edit and has content, add a new one + lastRowIdx := len(ts.edits) - 1 + lastEditIdx := len(ts.edits[lastRowIdx]) - 1 + if itemIdx == lastEditIdx { + b.addInput(ts, tagSpec, rowIdx, row, itemSpec, itemIdx+1) + } + } else { + // if this is second to last and the last one is empty delete the last + if itemIdx == len(ts.edits[rowIdx])-2 { + if strings.TrimSpace(ts.edits[rowIdx][itemIdx+1].Text()) == "" { + edit := ts.edits[rowIdx][itemIdx+1] + row.RemoveWidget(edit.QWidget) + edit.DeleteLater() + ts.edits[rowIdx] = ts.edits[rowIdx][:itemIdx+1] } } } @@ -385,21 +496,27 @@ func (b *buildVars) buildEvent() nostr.Event { ":", ) - for e, edits := range ts.edits { - tag := make(nostr.Tag, 1, 4) - tag[0] = tagName - - hasValue := false - for _, edit := range edits { - value := strings.TrimSpace(edit.Text()) - tag = append(tag, value) - if value != "" { - hasValue = true - } + if ts.isBoolean { + if ts.bool.CheckState() == qt.Checked { + base.Tags = append(base.Tags, nostr.Tag{tagName}) } + } else { + for e, edits := range ts.edits { + tag := make(nostr.Tag, 1, 4) + tag[0] = tagName - if ts.isBoolean || (ts.isRequired && e == 0) || hasValue { - base.Tags = append(base.Tags, tag) + hasValue := false + for _, edit := range edits { + value := strings.TrimSpace(edit.Text()) + tag = append(tag, value) + if value != "" { + hasValue = true + } + } + + if ts.isBoolean || (ts.isRequired && e == 0) || hasValue { + base.Tags = append(base.Tags, tag) + } } } } @@ -407,21 +524,84 @@ func (b *buildVars) buildEvent() nostr.Event { return base } +func (b *buildVars) validateTags() []string { + var errors []string + + for _, ts := range b.tagSections { + tagName := strings.TrimSuffix( + strings.SplitN(ts.label.Text(), " ", 2)[0], + ":", + ) + + if ts.isBoolean { + // boolean tags don't need type validation + continue + } + + for rowIdx, edits := range ts.edits { + for itemIdx, edit := range edits { + text := strings.TrimSpace(edit.Text()) + if text == "" { + continue // skip empty fields + } + + // find the tag spec for this tag + for _, tagSpec := range b.selected.Tags { + // try only tags with matching names, of course + if tagSpec.Name != tagName { + continue + } + + // navigate to the correct position in the spec + curr := tagSpec.Next + for range itemIdx { + if curr.Variadic { + // if we reach a variadic that's the end of it, all edits further on will be of this type + break + } + + if curr.Next == nil { + // this should only happen when we are the last iteration step anyway, + // but let's exit here instead of crashing + break + } + + curr = curr.Next + } + + // validate against expected type + if !validateTagInput(text, curr) { + errors = append(errors, fmt.Sprintf("%s[%d][%d]: invalid value for type '%s'", tagName, rowIdx, itemIdx, curr.Type)) + } + } + } + } + } + + return errors +} + func (b *buildVars) clearForm() { // clear all tag layouts and inputs for _, ts := range b.tagSections { ts.sectionBox.RemoveWidget(ts.label.QWidget) ts.label.DeleteLater() - for r, row := range ts.rows { - for _, edit := range ts.edits[r] { - row.RemoveWidget(edit.QWidget) - edit.DeleteLater() + if ts.isBoolean { + ts.sectionBox.RemoveWidget(ts.bool.QWidget) + ts.bool.DeleteLater() + } else { + for r, row := range ts.rows { + for _, edit := range ts.edits[r] { + row.RemoveWidget(edit.QWidget) + edit.DeleteLater() + } + ts.rowsBox.RemoveItem(row.QLayoutItem) + row.DeleteLater() } - ts.rowsBox.RemoveItem(row.QLayoutItem) - row.DeleteLater() + ts.sectionBox.RemoveItem(ts.rowsBox.QLayoutItem) + ts.rowsBox.DeleteLater() } - ts.rowsBox.DeleteLater() b.formLayout.RemoveItem(ts.sectionBox.QLayoutItem) ts.sectionBox.DeleteLater() @@ -431,3 +611,42 @@ func (b *buildVars) clearForm() { // but keep the content just in case // b.contentEdit.SetPlainText("") } + +func configureInputForType(edit *qt.QLineEdit, spec *schema.ContentSpec) { + switch spec.Type { + case "constrained": + edit.SetPlaceholderText("\"" + strings.Join(spec.Either, "\" or \"") + "\"") + case "pubkey": + edit.SetPlaceholderText("pubkey: npub1... (hex or npub)") + case "url": + edit.SetPlaceholderText("url: https://example.com") + case "relay": + edit.SetPlaceholderText("relay url: wss://relay.example.com") + case "event": + edit.SetPlaceholderText("event id: nevent1... (hex or nevent)") + case "hex": + edit.SetPlaceholderText("hex: 0a1b2c3d...") + case "timestamp": + edit.SetPlaceholderText("unix timestamp: " + strconv.Itoa(int(nostr.Now()))) + case "kind": + edit.SetPlaceholderText("kind: 1") + case "addr": + edit.SetPlaceholderText("address: ::") + case "date": + edit.SetPlaceholderText("date: " + time.Now().Format(time.DateOnly)) + case "giturl": + edit.SetPlaceholderText("git url: https://gitserver.com/repo.git") + case "json": + edit.SetPlaceholderText("json: {\"key\": \"value\"}") + default: + edit.SetPlaceholderText(spec.Type) + } + + edit.OnTextChanged(func(text string) { + if validateTagInput(text, spec) { + edit.SetStyleSheet("") + } else { + edit.SetStyleSheet("border: 1px solid red;") + } + }) +} diff --git a/go.mod b/go.mod index 1ebddc1..f9e1691 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.25 require ( fiatjaf.com/lib v0.3.2 - fiatjaf.com/nostr v0.0.0-20251126120447-7261a4b515ed + fiatjaf.com/nostr v0.0.0-20260110125515-a5e52684e880 github.com/btcsuite/btcd/btcec/v2 v2.3.4 github.com/mailru/easyjson v0.9.0 github.com/mappu/miqt v0.12.0 github.com/puzpuzpuz/xsync/v3 v3.5.1 + github.com/sahilm/fuzzy v0.1.1 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 ) @@ -34,6 +35,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/liamg/magic v0.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -55,5 +57,3 @@ require ( golang.org/x/text v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace fiatjaf.com/nostr => ../nostrlib diff --git a/go.sum b/go.sum index d5421b5..cced230 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q= fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= +fiatjaf.com/nostr v0.0.0-20260110125515-a5e52684e880 h1:WJpPdI0/5lKmtWN7EQE+C36wieDrjYSwvtSXYmKPMs8= +fiatjaf.com/nostr v0.0.0-20260110125515-a5e52684e880/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= @@ -103,6 +105,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM= github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -133,6 +137,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=