feat: comma-separated flags to REQ

This commit is contained in:
Alejandro Gómez
2025-12-11 16:57:40 +01:00
parent 78082ca6db
commit 54ee43bdaf
8 changed files with 973 additions and 62 deletions

View File

@@ -163,7 +163,7 @@ export default function ReqViewer({
Authors: {filter.authors.length}
</span>
{nip05Authors && nip05Authors.length > 0 && (
<div className="text-xs text-blue-500 ml-2">
<div className="text-xs text-muted-foreground ml-2">
{nip05Authors.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
@@ -178,7 +178,7 @@ export default function ReqViewer({
#p Tags: {filter["#p"].length}
</span>
{nip05PTags && nip05PTags.length > 0 && (
<div className="text-xs text-blue-500 ml-2">
<div className="text-xs text-muted-foreground ml-2">
{nip05PTags.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}

326
src/lib/req-parser.test.ts Normal file
View File

@@ -0,0 +1,326 @@
import { describe, it, expect, vi } from "vitest";
import { parseReqCommand } from "./req-parser";
describe("parseReqCommand", () => {
describe("kind flag (-k, --kind)", () => {
it("should parse single kind", () => {
const result = parseReqCommand(["-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
it("should parse comma-separated kinds", () => {
const result = parseReqCommand(["-k", "1,3,7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
it("should parse comma-separated kinds with spaces", () => {
const result = parseReqCommand(["-k", "1, 3, 7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
it("should deduplicate kinds", () => {
const result = parseReqCommand(["-k", "1,3,1,3"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
it("should deduplicate across multiple -k flags", () => {
const result = parseReqCommand(["-k", "1", "-k", "3", "-k", "1"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
it("should handle --kind long form", () => {
const result = parseReqCommand(["--kind", "1,3,7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
it("should ignore invalid kinds", () => {
const result = parseReqCommand(["-k", "1,invalid,3"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
});
describe("author flag (-a, --author)", () => {
it("should parse hex pubkey", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", hex]);
expect(result.filter.authors).toEqual([hex]);
});
it("should parse comma-separated hex pubkeys", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-a", `${hex1},${hex2}`]);
expect(result.filter.authors).toEqual([hex1, hex2]);
});
it("should deduplicate authors", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", `${hex},${hex}`]);
expect(result.filter.authors).toEqual([hex]);
});
it("should accumulate NIP-05 identifiers for async resolution", () => {
const result = parseReqCommand([
"-a",
"user@domain.com,alice@example.com",
]);
expect(result.nip05Authors).toEqual(["user@domain.com", "alice@example.com"]);
expect(result.filter.authors).toBeUndefined();
});
it("should handle mixed hex and NIP-05", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", `${hex},user@domain.com`]);
expect(result.filter.authors).toEqual([hex]);
expect(result.nip05Authors).toEqual(["user@domain.com"]);
});
it("should deduplicate NIP-05 identifiers", () => {
const result = parseReqCommand([
"-a",
"user@domain.com,user@domain.com",
]);
expect(result.nip05Authors).toEqual(["user@domain.com"]);
});
});
describe("event ID flag (-e)", () => {
it("should parse hex event ID", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-e", hex]);
expect(result.filter["#e"]).toEqual([hex]);
});
it("should parse comma-separated event IDs", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-e", `${hex1},${hex2}`]);
expect(result.filter["#e"]).toEqual([hex1, hex2]);
});
it("should deduplicate event IDs", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-e", `${hex},${hex}`]);
expect(result.filter["#e"]).toEqual([hex]);
});
});
describe("pubkey tag flag (-p)", () => {
it("should parse hex pubkey for #p tag", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-p", hex]);
expect(result.filter["#p"]).toEqual([hex]);
});
it("should parse comma-separated pubkeys", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-p", `${hex1},${hex2}`]);
expect(result.filter["#p"]).toEqual([hex1, hex2]);
});
it("should accumulate NIP-05 identifiers for #p tags", () => {
const result = parseReqCommand(["-p", "user@domain.com,alice@example.com"]);
expect(result.nip05PTags).toEqual(["user@domain.com", "alice@example.com"]);
expect(result.filter["#p"]).toBeUndefined();
});
it("should deduplicate #p tags", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-p", `${hex},${hex}`]);
expect(result.filter["#p"]).toEqual([hex]);
});
});
describe("hashtag flag (-t)", () => {
it("should parse single hashtag", () => {
const result = parseReqCommand(["-t", "nostr"]);
expect(result.filter["#t"]).toEqual(["nostr"]);
});
it("should parse comma-separated hashtags", () => {
const result = parseReqCommand(["-t", "nostr,bitcoin,lightning"]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]);
});
it("should parse comma-separated hashtags with spaces", () => {
const result = parseReqCommand(["-t", "nostr, bitcoin, lightning"]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]);
});
it("should deduplicate hashtags", () => {
const result = parseReqCommand(["-t", "nostr,bitcoin,nostr"]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
});
});
describe("d-tag flag (-d)", () => {
it("should parse single d-tag", () => {
const result = parseReqCommand(["-d", "article1"]);
expect(result.filter["#d"]).toEqual(["article1"]);
});
it("should parse comma-separated d-tags", () => {
const result = parseReqCommand(["-d", "article1,article2,article3"]);
expect(result.filter["#d"]).toEqual(["article1", "article2", "article3"]);
});
it("should deduplicate d-tags", () => {
const result = parseReqCommand(["-d", "article1,article2,article1"]);
expect(result.filter["#d"]).toEqual(["article1", "article2"]);
});
});
describe("limit flag (-l, --limit)", () => {
it("should parse limit", () => {
const result = parseReqCommand(["-l", "100"]);
expect(result.filter.limit).toBe(100);
});
it("should handle --limit long form", () => {
const result = parseReqCommand(["--limit", "50"]);
expect(result.filter.limit).toBe(50);
});
});
describe("time flags (--since, --until)", () => {
it("should parse unix timestamp for --since", () => {
const result = parseReqCommand(["--since", "1234567890"]);
expect(result.filter.since).toBe(1234567890);
});
it("should parse relative time for --since (hours)", () => {
const result = parseReqCommand(["--since", "2h"]);
expect(result.filter.since).toBeDefined();
expect(result.filter.since).toBeGreaterThan(0);
});
it("should parse relative time for --since (days)", () => {
const result = parseReqCommand(["--since", "7d"]);
expect(result.filter.since).toBeDefined();
expect(result.filter.since).toBeGreaterThan(0);
});
it("should parse unix timestamp for --until", () => {
const result = parseReqCommand(["--until", "1234567890"]);
expect(result.filter.until).toBe(1234567890);
});
});
describe("search flag (--search)", () => {
it("should parse search query", () => {
const result = parseReqCommand(["--search", "bitcoin"]);
expect(result.filter.search).toBe("bitcoin");
});
});
describe("relay parsing", () => {
it("should parse relay with wss:// protocol", () => {
const result = parseReqCommand(["wss://relay.example.com"]);
expect(result.relays).toEqual(["wss://relay.example.com"]);
});
it("should parse relay domain and add wss://", () => {
const result = parseReqCommand(["relay.example.com"]);
expect(result.relays).toEqual(["wss://relay.example.com"]);
});
it("should parse multiple relays", () => {
const result = parseReqCommand([
"wss://relay1.com",
"relay2.com",
"wss://relay3.com",
]);
expect(result.relays).toEqual([
"wss://relay1.com",
"wss://relay2.com",
"wss://relay3.com",
]);
});
});
describe("close-on-eose flag", () => {
it("should parse --close-on-eose", () => {
const result = parseReqCommand(["--close-on-eose"]);
expect(result.closeOnEose).toBe(true);
});
it("should default to false when not provided", () => {
const result = parseReqCommand(["-k", "1"]);
expect(result.closeOnEose).toBe(false);
});
});
describe("complex scenarios", () => {
it("should handle multiple flags together", () => {
const hex = "a".repeat(64);
const result = parseReqCommand([
"-k",
"1,3",
"-a",
hex,
"-t",
"nostr,bitcoin",
"-l",
"100",
"--since",
"1h",
"relay.example.com",
]);
expect(result.filter.kinds).toEqual([1, 3]);
expect(result.filter.authors).toEqual([hex]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
expect(result.filter.limit).toBe(100);
expect(result.filter.since).toBeDefined();
expect(result.relays).toEqual(["wss://relay.example.com"]);
});
it("should handle deduplication across multiple flags and commas", () => {
const result = parseReqCommand([
"-k",
"1,3",
"-k",
"3,7",
"-k",
"1",
"-t",
"nostr",
"-t",
"bitcoin,nostr",
]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
});
it("should handle empty comma-separated values", () => {
const result = parseReqCommand(["-k", "1,,3,,"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
it("should handle whitespace in comma-separated values", () => {
const result = parseReqCommand(["-t", " nostr , bitcoin , lightning "]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]);
});
});
describe("edge cases", () => {
it("should handle empty args", () => {
const result = parseReqCommand([]);
expect(result.filter).toEqual({});
expect(result.relays).toBeUndefined();
expect(result.closeOnEose).toBe(false);
});
it("should handle flag without value", () => {
const result = parseReqCommand(["-k"]);
expect(result.filter.kinds).toBeUndefined();
});
it("should handle unknown flags gracefully", () => {
const result = parseReqCommand(["-x", "value", "-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
});
});

View File

@@ -15,6 +15,30 @@ export interface ParsedReqCommand {
nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution
}
/**
* Parse comma-separated values and apply a parser function to each
* Returns true if at least one value was successfully parsed
*/
function parseCommaSeparated<T>(
value: string,
parser: (v: string) => T | null,
target: Set<T>
): boolean {
const values = value.split(',').map(v => v.trim());
let addedAny = false;
for (const val of values) {
if (!val) continue;
const parsed = parser(val);
if (parsed !== null) {
target.add(parsed);
addedAny = true;
}
}
return addedAny;
}
/**
* Parse REQ command arguments into a Nostr filter
* Supports:
@@ -27,8 +51,17 @@ export interface ParsedReqCommand {
export function parseReqCommand(args: string[]): ParsedReqCommand {
const filter: NostrFilter = {};
const relays: string[] = [];
const nip05Authors: string[] = [];
const nip05PTags: string[] = [];
const nip05Authors = new Set<string>();
const nip05PTags = new Set<string>();
// Use sets for deduplication during accumulation
const kinds = new Set<number>();
const authors = new Set<string>();
const eventIds = new Set<string>();
const pTags = new Set<string>();
const tTags = new Set<string>();
const dTags = new Set<string>();
let closeOnEose = false;
let i = 0;
@@ -58,33 +91,47 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
switch (flag) {
case "-k":
case "--kind": {
const kind = parseInt(nextArg, 10);
if (!isNaN(kind)) {
if (!filter.kinds) filter.kinds = [];
filter.kinds.push(kind);
i += 2;
} else {
// Support comma-separated kinds: -k 1,3,7
if (!nextArg) {
i++;
break;
}
const addedAny = parseCommaSeparated(
nextArg,
(v) => {
const kind = parseInt(v, 10);
return isNaN(kind) ? null : kind;
},
kinds
);
i += addedAny ? 2 : 1;
break;
}
case "-a":
case "--author": {
// Check if it's a NIP-05 identifier
if (isNip05(nextArg)) {
nip05Authors.push(nextArg);
i += 2;
} else {
const pubkey = parseNpubOrHex(nextArg);
if (pubkey) {
if (!filter.authors) filter.authors = [];
filter.authors.push(pubkey);
i += 2;
// Support comma-separated authors: -a npub1...,npub2...,user@domain.com
if (!nextArg) {
i++;
break;
}
let addedAny = false;
const values = nextArg.split(',').map(a => a.trim());
for (const authorStr of values) {
if (!authorStr) continue;
// Check if it's a NIP-05 identifier
if (isNip05(authorStr)) {
nip05Authors.add(authorStr);
addedAny = true;
} else {
i++;
const pubkey = parseNpubOrHex(authorStr);
if (pubkey) {
authors.add(pubkey);
addedAny = true;
}
}
}
i += addedAny ? 2 : 1;
break;
}
@@ -101,41 +148,55 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
case "-e": {
const eventId = parseNoteOrHex(nextArg);
if (eventId) {
if (!filter["#e"]) filter["#e"] = [];
filter["#e"].push(eventId);
i += 2;
} else {
// Support comma-separated event IDs: -e id1,id2,id3
if (!nextArg) {
i++;
break;
}
const addedAny = parseCommaSeparated(
nextArg,
parseNoteOrHex,
eventIds
);
i += addedAny ? 2 : 1;
break;
}
case "-p": {
// Check if it's a NIP-05 identifier
if (isNip05(nextArg)) {
nip05PTags.push(nextArg);
i += 2;
} else {
const pubkey = parseNpubOrHex(nextArg);
if (pubkey) {
if (!filter["#p"]) filter["#p"] = [];
filter["#p"].push(pubkey);
i += 2;
// Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com
if (!nextArg) {
i++;
break;
}
let addedAny = false;
const values = nextArg.split(',').map(p => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check if it's a NIP-05 identifier
if (isNip05(pubkeyStr)) {
nip05PTags.add(pubkeyStr);
addedAny = true;
} else {
i++;
const pubkey = parseNpubOrHex(pubkeyStr);
if (pubkey) {
pTags.add(pubkey);
addedAny = true;
}
}
}
i += addedAny ? 2 : 1;
break;
}
case "-t": {
// Hashtag filter
// Support comma-separated hashtags: -t nostr,bitcoin,lightning
if (nextArg) {
if (!filter["#t"]) filter["#t"] = [];
filter["#t"].push(nextArg);
i += 2;
const addedAny = parseCommaSeparated(
nextArg,
(v) => v, // hashtags are already strings
tTags
);
i += addedAny ? 2 : 1;
} else {
i++;
}
@@ -143,11 +204,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
case "-d": {
// D-tag filter (for replaceable events)
// Support comma-separated d-tags: -d article1,article2,article3
if (nextArg) {
if (!filter["#d"]) filter["#d"] = [];
filter["#d"].push(nextArg);
i += 2;
const addedAny = parseCommaSeparated(
nextArg,
(v) => v, // d-tags are already strings
dTags
);
i += addedAny ? 2 : 1;
} else {
i++;
}
@@ -201,16 +265,21 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
const result = {
// Convert accumulated sets to filter arrays (with deduplication)
if (kinds.size > 0) filter.kinds = Array.from(kinds);
if (authors.size > 0) filter.authors = Array.from(authors);
if (eventIds.size > 0) filter["#e"] = Array.from(eventIds);
if (pTags.size > 0) filter["#p"] = Array.from(pTags);
if (tTags.size > 0) filter["#t"] = Array.from(tTags);
if (dTags.size > 0) filter["#d"] = Array.from(dTags);
return {
filter,
relays: relays.length > 0 ? relays : undefined,
closeOnEose,
nip05Authors: nip05Authors.length > 0 ? nip05Authors : undefined,
nip05PTags: nip05PTags.length > 0 ? nip05PTags : undefined,
nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined,
nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined,
};
console.log("parseReqCommand result:", result);
return result;
}
/**

View File

@@ -144,12 +144,12 @@ export const manPages: Record<string, ManPageEntry> = {
{
flag: "-k, --kind <number>",
description:
"Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction)",
"Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7",
},
{
flag: "-a, --author <npub|hex|nip05>",
description:
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain)",
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain). Supports comma-separated values: -a npub1...,user@domain.com",
},
{
flag: "-l, --limit <number>",
@@ -157,20 +157,20 @@ export const manPages: Record<string, ManPageEntry> = {
},
{
flag: "-e <id>",
description: "Filter by referenced event ID (#e tag)",
description: "Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3",
},
{
flag: "-p <npub|hex|nip05>",
description:
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain)",
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain). Supports comma-separated values: -p npub1...,npub2...",
},
{
flag: "-t <hashtag>",
description: "Filter by hashtag (#t tag)",
description: "Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
},
{
flag: "-d <identifier>",
description: "Filter by d-tag identifier (replaceable events)",
description: "Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2",
},
{
flag: "--since <time>",
@@ -199,13 +199,15 @@ export const manPages: Record<string, ManPageEntry> = {
],
examples: [
"req -k 1 -l 20 Get 20 recent notes (streams live by default)",
"req -k 1,3,7 -l 50 Get notes, contact lists, and reactions",
"req -k 0 -a npub1... Get profile for author",
"req -k 1 -a user@domain.com Get notes from NIP-05 identifier",
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
"req -k 1 -a npub1...,npub2... Get notes from multiple authors",
"req -k 1 -p verbiricha@habla.news Get notes mentioning NIP-05 user",
"req -k 1 --since 1h relay.damus.io Get notes from last hour",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
"req -t nostr -l 50 Get 50 events tagged #nostr",
"req -t nostr,bitcoin -l 50 Get 50 events tagged #nostr or #bitcoin",
"req --search bitcoin -k 1 Search notes for 'bitcoin'",
"req -k 1 relay1.com relay2.com Query multiple relays",
],