diff --git a/src/lib/spell-conversion.ts b/src/lib/spell-conversion.ts index aefb9a6..2bdca9f 100644 --- a/src/lib/spell-conversion.ts +++ b/src/lib/spell-conversion.ts @@ -4,6 +4,7 @@ import type { EncodedSpell, ParsedSpell, SpellEvent, + SpellParameter, } from "@/types/spell"; import type { NostrFilter } from "@/types/nostr"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; @@ -72,7 +73,7 @@ export function detectCommandType(command: string): "REQ" | "COUNT" { * @throws Error if command is invalid or produces empty filter */ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { - const { command, name, description, topics, forkedFrom } = options; + const { command, name, description, topics, forkedFrom, parameter } = options; // Validate command if (!command || command.trim().length === 0) { @@ -131,6 +132,15 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { : `Grimoire ${cmdType} spell`; tags.push(["alt", altText]); + // Add parameter tag if this is a parameterized spell (lens) + if (parameter) { + const paramTag: [string, string, ...string[]] = ["l", parameter.type]; + if (parameter.default && parameter.default.length > 0) { + paramTag.push(...parameter.default); + } + tags.push(paramTag); + } + // Add provenance if forked if (forkedFrom) { tags.push(["e", forkedFrom]); @@ -275,6 +285,19 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { const topics = tagMap.get("t") || []; const forkedFrom = tagMap.get("e")?.[0]; + // Extract parameter configuration (lens support) + let parameter: ParsedSpell["parameter"]; + const lTag = event.tags.find((t) => t[0] === "l"); + if (lTag && lTag.length >= 2) { + const [, type, ...defaults] = lTag; + if (type === "$pubkey" || type === "$event" || type === "$relay") { + parameter = { + type, + default: defaults.length > 0 ? defaults : undefined, + }; + } + } + // Reconstruct filter from tags const filter: NostrFilter = {}; @@ -363,6 +386,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { closeOnEose, topics, forkedFrom, + parameter, event, }; } @@ -463,3 +487,94 @@ export function reconstructCommand( return parts.join(" "); } + +/** + * Apply parameter values to a parameterized spell + * Substitutes parameter placeholders with actual values + * + * @param parsed - Parsed spell (must have parameter configuration) + * @param args - Arguments to substitute (if empty, uses defaults) + * @returns Filter with parameters applied + */ +export function applySpellParameters( + parsed: ParsedSpell, + args: string[] = [], +): NostrFilter { + if (!parsed.parameter) { + // Not a parameterized spell, return filter as-is + return parsed.filter; + } + + // Use provided args or fall back to defaults + const values = args.length > 0 ? args : parsed.parameter.default || []; + + if (values.length === 0) { + throw new Error( + `Parameterized spell requires ${parsed.parameter.type} argument(s)`, + ); + } + + // Clone the filter + const filter: NostrFilter = { ...parsed.filter }; + + // Apply substitution based on parameter type + switch (parsed.parameter.type) { + case "$pubkey": + // Substitute in authors array + if (filter.authors) { + filter.authors = filter.authors.flatMap((author) => + author === "$pubkey" ? values : [author], + ); + } + + // Substitute in #p tag filters + if (filter["#p"]) { + filter["#p"] = filter["#p"].flatMap((p) => + p === "$pubkey" ? values : [p], + ); + } + + // Substitute in #P tag filters + if (filter["#P"]) { + filter["#P"] = filter["#P"].flatMap((p) => + p === "$pubkey" ? values : [p], + ); + } + break; + + case "$event": + // Substitute in #e tag filters + if (filter["#e"]) { + filter["#e"] = filter["#e"].flatMap((e) => + e === "$event" ? values : [e], + ); + } + + // Substitute in #a tag filters + if (filter["#a"]) { + filter["#a"] = filter["#a"].flatMap((a) => + a === "$event" ? values : [a], + ); + } + + // Substitute in ids array + if (filter.ids) { + filter.ids = filter.ids.flatMap((id) => + id === "$event" ? values : [id], + ); + } + break; + + case "$relay": + // Relay parameters are handled differently + // They could affect relay hints or #r tag filters + if (filter["#r"]) { + filter["#r"] = filter["#r"].flatMap((r) => + r === "$relay" ? values : [r], + ); + } + break; + } + + return filter; +} diff --git a/src/services/db.ts b/src/services/db.ts index 4dd8206..fa39a04 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -72,6 +72,10 @@ export interface LocalSpell { eventId?: string; // Nostr event ID if published event?: SpellEvent; // Full signed event for rebroadcasting deletedAt?: number; // Timestamp when soft-deleted + + // Parameter configuration for parameterized spells (lenses) + parameterType?: "$pubkey" | "$event" | "$relay"; // Type of parameter + parameterDefault?: string[]; // Default values (e.g., ["$me"]) } export interface LocalSpellbook { diff --git a/src/types/spell.ts b/src/types/spell.ts index 9f85c7e..262b9b3 100644 --- a/src/types/spell.ts +++ b/src/types/spell.ts @@ -81,10 +81,24 @@ export interface ParsedSpell { /** Fork provenance (event ID of source spell) */ forkedFrom?: string; + /** Parameter configuration (if spell is parameterized) */ + parameter?: SpellParameter; + /** Full event for reference */ event: SpellEvent; } +/** + * Parameter configuration for a parameterized spell + */ +export interface SpellParameter { + /** Parameter type */ + type: "$pubkey" | "$event" | "$relay"; + + /** Default values to use when parameter not provided (e.g., ["$me"]) */ + default?: string[]; +} + /** * Options for creating a spell from a REQ command */ @@ -103,6 +117,9 @@ export interface CreateSpellOptions { /** If forking, provide source event ID */ forkedFrom?: string; + + /** Parameter configuration to make spell reusable */ + parameter?: SpellParameter; } /**