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
This commit is contained in:
Claude
2026-01-15 13:00:35 +00:00
parent 573ea00e7e
commit b721ff76f8
3 changed files with 61 additions and 29 deletions

View File

@@ -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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleTurnIntoSpell}>
@@ -230,12 +233,12 @@ export function WindowToolbar({
</DropdownMenu>
{/* Spell Dialog */}
{isReqWindow && (
{isSpellableWindow && (
<SpellDialog
open={showSpellDialog}
onOpenChange={setShowSpellDialog}
mode="create"
initialCommand={reqCommand}
initialCommand={spellCommand}
onSuccess={() => {
toast.success("Spell published successfully!");
}}

View File

@@ -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({
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>

View File

@@ -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) {