From 9ae5798a247e060b7f41a799fcfffe2a6e089ed6 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 19 Apr 2026 19:10:29 -0300 Subject: [PATCH] print event in 'validate' and 'filter', apply jq on 'filter' and 'req' with gojq. --- filter.go | 29 +++++++++++++++++-- go.mod | 8 ++++-- go.sum | 18 ++++++++---- jq.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ req.go | 55 ++++++++++++++++++++++++++++++++--- spell.go | 4 ++- validate.go | 2 ++ 7 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 jq.go diff --git a/filter.go b/filter.go index 7272a24..d621d39 100644 --- a/filter.go +++ b/filter.go @@ -19,8 +19,13 @@ example: nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0 `, DisableSliceFlagSeparator: true, - Flags: reqFilterFlags, - ArgsUsage: "[event_json] [base_filter_json]", + Flags: append(append([]cli.Flag{}, reqFilterFlags...), + &cli.StringFlag{ + Name: "jq", + Usage: "filter matching events with jq expression", + }, + ), + ArgsUsage: "[event_json] [base_filter_json]", Action: func(ctx context.Context, c *cli.Command) error { args := c.Args().Slice() @@ -54,6 +59,11 @@ example: return err } + jq, err := jqPrepare(c.String("jq")) + if err != nil { + return err + } + // if there is no stdin we'll still get an empty object here for evtj := range getJsonsOrBlank() { var evt nostr.Event @@ -83,7 +93,20 @@ example: } if baseFilter.Matches(evt) { - stdout(evt) + var out string + if jq == nil { + out = evt.String() + } else { + v, matches, err := jq(evt) + if err != nil { + return fmt.Errorf("jq filter failed: %w", err) + } + if !matches { + continue + } + out, _ = json.MarshalToString(v) + } + stdout(out) } else { logverbose("event %s didn't match %s", evt, baseFilter) } diff --git a/go.mod b/go.mod index b29c57d..efec5af 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( require ( fiatjaf.com/lib v0.3.6 github.com/hanwen/go-fuse/v2 v2.9.0 + github.com/itchyny/gojq v0.12.19 ) require ( @@ -54,6 +55,8 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chzyer/logex v1.1.10 // indirect github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect @@ -69,6 +72,7 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/hablullah/go-hijri v1.0.2 // indirect github.com/hablullah/go-juliandays v1.0.0 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect @@ -76,7 +80,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magefile/mage v1.14.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -102,7 +106,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect diff --git a/go.sum b/go.sum index 791ff41..f4d115b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY= fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20260402062956-72a5be58d755 h1:Tt9XwQMaGaZw2cwujK8IAD/g6FkJC9WWRJuz+7qM1zM= -fiatjaf.com/nostr v0.0.0-20260402062956-72a5be58d755/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM= fiatjaf.com/nostr v0.0.0-20260416191442-f50b7b0f8dcb h1:zOni3zgiu+hnzZFjt8SAMzmntlAxm+c8T9kEr0qwGRw= fiatjaf.com/nostr v0.0.0-20260416191442-f50b7b0f8dcb/go.mod h1:1cmygNC87Pw06/WjkZqDV+Xo6rV10kpTjzuayosIX4Y= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= @@ -85,6 +83,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= @@ -153,6 +155,10 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -192,8 +198,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= @@ -324,8 +330,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= diff --git a/jq.go b/jq.go new file mode 100644 index 0000000..9937f0a --- /dev/null +++ b/jq.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + + "fiatjaf.com/nostr" + "github.com/itchyny/gojq" +) + +const eventJQPrelude = ` +def tags(tagName): .tags | map(select(.[0] == tagName)); +def tag(tagName): tags(tagName) | .[0]; +def value(tagName): tag(tagName)[1]; +def has(tagName): (tags(tagName) | length) > 0; +def hasnt(tagName): (tags(tagName) | length) == 0; +def has_value(tagName; tagValue): tags(tagName) | map(select(.[1] == tagValue)) | length > 0; +` + +type jqProcessor func(nostr.Event) (any, bool, error) + +func jqPrepare(expr string) (jqProcessor, error) { + if expr == "" { + return nil, nil + } + + query, err := gojq.Parse(eventJQPrelude + expr) + if err != nil { + return nil, fmt.Errorf("invalid jq expression: %w", err) + } + + code, err := gojq.Compile(query) + if err != nil { + return nil, fmt.Errorf("failed to compile jq expression: %w", err) + } + + return func(evt nostr.Event) (any, bool, error) { + input, err := toJQInput(evt) + if err != nil { + return nil, false, err + } + + iter := code.Run(input) + for { + v, ok := iter.Next() + if !ok { + return v, false, nil + } + + if err, ok := v.(error); ok { + return v, false, err + } + + if jqTruthy(v) { + return v, true, nil + } + } + }, nil +} + +func toJQInput(v any) (any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal jq input: %w", err) + } + + var input any + if err := json.Unmarshal(data, &input); err != nil { + return nil, fmt.Errorf("failed to unmarshal jq input: %w", err) + } + + return input, nil +} + +func jqTruthy(v any) bool { + switch v := v.(type) { + case nil: + return false + case bool: + return v + default: + return true + } +} diff --git a/req.go b/req.go index 502bbba..31db52f 100644 --- a/req.go +++ b/req.go @@ -44,6 +44,10 @@ example: DisableSliceFlagSeparator: true, Flags: append(defaultKeyFlags, append(reqFilterFlags, + &cli.StringFlag{ + Name: "jq", + Usage: "filter returned events with jq expression", + }, &cli.StringFlag{ Name: "only-missing", Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file", @@ -125,6 +129,14 @@ example: return fmt.Errorf("relay URLs are incompatible with --bare or --spell") } + jq, err := jqPrepare(c.String("jq")) + if err != nil { + return err + } + if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") { + return fmt.Errorf("--jq requires relay URLs or --outbox") + } + if len(relayUrls) > 0 && !negentropy { // this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth // connect to all relays we expect to use in this call in parallel @@ -206,6 +218,7 @@ example: target := PrintingQuerierPublisher{ QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt}, + jq: jq, } var source nostr.Querier = nil @@ -235,7 +248,9 @@ example: } } } else { - performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req") + if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil { + return err + } } } else { // no relays given, will just print the filter or spell @@ -277,7 +292,8 @@ func performReq( paginate bool, paginateInterval time.Duration, label string, -) { + jq jqProcessor, +) error { var results chan nostr.RelayEvent var closeds chan nostr.RelayClosed @@ -431,7 +447,22 @@ readevents: if !stillOpen { break readevents } - stdout(ie.Event) + + var out string + if jq == nil { + out = ie.Event.String() + } else { + v, matches, err := jq(ie.Event) + if err != nil { + return fmt.Errorf("jq filter failed: %w", err) + } + if !matches { + continue + } + out, _ = json.MarshalToString(v) + } + stdout(out) + case closed, stillOpen := <-closeds: if stillOpen { if closed.HandledAuth { @@ -444,6 +475,8 @@ readevents: break readevents } } + + return nil } var reqFilterFlags = []cli.Flag{ @@ -604,11 +637,25 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error { type PrintingQuerierPublisher struct { nostr.QuerierPublisher + jq jqProcessor } func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error { if err := p.QuerierPublisher.Publish(ctx, evt); err == nil { - stdout(evt) + var out string + if p.jq == nil { + out = evt.String() + } else { + v, matches, err := p.jq(evt) + if err != nil { + return fmt.Errorf("jq filter failed: %w", err) + } + if !matches { + return nil + } + out, _ = json.MarshalToString(v) + } + stdout(out) return nil } else if err == eventstore.ErrDupEvent { return nil diff --git a/spell.go b/spell.go index 95422ba..1237b08 100644 --- a/spell.go +++ b/spell.go @@ -248,7 +248,9 @@ func runSpell( // execute logSpellDetails(spell) - performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell") + if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil { + return err + } return nil } diff --git a/validate.go b/validate.go index 586df22..defadc5 100644 --- a/validate.go +++ b/validate.go @@ -55,6 +55,8 @@ nak event -k 1 -p not_a_pubkey | nak validate return fmt.Errorf("schema validation failed: %w", err) } + stdout(evt) + return nil }