From b721ff76f847c2e8fc21343b8240eddf907418d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 13:00:35 +0000 Subject: [PATCH] Add spell support for COUNT commands - Extend spell system to support both REQ and COUNT commands - Add detectCommandType() to identify command type from string - Update encodeSpell to use ["cmd", "COUNT"] tag for count commands - Update decodeSpell to handle COUNT spells - Update reconstructCommand to accept cmdType parameter - Add "Save as spell" option to COUNT windows in WindowToolbar - Update SpellDialog to handle both REQ and COUNT commands --- src/components/WindowToolbar.tsx | 25 +++++++------- src/components/nostr/SpellDialog.tsx | 16 +++++---- src/lib/spell-conversion.ts | 49 +++++++++++++++++++++------- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index 4646e31..e9bfaac 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -83,9 +83,9 @@ export function WindowToolbar({ const handleTurnIntoSpell = () => { if (!window) return; - // Only available for REQ windows - if (window.appId !== "req") { - toast.error("Only REQ windows can be turned into spells"); + // Only available for REQ and COUNT windows + if (window.appId !== "req" && window.appId !== "count") { + toast.error("Only REQ and COUNT windows can be turned into spells"); return; } @@ -123,12 +123,14 @@ export function WindowToolbar({ toast.success("NIP markdown copied to clipboard"); }; - // Check if this is a REQ window for spell creation + // Check if this is a REQ or COUNT window for spell creation const isReqWindow = window?.appId === "req"; + const isCountWindow = window?.appId === "count"; + const isSpellableWindow = isReqWindow || isCountWindow; - // Get REQ command for spell dialog - const reqCommand = - isReqWindow && window + // Get command for spell dialog + const spellCommand = + isSpellableWindow && window ? window.commandString || reconstructReqCommand( window.props?.filter || {}, @@ -136,6 +138,7 @@ export function WindowToolbar({ undefined, undefined, window.props?.closeOnEose, + isCountWindow ? "COUNT" : "REQ", ) : ""; @@ -216,8 +219,8 @@ export function WindowToolbar({ )} - {/* REQ-specific actions */} - {isReqWindow && ( + {/* REQ/COUNT-specific actions */} + {isSpellableWindow && ( <> @@ -230,12 +233,12 @@ export function WindowToolbar({ {/* Spell Dialog */} - {isReqWindow && ( + {isSpellableWindow && ( { toast.success("Spell published successfully!"); }} diff --git a/src/components/nostr/SpellDialog.tsx b/src/components/nostr/SpellDialog.tsx index fa2dbaa..9a365d4 100644 --- a/src/components/nostr/SpellDialog.tsx +++ b/src/components/nostr/SpellDialog.tsx @@ -14,7 +14,7 @@ import { toast } from "sonner"; import { use$ } from "applesauce-react/hooks"; import accounts from "@/services/accounts"; import { parseReqCommand } from "@/lib/req-parser"; -import { reconstructCommand } from "@/lib/spell-conversion"; +import { reconstructCommand, detectCommandType } from "@/lib/spell-conversion"; import type { ParsedSpell, SpellEvent } from "@/types/spell"; import { Loader2 } from "lucide-react"; import { saveSpell } from "@/services/spell-storage"; @@ -29,9 +29,12 @@ function filterSpellCommand(command: string): string { if (!command) return ""; try { - // Parse the command - const commandWithoutReq = command.replace(/^\s*req\s+/, ""); - const tokens = commandWithoutReq.split(/\s+/); + // Detect command type (REQ or COUNT) + const cmdType = detectCommandType(command); + + // Parse the command - remove prefix first + const commandWithoutPrefix = command.replace(/^\s*(req|count)\s+/i, ""); + const tokens = commandWithoutPrefix.split(/\s+/); // Parse to get filter and relays const parsed = parseReqCommand(tokens); @@ -43,6 +46,7 @@ function filterSpellCommand(command: string): string { undefined, undefined, parsed.closeOnEose, + cmdType, ); } catch { // If parsing fails, return original @@ -245,7 +249,7 @@ export function SpellDialog({ setErrorMessage("Signing was rejected. Please try again."); } else if (error.message.includes("No command provided")) { setErrorMessage( - "No command to save. Please try again from a REQ window.", + "No command to save. Please try again from a REQ or COUNT window.", ); } else { setErrorMessage(error.message); @@ -274,7 +278,7 @@ export function SpellDialog({ {mode === "create" - ? "Save this REQ command as a spell. You can save it locally or publish it to Nostr relays." + ? "Save this command as a spell. You can save it locally or publish it to Nostr relays." : "Edit your spell and republish it to relays."} diff --git a/src/lib/spell-conversion.ts b/src/lib/spell-conversion.ts index b8e5a0c..8fc1157 100644 --- a/src/lib/spell-conversion.ts +++ b/src/lib/spell-conversion.ts @@ -49,7 +49,19 @@ function tokenizeCommand(command: string): string[] { } /** - * Encode a REQ command as spell event tags + * Detect command type from a command string + * Returns "REQ" or "COUNT" based on the command prefix + */ +export function detectCommandType(command: string): "REQ" | "COUNT" { + const trimmed = command.trim().toLowerCase(); + if (trimmed.startsWith("count ") || trimmed === "count") { + return "COUNT"; + } + return "REQ"; +} + +/** + * Encode a REQ or COUNT command as spell event tags * * Parses the command and extracts filter parameters into Nostr tags. * Preserves relative timestamps (7d, now) for dynamic spell behavior. @@ -66,10 +78,15 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { throw new Error("Spell command is required"); } + // Detect command type (REQ or COUNT) + const cmdType = detectCommandType(command); + // 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); + // Remove "req" or "count" prefix if present and tokenize + const commandWithoutPrefix = command + .replace(/^\s*(req|count)\s+/i, "") + .trim(); + const tokens = tokenizeCommand(commandWithoutPrefix); // Validate we have tokens to parse if (tokens.length === 0) { @@ -98,7 +115,7 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { // Start with required tags const tags: [string, string, ...string[]][] = [ - ["cmd", "REQ"], + ["cmd", cmdType], ["client", "grimoire"], ]; @@ -109,8 +126,8 @@ export function encodeSpell(options: CreateSpellOptions): EncodedSpell { // Add alt tag for NIP-31 compatibility const altText = description - ? `Grimoire REQ spell: ${description.substring(0, 100)}` - : "Grimoire REQ spell"; + ? `Grimoire ${cmdType} spell: ${description.substring(0, 100)}` + : `Grimoire ${cmdType} spell`; tags.push(["alt", altText]); // Add provenance if forked @@ -246,7 +263,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { // Validate cmd tag const cmd = tagMap.get("cmd")?.[0]; - if (cmd !== "REQ") { + if (cmd !== "REQ" && cmd !== "COUNT") { throw new Error(`Invalid spell command type: ${cmd}`); } @@ -326,8 +343,15 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { const relays = tagMap.get("relays"); const closeOnEose = tagMap.has("close-on-eose"); - // Reconstruct command string - const command = reconstructCommand(filter, relays, since, until, closeOnEose); + // Reconstruct command string with appropriate command type + const command = reconstructCommand( + filter, + relays, + since, + until, + closeOnEose, + cmd as "REQ" | "COUNT", + ); return { name, @@ -343,7 +367,7 @@ export function decodeSpell(event: SpellEvent): ParsedSpell { } /** - * Reconstruct a canonical REQ command string from filter components + * Reconstruct a canonical command string from filter components */ export function reconstructCommand( filter: NostrFilter, @@ -351,8 +375,9 @@ export function reconstructCommand( since?: string, until?: string, closeOnEose?: boolean, + cmdType: "REQ" | "COUNT" = "REQ", ): string { - const parts: string[] = ["req"]; + const parts: string[] = [cmdType.toLowerCase()]; // Kinds if (filter.kinds && filter.kinds.length > 0) {