From f4c6c3abee1e2c14a0b4294aba1f34dfa8933758 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 9 Jan 2026 00:32:50 -0300 Subject: [PATCH] build tab working. --- build.go | 369 +++++++++++++++++++++++++++++++++++++++++++++--------- bunker.go | 2 +- event.go | 2 +- req.go | 4 +- 4 files changed, 312 insertions(+), 65 deletions(-) diff --git a/build.go b/build.go index b7331e4..49a0e33 100644 --- a/build.go +++ b/build.go @@ -3,14 +3,29 @@ package main import ( "fmt" "slices" - "strconv" + "strings" + "fiatjaf.com/nostr" "fiatjaf.com/nostr/schema" qt "github.com/mappu/miqt/qt6" + "github.com/mappu/miqt/qt6/mainthread" ) type buildVars struct { - kindsCombo *qt.QComboBox + tab *qt.QWidget + kindsList *qt.QListWidget + items []kindItem + selected *kindItem + + // dynamic form elements + descLabel *qt.QLabel + formLayout *qt.QVBoxLayout + formWidget *qt.QWidget + + tagSections []*tagSection + + contentEdit *qt.QTextEdit + contentLabel *qt.QLabel } type kindItem struct { @@ -18,88 +33,320 @@ type kindItem struct { schema.KindSchema } +type tagSection struct { + isMultiple bool + isBoolean bool + isRequired bool + + sectionBox *qt.QHBoxLayout + label *qt.QLabel + rowsBox *qt.QVBoxLayout + rows []*qt.QHBoxLayout + edits [][]*qt.QLineEdit +} + var build = &buildVars{} func setupBuildTab() *qt.QWidget { - tab := qt.NewQWidget(window.QWidget) + build.tab = qt.NewQWidget(window.QWidget) layout := qt.NewQVBoxLayout2() - tab.SetLayout(layout.QLayout) + build.tab.SetLayout(layout.QLayout) // label label := qt.NewQLabel2() label.SetText("select a kind to build an event:") layout.AddWidget(label.QWidget) - // dropdown - build.kindsCombo = qt.NewQComboBox(tab) - layout.AddWidget(build.kindsCombo.QWidget) + // search/filter input + searchInput := qt.NewQLineEdit(build.tab) + searchInput.SetPlaceholderText("filter kinds...") + layout.AddWidget(searchInput.QWidget) + + // list widget for kind selection + build.kindsList = qt.NewQListWidget(build.tab) + build.kindsList.SetMaximumHeight(100) + layout.AddWidget(build.kindsList.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)) + } + }) + + // description label + build.descLabel = qt.NewQLabel2() + build.descLabel.SetWordWrap(true) + layout.AddWidget(build.descLabel.QWidget) + + // form container for dynamic tag inputs + build.formWidget = qt.NewQWidget(build.tab) + build.formLayout = qt.NewQVBoxLayout2() + build.formWidget.SetLayout(build.formLayout.QLayout) + layout.AddWidget(build.formWidget) + + // content input + build.contentLabel = qt.NewQLabel2() + build.contentLabel.SetText("content:") + build.contentLabel.SetVisible(false) + layout.AddWidget(build.contentLabel.QWidget) + build.contentEdit = qt.NewQTextEdit(build.tab) + build.contentEdit.SetVisible(false) + build.contentEdit.SetMaximumHeight(40) + layout.AddWidget(build.contentEdit.QWidget) + + // button to continue to event tab + buttonHBox := qt.NewQHBoxLayout2() + continueButton := qt.NewQPushButton5("event "+ARROW, build.tab) + continueButton.SetVisible(false) + buttonHBox.AddWidget(continueButton.QWidget) + buttonHBox.AddStretch() + layout.AddLayout(buttonHBox.QLayout) + + continueButton.OnClicked(func() { + eventBuilt := build.buildEvent() + + // TODO: check for missing tags in event + missing := []string{} + if len(missing) > 0 { + setStatus(tabs.build, "missing required tags: %s", strings.Join(missing, ", ")) + return + } + + // 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') + // ... + + event.populate(eventBuilt) + tabWidget.SetCurrentIndex(tabs.event) + }) + + // spacer at bottom + layout.AddStretch() // load kinds - loadKinds() + 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) + mainthread.Wait(func() { + setStatus(tabs.build, "error loading schema: %s", err) + }) + return + } - return tab + build.items = make([]kindItem, 0, len(sch.Kinds)) + for k, v := range sch.Kinds { + num := -1 + fmt.Sscanf(k, "%d", &num) + if v.InUse { + build.items = append(build.items, kindItem{kind: uint16(num), KindSchema: v}) + } + } + slices.SortFunc(build.items, func(a, b kindItem) int { return int(a.kind) - int(b.kind) }) + + mainthread.Wait(func() { + // populate list widget with items + for _, item := range build.items { + build.kindsList.AddItem(fmt.Sprintf("%d - %s", item.kind, item.Description)) + } + + // handle item selection + build.kindsList.OnItemClicked(func(li *qt.QListWidgetItem) { + row := build.kindsList.Row(li) + if row >= 0 && row < len(build.items) { + kindItem := build.items[row] + build.selected = &kindItem + item := kindItem.KindSchema + + build.clearForm() + + // show description + build.descLabel.SetText(item.Description) + + // generate tag inputs based on schema + for _, tagSpec := range item.Tags { + isMultiple := slices.Contains(item.Multiple, tagSpec.Name) + if tagSpec.Prefix != "" { + isMultiple = isMultiple || slices.Contains(item.Multiple, tagSpec.Prefix) + } + build.addTag(&tagSpec, slices.Contains(item.Required, tagSpec.Name), isMultiple) + } + + // show content input if the kind has content + hasContent := item.Content.Type != "empty" + build.contentLabel.SetVisible(hasContent) + build.contentEdit.SetVisible(hasContent) + if hasContent { + build.contentLabel.SetText(fmt.Sprintf("content (%s):", item.Content.Type)) + } + + continueButton.SetVisible(true) + } + }) + }) + }() + + return build.tab } -func loadKinds() { - sch, err := schema.FetchSchemaFromURL(schema.DefaultSchemaURL) - if err != nil { - fmt.Println("error loading schema:", err) - setStatus(tabs.build, "error loading schema: %s", err) - return +func (b *buildVars) addTag(tagSpec *schema.TagSpec, required bool, isMultiple bool) { + ts := &tagSection{ + isMultiple: isMultiple, + isBoolean: tagSpec.Next == nil, + isRequired: required, } - items := make([]kindItem, 0, len(sch.Kinds)) - for k, v := range sch.Kinds { - num := -1 - fmt.Sscanf(k, "%d", &num) - items = append(items, kindItem{kind: uint16(num), KindSchema: v}) + ts.sectionBox = qt.NewQHBoxLayout2() + b.formLayout.AddLayout(ts.sectionBox.QLayout) + + // label for the tag + labelText := tagSpec.Name + if required { + labelText += " (required)" } - slices.SortFunc(items, func(a, b kindItem) int { return int(a.kind - b.kind) }) - model := qt.NewQStandardItemModel2(len(items)+2, 2) - inUseCategory := qt.NewQStandardItem2("common") - inUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag - inUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font - model.AppendRowWithItem(inUseCategory) + ts.label = qt.NewQLabel2() + ts.label.SetText(labelText + ":") + ts.label.SetMinimumWidth(80) + ts.sectionBox.AddWidget(ts.label.QWidget) - notInUseCategory := qt.NewQStandardItem2("weird") - notInUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag - notInUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font - model.AppendRowWithItem(notInUseCategory) + b.tagSections = append(b.tagSections, ts) - for _, item := range items { - qitem := []*qt.QStandardItem{ - qt.NewQStandardItem2(strconv.Itoa(int(item.kind))), - qt.NewQStandardItem2(item.Description), + // input for the tag value + if tagSpec.Next != nil { + vbox := qt.NewQVBoxLayout2() + ts.rowsBox = vbox + ts.sectionBox.AddLayout(vbox.Layout()) + build.addTagRow(0, ts, tagSpec) + } +} + +func (b *buildVars) addTagRow(i int, ts *tagSection, tagSpec *schema.TagSpec) { + 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 item.InUse { - inUseCategory.AppendRow(qitem) - } else { - notInUseCategory.AppendRow(qitem) + hbox.AddWidget(edit.QWidget) + ts.edits[i] = append(ts.edits[i], 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 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 + } + } + } + + 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] + } + } + } + }) + } +} + +func (b *buildVars) buildEvent() nostr.Event { + base := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: nostr.Kind(b.selected.kind), + Tags: make(nostr.Tags, 0, 3), + Content: b.contentEdit.ToPlainText(), + } + + // of all the hboxes + for _, ts := range b.tagSections { + // if the second element of an hbox is a vbox that means it is a top-level hbox + // get tag name from the hbox + tagName := strings.TrimSuffix( + strings.SplitN(ts.label.Text(), " ", 2)[0], + ":", + ) + + 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 || (ts.isRequired && e == 0) || hasValue { + base.Tags = append(base.Tags, tag) + } } } - proxy := qt.NewQSortFilterProxyModel() - proxy.SetSourceModel(model.QAbstractItemModel) - proxy.SetFilterCaseSensitivity(qt.CaseInsensitive) - build.kindsCombo.SetModel(proxy.QAbstractItemModel) - build.kindsCombo.SetEditable(true) - - tableView := qt.NewQAbstractItemView2() - tableView.SetSelectionBehavior(qt.QAbstractItemView__SelectRows) - tableView.SetSelectionMode(qt.QAbstractItemView__SingleSelection) - build.kindsCombo.SetView(tableView) - - build.kindsCombo.OnCurrentIndexChanged(func(index int) { - sourceIndex := proxy.MapToSource(proxy.Index(index, 0, qt.NewQModelIndex())) - actualIndex := sourceIndex.Row() - selectedItem := items[actualIndex] - fmt.Println("selected", selectedItem) - }) - - build.kindsCombo.OnEditTextChanged(func(text string) { - fmt.Println("########", text) - - proxy.SetFilterFixedString(text) - }) + return base +} + +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() + } + ts.rowsBox.RemoveItem(row.QLayoutItem) + row.DeleteLater() + } + ts.rowsBox.DeleteLater() + + b.formLayout.RemoveItem(ts.sectionBox.QLayoutItem) + ts.sectionBox.DeleteLater() + } + b.tagSections = b.tagSections[:0] + + // but keep the content just in case + // b.contentEdit.SetPlainText("") } diff --git a/bunker.go b/bunker.go index fe7e998..0aea0be 100644 --- a/bunker.go +++ b/bunker.go @@ -333,7 +333,7 @@ func (bunker *bunkerVars) startBunker() { return true } - authorized := slices.Contains(bunker.config.AuthorizedKeys, from) || slices.Contains([]string{}, secret) // TODO: authorized secrets + authorized := slices.Contains(bunker.config.AuthorizedKeys, from) || slices.Contains([]string{}, secret) if authorized { if harmless { bunker.log("harmless request from %s", from.Hex()) diff --git a/event.go b/event.go index d4abb02..c1ca53e 100644 --- a/event.go +++ b/event.go @@ -91,7 +91,7 @@ func setupEventTab() *qt.QWidget { event.outputEdit = qt.NewQTextEdit(event.tab) event.outputEdit.SetReadOnly(true) outputHBox.AddWidget(event.outputEdit.QWidget) - copyButton := qt.NewQPushButton5(ARROW, event.tab) + copyButton := qt.NewQPushButton5("manual edit "+ARROW, event.tab) outputHBox.AddWidget(copyButton.QWidget) copyButton.OnClicked(func() { tabWidget.SetCurrentIndex(tabs.paste) diff --git a/req.go b/req.go index af3c66e..b5c2cfa 100644 --- a/req.go +++ b/req.go @@ -261,7 +261,7 @@ func setupReqTab() *qt.QWidget { buttonsHBox := qt.NewQHBoxLayout2() dlayout.AddLayout(buttonsHBox.QLayout) - editButton := qt.NewQPushButton5(ARROW+" paste", dialog.QWidget) + editButton := qt.NewQPushButton5("manual edit "+ARROW, dialog.QWidget) editButton.OnClicked(func() { paste.inputEdit.SetPlainText(string(pretty)) tabWidget.SetCurrentIndex(tabs.paste) @@ -269,7 +269,7 @@ func setupReqTab() *qt.QWidget { }) buttonsHBox.AddWidget(editButton.QWidget) - publishButton := qt.NewQPushButton5(ARROW+" event", dialog.QWidget) + publishButton := qt.NewQPushButton5("structured edit "+ARROW, dialog.QWidget) publishButton.OnClicked(func() { event.populate(evt) tabWidget.SetCurrentIndex(tabs.event)