mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-27 23:38:38 +02:00
feat: spells
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCommandInput } from "./command-parser";
|
||||
import { parseCommandInput, executeCommandParser } from "./command-parser";
|
||||
|
||||
/**
|
||||
* Regression tests for parseCommandInput
|
||||
@@ -254,3 +254,27 @@ describe("parseCommandInput - regression tests", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCommandParser - alias resolution", () => {
|
||||
it("should resolve $me in profile command when activeAccountPubkey is provided", async () => {
|
||||
const input = "profile $me";
|
||||
const parsed = parseCommandInput(input);
|
||||
const activeAccountPubkey =
|
||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
|
||||
|
||||
const result = await executeCommandParser(parsed, activeAccountPubkey);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.props.pubkey).toBe(activeAccountPubkey);
|
||||
});
|
||||
|
||||
it("should return $me literal in profile command when activeAccountPubkey is NOT provided", async () => {
|
||||
const input = "profile $me";
|
||||
const parsed = parseCommandInput(input);
|
||||
|
||||
const result = await executeCommandParser(parsed);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.props.pubkey).toBe("$me");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ export function parseCommandInput(input: string): ParsedCommand {
|
||||
*/
|
||||
export async function executeCommandParser(
|
||||
parsed: ParsedCommand,
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<ParsedCommand> {
|
||||
if (!parsed.command) {
|
||||
return parsed; // Already has error, return as-is
|
||||
@@ -105,7 +106,9 @@ export async function executeCommandParser(
|
||||
try {
|
||||
// Use argParser if available, otherwise use defaultProps
|
||||
const props = parsed.command.argParser
|
||||
? await Promise.resolve(parsed.command.argParser(parsed.args))
|
||||
? await Promise.resolve(
|
||||
parsed.command.argParser(parsed.args, activeAccountPubkey),
|
||||
)
|
||||
: parsed.command.defaultProps || {};
|
||||
|
||||
return {
|
||||
@@ -129,10 +132,11 @@ export async function executeCommandParser(
|
||||
*/
|
||||
export async function parseAndExecuteCommand(
|
||||
input: string,
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<ParsedCommand> {
|
||||
const parsed = parseCommandInput(input);
|
||||
if (parsed.error || !parsed.command) {
|
||||
return parsed;
|
||||
}
|
||||
return executeCommandParser(parsed);
|
||||
return executeCommandParser(parsed, activeAccountPubkey);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ const RESERVED_GLOBAL_FLAGS = ["--title"] as const;
|
||||
*/
|
||||
function sanitizeTitle(title: string): string | undefined {
|
||||
const sanitized = title
|
||||
.replace(/[\x00-\x1F\x7F]/g, "") // Strip control chars (newlines, tabs, null bytes)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "") // Strip control chars (newlines, tabs, null bytes)
|
||||
.trim();
|
||||
|
||||
if (!sanitized) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { GrimoireState } from "@/types/app";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const CURRENT_VERSION = 9;
|
||||
export const CURRENT_VERSION = 10;
|
||||
|
||||
/**
|
||||
* Migration function type
|
||||
@@ -105,6 +105,14 @@ const migrations: Record<number, MigrationFn> = {
|
||||
__version: 9,
|
||||
};
|
||||
},
|
||||
// Migration from v9 to v10 - adds compactModeKinds
|
||||
9: (state: any) => {
|
||||
return {
|
||||
...state,
|
||||
__version: 10,
|
||||
compactModeKinds: [6, 7, 16, 9735],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,6 +142,11 @@ export function validateState(state: any): state is GrimoireState {
|
||||
return false;
|
||||
}
|
||||
|
||||
// compactModeKinds must be an array if present
|
||||
if (state.compactModeKinds && !Array.isArray(state.compactModeKinds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows must be an object
|
||||
if (typeof state.windows !== "object") {
|
||||
return false;
|
||||
|
||||
@@ -1,11 +1,61 @@
|
||||
import type { ProfileContent } from "applesauce-core/helpers";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import { getNip10References } from "applesauce-core/helpers/threading";
|
||||
import { getCommentReplyPointer } from "applesauce-core/helpers/comment";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
|
||||
export function derivePlaceholderName(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reply pointer for an event, abstracting the differences between NIP-10 and NIP-22 (comments).
|
||||
*/
|
||||
export function getEventReply(
|
||||
event: NostrEvent,
|
||||
):
|
||||
| { type: "root"; pointer: EventPointer | AddressPointer }
|
||||
| { type: "reply"; pointer: EventPointer | AddressPointer }
|
||||
| { type: "comment"; pointer: any }
|
||||
| null {
|
||||
// Handle Kind 1 (Text Note) - NIP-10
|
||||
if (event.kind === 1) {
|
||||
const references = getNip10References(event);
|
||||
if (references.reply) {
|
||||
const pointer = references.reply.e || references.reply.a;
|
||||
if (pointer) return { type: "reply", pointer };
|
||||
}
|
||||
if (references.root) {
|
||||
const pointer = references.root.e || references.root.a;
|
||||
if (pointer) return { type: "root", pointer };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Kind 1111 (Comment) - NIP-22
|
||||
if (event.kind === 1111) {
|
||||
const pointer = getCommentReplyPointer(event);
|
||||
if (pointer) {
|
||||
return { type: "comment", pointer };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for generic replies (using NIP-10 logic for other kinds usually works)
|
||||
if (event.kind !== 1111) {
|
||||
const references = getNip10References(event);
|
||||
if (references.reply) {
|
||||
const pointer = references.reply.e || references.reply.a;
|
||||
if (pointer) return { type: "reply", pointer };
|
||||
}
|
||||
if (references.root) {
|
||||
const pointer = references.root.e || references.root.a;
|
||||
if (pointer) return { type: "root", pointer };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTagValues(event: NostrEvent, tagName: string): string[] {
|
||||
return event.tags
|
||||
.filter((tag) => tag[0] === tagName && tag[1])
|
||||
|
||||
@@ -14,9 +14,11 @@ export interface ParsedProfileCommand {
|
||||
* - abc123... (64-char hex pubkey)
|
||||
* - user@domain.com (NIP-05 identifier)
|
||||
* - domain.com (bare domain, resolved as _@domain.com)
|
||||
* - $me (active account alias)
|
||||
*/
|
||||
export async function parseProfileCommand(
|
||||
args: string[],
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<ParsedProfileCommand> {
|
||||
const identifier = args[0];
|
||||
|
||||
@@ -24,6 +26,13 @@ export async function parseProfileCommand(
|
||||
throw new Error("User identifier required");
|
||||
}
|
||||
|
||||
// Handle $me alias
|
||||
if (identifier.toLowerCase() === "$me") {
|
||||
return {
|
||||
pubkey: activeAccountPubkey || "$me",
|
||||
};
|
||||
}
|
||||
|
||||
// Try bech32 decode first (npub, nprofile)
|
||||
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
|
||||
try {
|
||||
|
||||
@@ -179,7 +179,8 @@ describe("parseReqCommand", () => {
|
||||
describe("event ID flag (-e) with nevent/naddr support", () => {
|
||||
describe("nevent support", () => {
|
||||
it("should parse nevent and populate filter.ids", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
});
|
||||
@@ -192,7 +193,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should extract relay hints from nevent", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays: ["wss://relay.damus.io"],
|
||||
@@ -204,7 +206,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should normalize relay URLs from nevent", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays: ["wss://relay.damus.io"],
|
||||
@@ -218,7 +221,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should handle nevent without relay hints", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
});
|
||||
@@ -231,7 +235,8 @@ describe("parseReqCommand", () => {
|
||||
|
||||
describe("naddr support", () => {
|
||||
it("should parse naddr and populate filter['#a']", () => {
|
||||
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: pubkey,
|
||||
@@ -245,7 +250,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should extract relay hints from naddr", () => {
|
||||
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: pubkey,
|
||||
@@ -261,7 +267,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should format coordinate correctly (kind:pubkey:identifier)", () => {
|
||||
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: pubkey,
|
||||
@@ -280,7 +287,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should handle naddr without relay hints", () => {
|
||||
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: pubkey,
|
||||
@@ -295,7 +303,8 @@ describe("parseReqCommand", () => {
|
||||
|
||||
describe("note/hex support (existing behavior)", () => {
|
||||
it("should parse note and populate filter['#e']", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const note = nip19.noteEncode(eventId);
|
||||
const result = parseReqCommand(["-e", note]);
|
||||
|
||||
@@ -318,7 +327,8 @@ describe("parseReqCommand", () => {
|
||||
describe("mixed format support", () => {
|
||||
it("should handle comma-separated mix of all formats", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey = "b".repeat(64);
|
||||
|
||||
const note = nip19.noteEncode(eventId);
|
||||
@@ -349,9 +359,13 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should deduplicate within each filter field", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent1 = nip19.neventEncode({ id: eventId });
|
||||
const nevent2 = nip19.neventEncode({ id: eventId, relays: ["wss://relay.damus.io"] });
|
||||
const nevent2 = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays: ["wss://relay.damus.io"],
|
||||
});
|
||||
|
||||
const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]);
|
||||
|
||||
@@ -360,7 +374,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should collect relay hints from mixed formats", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const pubkey = "b".repeat(64);
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
@@ -383,7 +398,8 @@ describe("parseReqCommand", () => {
|
||||
});
|
||||
|
||||
it("should handle multiple nevents with different relay hints", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent1 = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays: ["wss://relay.damus.io"],
|
||||
@@ -438,7 +454,8 @@ describe("parseReqCommand", () => {
|
||||
|
||||
describe("integration with other flags", () => {
|
||||
it("should work with kind filter", () => {
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({ id: eventId });
|
||||
const result = parseReqCommand(["-k", "1", "-e", nevent]);
|
||||
|
||||
@@ -465,7 +482,8 @@ describe("parseReqCommand", () => {
|
||||
|
||||
it("should work with author and time filters", () => {
|
||||
const hex = "c".repeat(64);
|
||||
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const eventId =
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
||||
const nevent = nip19.neventEncode({ id: eventId });
|
||||
const result = parseReqCommand([
|
||||
"-k",
|
||||
|
||||
768
src/lib/spell-conversion.test.ts
Normal file
768
src/lib/spell-conversion.test.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encodeSpell, decodeSpell } from "./spell-conversion";
|
||||
import type { SpellEvent } from "@/types/spell";
|
||||
|
||||
describe("Spell Conversion", () => {
|
||||
describe("encodeSpell", () => {
|
||||
it("should encode a simple REQ command with kinds", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1,3,7",
|
||||
description: "Test spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["cmd", "REQ"]);
|
||||
expect(result.tags).toContainEqual(["client", "grimoire"]);
|
||||
expect(result.tags).toContainEqual(["k", "1"]);
|
||||
expect(result.tags).toContainEqual(["k", "3"]);
|
||||
expect(result.tags).toContainEqual(["k", "7"]);
|
||||
expect(result.filter.kinds).toEqual([1, 3, 7]);
|
||||
expect(result.content).toBe("Test spell");
|
||||
});
|
||||
|
||||
it("should encode with optional description", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
description: "Test spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["cmd", "REQ"]);
|
||||
expect(result.content).toBe("Test spell");
|
||||
});
|
||||
|
||||
it("should encode with empty description", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["cmd", "REQ"]);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
it("should encode optional name tag", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
name: "Bitcoin Feed",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
|
||||
expect(result.tags).toContainEqual(["cmd", "REQ"]);
|
||||
});
|
||||
|
||||
it("should skip name tag if not provided", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
});
|
||||
|
||||
const nameTag = result.tags.find((t) => t[0] === "name");
|
||||
expect(nameTag).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should trim and skip empty name", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
name: " ",
|
||||
});
|
||||
|
||||
const nameTag = result.tags.find((t) => t[0] === "name");
|
||||
expect(nameTag).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should encode both name and description", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
name: "Bitcoin Feed",
|
||||
description: "Notes about Bitcoin",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
|
||||
expect(result.content).toBe("Notes about Bitcoin");
|
||||
});
|
||||
|
||||
it("should encode authors as array tag", () => {
|
||||
const hex1 = "a".repeat(64);
|
||||
const hex2 = "b".repeat(64);
|
||||
const result = encodeSpell({
|
||||
command: `req -k 1 -a ${hex1},${hex2}`,
|
||||
description: "Author spell",
|
||||
});
|
||||
|
||||
const authorsTag = result.tags.find((t) => t[0] === "authors");
|
||||
expect(authorsTag).toEqual(["authors", hex1, hex2]);
|
||||
expect(result.filter.authors).toEqual([hex1, hex2]);
|
||||
});
|
||||
|
||||
it("should encode limit, since, until", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 -l 50 --since 7d --until now",
|
||||
description: "Time spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["limit", "50"]);
|
||||
expect(result.tags).toContainEqual(["since", "7d"]);
|
||||
expect(result.tags).toContainEqual(["until", "now"]);
|
||||
expect(result.filter.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("should encode tag filters with new format", () => {
|
||||
const hex = "c".repeat(64);
|
||||
const result = encodeSpell({
|
||||
command: `req -k 1 -t bitcoin,nostr -p ${hex} -d article1`,
|
||||
description: "Tag spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
|
||||
expect(result.tags).toContainEqual(["tag", "p", hex]);
|
||||
expect(result.tags).toContainEqual(["tag", "d", "article1"]);
|
||||
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
|
||||
expect(result.filter["#p"]).toEqual([hex]);
|
||||
expect(result.filter["#d"]).toEqual(["article1"]);
|
||||
});
|
||||
|
||||
it("should encode search query", () => {
|
||||
const result = encodeSpell({
|
||||
command: 'req -k 1 --search "bitcoin price"',
|
||||
description: "Search spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["search", "bitcoin price"]);
|
||||
expect(result.filter.search).toBe("bitcoin price");
|
||||
});
|
||||
|
||||
it("should encode relays", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 wss://relay1.com wss://relay2.com",
|
||||
description: "Relay spell",
|
||||
});
|
||||
|
||||
const relaysTag = result.tags.find((t) => t[0] === "relays");
|
||||
expect(relaysTag).toEqual([
|
||||
"relays",
|
||||
"wss://relay1.com/",
|
||||
"wss://relay2.com/",
|
||||
]);
|
||||
expect(result.relays).toEqual(["wss://relay1.com/", "wss://relay2.com/"]);
|
||||
});
|
||||
|
||||
it("should encode close-on-eose flag", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 --close-on-eose",
|
||||
description: "Close spell",
|
||||
});
|
||||
|
||||
const closeTag = result.tags.find((t) => t[0] === "close-on-eose");
|
||||
expect(closeTag).toBeDefined();
|
||||
expect(result.closeOnEose).toBe(true);
|
||||
});
|
||||
|
||||
it("should add topic tags", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
description: "A test spell",
|
||||
topics: ["bitcoin", "news"],
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual([
|
||||
"alt",
|
||||
"Grimoire REQ spell: A test spell",
|
||||
]);
|
||||
expect(result.tags).toContainEqual(["t", "bitcoin"]);
|
||||
expect(result.tags).toContainEqual(["t", "news"]);
|
||||
expect(result.content).toBe("A test spell");
|
||||
});
|
||||
|
||||
it("should add fork provenance", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
description: "Forked spell",
|
||||
forkedFrom: "abc123def456",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["e", "abc123def456"]);
|
||||
});
|
||||
|
||||
it("should handle special aliases $me and $contacts", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 -a $me,$contacts",
|
||||
description: "Alias spell",
|
||||
});
|
||||
|
||||
const authorsTag = result.tags.find((t) => t[0] === "authors");
|
||||
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
|
||||
expect(result.filter.authors).toEqual(["$me", "$contacts"]);
|
||||
});
|
||||
|
||||
it("should handle uppercase P tag separately from lowercase p", () => {
|
||||
const hex1 = "d".repeat(64);
|
||||
const hex2 = "e".repeat(64);
|
||||
const result = encodeSpell({
|
||||
command: `req -k 9735 -p ${hex1} -P ${hex2}`,
|
||||
description: "P tag spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["tag", "p", hex1]);
|
||||
expect(result.tags).toContainEqual(["tag", "P", hex2]);
|
||||
expect(result.filter["#p"]).toEqual([hex1]);
|
||||
expect(result.filter["#P"]).toEqual([hex2]);
|
||||
});
|
||||
|
||||
it("should handle generic tags with -T flag", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 -T x value1,value2",
|
||||
description: "Generic tag spell",
|
||||
});
|
||||
|
||||
expect(result.tags).toContainEqual(["tag", "x", "value1", "value2"]);
|
||||
expect(result.filter["#x"]).toEqual(["value1", "value2"]);
|
||||
});
|
||||
|
||||
it("should handle complex command with multiple filters", () => {
|
||||
const hex1 = "f".repeat(64);
|
||||
const hex2 = "0".repeat(64);
|
||||
const result = encodeSpell({
|
||||
command: `req -k 1,3,30023 -a ${hex1},${hex2} -l 100 -t bitcoin,nostr --since 7d --search crypto wss://relay.com --close-on-eose`,
|
||||
description: "Multi-filter spell",
|
||||
topics: ["test"],
|
||||
});
|
||||
|
||||
// Verify all components are present
|
||||
expect(result.tags).toContainEqual(["k", "1"]);
|
||||
expect(result.tags).toContainEqual(["k", "3"]);
|
||||
expect(result.tags).toContainEqual(["k", "30023"]);
|
||||
expect(result.tags).toContainEqual(["authors", hex1, hex2]);
|
||||
expect(result.tags).toContainEqual(["limit", "100"]);
|
||||
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
|
||||
expect(result.tags).toContainEqual(["since", "7d"]);
|
||||
expect(result.tags).toContainEqual(["search", "crypto"]);
|
||||
expect(result.tags).toContainEqual(["relays", "wss://relay.com/"]);
|
||||
expect(result.tags).toContainEqual(["close-on-eose", ""]);
|
||||
expect(result.tags).toContainEqual(["t", "test"]);
|
||||
|
||||
// Verify filter
|
||||
expect(result.filter.kinds).toEqual([1, 3, 30023]);
|
||||
expect(result.filter.authors).toEqual([hex1, hex2]);
|
||||
expect(result.filter.limit).toBe(100);
|
||||
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
|
||||
expect(result.filter.search).toBe("crypto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeSpell", () => {
|
||||
it("should decode a simple spell back to command", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["k", "3"],
|
||||
],
|
||||
content: "Test spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.description).toBe("Test spell");
|
||||
expect(parsed.filter.kinds).toEqual([1, 3]);
|
||||
expect(parsed.command).toContain("-k 1,3");
|
||||
});
|
||||
|
||||
it("should decode description from content", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["k", "1"],
|
||||
],
|
||||
content: "Test spell description",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.description).toBe("Test spell description");
|
||||
expect(parsed.filter.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["k", "1"],
|
||||
],
|
||||
content: "",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.description).toBeUndefined();
|
||||
expect(parsed.filter.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should decode name from tags", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["name", "Bitcoin Feed"],
|
||||
["k", "1"],
|
||||
],
|
||||
content: "Notes about Bitcoin",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.name).toBe("Bitcoin Feed");
|
||||
expect(parsed.description).toBe("Notes about Bitcoin");
|
||||
expect(parsed.filter.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle missing name tag", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["k", "1"],
|
||||
],
|
||||
content: "Test",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.name).toBeUndefined();
|
||||
expect(parsed.description).toBe("Test");
|
||||
});
|
||||
|
||||
it("should decode authors", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["authors", "abc123", "def456"],
|
||||
],
|
||||
content: "Author spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.filter.authors).toEqual(["abc123", "def456"]);
|
||||
expect(parsed.command).toContain("-a abc123,def456");
|
||||
});
|
||||
|
||||
it("should decode tag filters with new format", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["tag", "t", "bitcoin", "nostr"],
|
||||
["tag", "p", "abc123"],
|
||||
["tag", "P", "def456"],
|
||||
],
|
||||
content: "Tag spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.filter["#t"]).toEqual(["bitcoin", "nostr"]);
|
||||
expect(parsed.filter["#p"]).toEqual(["abc123"]);
|
||||
expect(parsed.filter["#P"]).toEqual(["def456"]);
|
||||
expect(parsed.command).toContain("-t bitcoin,nostr");
|
||||
expect(parsed.command).toContain("-p abc123");
|
||||
expect(parsed.command).toContain("-P def456");
|
||||
});
|
||||
|
||||
it("should decode time bounds with relative format", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["since", "7d"],
|
||||
["until", "now"],
|
||||
],
|
||||
content: "Time spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.command).toContain("--since 7d");
|
||||
expect(parsed.command).toContain("--until now");
|
||||
});
|
||||
|
||||
it("should decode topics", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["t", "bitcoin"],
|
||||
["t", "news"],
|
||||
],
|
||||
content: "A test spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.description).toBe("A test spell");
|
||||
expect(parsed.topics).toEqual(["bitcoin", "news"]);
|
||||
});
|
||||
|
||||
it("should decode fork provenance", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
["k", "1"],
|
||||
["e", "abc123def456"],
|
||||
],
|
||||
content: "Forked spell",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const parsed = decodeSpell(event);
|
||||
|
||||
expect(parsed.forkedFrom).toBe("abc123def456");
|
||||
});
|
||||
|
||||
it("should throw error if cmd is not REQ", () => {
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: [["cmd", "INVALID"]],
|
||||
content: "Test",
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
expect(() => decodeSpell(event)).toThrow(
|
||||
"Invalid spell command type: INVALID",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip conversion", () => {
|
||||
it("should preserve filter semantics through encode → decode", () => {
|
||||
const hex1 = "1".repeat(64);
|
||||
const hex2 = "2".repeat(64);
|
||||
const original = {
|
||||
command: `req -k 1,3,7 -a ${hex1},${hex2} -l 50 -t bitcoin,nostr --since 7d --search crypto`,
|
||||
description: "Testing round-trip conversion",
|
||||
topics: ["test"],
|
||||
};
|
||||
|
||||
// Encode
|
||||
const encoded = encodeSpell(original);
|
||||
|
||||
// Create event
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: encoded.tags,
|
||||
content: encoded.content,
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
// Decode
|
||||
const decoded = decodeSpell(event);
|
||||
|
||||
// Verify filter semantics are preserved
|
||||
expect(decoded.filter.kinds).toEqual([1, 3, 7]);
|
||||
expect(decoded.filter.authors).toEqual([hex1, hex2]);
|
||||
expect(decoded.filter.limit).toBe(50);
|
||||
expect(decoded.filter["#t"]).toEqual(["bitcoin", "nostr"]);
|
||||
expect(decoded.filter.search).toBe("crypto");
|
||||
|
||||
// Verify metadata
|
||||
expect(decoded.description).toBe("Testing round-trip conversion");
|
||||
expect(decoded.topics).toEqual(["test"]);
|
||||
|
||||
// Verify command contains key components (order may differ)
|
||||
expect(decoded.command).toContain("-k 1,3,7");
|
||||
expect(decoded.command).toContain(`-a ${hex1},${hex2}`);
|
||||
expect(decoded.command).toContain("-l 50");
|
||||
expect(decoded.command).toContain("-t bitcoin,nostr");
|
||||
expect(decoded.command).toContain("--since 7d");
|
||||
expect(decoded.command).toContain("--search");
|
||||
});
|
||||
|
||||
it("should handle minimal spell without description", () => {
|
||||
const original = {
|
||||
command: "req -k 1",
|
||||
};
|
||||
|
||||
const encoded = encodeSpell(original);
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: encoded.tags,
|
||||
content: encoded.content,
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const decoded = decodeSpell(event);
|
||||
|
||||
expect(decoded.description).toBeUndefined();
|
||||
expect(decoded.filter.kinds).toEqual([1]);
|
||||
expect(decoded.command).toBe("req -k 1");
|
||||
});
|
||||
|
||||
it("should round-trip with name", () => {
|
||||
const original = {
|
||||
command: "req -k 1",
|
||||
name: "Bitcoin Feed",
|
||||
description: "Notes about Bitcoin",
|
||||
};
|
||||
|
||||
const encoded = encodeSpell(original);
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: encoded.tags,
|
||||
content: encoded.content,
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const decoded = decodeSpell(event);
|
||||
|
||||
expect(decoded.name).toBe("Bitcoin Feed");
|
||||
expect(decoded.description).toBe("Notes about Bitcoin");
|
||||
expect(decoded.filter.kinds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should preserve special aliases through round-trip", () => {
|
||||
const original = {
|
||||
command: "req -k 1 -a $me,$contacts -p $me",
|
||||
description: "Alias spell",
|
||||
};
|
||||
|
||||
const encoded = encodeSpell(original);
|
||||
|
||||
// Debug: Check what was encoded
|
||||
const authorsTag = encoded.tags.find((t) => t[0] === "authors");
|
||||
const pTag = encoded.tags.find((t) => t[0] === "tag" && t[1] === "p");
|
||||
|
||||
// Verify encoding worked
|
||||
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
|
||||
expect(pTag).toEqual(["tag", "p", "$me"]);
|
||||
|
||||
const event: SpellEvent = {
|
||||
id: "test-id",
|
||||
pubkey: "test-pubkey",
|
||||
created_at: 1234567890,
|
||||
kind: 777,
|
||||
tags: encoded.tags,
|
||||
content: encoded.content,
|
||||
sig: "test-sig",
|
||||
};
|
||||
|
||||
const decoded = decodeSpell(event);
|
||||
|
||||
expect(decoded.filter.authors).toEqual(["$me", "$contacts"]);
|
||||
expect(decoded.filter["#p"]).toEqual(["$me"]);
|
||||
expect(decoded.command).toContain("-a $me,$contacts");
|
||||
expect(decoded.command).toContain("-p $me");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation and edge cases", () => {
|
||||
it("should throw error for empty command", () => {
|
||||
expect(() =>
|
||||
encodeSpell({
|
||||
command: "",
|
||||
}),
|
||||
).toThrow("Spell command is required");
|
||||
});
|
||||
|
||||
it("should throw error for whitespace-only command", () => {
|
||||
expect(() =>
|
||||
encodeSpell({
|
||||
command: " ",
|
||||
}),
|
||||
).toThrow("Spell command is required");
|
||||
});
|
||||
|
||||
it("should throw error for 'req' with no filters", () => {
|
||||
expect(() =>
|
||||
encodeSpell({
|
||||
command: "req",
|
||||
}),
|
||||
).toThrow(); // Will throw either empty tokens or no constraints error
|
||||
});
|
||||
|
||||
it("should throw error for command with no valid filters", () => {
|
||||
expect(() =>
|
||||
encodeSpell({
|
||||
command: "req --invalid-flag",
|
||||
}),
|
||||
).toThrow(
|
||||
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for command with only invalid values", () => {
|
||||
expect(() =>
|
||||
encodeSpell({
|
||||
command: "req -k invalid",
|
||||
}),
|
||||
).toThrow(
|
||||
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle malformed author values gracefully", () => {
|
||||
// Invalid hex should be ignored, not cause errors
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 -a invalid",
|
||||
});
|
||||
|
||||
// Should have kinds but no authors
|
||||
expect(result.filter.kinds).toEqual([1]);
|
||||
expect(result.filter.authors).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle mixed valid and invalid values", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const result = encodeSpell({
|
||||
command: `req -k 1,invalid,3 -a ${hex},invalid`,
|
||||
});
|
||||
|
||||
// Should keep only valid values
|
||||
expect(result.filter.kinds).toEqual([1, 3]);
|
||||
expect(result.filter.authors).toEqual([hex]);
|
||||
});
|
||||
|
||||
it("should handle quotes in search query", () => {
|
||||
const result = encodeSpell({
|
||||
command: 'req -k 1 --search "quoted text"',
|
||||
});
|
||||
|
||||
expect(result.filter.search).toBe("quoted text");
|
||||
});
|
||||
|
||||
it("should handle single quotes in search query", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 --search 'single quoted'",
|
||||
});
|
||||
|
||||
expect(result.filter.search).toBe("single quoted");
|
||||
});
|
||||
|
||||
it("should handle special characters in search", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 --search 'text with #hashtag @mention'",
|
||||
});
|
||||
|
||||
expect(result.filter.search).toBe("text with #hashtag @mention");
|
||||
});
|
||||
|
||||
it("should accept commands with only limit", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -l 50",
|
||||
});
|
||||
|
||||
expect(result.filter.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("should accept commands with only time bounds", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req --since 7d",
|
||||
});
|
||||
|
||||
expect(result.filter.since).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept commands with only search", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req --search bitcoin",
|
||||
});
|
||||
|
||||
expect(result.filter.search).toBe("bitcoin");
|
||||
});
|
||||
|
||||
it("should handle very long commands", () => {
|
||||
// Generate 10 different hex values to test long author lists
|
||||
const hexValues = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => i.toString(16).repeat(64).slice(0, 64))
|
||||
.join(",");
|
||||
|
||||
const result = encodeSpell({
|
||||
command: `req -k 1 -a ${hexValues}`,
|
||||
});
|
||||
|
||||
expect(result.filter.authors).toBeDefined();
|
||||
expect(result.filter.authors!.length).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle Unicode in descriptions", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1",
|
||||
description: "Testing with emoji 🎨 and unicode 你好",
|
||||
});
|
||||
|
||||
expect(result.content).toBe("Testing with emoji 🎨 and unicode 你好");
|
||||
});
|
||||
|
||||
it("should preserve exact capitalization in $me/$contacts", () => {
|
||||
const result = encodeSpell({
|
||||
command: "req -k 1 -a $Me,$CONTACTS",
|
||||
});
|
||||
|
||||
// Parser normalizes to lowercase
|
||||
const authorsTag = result.tags.find((t) => t[0] === "authors");
|
||||
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
439
src/lib/spell-conversion.ts
Normal file
439
src/lib/spell-conversion.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { parseReqCommand } from "./req-parser";
|
||||
import type {
|
||||
CreateSpellOptions,
|
||||
EncodedSpell,
|
||||
ParsedSpell,
|
||||
SpellEvent,
|
||||
} from "@/types/spell";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Simple tokenization that doesn't expand shell variables
|
||||
* Splits on whitespace while respecting quoted strings
|
||||
*/
|
||||
function tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let quoteChar = "";
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const char = command[i];
|
||||
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
// Start quoted string
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === quoteChar && inQuotes) {
|
||||
// End quoted string
|
||||
inQuotes = false;
|
||||
quoteChar = "";
|
||||
} else if (char === " " && !inQuotes) {
|
||||
// Whitespace outside quotes - end token
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final token
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a REQ command as spell event tags
|
||||
*
|
||||
* Parses the command and extracts filter parameters into Nostr tags.
|
||||
* Preserves relative timestamps (7d, now) for dynamic spell behavior.
|
||||
*
|
||||
* @param options - Spell creation options with command string
|
||||
* @returns Encoded spell with tags, content, and parsed filter
|
||||
* @throws Error if command is invalid or produces empty filter
|
||||
*/
|
||||
export function encodeSpell(options: CreateSpellOptions): EncodedSpell {
|
||||
const { command, name, description, topics, forkedFrom } = options;
|
||||
|
||||
// Validate command
|
||||
if (!command || command.trim().length === 0) {
|
||||
throw new Error("Spell command is required");
|
||||
}
|
||||
|
||||
// Parse the command to extract filter components
|
||||
// Remove "req" prefix if present and tokenize
|
||||
const commandWithoutReq = command.replace(/^\s*req\s+/, "");
|
||||
const tokens = tokenizeCommand(commandWithoutReq);
|
||||
|
||||
// Validate we have tokens to parse
|
||||
if (tokens.length === 0) {
|
||||
throw new Error("Spell command must contain filters or parameters");
|
||||
}
|
||||
|
||||
const parsed = parseReqCommand(tokens);
|
||||
|
||||
// Validate that parsing produced a useful filter
|
||||
// A filter must have at least one constraint
|
||||
const hasConstraints =
|
||||
(parsed.filter.kinds && parsed.filter.kinds.length > 0) ||
|
||||
(parsed.filter.authors && parsed.filter.authors.length > 0) ||
|
||||
(parsed.filter.ids && parsed.filter.ids.length > 0) ||
|
||||
parsed.filter.limit !== undefined ||
|
||||
parsed.filter.since !== undefined ||
|
||||
parsed.filter.until !== undefined ||
|
||||
parsed.filter.search !== undefined ||
|
||||
Object.keys(parsed.filter).some((k) => k.startsWith("#"));
|
||||
|
||||
if (!hasConstraints) {
|
||||
throw new Error(
|
||||
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
|
||||
);
|
||||
}
|
||||
|
||||
// Start with required tags
|
||||
const tags: [string, string, ...string[]][] = [
|
||||
["cmd", "REQ"],
|
||||
["client", "grimoire"],
|
||||
];
|
||||
|
||||
// Add name tag if provided
|
||||
if (name && name.trim().length > 0) {
|
||||
tags.push(["name", name.trim()]);
|
||||
}
|
||||
|
||||
// Add alt tag for NIP-31 compatibility
|
||||
const altText = description
|
||||
? `Grimoire REQ spell: ${description.substring(0, 100)}`
|
||||
: "Grimoire REQ spell";
|
||||
tags.push(["alt", altText]);
|
||||
|
||||
// Add provenance if forked
|
||||
if (forkedFrom) {
|
||||
tags.push(["e", forkedFrom]);
|
||||
}
|
||||
|
||||
// Encode filter.kinds as multiple k tags for queryability
|
||||
if (parsed.filter.kinds) {
|
||||
for (const kind of parsed.filter.kinds) {
|
||||
tags.push(["k", kind.toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Encode filter.authors as single array tag
|
||||
if (parsed.filter.authors && parsed.filter.authors.length > 0) {
|
||||
tags.push(["authors", ...parsed.filter.authors] as [
|
||||
string,
|
||||
string,
|
||||
...string[],
|
||||
]);
|
||||
}
|
||||
|
||||
// Encode filter.ids as single array tag
|
||||
if (parsed.filter.ids && parsed.filter.ids.length > 0) {
|
||||
tags.push(["ids", ...parsed.filter.ids] as [string, string, ...string[]]);
|
||||
}
|
||||
|
||||
// Encode tag filters (#e, #p, #P, #t, #d, #a, and any generic tags)
|
||||
// New format: ["tag", "letter", ...values]
|
||||
const tagFilters: Record<string, string[]> = {};
|
||||
|
||||
// Collect all # tags from filter
|
||||
for (const [key, value] of Object.entries(parsed.filter)) {
|
||||
if (key.startsWith("#") && Array.isArray(value)) {
|
||||
tagFilters[key] = value as string[];
|
||||
}
|
||||
}
|
||||
|
||||
// Add tag filter tags with new format
|
||||
for (const [tagName, values] of Object.entries(tagFilters)) {
|
||||
if (values.length > 0) {
|
||||
// Extract the letter from #letter format
|
||||
const letter = tagName.substring(1); // Remove the # prefix
|
||||
tags.push(["tag", letter, ...values] as [string, string, ...string[]]);
|
||||
}
|
||||
}
|
||||
|
||||
// Encode scalars
|
||||
if (parsed.filter.limit !== undefined) {
|
||||
tags.push(["limit", parsed.filter.limit.toString()]);
|
||||
}
|
||||
|
||||
// For timestamps, we need to preserve the original format if it was relative
|
||||
// The parser converts everything to unix timestamps, losing this info
|
||||
// We'll need to detect relative times in the original command
|
||||
// This is a limitation - for MVP, we'll store the resolved timestamps
|
||||
// TODO: Enhance parser to preserve original time format
|
||||
if (parsed.filter.since !== undefined) {
|
||||
// Try to extract original since value from command
|
||||
const sinceMatch = command.match(/--since\s+(\S+)/);
|
||||
if (sinceMatch && sinceMatch[1]) {
|
||||
tags.push(["since", sinceMatch[1]]);
|
||||
} else {
|
||||
tags.push(["since", parsed.filter.since.toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.filter.until !== undefined) {
|
||||
// Try to extract original until value from command
|
||||
const untilMatch = command.match(/--until\s+(\S+)/);
|
||||
if (untilMatch && untilMatch[1]) {
|
||||
tags.push(["until", untilMatch[1]]);
|
||||
} else {
|
||||
tags.push(["until", parsed.filter.until.toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.filter.search) {
|
||||
tags.push(["search", parsed.filter.search]);
|
||||
}
|
||||
|
||||
// Add relays if specified
|
||||
if (parsed.relays && parsed.relays.length > 0) {
|
||||
tags.push(["relays", ...parsed.relays] as [string, string, ...string[]]);
|
||||
}
|
||||
|
||||
// Add close-on-eose flag if set
|
||||
if (parsed.closeOnEose) {
|
||||
tags.push(["close-on-eose", ""] as [string, string, ...string[]]);
|
||||
}
|
||||
|
||||
// Add topic tags for categorization
|
||||
if (topics && topics.length > 0) {
|
||||
for (const topic of topics) {
|
||||
tags.push(["t", topic]);
|
||||
}
|
||||
}
|
||||
|
||||
// Content is the description (or empty if not provided)
|
||||
const content = description || "";
|
||||
|
||||
return {
|
||||
tags,
|
||||
content,
|
||||
filter: parsed.filter,
|
||||
relays: parsed.relays,
|
||||
closeOnEose: parsed.closeOnEose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a spell event back to a REQ command string
|
||||
*
|
||||
* Reconstructs a canonical REQ command from the spell's tags.
|
||||
* The reconstructed command may differ in formatting from the original
|
||||
* but produces an equivalent Nostr filter.
|
||||
*
|
||||
* @param event - Spell event (kind 777)
|
||||
* @returns Parsed spell with reconstructed command
|
||||
*/
|
||||
export function decodeSpell(event: SpellEvent): ParsedSpell {
|
||||
// Extract tags into a map for easier access
|
||||
const tagMap = new Map<string, string[]>();
|
||||
|
||||
for (const tag of event.tags) {
|
||||
const [name, ...values] = tag;
|
||||
if (!tagMap.has(name)) {
|
||||
tagMap.set(name, []);
|
||||
}
|
||||
tagMap.get(name)!.push(...values);
|
||||
}
|
||||
|
||||
// Validate cmd tag
|
||||
const cmd = tagMap.get("cmd")?.[0];
|
||||
if (cmd !== "REQ") {
|
||||
throw new Error(`Invalid spell command type: ${cmd}`);
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const name = tagMap.get("name")?.[0];
|
||||
const description = event.content || undefined;
|
||||
|
||||
const topics = tagMap.get("t") || [];
|
||||
const forkedFrom = tagMap.get("e")?.[0];
|
||||
|
||||
// Reconstruct filter from tags
|
||||
const filter: NostrFilter = {};
|
||||
|
||||
// Kinds
|
||||
const kinds = tagMap.get("k");
|
||||
if (kinds && kinds.length > 0) {
|
||||
filter.kinds = kinds.map((k) => parseInt(k, 10)).filter((k) => !isNaN(k));
|
||||
}
|
||||
|
||||
// Authors
|
||||
const authors = tagMap.get("authors");
|
||||
if (authors && authors.length > 0) {
|
||||
filter.authors = authors;
|
||||
}
|
||||
|
||||
// IDs
|
||||
const ids = tagMap.get("ids");
|
||||
if (ids && ids.length > 0) {
|
||||
filter.ids = ids;
|
||||
}
|
||||
|
||||
// Tag filters - new format: ["tag", "letter", ...values]
|
||||
// Parse all "tag" tags and convert to filter[#letter] format
|
||||
const tagFilterTags = event.tags.filter((t) => t[0] === "tag");
|
||||
for (const tag of tagFilterTags) {
|
||||
const [, letter, ...values] = tag;
|
||||
if (letter && values.length > 0) {
|
||||
(filter as any)[`#${letter}`] = values;
|
||||
}
|
||||
}
|
||||
|
||||
// Scalars
|
||||
const limit = tagMap.get("limit")?.[0];
|
||||
if (limit) {
|
||||
filter.limit = parseInt(limit, 10);
|
||||
}
|
||||
|
||||
const since = tagMap.get("since")?.[0];
|
||||
if (since) {
|
||||
// Check if it's a relative time or unix timestamp
|
||||
if (/^\d{10}$/.test(since)) {
|
||||
filter.since = parseInt(since, 10);
|
||||
} else {
|
||||
// It's a relative time format - preserve it as a comment
|
||||
// For actual filtering, we'd need to resolve it at runtime
|
||||
// For now, skip adding to filter (will be resolved at execution)
|
||||
}
|
||||
}
|
||||
|
||||
const until = tagMap.get("until")?.[0];
|
||||
if (until) {
|
||||
// Check if it's a relative time or unix timestamp
|
||||
if (/^\d{10}$/.test(until)) {
|
||||
filter.until = parseInt(until, 10);
|
||||
} else {
|
||||
// It's a relative time format - preserve it as a comment
|
||||
// For now, skip adding to filter (will be resolved at execution)
|
||||
}
|
||||
}
|
||||
|
||||
const search = tagMap.get("search")?.[0];
|
||||
if (search) {
|
||||
filter.search = search;
|
||||
}
|
||||
|
||||
// Options
|
||||
const relays = tagMap.get("relays");
|
||||
const closeOnEose = tagMap.has("close-on-eose");
|
||||
|
||||
// Reconstruct command string
|
||||
const command = reconstructCommand(filter, relays, since, until, closeOnEose);
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
command,
|
||||
filter,
|
||||
relays,
|
||||
closeOnEose,
|
||||
topics,
|
||||
forkedFrom,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a canonical REQ command string from filter components
|
||||
*/
|
||||
export function reconstructCommand(
|
||||
filter: NostrFilter,
|
||||
relays?: string[],
|
||||
since?: string,
|
||||
until?: string,
|
||||
closeOnEose?: boolean,
|
||||
): string {
|
||||
const parts: string[] = ["req"];
|
||||
|
||||
// Kinds
|
||||
if (filter.kinds && filter.kinds.length > 0) {
|
||||
parts.push(`-k ${filter.kinds.join(",")}`);
|
||||
}
|
||||
|
||||
// Authors
|
||||
if (filter.authors && filter.authors.length > 0) {
|
||||
parts.push(`-a ${filter.authors.join(",")}`);
|
||||
}
|
||||
|
||||
// Limit
|
||||
if (filter.limit !== undefined) {
|
||||
parts.push(`-l ${filter.limit}`);
|
||||
}
|
||||
|
||||
// IDs (use -e flag, though semantics differ slightly)
|
||||
if (filter.ids && filter.ids.length > 0) {
|
||||
parts.push(`-e ${filter.ids.join(",")}`);
|
||||
}
|
||||
|
||||
// Tag filters
|
||||
if (filter["#e"] && filter["#e"].length > 0) {
|
||||
parts.push(`-e ${filter["#e"].join(",")}`);
|
||||
}
|
||||
|
||||
if (filter["#p"] && filter["#p"].length > 0) {
|
||||
parts.push(`-p ${filter["#p"].join(",")}`);
|
||||
}
|
||||
|
||||
if (filter["#P"] && filter["#P"].length > 0) {
|
||||
parts.push(`-P ${filter["#P"].join(",")}`);
|
||||
}
|
||||
|
||||
if (filter["#t"] && filter["#t"].length > 0) {
|
||||
parts.push(`-t ${filter["#t"].join(",")}`);
|
||||
}
|
||||
|
||||
if (filter["#d"] && filter["#d"].length > 0) {
|
||||
parts.push(`-d ${filter["#d"].join(",")}`);
|
||||
}
|
||||
|
||||
if (filter["#a"] && filter["#a"].length > 0) {
|
||||
// Note: #a filters came from naddr, but we reconstruct as comma-separated
|
||||
parts.push(`-e ${filter["#a"].join(",")}`);
|
||||
}
|
||||
|
||||
// Generic single-letter tags
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key.startsWith("#") && key.length === 2 && Array.isArray(value)) {
|
||||
const letter = key[1];
|
||||
// Skip already handled tags
|
||||
if (!["e", "p", "P", "t", "d", "a"].includes(letter)) {
|
||||
parts.push(`-T ${letter} ${(value as string[]).join(",")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time bounds (preserve relative format if available)
|
||||
if (since) {
|
||||
parts.push(`--since ${since}`);
|
||||
}
|
||||
|
||||
if (until) {
|
||||
parts.push(`--until ${until}`);
|
||||
}
|
||||
|
||||
// Search
|
||||
if (filter.search) {
|
||||
parts.push(`--search "${filter.search}"`);
|
||||
}
|
||||
|
||||
// Relays
|
||||
if (relays && relays.length > 0) {
|
||||
parts.push(...relays);
|
||||
}
|
||||
|
||||
// Close on EOSE
|
||||
if (closeOnEose) {
|
||||
parts.push("--close-on-eose");
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user