mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
WIP
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { PublishSpellbook } from "./publish-spellbook";
|
||||
import type { ActionHub } from "applesauce-actions";
|
||||
import type { ActionContext } from "applesauce-actions";
|
||||
import type { GrimoireState } from "@/types/app";
|
||||
import type { NostrEvent } from "nostr-tools/core";
|
||||
|
||||
@@ -35,9 +35,11 @@ const mockFactory = {
|
||||
})),
|
||||
};
|
||||
|
||||
const mockHub: ActionHub = {
|
||||
factory: mockFactory,
|
||||
} as any;
|
||||
const mockContext: ActionContext = {
|
||||
factory: mockFactory as any,
|
||||
events: {} as any,
|
||||
self: "test-pubkey",
|
||||
};
|
||||
|
||||
const mockState: GrimoireState = {
|
||||
windows: {
|
||||
@@ -62,6 +64,18 @@ const mockState: GrimoireState = {
|
||||
workspaceOrder: ["ws-1"],
|
||||
} as any;
|
||||
|
||||
// Helper to run action with context
|
||||
async function runAction(
|
||||
options: Parameters<typeof PublishSpellbook>[0],
|
||||
): Promise<NostrEvent[]> {
|
||||
const events: NostrEvent[] = [];
|
||||
const action = PublishSpellbook(options);
|
||||
for await (const event of action(mockContext)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("PublishSpellbook action", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -73,39 +87,33 @@ describe("PublishSpellbook action", () => {
|
||||
|
||||
describe("validation", () => {
|
||||
it("should throw error if title is empty", async () => {
|
||||
await expect(async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
await expect(
|
||||
runAction({
|
||||
state: mockState,
|
||||
title: "",
|
||||
})) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("Title is required");
|
||||
}),
|
||||
).rejects.toThrow("Title is required");
|
||||
});
|
||||
|
||||
it("should throw error if title is only whitespace", async () => {
|
||||
await expect(async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
await expect(
|
||||
runAction({
|
||||
state: mockState,
|
||||
title: " ",
|
||||
})) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("Title is required");
|
||||
}),
|
||||
).rejects.toThrow("Title is required");
|
||||
});
|
||||
|
||||
it("should throw error if no active account", async () => {
|
||||
const accountManager = await import("@/services/accounts");
|
||||
(accountManager.default as any).active = null;
|
||||
|
||||
await expect(async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
await expect(
|
||||
runAction({
|
||||
state: mockState,
|
||||
title: "Test Spellbook",
|
||||
})) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("No active account");
|
||||
}),
|
||||
).rejects.toThrow("No active account");
|
||||
|
||||
// Restore for other tests
|
||||
(accountManager.default as any).active = mockAccount;
|
||||
@@ -116,14 +124,12 @@ describe("PublishSpellbook action", () => {
|
||||
const accountWithoutSigner = { ...mockAccount, signer: null };
|
||||
(accountManager.default as any).active = accountWithoutSigner;
|
||||
|
||||
await expect(async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
await expect(
|
||||
runAction({
|
||||
state: mockState,
|
||||
title: "Test Spellbook",
|
||||
})) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("No signer available");
|
||||
}),
|
||||
).rejects.toThrow("No signer available");
|
||||
|
||||
// Restore for other tests
|
||||
(accountManager.default as any).active = mockAccount;
|
||||
@@ -132,15 +138,11 @@ describe("PublishSpellbook action", () => {
|
||||
|
||||
describe("event creation", () => {
|
||||
it("should yield properly formatted spellbook event", async () => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "Test Spellbook",
|
||||
description: "Test description",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0];
|
||||
@@ -170,14 +172,10 @@ describe("PublishSpellbook action", () => {
|
||||
});
|
||||
|
||||
it("should create event from state when no content provided", async () => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "My Dashboard",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0];
|
||||
@@ -197,15 +195,11 @@ describe("PublishSpellbook action", () => {
|
||||
windows: { "custom-win": {} as any },
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "Custom Spellbook",
|
||||
content: explicitContent,
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
const event = events[0];
|
||||
@@ -215,14 +209,10 @@ describe("PublishSpellbook action", () => {
|
||||
});
|
||||
|
||||
it("should not include description tag when description is empty", async () => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "No Description",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const event = events[0];
|
||||
const tags = event.tags as [string, string, ...string[]][];
|
||||
@@ -234,42 +224,36 @@ describe("PublishSpellbook action", () => {
|
||||
|
||||
describe("slug generation", () => {
|
||||
it("should generate slug from title", async () => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "My Awesome Dashboard!",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d");
|
||||
const dTag = (events[0].tags as [string, string][]).find(
|
||||
(t) => t[0] === "d",
|
||||
);
|
||||
expect(dTag?.[1]).toBe("my-awesome-dashboard");
|
||||
});
|
||||
|
||||
it("should handle special characters in title", async () => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: mockState,
|
||||
title: "Test@123#Special$Characters",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d");
|
||||
const dTag = (events[0].tags as [string, string][]).find(
|
||||
(t) => t[0] === "d",
|
||||
);
|
||||
expect(dTag?.[1]).toMatch(/^test123specialcharacters$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("factory integration", () => {
|
||||
it("should call factory.build with correct props", async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
await runAction({
|
||||
state: mockState,
|
||||
title: "Test",
|
||||
})) {
|
||||
// Event yielded
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockFactory.build).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -279,24 +263,20 @@ describe("PublishSpellbook action", () => {
|
||||
["d", expect.any(String)],
|
||||
["title", "Test"],
|
||||
]),
|
||||
signer: mockSigner,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call factory.sign with draft and signer", async () => {
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
it("should call factory.sign with draft", async () => {
|
||||
await runAction({
|
||||
state: mockState,
|
||||
title: "Test",
|
||||
})) {
|
||||
// Event yielded
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockFactory.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 30777,
|
||||
}),
|
||||
mockSigner
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -317,14 +297,10 @@ describe("PublishSpellbook action", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: multiWorkspaceState,
|
||||
title: "Multi Workspace",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const content = JSON.parse(events[0].content);
|
||||
expect(Object.keys(content.workspaces).length).toBe(2);
|
||||
@@ -345,15 +321,11 @@ describe("PublishSpellbook action", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for await (const event of PublishSpellbook(mockHub, {
|
||||
const events = await runAction({
|
||||
state: multiWorkspaceState,
|
||||
title: "Single Workspace",
|
||||
workspaceIds: ["ws-1"],
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const content = JSON.parse(events[0].content);
|
||||
expect(Object.keys(content.workspaces).length).toBe(1);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SpellbookEvent } from "@/types/spell";
|
||||
import { GrimoireState } from "@/types/app";
|
||||
import { SpellbookContent } from "@/types/spell";
|
||||
import accountManager from "@/services/accounts";
|
||||
import type { ActionHub } from "applesauce-actions";
|
||||
import type { ActionContext } from "applesauce-actions";
|
||||
import type { NostrEvent } from "nostr-tools/core";
|
||||
|
||||
export interface PublishSpellbookOptions {
|
||||
@@ -26,9 +26,8 @@ export interface PublishSpellbookOptions {
|
||||
* 4. Yields the signed event (ActionHub handles publishing)
|
||||
* 5. Marks local spellbook as published if localId provided
|
||||
*
|
||||
* @param hub - The action hub instance
|
||||
* @param options - Spellbook publishing options
|
||||
* @yields Signed spellbook event ready for publishing
|
||||
* @returns Action generator for ActionHub
|
||||
*
|
||||
* @throws Error if title is empty, no active account, or no signer available
|
||||
*
|
||||
@@ -43,73 +42,73 @@ export interface PublishSpellbookOptions {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function* PublishSpellbook(
|
||||
hub: ActionHub,
|
||||
options: PublishSpellbookOptions
|
||||
): AsyncGenerator<NostrEvent> {
|
||||
export function PublishSpellbook(options: PublishSpellbookOptions) {
|
||||
const { state, title, description, workspaceIds, localId, content } = options;
|
||||
|
||||
// 1. Validate inputs
|
||||
if (!title || !title.trim()) {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
const account = accountManager.active;
|
||||
if (!account) {
|
||||
throw new Error("No active account. Please log in first.");
|
||||
}
|
||||
|
||||
const signer = account.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available. Please connect a signer.");
|
||||
}
|
||||
|
||||
// 2. Create event props from state or use provided content
|
||||
let eventProps;
|
||||
if (content) {
|
||||
// Use provided content directly
|
||||
eventProps = {
|
||||
kind: 30777,
|
||||
content: JSON.stringify(content),
|
||||
tags: [
|
||||
["d", slugify(title)],
|
||||
["title", title],
|
||||
["client", "grimoire"],
|
||||
] as [string, string, ...string[]][],
|
||||
};
|
||||
if (description) {
|
||||
eventProps.tags.push(["description", description]);
|
||||
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
} else {
|
||||
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
return async function* ({
|
||||
factory,
|
||||
}: ActionContext): AsyncGenerator<NostrEvent> {
|
||||
// 1. Validate inputs
|
||||
if (!title || !title.trim()) {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
} else {
|
||||
// Create from state
|
||||
const encoded = createSpellbook({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
workspaceIds,
|
||||
|
||||
const account = accountManager.active;
|
||||
if (!account) {
|
||||
throw new Error("No active account. Please log in first.");
|
||||
}
|
||||
|
||||
const signer = account.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available. Please connect a signer.");
|
||||
}
|
||||
|
||||
// 2. Create event props from state or use provided content
|
||||
let eventProps;
|
||||
if (content) {
|
||||
// Use provided content directly
|
||||
eventProps = {
|
||||
kind: 30777,
|
||||
content: JSON.stringify(content),
|
||||
tags: [
|
||||
["d", slugify(title)],
|
||||
["title", title],
|
||||
["client", "grimoire"],
|
||||
] as [string, string, ...string[]][],
|
||||
};
|
||||
if (description) {
|
||||
eventProps.tags.push(["description", description]);
|
||||
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
} else {
|
||||
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
|
||||
}
|
||||
} else {
|
||||
// Create from state
|
||||
const encoded = createSpellbook({
|
||||
state,
|
||||
title,
|
||||
description,
|
||||
workspaceIds,
|
||||
});
|
||||
eventProps = encoded.eventProps;
|
||||
}
|
||||
|
||||
// 3. Build draft using factory from context
|
||||
const draft = await factory.build({
|
||||
kind: eventProps.kind,
|
||||
content: eventProps.content,
|
||||
tags: eventProps.tags,
|
||||
});
|
||||
eventProps = encoded.eventProps;
|
||||
}
|
||||
|
||||
// 3. Build draft using hub's factory
|
||||
const draft = await hub.factory.build({
|
||||
kind: eventProps.kind,
|
||||
content: eventProps.content,
|
||||
tags: eventProps.tags,
|
||||
signer,
|
||||
});
|
||||
// 4. Sign the event
|
||||
const event = (await factory.sign(draft)) as SpellbookEvent;
|
||||
|
||||
// 4. Sign the event
|
||||
const event = (await hub.factory.sign(draft, signer)) as SpellbookEvent;
|
||||
// 5. Mark as published in local DB (before yielding for better UX)
|
||||
if (localId) {
|
||||
await markSpellbookPublished(localId, event);
|
||||
}
|
||||
|
||||
// 5. Mark as published in local DB (before yielding for better UX)
|
||||
if (localId) {
|
||||
await markSpellbookPublished(localId, event);
|
||||
}
|
||||
|
||||
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
|
||||
yield event;
|
||||
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
|
||||
yield event;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,13 +48,13 @@ export function ShareSpellbookDialog({
|
||||
id: "web",
|
||||
label: "Web Link",
|
||||
description: "Share as a web URL that anyone can open",
|
||||
getValue: (e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
|
||||
getValue: (_e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
|
||||
},
|
||||
{
|
||||
id: "naddr",
|
||||
label: "Nostr Address (naddr)",
|
||||
description: "NIP-19 address pointer for Nostr clients",
|
||||
getValue: (e, s) => {
|
||||
getValue: (e, _s) => {
|
||||
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (!dTag) return "";
|
||||
return nip19.naddrEncode({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { Layout, ExternalLink, Eye, Share2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useNavigate } from "react-router";
|
||||
@@ -283,7 +282,6 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
|
||||
* Shows detailed workspace information with preview and sharing options
|
||||
*/
|
||||
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const profile = useProfile(event.pubkey);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
|
||||
const spellbook = useMemo(() => {
|
||||
|
||||
@@ -353,6 +353,7 @@ export function loadSpellbook(
|
||||
id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local
|
||||
slug: spellbook.slug,
|
||||
title: spellbook.title,
|
||||
description: spellbook.description,
|
||||
pubkey: spellbook.event?.pubkey,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,7 +63,9 @@ export async function saveSpellbook(
|
||||
/**
|
||||
* Get a spellbook by ID
|
||||
*/
|
||||
export async function getSpellbook(id: string): Promise<LocalSpellbook | undefined> {
|
||||
export async function getSpellbook(
|
||||
id: string,
|
||||
): Promise<LocalSpellbook | undefined> {
|
||||
return db.spellbooks.get(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface GrimoireState {
|
||||
id: string; // event id or local uuid
|
||||
slug: string; // d-tag
|
||||
title: string;
|
||||
description?: string;
|
||||
pubkey?: string; // owner
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user