Files
vnak/build.go
2026-01-10 17:31:00 -03:00

730 lines
19 KiB
Go

package main
import (
"encoding/hex"
"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 {
tab *qt.QWidget
kindsList *qt.QListWidget
items []kindItem
selected *kindItem
// dynamic form elements
descLabel *qt.QLabel
formLayout *qt.QVBoxLayout
formWidget *qt.QWidget
tagSections []*tagSection
extraButton *qt.QPushButton
extraWidget *qt.QWidget
extraLayout *qt.QVBoxLayout
extraVisible bool
contentEdit *qt.QTextEdit
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
}
type tagSection struct {
isMultiple bool
isBoolean bool
isRequired bool
parent *qt.QLayout
sectionBox *qt.QHBoxLayout
spec *schema.TagSpec
label *qt.QLabel
rowsBox *qt.QVBoxLayout
rows []*qt.QHBoxLayout
edits [][]*qt.QLineEdit
bool *qt.QCheckBox
}
var build = &buildVars{}
func setupBuildTab() *qt.QWidget {
build.tab = qt.NewQWidget(window.QWidget)
layout := qt.NewQVBoxLayout2()
build.tab.SetLayout(layout.QLayout)
// label
label := qt.NewQLabel2()
label.SetText("select a kind to build an event:")
layout.AddWidget(label.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)
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)
}
}
})
// 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)
// collapsible extra fields section
extraLabelText := "extra optional fields"
build.extraButton = qt.NewQPushButton5("▶ "+extraLabelText, build.tab)
build.extraButton.SetFlat(true)
build.extraButton.SetHidden(true)
layout.AddWidget(build.extraButton.QWidget)
build.extraWidget = qt.NewQWidget(build.tab)
build.extraLayout = qt.NewQVBoxLayout2()
build.extraWidget.SetLayout(build.extraLayout.QLayout)
build.extraWidget.SetVisible(false)
build.extraVisible = false
layout.AddWidget(build.extraWidget)
build.extraButton.OnClicked(func() {
build.extraVisible = !build.extraVisible
build.extraWidget.SetVisible(build.extraVisible)
if build.extraVisible {
build.extraButton.SetText("▼" + extraLabelText)
} else {
build.extraButton.SetText("▶" + extraLabelText)
}
})
// 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()
// check for missing required tags
missing := []string{}
for _, ts := range build.tagSections {
if ts.isRequired {
tagName := ts.spec.Name
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)
}
}
}
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)
})
// spacer at bottom
layout.AddStretch()
// load kinds
go func() {
sch, err := schema.FetchSchemaFromURL("https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "error loading schema:", err)
mainthread.Wait(func() {
setStatus(tabs.build, "error loading schema: %s", err)
})
return
}
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) {
build.extraButton.SetHidden(false)
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)
// 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)
if tagSpec.Prefix != "" {
isMultiple = isMultiple || slices.Contains(item.Multiple, tagSpec.Prefix)
}
build.addTag(&tagSpec, slices.Contains(item.Required, tagSpec.Name), isMultiple)
}
// all events have the option to be protected
build.addTagToParent(&schema.TagSpec{
Name: "-",
}, false, false, build.extraLayout)
// and nip29nabble
build.addTagToParent(&schema.TagSpec{
Name: "h",
Next: &schema.ContentSpec{
Type: "free",
Required: true,
},
}, false, false, build.extraLayout)
// and expirable
build.addTagToParent(&schema.TagSpec{
Name: "expiration",
Next: &schema.ContentSpec{
Type: "timestamp",
Required: true,
},
}, false, false, build.extraLayout)
// and taggable
build.addTagToParent(&schema.TagSpec{
Name: "t",
Next: &schema.ContentSpec{
Type: "lowercase",
Required: true,
},
}, false, true, build.extraLayout)
// 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 (b *buildVars) addTag(tagSpec *schema.TagSpec, required bool, isMultiple bool) {
b.addTagToParent(tagSpec, required, isMultiple, b.formLayout)
}
func (b *buildVars) addTagToParent(tagSpec *schema.TagSpec, required bool, isMultiple bool, parent *qt.QVBoxLayout) {
ts := &tagSection{
isMultiple: isMultiple,
isBoolean: tagSpec.Next == nil,
isRequired: required,
spec: tagSpec,
parent: parent.QLayout,
}
ts.sectionBox = qt.NewQHBoxLayout2()
parent.AddLayout(ts.sectionBox.QLayout)
// label for the tag
labelText := tagSpec.Name
if tagSpec.Name == "-" {
labelText = "protected"
}
if required {
labelText += " (required)"
}
ts.label = qt.NewQLabel2()
ts.label.SetText(labelText + ":")
ts.label.SetMinimumWidth(80)
ts.sectionBox.AddWidget(ts.label.QWidget)
b.tagSections = append(b.tagSections, ts)
// input for the tag value
if tagSpec.Next != nil {
vbox := qt.NewQVBoxLayout2()
ts.rowsBox = vbox
ts.sectionBox.AddLayout(vbox.Layout())
build.addTagRow(ts, tagSpec, 0)
} else {
// boolean tag - add checkbox
ts.bool = qt.NewQCheckBox2()
ts.sectionBox.AddWidget(ts.bool.QWidget)
ts.sectionBox.AddStretch()
}
}
var (
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, spec *schema.ContentSpec) bool {
text = strings.TrimSpace(text)
if text == "" {
return true // empty is always valid (required check is separate)
}
switch spec.Type {
case "free":
return true
case "timestamp":
n, err := strconv.ParseInt(text, 10, 64)
return err == nil && n >= 0
case "lowercase":
return strings.ToLower(text) == text
case "imeta":
return len(strings.SplitN(text, " ", 2)) == 2
case "empty":
return text == ""
case "constrained":
return slices.Contains(spec.Either, text)
case "hex":
_, err := hex.DecodeString(text)
return err == nil
case "url":
u, err := url.Parse(text)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
case "giturl":
return gitURLRegex.MatchString(text)
case "relay":
u, err := url.Parse(text)
return err == nil && (u.Scheme == "ws" || u.Scheme == "wss") && u.Host != ""
case "pubkey":
_, err := nostr.PubKeyFromHex(text)
return err == nil
case "event":
_, err := nostr.IDFromHex(text)
return err == nil
case "addr":
return addrRegex.MatchString(text)
case "date":
return dateRegex.MatchString(text)
case "json":
var v any
err := json.Unmarshal([]byte(text), &v)
return err == nil
case "kind":
n, err := strconv.ParseUint(text, 10, 16)
return err == nil && n <= 65535
default:
return true
}
}
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))
if ts.isBoolean {
ts.bool = qt.NewQCheckBox2()
hbox.AddWidget(ts.bool.QWidget)
hbox.AddStretch()
} else {
itemIdx := 0
for curr := tagSpec.Next; curr != nil; curr = curr.Next {
b.addInput(ts, tagSpec, rowIdx, hbox, curr, itemIdx)
itemIdx++
}
}
}
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)
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)
}
} 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]
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]
}
}
}
})
}
}
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 ts.isBoolean {
if ts.bool.CheckState() == qt.Checked {
base.Tags = append(base.Tags, nostr.Tag{ts.spec.Name})
}
} else {
for e, edits := range ts.edits {
tag := make(nostr.Tag, 1, 4)
tag[0] = ts.spec.Name
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)
}
}
}
}
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 _, ts := range b.tagSections {
// try only tags with matching names, of course
if ts.spec.Name != tagName {
continue
}
// navigate to the correct position in the spec
curr := ts.spec.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()
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.sectionBox.RemoveItem(ts.rowsBox.QLayoutItem)
ts.rowsBox.DeleteLater()
}
// remove from appropriate parent
ts.parent.RemoveItem(ts.sectionBox.QLayoutItem)
b.formLayout.RemoveItem(ts.sectionBox.QLayoutItem)
ts.sectionBox.DeleteLater()
}
b.tagSections = b.tagSections[:0]
}
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: <kind>:<hex-pubkey>:<identifier>")
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;")
}
})
}