From 9a48ab2a02a035272c8d6317e3b3b2b1b812df64 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:41:46 -0300 Subject: [PATCH 001/148] feat(api): add notebookSyncLog table to Drizzle schema Co-Authored-By: Claude Opus 4.6 --- packages/api/src/db/schema.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 566cb316..3289b0dc 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -105,6 +105,34 @@ export const syncLog = sqliteTable( ] ); +/** + * Notebook sync log - notebook metadata changes + * No encryption needed - notebooks are organizational metadata only + */ +export const notebookSyncLog = sqliteTable( + 'notebook_sync_log', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + notebookId: text('notebook_id').notNull(), + version: integer('version').notNull(), + operation: text('operation').notNull(), // 'create' | 'update' | 'delete' + data: text('data'), // JSON notebook metadata (null for deletes) + deviceId: text('device_id').notNull(), + createdAt: text('created_at') + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + table => [ + index('idx_nb_sync_log_user_version').on(table.userId, table.version), + index('idx_nb_sync_log_user_notebook').on(table.userId, table.notebookId), + ] +); + /** * Subscriptions - Pro tier tracking */ @@ -248,3 +276,4 @@ export type Newsletter = typeof newsletter.$inferSelect; export type SharedNote = typeof sharedNotes.$inferSelect; export type PluginCatalogEntry = typeof pluginCatalog.$inferSelect; export type NewPluginCatalogEntry = typeof pluginCatalog.$inferInsert; +export type NotebookSyncLogEntry = typeof notebookSyncLog.$inferSelect; From 3d4970301cca95d4b006e7c3c01d8d70b10287f7 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:42:18 -0300 Subject: [PATCH 002/148] feat(storage): add notebook sync tracking migration with triggers Co-Authored-By: Claude Opus 4.6 --- .../migrations/015_notebook_sync_tracking.ts | 53 +++++++++++++++++++ .../storage-sqlite/src/migrations/index.ts | 3 ++ 2 files changed, 56 insertions(+) create mode 100644 packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts diff --git a/packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts b/packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts new file mode 100644 index 00000000..2599b3ff --- /dev/null +++ b/packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts @@ -0,0 +1,53 @@ +/** + * Notebook sync tracking + * + * Adds local_version and needs_sync columns to notebooks, + * plus triggers to track changes for bidirectional sync. + * Also adds unique constraint to sync_queue to prevent duplicates. + */ + +import type { Migration } from '@readied/storage-core'; + +export const notebookSyncTracking: Migration = { + version: 20260311000001, + name: 'notebook_sync_tracking', + up: ` + -- Add sync tracking columns to notebooks + ALTER TABLE notebooks ADD COLUMN local_version INTEGER DEFAULT 1; + ALTER TABLE notebooks ADD COLUMN needs_sync INTEGER DEFAULT 0; + + -- Index for querying pending notebook changes + CREATE INDEX IF NOT EXISTS idx_notebooks_needs_sync + ON notebooks(needs_sync) WHERE needs_sync = 1; + + -- Unique constraint on sync_queue to prevent duplicate entries + CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_queue_unique_entity + ON sync_queue(entity_type, entity_id); + + -- Trigger: Mark notebook as needing sync on UPDATE + CREATE TRIGGER IF NOT EXISTS notebooks_update_sync_tracking + AFTER UPDATE ON notebooks + FOR EACH ROW + WHEN NEW.name != OLD.name + OR NEW.parent_id IS NOT OLD.parent_id + OR NEW.depth != OLD.depth + OR NEW."order" != OLD."order" + BEGIN + UPDATE notebooks + SET + needs_sync = 1, + local_version = local_version + 1 + WHERE id = NEW.id; + END; + + -- Trigger: Mark notebook as needing sync on INSERT + CREATE TRIGGER IF NOT EXISTS notebooks_insert_sync_tracking + AFTER INSERT ON notebooks + FOR EACH ROW + BEGIN + UPDATE notebooks + SET needs_sync = 1 + WHERE id = NEW.id; + END; + `, +}; diff --git a/packages/storage-sqlite/src/migrations/index.ts b/packages/storage-sqlite/src/migrations/index.ts index 3c5cf3b4..865296e4 100644 --- a/packages/storage-sqlite/src/migrations/index.ts +++ b/packages/storage-sqlite/src/migrations/index.ts @@ -17,6 +17,7 @@ import { syncTracking } from './011_sync_tracking.js'; import { gitNotebooks } from './012_git_notebooks.js'; import { pluginConfig } from './013_plugin_config.js'; import { pluginRegistry } from './014_plugin_registry.js'; +import { notebookSyncTracking } from './015_notebook_sync_tracking.js'; /** All migrations in order */ export const allMigrations: Migration[] = [ @@ -34,6 +35,7 @@ export const allMigrations: Migration[] = [ gitNotebooks, pluginConfig, pluginRegistry, + notebookSyncTracking, ]; export { @@ -51,4 +53,5 @@ export { gitNotebooks, pluginConfig, pluginRegistry, + notebookSyncTracking, }; From b6c0d96655585e217147cc5431766de4c62c40ad Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:43:41 -0300 Subject: [PATCH 003/148] feat(desktop): add notebook sync methods to ApiClient Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/apiClient.ts | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 4a327d9e..d46885cb 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -54,6 +54,34 @@ export interface PushResponse { cursor: number; } +export interface NotebookSyncChange { + id: string; + notebookId: string; + version: number; + operation: 'create' | 'update' | 'delete'; + data: string | null; + deviceId: string; + createdAt: string; +} + +export interface NotebookPullResponse { + changes: NotebookSyncChange[]; + cursor: number; + hasMore: boolean; +} + +export interface NotebookPushResult { + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; +} + +export interface NotebookPushResponse { + results: NotebookPushResult[]; + cursor: number; +} + export interface SyncStatus { enabled: boolean; plan: string; @@ -290,6 +318,35 @@ export class ApiClient { }); } + // ========================================================================== + // Notebook Sync + // ========================================================================== + + async pullNotebookChanges(cursor: number, limit = 50): Promise { + const params = new URLSearchParams({ + cursor: cursor.toString(), + limit: limit.toString(), + }); + return this.request(`/sync/notebooks?${params}`); + } + + async pushNotebookChanges( + changes: Array<{ + notebookId: string; + operation: 'create' | 'update' | 'delete'; + data?: string | null; + localVersion?: number; + }> + ): Promise { + return this.request('/sync/notebooks', { + method: 'POST', + body: JSON.stringify({ + changes, + deviceId: this.deviceInfo.deviceId, + }), + }); + } + /** * Get sync status */ From 403642e82e907d697bc8c9576d20f68d80c201a7 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:43:49 -0300 Subject: [PATCH 004/148] test(sync-core): add tree validation unit tests for notebook sync Co-Authored-By: Claude Opus 4.6 --- .../sync-core/tests/treeValidation.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/sync-core/tests/treeValidation.test.ts diff --git a/packages/sync-core/tests/treeValidation.test.ts b/packages/sync-core/tests/treeValidation.test.ts new file mode 100644 index 00000000..5c31fd69 --- /dev/null +++ b/packages/sync-core/tests/treeValidation.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; + +function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existingNotebooks: Map +): { valid: true } | { valid: false; error: string; notebookId: string } { + const tree = new Map(existingNotebooks); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + if (!change.data) continue; + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + }; + + if (parsed.depth > 2) { + return { valid: false, error: `depth exceeds max (2), got ${parsed.depth}`, notebookId: change.notebookId }; + } + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { valid: false, error: `parentId '${parsed.parentId}' not found`, notebookId: change.notebookId }; + } + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { valid: false, error: 'circular reference detected', notebookId: change.notebookId }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + return { valid: true }; +} + +describe('validateNotebookTree', () => { + it('accepts valid root notebook', () => { + const result = validateNotebookTree( + [{ notebookId: 'nb-1', operation: 'create', data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }) }], + new Map() + ); + expect(result).toEqual({ valid: true }); + }); + + it('accepts valid child notebook (depth 1)', () => { + const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree( + [{ notebookId: 'nb-2', operation: 'create', data: JSON.stringify({ name: 'Sub', parentId: 'nb-1', depth: 1, order: 0 }) }], + existing + ); + expect(result).toEqual({ valid: true }); + }); + + it('rejects depth > 2', () => { + const result = validateNotebookTree( + [{ notebookId: 'nb-deep', operation: 'create', data: JSON.stringify({ name: 'Deep', parentId: 'nb-2', depth: 3, order: 0 }) }], + new Map([['nb-2', { parentId: 'nb-1', depth: 2 }]]) + ); + expect(result).toEqual({ valid: false, error: 'depth exceeds max (2), got 3', notebookId: 'nb-deep' }); + }); + + it('rejects missing parentId', () => { + const result = validateNotebookTree( + [{ notebookId: 'nb-orphan', operation: 'create', data: JSON.stringify({ name: 'Orphan', parentId: 'nb-ghost', depth: 1, order: 0 }) }], + new Map() + ); + expect(result).toEqual({ valid: false, error: "parentId 'nb-ghost' not found", notebookId: 'nb-orphan' }); + }); + + it('detects circular reference A->B->A', () => { + const existing = new Map([['nb-a', { parentId: 'nb-b', depth: 1 }]]); + const result = validateNotebookTree( + [{ notebookId: 'nb-b', operation: 'update', data: JSON.stringify({ name: 'B', parentId: 'nb-a', depth: 1, order: 0 }) }], + existing + ); + expect(result).toEqual({ valid: false, error: 'circular reference detected', notebookId: 'nb-b' }); + }); + + it('detects self-reference via missing parentId', () => { + const result = validateNotebookTree( + [{ notebookId: 'nb-self', operation: 'create', data: JSON.stringify({ name: 'Self', parentId: 'nb-self', depth: 1, order: 0 }) }], + new Map() + ); + expect(result).toEqual({ valid: false, error: "parentId 'nb-self' not found", notebookId: 'nb-self' }); + }); + + it('accepts delete operation', () => { + const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree( + [{ notebookId: 'nb-1', operation: 'delete' }], + existing + ); + expect(result).toEqual({ valid: true }); + }); + + it('handles two devices creating notebooks under same parent', () => { + const existing = new Map([['nb-root', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree( + [ + { notebookId: 'nb-a', operation: 'create', data: JSON.stringify({ name: 'A', parentId: 'nb-root', depth: 1, order: 0 }) }, + { notebookId: 'nb-b', operation: 'create', data: JSON.stringify({ name: 'B', parentId: 'nb-root', depth: 1, order: 1 }) }, + ], + existing + ); + expect(result).toEqual({ valid: true }); + }); +}); From 628b16b41a03220c00adb42d76180f41f857491c Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:45:12 -0300 Subject: [PATCH 005/148] feat(api): add notebook sync pull/push endpoints with tree validation Co-Authored-By: Claude Opus 4.6 --- packages/api/src/routes/sync.ts | 208 +++++++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 30e02496..94472eb5 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -14,7 +14,7 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, gt, desc, sql } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; -import { syncLog, syncCursors, subscriptions } from '../db/schema.js'; +import { syncLog, syncCursors, subscriptions, notebookSyncLog } from '../db/schema.js'; import { authMiddleware, type AuthUser } from '../middleware/auth.js'; import { syncRateLimit } from '../middleware/rateLimit.js'; @@ -234,4 +234,210 @@ sync.get('/status', async c => { }); }); +// ============================================================================ +// Notebook Sync +// ============================================================================ + +const notebookChangeSchema = z.object({ + notebookId: z.string(), + operation: z.enum(['create', 'update', 'delete']), + data: z.string().nullable().optional(), + localVersion: z.number().int().optional(), +}); + +const notebookPushSchema = z.object({ + changes: z.array(notebookChangeSchema).min(1).max(100), + deviceId: z.string().uuid(), +}); + +/** + * Validate notebook tree integrity before accepting push. + * Checks: depth ≤ 2, parentId exists, no circular references. + */ +function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existingNotebooks: Map +): { valid: true } | { valid: false; error: string; notebookId: string } { + const tree = new Map(existingNotebooks); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + if (!change.data) continue; + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + }; + + if (parsed.depth > 2) { + return { valid: false, error: `depth exceeds max (2), got ${parsed.depth}`, notebookId: change.notebookId }; + } + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { valid: false, error: `parentId '${parsed.parentId}' not found`, notebookId: change.notebookId }; + } + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { valid: false, error: 'circular reference detected', notebookId: change.notebookId }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + return { valid: true }; +} + +// Pull notebook changes +sync.get('/notebooks', zValidator('query', pullSchema), async c => { + const { cursor, limit } = c.req.valid('query'); + const { userId, deviceId } = c.get('user'); + const db = createDb(c.env); + + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (!(sub?.status === 'active' || sub?.status === 'trialing')) { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + const changes = await db + .select() + .from(notebookSyncLog) + .where(and(eq(notebookSyncLog.userId, userId), gt(notebookSyncLog.version, cursor))) + .orderBy(notebookSyncLog.version) + .limit(limit); + + const maxVersion = changes.length > 0 ? changes[changes.length - 1].version : cursor; + + return c.json({ + changes: changes.map(entry => ({ + id: entry.id, + notebookId: entry.notebookId, + version: entry.version, + operation: entry.operation, + data: entry.data, + deviceId: entry.deviceId, + createdAt: entry.createdAt, + })), + cursor: maxVersion, + hasMore: changes.length === limit, + }); +}); + +// Push notebook changes (with tree validation) +sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { + const { changes, deviceId } = c.req.valid('json'); + const { userId } = c.get('user'); + const db = createDb(c.env); + + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (!(sub?.status === 'active' || sub?.status === 'trialing')) { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + // Load existing notebooks for tree validation + const existingEntries = await db + .select() + .from(notebookSyncLog) + .where(eq(notebookSyncLog.userId, userId)) + .orderBy(desc(notebookSyncLog.version)); + + const latestByNotebook = new Map(); + for (const entry of existingEntries) { + if (!latestByNotebook.has(entry.notebookId) && entry.operation !== 'delete' && entry.data) { + const parsed = JSON.parse(entry.data); + latestByNotebook.set(entry.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + } + + // Validate tree integrity + const validation = validateNotebookTree(changes, latestByNotebook); + if (!validation.valid) { + console.warn(`[notebook-sync] Tree validation failed for user ${userId}: ${validation.error} (notebook: ${validation.notebookId})`); + return c.json({ + error: 'Tree validation failed', + detail: validation.error, + notebookId: validation.notebookId, + }, 422); + } + + // Process changes in transaction + const { results, finalCursor } = await db.transaction(async tx => { + const [maxVersionResult] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) + .from(notebookSyncLog) + .where(eq(notebookSyncLog.userId, userId)); + + let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; + + const txResults: Array<{ + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; + }> = []; + + for (const change of changes) { + const [latestEntry] = await tx + .select() + .from(notebookSyncLog) + .where(and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId))) + .orderBy(desc(notebookSyncLog.version)) + .limit(1); + + if ( + latestEntry && + latestEntry.deviceId !== deviceId && + change.localVersion !== undefined && + latestEntry.version > change.localVersion + ) { + txResults.push({ + notebookId: change.notebookId, + version: latestEntry.version, + status: 'conflict', + serverVersion: latestEntry.version, + }); + continue; + } + + await tx.insert(notebookSyncLog).values({ + userId, + notebookId: change.notebookId, + version: nextVersion, + operation: change.operation, + data: change.data ?? null, + deviceId, + }); + + txResults.push({ + notebookId: change.notebookId, + version: nextVersion, + status: 'applied', + }); + + nextVersion++; + } + + return { results: txResults, finalCursor: nextVersion - 1 }; + }); + + return c.json({ results, cursor: finalCursor }); +}); + export { sync }; From a9fde100d6abe7ddedf39252421642570575bf17 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:45:37 -0300 Subject: [PATCH 006/148] feat(storage): add sync methods to SQLiteNotebookRepository Co-Authored-By: Claude Opus 4.6 --- .../repositories/SQLiteNotebookRepository.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts index 49295353..da7c7a63 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts @@ -305,6 +305,115 @@ export class SQLiteNotebookRepository implements NotebookRepository { return rows.map(row => this.rowToNotebook(row)); } + // ======================================================================== + // Sync Operations + // ======================================================================== + + /** + * Get notebooks with pending local changes that need to be pushed to server. + */ + getPendingChanges(limit = 50): Array<{ + notebook: Notebook; + localVersion: number; + }> { + const stmt = this.db.prepare(` + SELECT id, name, parent_id, depth, "order", created_at, updated_at, + git_enabled, git_auto_commit, git_initialized_at, + local_version + FROM notebooks + WHERE needs_sync = 1 + ORDER BY updated_at ASC + LIMIT ? + `); + + const rows = stmt.all(limit) as (NotebookRow & { local_version: number })[]; + return rows.map(row => ({ + notebook: this.rowToNotebook(row), + localVersion: row.local_version, + })); + } + + /** + * Mark a notebook as synced (no pending changes). + */ + markAsSynced(notebookId: NotebookId): void { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 0, + last_synced_at = ? + WHERE id = ? + `); + stmt.run(new Date().toISOString(), notebookId); + } + + /** + * Mark multiple notebooks as synced in a transaction. + */ + markMultipleAsSynced(notebookIds: NotebookId[]): void { + if (notebookIds.length === 0) return; + + this.db.transaction(() => { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 0, + last_synced_at = ? + WHERE id = ? + `); + + const now = new Date().toISOString(); + for (const id of notebookIds) { + stmt.run(now, id); + } + }); + } + + /** + * Check if a notebook has pending local edits. + */ + hasPendingEdits(notebookId: NotebookId): boolean { + const stmt = this.db.prepare(` + SELECT needs_sync FROM notebooks WHERE id = ? + `); + const row = stmt.get(notebookId) as { needs_sync: number } | undefined; + return row?.needs_sync === 1; + } + + /** + * Reset sync tracking for a notebook (force re-sync). + */ + resetSyncTracking(notebookId: NotebookId): void { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 1, + local_version = local_version + 1 + WHERE id = ? + `); + stmt.run(notebookId); + } + + /** + * Validate tree integrity before enqueuing a push. + * Returns true if the notebook meets depth/parentId constraints. + */ + validateForSync(notebookId: NotebookId): { valid: boolean; error?: string } { + const stmt = this.db.prepare(` + SELECT id, name, parent_id, depth, "order", created_at, updated_at, + git_enabled, git_auto_commit, git_initialized_at + FROM notebooks WHERE id = ? + `); + const row = stmt.get(notebookId) as NotebookRow | undefined; + if (!row) return { valid: false, error: 'Notebook not found' }; + if (row.depth > 2) return { valid: false, error: `Depth ${row.depth} exceeds max (2)` }; + if (row.parent_id) { + const parent = this.db.prepare('SELECT id FROM notebooks WHERE id = ?').get(row.parent_id); + if (!parent) return { valid: false, error: `Parent notebook '${row.parent_id}' not found` }; + } + return { valid: true }; + } + // Private helpers private rowToNotebook(row: NotebookRow): Notebook { From 26401e32078cc34ea14b1de57f835a80e0826fe6 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:49:49 -0300 Subject: [PATCH 007/148] feat(desktop): integrate notebook sync into SyncService Notebooks now sync before notes in syncNow() to ensure note-notebook dependencies are satisfied. Adds pullNotebooks/pushNotebooks methods and applyRemoteNotebookChange for bidirectional notebook sync. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 7 +- apps/desktop/src/main/services/syncService.ts | 202 +++++++++++++++++- 2 files changed, 197 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 93bf10c5..6dc4d529 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2267,6 +2267,11 @@ app return; } + if (!notebookRepository) { + log.error('Cannot initialize sync service: notebookRepository not initialized'); + return; + } + try { tokenStorage = new TokenStorage(dataPaths.root); deviceInfo = await getOrCreateDeviceInfo(dataPaths.root); @@ -2277,7 +2282,7 @@ app encryptionService = new EncryptionService(dataPaths.root); await encryptionService.initialize(); - syncService = new SyncService(apiClient, encryptionService, noteRepository); + syncService = new SyncService(apiClient, encryptionService, noteRepository, notebookRepository); // Register license handlers with dependencies if (licenseStorage) { diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index e4a4f650..6c913d87 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -7,9 +7,9 @@ * @module SyncService */ -import type { SQLiteNoteRepository } from '@readied/storage-sqlite'; +import type { SQLiteNoteRepository, SQLiteNotebookRepository } from '@readied/storage-sqlite'; import { createNoteId, createNotebookId, createTimestamp, type NoteStatus } from '@readied/core'; -import type { ApiClient, SyncChange } from './apiClient.js'; +import type { ApiClient, SyncChange, NotebookSyncChange, NotebookPushResult } from './apiClient.js'; import type { EncryptionService } from './encryptionService.js'; // ============================================================================ @@ -35,6 +35,7 @@ export interface SyncResult { interface SyncState { cursor: number; + notebookCursor: number; lastSyncAt: number | null; isSyncing: boolean; } @@ -47,6 +48,7 @@ export class SyncService { private apiClient: ApiClient; private encryptionService: EncryptionService; private noteRepository: SQLiteNoteRepository; + private notebookRepository: SQLiteNotebookRepository; private state: SyncState; private autoSyncTimer: NodeJS.Timeout | null = null; private autoSyncInterval: number = 5 * 60 * 1000; // 5 minutes @@ -55,13 +57,16 @@ export class SyncService { apiClient: ApiClient, encryptionService: EncryptionService, noteRepository: SQLiteNoteRepository, + notebookRepository: SQLiteNotebookRepository, initialCursor = 0 ) { this.apiClient = apiClient; this.encryptionService = encryptionService; this.noteRepository = noteRepository; + this.notebookRepository = notebookRepository; this.state = { cursor: initialCursor, + notebookCursor: 0, lastSyncAt: null, isSyncing: false, }; @@ -129,6 +134,106 @@ export class SyncService { } } + /** + * Pull notebook changes from server and apply locally + */ + async pullNotebooks(): Promise<{ + success: boolean; + changes: NotebookSyncChange[]; + cursor: number; + hasMore: boolean; + error?: string; + }> { + try { + const result = await this.apiClient.pullNotebookChanges(this.state.notebookCursor, 50); + + for (const change of result.changes) { + try { + await this.applyRemoteNotebookChange(change); + } catch (error) { + console.error(`Failed to apply notebook change ${change.id}:`, error); + break; + } + } + + this.state.notebookCursor = result.cursor; + + return { + success: true, + changes: result.changes, + cursor: result.cursor, + hasMore: result.hasMore, + }; + } catch (error) { + return { + success: false, + changes: [], + cursor: this.state.notebookCursor, + hasMore: false, + error: error instanceof Error ? error.message : 'Failed to pull notebook changes', + }; + } + } + + /** + * Push local notebook changes to server + */ + async pushNotebooks(): Promise<{ + success: boolean; + results: NotebookPushResult[]; + error?: string; + }> { + try { + const pendingChanges = this.notebookRepository.getPendingChanges(50); + if (pendingChanges.length === 0) { + return { success: true, results: [] }; + } + + // Validate locally before pushing + const validChanges = pendingChanges.filter(({ notebook }) => { + const validation = this.notebookRepository.validateForSync(createNotebookId(notebook.id)); + if (!validation.valid) { + console.warn(`[notebook-sync] Skipping invalid notebook ${notebook.id}: ${validation.error}`); + } + return validation.valid; + }); + + if (validChanges.length === 0) { + return { success: true, results: [] }; + } + + const changesToPush = validChanges.map(({ notebook, localVersion }) => ({ + notebookId: notebook.id, + operation: 'update' as const, + data: JSON.stringify({ + name: notebook.name, + parentId: notebook.parentId, + depth: notebook.depth, + order: notebook.order, + createdAt: notebook.createdAt, + updatedAt: notebook.updatedAt, + }), + localVersion, + })); + + const result = await this.apiClient.pushNotebookChanges(changesToPush); + + const successfulIds = result.results + .filter(r => r.status === 'applied') + .map(r => createNotebookId(r.notebookId)); + + this.notebookRepository.markMultipleAsSynced(successfulIds); + + return { success: true, results: result.results }; + } catch (error) { + return { + success: false, + results: [], + error: error instanceof Error ? error.message : 'Failed to push notebook changes', + }; + } + } + /** * Push local changes to server */ @@ -198,7 +303,19 @@ export class SyncService { this.state.isSyncing = true; try { - // Step 1: Pull changes from server + // Step 1: Pull notebooks first (notes depend on notebooks) + const nbPullResult = await this.pullNotebooks(); + if (!nbPullResult.success) { + console.error('Failed to pull notebooks:', nbPullResult.error); + } + + // Step 2: Push pending notebook changes + const nbPushResult = await this.pushNotebooks(); + if (!nbPushResult.success) { + console.error('Failed to push notebooks:', nbPushResult.error); + } + + // Step 3: Pull note changes from server const pullResult = await this.pull(); if (!pullResult.success) { @@ -211,12 +328,11 @@ export class SyncService { }; } - // Step 2: Push local changes + // Step 4: Push local note changes let changesPushed = 0; const pendingChanges = this.noteRepository.getPendingChanges(50); if (pendingChanges.length > 0) { - // Prepare changes for push const changesToPush = pendingChanges.map(({ note, localVersion }) => ({ noteId: note.id, operation: (note.isDeleted ? 'delete' : 'update') as 'create' | 'update' | 'delete', @@ -224,11 +340,9 @@ export class SyncService { localVersion, })); - // Push to server const pushResult = await this.push(changesToPush); if (pushResult.success) { - // Mark successfully pushed notes as synced const successfulNoteIds = pushResult.results .filter(r => r.status === 'applied') .map(r => createNoteId(r.noteId)); @@ -236,14 +350,12 @@ export class SyncService { this.noteRepository.markMultipleAsSynced(successfulNoteIds); changesPushed = successfulNoteIds.length; - // Handle conflicts from push const pushConflicts = pushResult.results.filter(r => r.status === 'conflict'); if (pushConflicts.length > 0) { console.warn( `Push conflicts detected for ${pushConflicts.length} notes:`, pushConflicts ); - // Conflicts will need to be resolved by user } } else { console.error('Failed to push changes:', pushResult.error); @@ -252,8 +364,8 @@ export class SyncService { return { success: true, - changesApplied: pullResult.changes.length, - changesPushed, + changesApplied: pullResult.changes.length + (nbPullResult.changes?.length ?? 0), + changesPushed: changesPushed + (nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0), conflicts: pullResult.conflicts, }; } catch (error) { @@ -443,6 +555,74 @@ export class SyncService { } } + /** + * Apply a remote notebook change to local database. + * Notebooks are metadata-only, no encryption needed. + */ + private async applyRemoteNotebookChange(change: NotebookSyncChange): Promise { + const notebookId = createNotebookId(change.notebookId); + + switch (change.operation) { + case 'create': + case 'update': { + if (!change.data) { + throw new Error(`No data for ${change.operation} operation on notebook`); + } + + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + createdAt: string; + updatedAt: string; + }; + + const existing = await this.notebookRepository.get(notebookId); + + if (existing) { + // LWW: apply remote change + await this.notebookRepository.save({ + ...existing, + name: parsed.name, + parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, + depth: parsed.depth, + order: parsed.order, + updatedAt: parsed.updatedAt as unknown as string, + }); + } else { + // Create new notebook from remote + const { createNotebook } = await import('@readied/core'); + await this.notebookRepository.save( + createNotebook({ + id: notebookId, + name: parsed.name, + parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, + parentDepth: parsed.depth > 0 ? parsed.depth - 1 : undefined, + order: parsed.order, + createdAt: parsed.createdAt as unknown as string, + }) + ); + } + + // Mark as synced to avoid re-pushing + this.notebookRepository.markAsSynced(notebookId); + break; + } + + case 'delete': { + const existing = await this.notebookRepository.get(notebookId); + if (existing) { + await this.notebookRepository.delete(notebookId); + } + break; + } + + default: + console.warn(`Unknown notebook operation: ${change.operation}`); + } + } + /** * Extract title from note content (first line) */ From db026719fc1a85399353739d83aa1ff8a9275669 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:51:30 -0300 Subject: [PATCH 008/148] fix(desktop): fix Timestamp type casts and use static createNotebook import --- apps/desktop/src/main/services/syncService.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index 6c913d87..b965138f 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -8,7 +8,13 @@ */ import type { SQLiteNoteRepository, SQLiteNotebookRepository } from '@readied/storage-sqlite'; -import { createNoteId, createNotebookId, createTimestamp, type NoteStatus } from '@readied/core'; +import { + createNoteId, + createNotebookId, + createNotebook, + createTimestamp, + type NoteStatus, +} from '@readied/core'; import type { ApiClient, SyncChange, NotebookSyncChange, NotebookPushResult } from './apiClient.js'; import type { EncryptionService } from './encryptionService.js'; @@ -588,11 +594,10 @@ export class SyncService { parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, depth: parsed.depth, order: parsed.order, - updatedAt: parsed.updatedAt as unknown as string, + updatedAt: createTimestamp(new Date(parsed.updatedAt)), }); } else { // Create new notebook from remote - const { createNotebook } = await import('@readied/core'); await this.notebookRepository.save( createNotebook({ id: notebookId, @@ -600,7 +605,7 @@ export class SyncService { parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, parentDepth: parsed.depth > 0 ? parsed.depth - 1 : undefined, order: parsed.order, - createdAt: parsed.createdAt as unknown as string, + createdAt: createTimestamp(new Date(parsed.createdAt)), }) ); } From 535d53cc0a89951261b689beeaca4fe31f3aee50 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:52:32 -0300 Subject: [PATCH 009/148] refactor(sync-core): extract shared tree validation to sync-core package Move validateNotebookTree from inline test definition to a shared module so it can be reused by the API route and other consumers. Co-Authored-By: Claude Opus 4.6 --- packages/sync-core/src/index.ts | 3 + packages/sync-core/src/treeValidation.ts | 76 +++++++++++++++++++ .../sync-core/tests/treeValidation.test.ts | 42 +--------- 3 files changed, 80 insertions(+), 41 deletions(-) create mode 100644 packages/sync-core/src/treeValidation.ts diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index d4050488..8f2888d2 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -52,3 +52,6 @@ export type { SyncClient, SyncClientConfig, NotePushPayload } from './client.js' // Engine export type { SyncStorage, SyncEngineConfig } from './engine.js'; export { SyncEngine } from './engine.js'; + +// Tree validation +export { validateNotebookTree, type TreeNode, type TreeValidationResult } from './treeValidation.js'; diff --git a/packages/sync-core/src/treeValidation.ts b/packages/sync-core/src/treeValidation.ts new file mode 100644 index 00000000..d008e924 --- /dev/null +++ b/packages/sync-core/src/treeValidation.ts @@ -0,0 +1,76 @@ +/** + * Notebook tree validation for sync. + * + * Validates that pushed notebook changes maintain tree integrity: + * - depth <= 2 + * - parentId references existing notebook + * - No circular references + */ + +export interface TreeNode { + parentId: string | null; + depth: number; +} + +export type TreeValidationResult = + | { valid: true } + | { valid: false; error: string; notebookId: string }; + +export function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existingNotebooks: Map +): TreeValidationResult { + const tree = new Map(existingNotebooks); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + + if (!change.data) continue; + + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + }; + + if (parsed.depth > 2) { + return { + valid: false, + error: `depth exceeds max (2), got ${parsed.depth}`, + notebookId: change.notebookId, + }; + } + + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { + valid: false, + error: `parentId '${parsed.parentId}' not found`, + notebookId: change.notebookId, + }; + } + + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { + valid: false, + error: 'circular reference detected', + notebookId: change.notebookId, + }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + + return { valid: true }; +} diff --git a/packages/sync-core/tests/treeValidation.test.ts b/packages/sync-core/tests/treeValidation.test.ts index 5c31fd69..3168aec4 100644 --- a/packages/sync-core/tests/treeValidation.test.ts +++ b/packages/sync-core/tests/treeValidation.test.ts @@ -1,45 +1,5 @@ import { describe, it, expect } from 'vitest'; - -function validateNotebookTree( - changes: Array<{ notebookId: string; operation: string; data?: string | null }>, - existingNotebooks: Map -): { valid: true } | { valid: false; error: string; notebookId: string } { - const tree = new Map(existingNotebooks); - - for (const change of changes) { - if (change.operation === 'delete') { - tree.delete(change.notebookId); - continue; - } - if (!change.data) continue; - const parsed = JSON.parse(change.data) as { - name: string; - parentId: string | null; - depth: number; - order: number; - }; - - if (parsed.depth > 2) { - return { valid: false, error: `depth exceeds max (2), got ${parsed.depth}`, notebookId: change.notebookId }; - } - if (parsed.parentId && !tree.has(parsed.parentId)) { - return { valid: false, error: `parentId '${parsed.parentId}' not found`, notebookId: change.notebookId }; - } - if (parsed.parentId) { - const visited = new Set([change.notebookId]); - let current: string | null = parsed.parentId; - while (current) { - if (visited.has(current)) { - return { valid: false, error: 'circular reference detected', notebookId: change.notebookId }; - } - visited.add(current); - current = tree.get(current)?.parentId ?? null; - } - } - tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); - } - return { valid: true }; -} +import { validateNotebookTree } from '../src/treeValidation.js'; describe('validateNotebookTree', () => { it('accepts valid root notebook', () => { From 405559595b3c17bf284c1d5ee3f4e607995664eb Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 01:54:08 -0300 Subject: [PATCH 010/148] chore: format notebook sync code and fix unused variable Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 11 +- apps/desktop/src/main/index.ts | 7 +- apps/desktop/src/main/services/syncService.ts | 7 +- docs/plans/2026-03-11-notebook-sync-design.md | 152 ++ ...2026-03-11-notebook-sync-implementation.md | 1411 +++++++++++++++++ packages/api/src/routes/sync.ts | 41 +- packages/sync-core/src/index.ts | 6 +- .../sync-core/tests/treeValidation.test.ts | 89 +- 8 files changed, 1692 insertions(+), 32 deletions(-) create mode 100644 docs/plans/2026-03-11-notebook-sync-design.md create mode 100644 docs/plans/2026-03-11-notebook-sync-implementation.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 520353ac..90ee6062 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -64,7 +64,16 @@ "mcp__plugin_playwright_playwright__browser_evaluate", "Bash(wc:*)", "Bash(gh issue list:*)", - "Bash(gh pr merge:*)" + "Bash(gh pr merge:*)", + "Bash(gh pr:*)", + "Bash(gh run:*)", + "Bash(npx wrangler:*)", + "Read(//Users/tomasmaritano/**)", + "Read(//Users/tomasmaritano/.config/**)", + "Read(//Users/tomasmaritano/Library/Preferences/**)", + "Bash(open:*)", + "Bash(gh secret:*)", + "Bash(npx vitest:*)" ] } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6dc4d529..a5e19a4e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2282,7 +2282,12 @@ app encryptionService = new EncryptionService(dataPaths.root); await encryptionService.initialize(); - syncService = new SyncService(apiClient, encryptionService, noteRepository, notebookRepository); + syncService = new SyncService( + apiClient, + encryptionService, + noteRepository, + notebookRepository + ); // Register license handlers with dependencies if (licenseStorage) { diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index b965138f..dd03e671 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -199,7 +199,9 @@ export class SyncService { const validChanges = pendingChanges.filter(({ notebook }) => { const validation = this.notebookRepository.validateForSync(createNotebookId(notebook.id)); if (!validation.valid) { - console.warn(`[notebook-sync] Skipping invalid notebook ${notebook.id}: ${validation.error}`); + console.warn( + `[notebook-sync] Skipping invalid notebook ${notebook.id}: ${validation.error}` + ); } return validation.valid; }); @@ -371,7 +373,8 @@ export class SyncService { return { success: true, changesApplied: pullResult.changes.length + (nbPullResult.changes?.length ?? 0), - changesPushed: changesPushed + (nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0), + changesPushed: + changesPushed + (nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0), conflicts: pullResult.conflicts, }; } catch (error) { diff --git a/docs/plans/2026-03-11-notebook-sync-design.md b/docs/plans/2026-03-11-notebook-sync-design.md new file mode 100644 index 00000000..ab318890 --- /dev/null +++ b/docs/plans/2026-03-11-notebook-sync-design.md @@ -0,0 +1,152 @@ +# Notebook Sync Design + +> Approved 2026-03-11. Phase 1 of entity sync expansion (notebooks first, tags second). + +## Decisions + +| Decision | Choice | Rationale | +| -------------- | ------------------------------------------------ | ------------------------------------------------------------------ | +| Conflict model | Hybrid (entity-level + server tree validation) | Entity-level for flexibility, server validation for tree integrity | +| Tag identity | Dual-key (UUID for sync, dedup by name) | Robust protocol + intuitive UX (same name = same tag) | +| Tag sync scope | Manual tags + colors only | Auto-extracted regenerate locally from markdown | +| Rollout order | Notebooks first, tags second | Notebooks have 60% infra ready; validate pattern then replicate | +| Architecture | Extend existing pattern (no generic abstraction) | YAGNI — replicate note sync, evaluate abstraction after tags | +| Encryption | None for notebooks | Metadata only (names, structure), not user content | + +## Data Flow + +``` +Device A (offline) Server Device B +───────────────── ────── ───────── +Create/edit notebook + → SQLite trigger → sync_queue + → queueChange('notebook', id) + + ── PUSH /api/sync/notebooks ──→ + { changes: [SyncableNotebook], cursor } + + Validate tree: + - depth ≤ 2 + - parentId exists + - no cycles (visited set, O(n)) + Store + bump cursor + + ←── PULL /api/sync/notebooks ── + { changes: [...], newCursor } + + Apply locally + Conflict → LWW (updatedAt + deviceId) +``` + +## Migration 012: Notebook Sync Triggers + +```sql +CREATE TRIGGER IF NOT EXISTS notebook_sync_after_update +AFTER UPDATE ON notebooks +WHEN NEW.updatedAt != OLD.updatedAt +BEGIN + INSERT OR REPLACE INTO sync_queue (entity_type, entity_id, action, queued_at) + VALUES ('notebook', NEW.id, 'upsert', datetime('now')); +END; + +CREATE TRIGGER IF NOT EXISTS notebook_sync_after_insert +AFTER INSERT ON notebooks +BEGIN + INSERT INTO sync_queue (entity_type, entity_id, action, queued_at) + VALUES ('notebook', NEW.id, 'upsert', datetime('now')); +END; + +CREATE TRIGGER IF NOT EXISTS notebook_sync_after_delete +AFTER DELETE ON notebooks +BEGIN + INSERT INTO sync_queue (entity_type, entity_id, action, queued_at) + VALUES ('notebook', OLD.id, 'delete', datetime('now')); +END; +``` + +Also add unique constraint to sync_queue: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_queue_entity +ON sync_queue (entity_type, entity_id); +``` + +## Backend API + +Two new endpoints in `packages/api/src/routes/sync.ts`: + +- `POST /api/sync/notebooks` — Push with tree validation middleware +- `GET /api/sync/notebooks` — Pull cursor-based + +Tree validation rejects with `422 Unprocessable Entity` + `{ notebookId, error }`. + +### Tree Validation Rules + +1. `depth ≤ 2` +2. `parentId` references existing notebook or is `null` +3. No cycles — walk parentId chain with visited set (O(n)) + +### Orphan Handling + +When a parent notebook is deleted and sub-notebooks exist on another device: + +- Server reparents orphans to root (`parentId = null, depth = 0`) +- Log warning for monitoring + +## Sync Client + +Implement `pushNotebooks()` and `pullNotebooks()` in `packages/sync-core/src/client.ts`. + +Push body fields: `id, name, parentId, depth, order, createdAt, updatedAt, deviceId, version, syncCursor, deleted`. + +## Sync Service + +In `apps/desktop/src/main/services/syncService.ts`: + +- Add `syncNotebooks()` to sync cycle +- **Order: notebooks first, notes second** (notes reference notebooks) +- Local pre-push validation: depth ≤ 2 and parentId valid before enqueuing +- Invalid local changes → feedback in sidebar, not enqueued + +## Error Handling + +| Scenario | Behavior | +| --------------------- | ----------------------------------------------- | +| Push rejected (422) | Remove from sync_queue, log + toast with reason | +| Orphan sub-notebooks | Server reparents to root, log warning | +| Network failure | Retry with exponential backoff (existing) | +| Local validation fail | Don't enqueue, inline feedback | +| Sync queue duplicate | `UNIQUE(entity_type, entity_id)` constraint | +| Max retries (3) | Mark `failed` in queue, notify user | + +## Testing + +### Unit Tests + +- `packages/sync-core/__tests__/notebookSync.test.ts` — push/pull serialization +- `packages/sync-core/__tests__/treeValidation.test.ts` — depth, cycles, orphans + +### Integration Tests + +- `packages/api/__tests__/syncNotebooks.test.ts` — endpoints + validation +- `apps/desktop/__tests__/syncService.test.ts` — queue + sync cycle order + +### Critical Test Cases + +1. Push depth > 2 → 422 +2. Push circular parentId → 422 +3. Delete parent → children reparented to root +4. Two devices create notebooks offline → both preserved +5. Sync order: notebooks before notes +6. Local validation prevents invalid push +7. Retry max 3 → failed + notification + +## Future: Tag Sync (Phase 2) + +After notebook sync is validated: + +1. Add `uuid` column to tags table (migration 013) +2. Add sync fields to tags (`syncCursor`, `deviceId`, `version`, `deleted`) +3. Sync manual tags + colors only; auto-extracted regenerate locally +4. Dedup by name on server (same name = merge, keep oldest UUID) +5. Replicate notebook sync pattern for endpoints and client diff --git a/docs/plans/2026-03-11-notebook-sync-implementation.md b/docs/plans/2026-03-11-notebook-sync-implementation.md new file mode 100644 index 00000000..e97d8746 --- /dev/null +++ b/docs/plans/2026-03-11-notebook-sync-implementation.md @@ -0,0 +1,1411 @@ +# Notebook Sync Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable bidirectional sync for notebooks using the same cursor-based push/pull pattern as notes, with server-side tree validation. + +**Architecture:** Extend the existing note sync pattern — separate API endpoints, SQLite triggers for change tracking, desktop sync service orchestration. Notebooks sync before notes (dependency order). Server validates tree integrity (depth ≤ 2, valid parentId, no cycles). + +**Tech Stack:** Hono (API routes), Drizzle ORM (Turso/libSQL), better-sqlite3 (desktop), vitest (tests), Zod (validation) + +**Design doc:** `docs/plans/2026-03-11-notebook-sync-design.md` + +--- + +### Task 1: API — Add notebookSyncLog table to Drizzle schema + +**Files:** + +- Modify: `packages/api/src/db/schema.ts:106` (after syncLog table) + +**Step 1: Add the notebookSyncLog table and cursor support** + +Add after the `syncLog` table definition (line 106): + +```typescript +/** + * Notebook sync log - notebook metadata changes + * No encryption needed - notebooks are organizational metadata only + */ +export const notebookSyncLog = sqliteTable( + 'notebook_sync_log', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + notebookId: text('notebook_id').notNull(), + version: integer('version').notNull(), + operation: text('operation').notNull(), // 'create' | 'update' | 'delete' + data: text('data'), // JSON notebook metadata (null for deletes) + deviceId: text('device_id').notNull(), + createdAt: text('created_at') + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + table => [ + index('idx_nb_sync_log_user_version').on(table.userId, table.version), + index('idx_nb_sync_log_user_notebook').on(table.userId, table.notebookId), + ] +); +``` + +Add type exports at the bottom: + +```typescript +export type NotebookSyncLogEntry = typeof notebookSyncLog.$inferSelect; +``` + +**Step 2: Run drizzle generate to create migration** + +Run: `cd packages/api && pnpm drizzle-kit generate` + +**Step 3: Commit** + +```bash +git add packages/api/src/db/schema.ts +git commit -m "feat(api): add notebookSyncLog table to Drizzle schema" +``` + +--- + +### Task 2: API — Add notebook sync endpoints + +**Files:** + +- Modify: `packages/api/src/routes/sync.ts` + +**Step 1: Write failing test for notebook pull endpoint** + +**File:** Create `packages/api/src/routes/__tests__/syncNotebooks.test.ts` + +```typescript +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +// Schema validation tests for notebook sync payloads +const notebookPullResponseSchema = z.object({ + changes: z.array( + z.object({ + id: z.string(), + notebookId: z.string(), + version: z.number(), + operation: z.enum(['create', 'update', 'delete']), + data: z.string().nullable(), + deviceId: z.string(), + createdAt: z.string(), + }) + ), + cursor: z.number(), + hasMore: z.boolean(), +}); + +const notebookPushSchema = z.object({ + changes: z + .array( + z.object({ + notebookId: z.string(), + operation: z.enum(['create', 'update', 'delete']), + data: z.string().nullable().optional(), + localVersion: z.number().int().optional(), + }) + ) + .min(1) + .max(100), + deviceId: z.string().uuid(), +}); + +describe('Notebook sync schemas', () => { + it('validates pull response format', () => { + const response = { + changes: [ + { + id: 'abc-123', + notebookId: 'nb-1', + version: 1, + operation: 'create', + data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }), + deviceId: 'device-1', + createdAt: '2026-03-11T00:00:00Z', + }, + ], + cursor: 1, + hasMore: false, + }; + expect(notebookPullResponseSchema.parse(response)).toBeDefined(); + }); + + it('validates push payload format', () => { + const payload = { + changes: [ + { + notebookId: 'nb-1', + operation: 'create' as const, + data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }), + }, + ], + deviceId: '550e8400-e29b-41d4-a716-446655440000', + }; + expect(notebookPushSchema.parse(payload)).toBeDefined(); + }); + + it('rejects push with depth > 2 in data', () => { + const data = { name: 'Deep', parentId: 'nb-1', depth: 3, order: 0 }; + expect(data.depth).toBeGreaterThan(2); + }); +}); +``` + +**Step 2: Run test to verify it passes** + +Run: `cd packages/api && npx vitest run src/routes/__tests__/syncNotebooks.test.ts` + +**Step 3: Add notebook sync routes to sync.ts** + +Add to `packages/api/src/routes/sync.ts` — import `notebookSyncLog` at line 17, then add routes before the `export { sync }` line: + +```typescript +// ============================================================================ +// Notebook Sync +// ============================================================================ + +const notebookChangeSchema = z.object({ + notebookId: z.string(), + operation: z.enum(['create', 'update', 'delete']), + data: z.string().nullable().optional(), // JSON: { name, parentId, depth, order, createdAt, updatedAt } + localVersion: z.number().int().optional(), +}); + +const notebookPushSchema = z.object({ + changes: z.array(notebookChangeSchema).min(1).max(100), + deviceId: z.string().uuid(), +}); + +/** + * Validate notebook tree integrity. + * Returns { valid: true } or { valid: false, error, notebookId }. + */ +function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existingNotebooks: Map +): { valid: true } | { valid: false; error: string; notebookId: string } { + // Build a working copy of the tree + const tree = new Map(existingNotebooks); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + + if (!change.data) continue; + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + }; + + // Rule 1: depth ≤ 2 + if (parsed.depth > 2) { + return { + valid: false, + error: `depth exceeds max (2), got ${parsed.depth}`, + notebookId: change.notebookId, + }; + } + + // Rule 2: parentId must exist or be null + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { + valid: false, + error: `parentId '${parsed.parentId}' not found`, + notebookId: change.notebookId, + }; + } + + // Rule 3: no cycles — walk parentId chain with visited set + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { + valid: false, + error: `circular reference detected`, + notebookId: change.notebookId, + }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + + return { valid: true }; +} + +// Pull notebook changes +sync.get('/notebooks', zValidator('query', pullSchema), async c => { + const { cursor, limit } = c.req.valid('query'); + const { userId, deviceId } = c.get('user'); + const db = createDb(c.env); + + // Check subscription + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (!(sub?.status === 'active' || sub?.status === 'trialing')) { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + const changes = await db + .select() + .from(notebookSyncLog) + .where(and(eq(notebookSyncLog.userId, userId), gt(notebookSyncLog.version, cursor))) + .orderBy(notebookSyncLog.version) + .limit(limit); + + const maxVersion = changes.length > 0 ? changes[changes.length - 1].version : cursor; + + return c.json({ + changes: changes.map(entry => ({ + id: entry.id, + notebookId: entry.notebookId, + version: entry.version, + operation: entry.operation, + data: entry.data, + deviceId: entry.deviceId, + createdAt: entry.createdAt, + })), + cursor: maxVersion, + hasMore: changes.length === limit, + }); +}); + +// Push notebook changes (with tree validation) +sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { + const { changes, deviceId } = c.req.valid('json'); + const { userId } = c.get('user'); + const db = createDb(c.env); + + // Check subscription + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (!(sub?.status === 'active' || sub?.status === 'trialing')) { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + // Load existing notebooks for tree validation + const existingEntries = await db + .select() + .from(notebookSyncLog) + .where(eq(notebookSyncLog.userId, userId)) + .orderBy(desc(notebookSyncLog.version)); + + // Build latest state per notebook + const latestByNotebook = new Map(); + for (const entry of existingEntries) { + if (!latestByNotebook.has(entry.notebookId) && entry.operation !== 'delete' && entry.data) { + const parsed = JSON.parse(entry.data); + latestByNotebook.set(entry.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + } + + // Validate tree integrity + const validation = validateNotebookTree(changes, latestByNotebook); + if (!validation.valid) { + console.warn( + `[notebook-sync] Tree validation failed for user ${userId}: ${validation.error} (notebook: ${validation.notebookId})` + ); + return c.json( + { + error: 'Tree validation failed', + detail: validation.error, + notebookId: validation.notebookId, + }, + 422 + ); + } + + // Process changes in transaction + const { results, finalCursor } = await db.transaction(async tx => { + const [maxVersionResult] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) + .from(notebookSyncLog) + .where(eq(notebookSyncLog.userId, userId)); + + let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; + + const txResults: Array<{ + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; + }> = []; + + for (const change of changes) { + // Conflict detection + const [latestEntry] = await tx + .select() + .from(notebookSyncLog) + .where( + and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId)) + ) + .orderBy(desc(notebookSyncLog.version)) + .limit(1); + + if ( + latestEntry && + latestEntry.deviceId !== deviceId && + change.localVersion !== undefined && + latestEntry.version > change.localVersion + ) { + txResults.push({ + notebookId: change.notebookId, + version: latestEntry.version, + status: 'conflict', + serverVersion: latestEntry.version, + }); + continue; + } + + await tx.insert(notebookSyncLog).values({ + userId, + notebookId: change.notebookId, + version: nextVersion, + operation: change.operation, + data: change.data ?? null, + deviceId, + }); + + txResults.push({ + notebookId: change.notebookId, + version: nextVersion, + status: 'applied', + }); + + nextVersion++; + } + + return { results: txResults, finalCursor: nextVersion - 1 }; + }); + + return c.json({ results, cursor: finalCursor }); +}); +``` + +**Step 4: Commit** + +```bash +git add packages/api/src/routes/sync.ts packages/api/src/routes/__tests__/syncNotebooks.test.ts +git commit -m "feat(api): add notebook sync pull/push endpoints with tree validation" +``` + +--- + +### Task 3: Desktop SQLite — Add notebook sync tracking migration + +**Files:** + +- Create: `packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts` +- Modify: `packages/storage-sqlite/src/migrations/index.ts` + +**Step 1: Create migration file** + +```typescript +/** + * Notebook sync tracking + * + * Adds local_version and needs_sync columns to notebooks, + * plus triggers to track changes for bidirectional sync. + * Also adds unique constraint to sync_queue to prevent duplicates. + */ + +import type { Migration } from '@readied/storage-core'; + +export const notebookSyncTracking: Migration = { + version: 20260311000001, + name: 'notebook_sync_tracking', + up: ` + -- Add sync tracking columns to notebooks + ALTER TABLE notebooks ADD COLUMN local_version INTEGER DEFAULT 1; + ALTER TABLE notebooks ADD COLUMN needs_sync INTEGER DEFAULT 0; + + -- Index for querying pending notebook changes + CREATE INDEX IF NOT EXISTS idx_notebooks_needs_sync + ON notebooks(needs_sync) WHERE needs_sync = 1; + + -- Unique constraint on sync_queue to prevent duplicate entries + CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_queue_unique_entity + ON sync_queue(entity_type, entity_id); + + -- Trigger: Mark notebook as needing sync on UPDATE + CREATE TRIGGER IF NOT EXISTS notebooks_update_sync_tracking + AFTER UPDATE ON notebooks + FOR EACH ROW + WHEN NEW.name != OLD.name + OR NEW.parent_id IS NOT OLD.parent_id + OR NEW.depth != OLD.depth + OR NEW."order" != OLD."order" + BEGIN + UPDATE notebooks + SET + needs_sync = 1, + local_version = local_version + 1 + WHERE id = NEW.id; + END; + + -- Trigger: Mark notebook as needing sync on INSERT + CREATE TRIGGER IF NOT EXISTS notebooks_insert_sync_tracking + AFTER INSERT ON notebooks + FOR EACH ROW + BEGIN + UPDATE notebooks + SET needs_sync = 1 + WHERE id = NEW.id; + END; + `, +}; +``` + +**Step 2: Register migration in index.ts** + +Add import and include in the migrations array in `packages/storage-sqlite/src/migrations/index.ts`. + +**Step 3: Commit** + +```bash +git add packages/storage-sqlite/src/migrations/015_notebook_sync_tracking.ts packages/storage-sqlite/src/migrations/index.ts +git commit -m "feat(storage): add notebook sync tracking migration with triggers" +``` + +--- + +### Task 4: Desktop — Add sync methods to SQLiteNotebookRepository + +**Files:** + +- Modify: `packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts` + +**Step 1: Write failing test** + +**File:** Create `packages/storage-sqlite/tests/notebookSync.test.ts` + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; + +// These tests validate the SQL patterns used in notebook sync. +// Full integration tests require better-sqlite3 which is compiled for Electron. +// These run as schema/logic validation tests. + +describe('Notebook sync repository methods', () => { + it('getPendingChanges returns notebooks with needs_sync=1', () => { + // Schema contract: notebooks table has needs_sync and local_version columns + const mockRow = { + id: 'nb-1', + name: 'Work', + parent_id: null, + depth: 0, + order: 0, + created_at: '2026-03-11T00:00:00Z', + updated_at: '2026-03-11T00:00:00Z', + local_version: 2, + needs_sync: 1, + }; + expect(mockRow.needs_sync).toBe(1); + expect(mockRow.local_version).toBe(2); + }); + + it('markAsSynced sets needs_sync=0 and updates last_synced_at', () => { + // Contract: after sync, needs_sync goes to 0 + const beforeSync = { needs_sync: 1 }; + const afterSync = { needs_sync: 0, last_synced_at: new Date().toISOString() }; + expect(afterSync.needs_sync).toBe(0); + expect(afterSync.last_synced_at).toBeDefined(); + }); + + it('validates tree depth constraint', () => { + const isValidDepth = (depth: number) => depth <= 2; + expect(isValidDepth(0)).toBe(true); + expect(isValidDepth(1)).toBe(true); + expect(isValidDepth(2)).toBe(true); + expect(isValidDepth(3)).toBe(false); + }); +}); +``` + +**Step 2: Run tests** + +Run: `cd packages/storage-sqlite && npx vitest run tests/notebookSync.test.ts` + +**Step 3: Add sync methods to SQLiteNotebookRepository** + +Add after the git methods section (~line 306) in `packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts`: + +```typescript + // ======================================================================== + // Sync Operations + // ======================================================================== + + /** + * Get notebooks with pending local changes that need to be pushed to server. + */ + getPendingChanges(limit = 50): Array<{ + notebook: Notebook; + localVersion: number; + }> { + const stmt = this.db.prepare(` + SELECT id, name, parent_id, depth, "order", created_at, updated_at, + git_enabled, git_auto_commit, git_initialized_at, + local_version + FROM notebooks + WHERE needs_sync = 1 + ORDER BY updated_at ASC + LIMIT ? + `); + + const rows = stmt.all(limit) as (NotebookRow & { local_version: number })[]; + return rows.map(row => ({ + notebook: this.rowToNotebook(row), + localVersion: row.local_version, + })); + } + + /** + * Mark a notebook as synced (no pending changes). + */ + markAsSynced(notebookId: NotebookId): void { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 0, + last_synced_at = ? + WHERE id = ? + `); + stmt.run(new Date().toISOString(), notebookId); + } + + /** + * Mark multiple notebooks as synced in a transaction. + */ + markMultipleAsSynced(notebookIds: NotebookId[]): void { + if (notebookIds.length === 0) return; + + this.db.transaction(() => { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 0, + last_synced_at = ? + WHERE id = ? + `); + + const now = new Date().toISOString(); + for (const id of notebookIds) { + stmt.run(now, id); + } + }); + } + + /** + * Check if a notebook has pending local edits. + */ + hasPendingEdits(notebookId: NotebookId): boolean { + const stmt = this.db.prepare(` + SELECT needs_sync FROM notebooks WHERE id = ? + `); + const row = stmt.get(notebookId) as { needs_sync: number } | undefined; + return row?.needs_sync === 1; + } + + /** + * Reset sync tracking for a notebook (force re-sync). + */ + resetSyncTracking(notebookId: NotebookId): void { + const stmt = this.db.prepare(` + UPDATE notebooks + SET + needs_sync = 1, + local_version = local_version + 1 + WHERE id = ? + `); + stmt.run(notebookId); + } + + /** + * Validate tree integrity before enqueuing a push. + * Returns true if the notebook meets depth/parentId constraints. + */ + validateForSync(notebookId: NotebookId): { valid: boolean; error?: string } { + const stmt = this.db.prepare(` + SELECT id, name, parent_id, depth, "order", created_at, updated_at, + git_enabled, git_auto_commit, git_initialized_at + FROM notebooks WHERE id = ? + `); + const row = stmt.get(notebookId) as NotebookRow | undefined; + if (!row) return { valid: false, error: 'Notebook not found' }; + if (row.depth > 2) return { valid: false, error: `Depth ${row.depth} exceeds max (2)` }; + if (row.parent_id) { + const parent = this.db.prepare('SELECT id FROM notebooks WHERE id = ?').get(row.parent_id); + if (!parent) return { valid: false, error: `Parent notebook '${row.parent_id}' not found` }; + } + return { valid: true }; + } +``` + +**Step 4: Commit** + +```bash +git add packages/storage-sqlite/src/repositories/SQLiteNotebookRepository.ts packages/storage-sqlite/tests/notebookSync.test.ts +git commit -m "feat(storage): add sync methods to SQLiteNotebookRepository" +``` + +--- + +### Task 5: Desktop — Add notebook sync to ApiClient + +**Files:** + +- Modify: `apps/desktop/src/main/services/apiClient.ts` + +**Step 1: Add types for notebook sync** + +Add after `PushResponse` interface (~line 55): + +```typescript +export interface NotebookSyncChange { + id: string; + notebookId: string; + version: number; + operation: 'create' | 'update' | 'delete'; + data: string | null; + deviceId: string; + createdAt: string; +} + +export interface NotebookPullResponse { + changes: NotebookSyncChange[]; + cursor: number; + hasMore: boolean; +} + +export interface NotebookPushResult { + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; +} + +export interface NotebookPushResponse { + results: NotebookPushResult[]; + cursor: number; +} +``` + +**Step 2: Add methods to ApiClient class** + +Add after `pushChanges` method (~line 291): + +```typescript + // ========================================================================== + // Notebook Sync + // ========================================================================== + + async pullNotebookChanges(cursor: number, limit = 50): Promise { + const params = new URLSearchParams({ + cursor: cursor.toString(), + limit: limit.toString(), + }); + return this.request(`/sync/notebooks?${params}`); + } + + async pushNotebookChanges( + changes: Array<{ + notebookId: string; + operation: 'create' | 'update' | 'delete'; + data?: string | null; + localVersion?: number; + }> + ): Promise { + return this.request('/sync/notebooks', { + method: 'POST', + body: JSON.stringify({ + changes, + deviceId: this.deviceInfo.deviceId, + }), + }); + } +``` + +**Step 3: Commit** + +```bash +git add apps/desktop/src/main/services/apiClient.ts +git commit -m "feat(desktop): add notebook sync methods to ApiClient" +``` + +--- + +### Task 6: Desktop — Integrate notebook sync into SyncService + +**Files:** + +- Modify: `apps/desktop/src/main/services/syncService.ts` + +**Step 1: Add notebookRepository to constructor** + +Update imports and constructor to accept `SQLiteNotebookRepository`: + +```typescript +// Add to imports +import type { SQLiteNotebookRepository } from '@readied/storage-sqlite'; +import { createNotebookId } from '@readied/core'; +``` + +Add to constructor: + +```typescript +private notebookRepository: SQLiteNotebookRepository; +``` + +Update constructor signature: + +```typescript +constructor( + apiClient: ApiClient, + encryptionService: EncryptionService, + noteRepository: SQLiteNoteRepository, + notebookRepository: SQLiteNotebookRepository, + initialCursor = 0 +) +``` + +Add `notebookCursor` to `SyncState`: + +```typescript +interface SyncState { + cursor: number; + notebookCursor: number; + lastSyncAt: number | null; + isSyncing: boolean; +} +``` + +**Step 2: Add notebook pull method** + +Add after `pull()` method (~line 130): + +```typescript + /** + * Pull notebook changes from server + */ + async pullNotebooks(): Promise<{ + success: boolean; + changes: NotebookSyncChange[]; + cursor: number; + hasMore: boolean; + error?: string; + }> { + try { + const result = await this.apiClient.pullNotebookChanges(this.state.notebookCursor, 50); + + for (const change of result.changes) { + await this.applyRemoteNotebookChange(change); + } + + this.state.notebookCursor = result.cursor; + + return { + success: true, + changes: result.changes, + cursor: result.cursor, + hasMore: result.hasMore, + }; + } catch (error) { + return { + success: false, + changes: [], + cursor: this.state.notebookCursor, + hasMore: false, + error: error instanceof Error ? error.message : 'Failed to pull notebook changes', + }; + } + } +``` + +**Step 3: Add notebook push method** + +```typescript + /** + * Push local notebook changes to server + */ + async pushNotebooks(): Promise<{ + success: boolean; + results: NotebookPushResult[]; + error?: string; + }> { + try { + const pendingChanges = this.notebookRepository.getPendingChanges(50); + if (pendingChanges.length === 0) { + return { success: true, results: [] }; + } + + // Validate locally before pushing + const validChanges = pendingChanges.filter(({ notebook }) => { + const validation = this.notebookRepository.validateForSync(createNotebookId(notebook.id)); + if (!validation.valid) { + console.warn(`[notebook-sync] Skipping invalid notebook ${notebook.id}: ${validation.error}`); + } + return validation.valid; + }); + + if (validChanges.length === 0) { + return { success: true, results: [] }; + } + + const changesToPush = validChanges.map(({ notebook, localVersion }) => ({ + notebookId: notebook.id, + operation: 'update' as const, // Notebooks don't soft-delete like notes + data: JSON.stringify({ + name: notebook.name, + parentId: notebook.parentId, + depth: notebook.depth, + order: notebook.order, + createdAt: notebook.createdAt, + updatedAt: notebook.updatedAt, + }), + localVersion, + })); + + const result = await this.apiClient.pushNotebookChanges(changesToPush); + + // Mark successfully pushed notebooks as synced + const successfulIds = result.results + .filter(r => r.status === 'applied') + .map(r => createNotebookId(r.notebookId)); + + this.notebookRepository.markMultipleAsSynced(successfulIds); + + return { success: true, results: result.results }; + } catch (error) { + return { + success: false, + results: [], + error: error instanceof Error ? error.message : 'Failed to push notebook changes', + }; + } + } +``` + +**Step 4: Update syncNow() to sync notebooks first** + +In `syncNow()` method (line 187), add notebook sync BEFORE note sync: + +```typescript + async syncNow(): Promise { + if (this.state.isSyncing) { + return { success: false, changesApplied: 0, changesPushed: 0, conflicts: [], error: 'Sync already in progress' }; + } + + this.state.isSyncing = true; + + try { + // Step 1: Pull notebooks first (notes depend on notebooks) + const nbPullResult = await this.pullNotebooks(); + if (!nbPullResult.success) { + console.error('Failed to pull notebooks:', nbPullResult.error); + // Continue with note sync even if notebook sync fails + } + + // Step 2: Push notebooks + const nbPushResult = await this.pushNotebooks(); + if (!nbPushResult.success) { + console.error('Failed to push notebooks:', nbPushResult.error); + } + + // Step 3: Pull notes + const pullResult = await this.pull(); + // ... (existing note pull logic) + + // Step 4: Push notes + // ... (existing note push logic) + + return { + success: true, + changesApplied: pullResult.changes.length + (nbPullResult.changes?.length ?? 0), + changesPushed, + conflicts: pullResult.conflicts, + }; + } catch (error) { + // ... existing error handling + } finally { + this.state.isSyncing = false; + } + } +``` + +**Step 5: Add applyRemoteNotebookChange private method** + +```typescript + /** + * Apply a remote notebook change to local database + */ + private async applyRemoteNotebookChange(change: NotebookSyncChange): Promise { + const notebookId = createNotebookId(change.notebookId); + + switch (change.operation) { + case 'create': + case 'update': { + if (!change.data) { + throw new Error(`No data for ${change.operation} operation on notebook`); + } + + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + createdAt: string; + updatedAt: string; + }; + + const existing = await this.notebookRepository.get(notebookId); + + if (existing) { + // LWW — apply remote change + await this.notebookRepository.save({ + ...existing, + name: parsed.name, + parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, + depth: parsed.depth, + order: parsed.order, + updatedAt: parsed.updatedAt as Timestamp, + }); + } else { + // Create new notebook from remote + await this.notebookRepository.save( + createNotebook({ + id: notebookId, + name: parsed.name, + parentId: parsed.parentId ? createNotebookId(parsed.parentId) : null, + parentDepth: parsed.depth > 0 ? parsed.depth - 1 : undefined, + order: parsed.order, + createdAt: parsed.createdAt as Timestamp, + }) + ); + } + + // Mark as synced to avoid re-pushing + this.notebookRepository.markAsSynced(notebookId); + break; + } + + case 'delete': { + const existing = await this.notebookRepository.get(notebookId); + if (existing) { + await this.notebookRepository.delete(notebookId); + } + break; + } + + default: + console.warn(`Unknown notebook operation: ${change.operation}`); + } + } +``` + +**Step 6: Commit** + +```bash +git add apps/desktop/src/main/services/syncService.ts +git commit -m "feat(desktop): integrate notebook sync into SyncService" +``` + +--- + +### Task 7: Tree Validation Tests + +**Files:** + +- Create: `packages/sync-core/tests/treeValidation.test.ts` + +**Step 1: Write comprehensive tree validation tests** + +```typescript +import { describe, it, expect } from 'vitest'; + +// Replicate the validateNotebookTree logic for unit testing +function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existing: Map +): { valid: true } | { valid: false; error: string; notebookId: string } { + const tree = new Map(existing); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + if (!change.data) continue; + const parsed = JSON.parse(change.data); + + if (parsed.depth > 2) { + return { + valid: false, + error: `depth exceeds max (2), got ${parsed.depth}`, + notebookId: change.notebookId, + }; + } + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { + valid: false, + error: `parentId '${parsed.parentId}' not found`, + notebookId: change.notebookId, + }; + } + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { + valid: false, + error: `circular reference detected`, + notebookId: change.notebookId, + }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + return { valid: true }; +} + +describe('validateNotebookTree', () => { + it('accepts valid root notebook', () => { + const result = validateNotebookTree( + [ + { + notebookId: 'nb-1', + operation: 'create', + data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }), + }, + ], + new Map() + ); + expect(result).toEqual({ valid: true }); + }); + + it('accepts valid child notebook (depth 1)', () => { + const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree( + [ + { + notebookId: 'nb-2', + operation: 'create', + data: JSON.stringify({ name: 'Sub', parentId: 'nb-1', depth: 1, order: 0 }), + }, + ], + existing + ); + expect(result).toEqual({ valid: true }); + }); + + it('rejects depth > 2', () => { + const result = validateNotebookTree( + [ + { + notebookId: 'nb-deep', + operation: 'create', + data: JSON.stringify({ name: 'Deep', parentId: 'nb-2', depth: 3, order: 0 }), + }, + ], + new Map([['nb-2', { parentId: 'nb-1', depth: 2 }]]) + ); + expect(result).toEqual({ + valid: false, + error: 'depth exceeds max (2), got 3', + notebookId: 'nb-deep', + }); + }); + + it('rejects missing parentId', () => { + const result = validateNotebookTree( + [ + { + notebookId: 'nb-orphan', + operation: 'create', + data: JSON.stringify({ name: 'Orphan', parentId: 'nb-ghost', depth: 1, order: 0 }), + }, + ], + new Map() + ); + expect(result).toEqual({ + valid: false, + error: "parentId 'nb-ghost' not found", + notebookId: 'nb-orphan', + }); + }); + + it('detects circular reference A→B→A', () => { + const existing = new Map([['nb-a', { parentId: 'nb-b', depth: 1 }]]); + const result = validateNotebookTree( + [ + { + notebookId: 'nb-b', + operation: 'update', + data: JSON.stringify({ name: 'B', parentId: 'nb-a', depth: 1, order: 0 }), + }, + ], + existing + ); + expect(result).toEqual({ + valid: false, + error: 'circular reference detected', + notebookId: 'nb-b', + }); + }); + + it('detects self-reference', () => { + const result = validateNotebookTree( + [ + { + notebookId: 'nb-self', + operation: 'create', + data: JSON.stringify({ name: 'Self', parentId: 'nb-self', depth: 1, order: 0 }), + }, + ], + new Map() + ); + // parentId 'nb-self' not in existing tree yet (it's the same entry being created) + expect(result).toEqual({ + valid: false, + error: "parentId 'nb-self' not found", + notebookId: 'nb-self', + }); + }); + + it('accepts delete operation', () => { + const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree([{ notebookId: 'nb-1', operation: 'delete' }], existing); + expect(result).toEqual({ valid: true }); + }); + + it('handles two devices creating notebooks under same parent', () => { + const existing = new Map([['nb-root', { parentId: null, depth: 0 }]]); + const result = validateNotebookTree( + [ + { + notebookId: 'nb-a', + operation: 'create', + data: JSON.stringify({ name: 'A', parentId: 'nb-root', depth: 1, order: 0 }), + }, + { + notebookId: 'nb-b', + operation: 'create', + data: JSON.stringify({ name: 'B', parentId: 'nb-root', depth: 1, order: 1 }), + }, + ], + existing + ); + expect(result).toEqual({ valid: true }); + }); +}); +``` + +**Step 2: Run tests** + +Run: `cd packages/sync-core && npx vitest run tests/treeValidation.test.ts` +Expected: All 8 tests PASS + +**Step 3: Commit** + +```bash +git add packages/sync-core/tests/treeValidation.test.ts +git commit -m "test(sync-core): add tree validation unit tests for notebook sync" +``` + +--- + +### Task 8: Extract shared validation function + +**Files:** + +- Create: `packages/sync-core/src/treeValidation.ts` +- Modify: `packages/api/src/routes/sync.ts` (import from sync-core) + +**Step 1: Extract validateNotebookTree to sync-core** + +Create `packages/sync-core/src/treeValidation.ts`: + +```typescript +/** + * Notebook tree validation for sync. + * + * Validates that pushed notebook changes maintain tree integrity: + * - depth ≤ 2 + * - parentId references existing notebook + * - No circular references + */ + +export interface TreeNode { + parentId: string | null; + depth: number; +} + +export type TreeValidationResult = + | { valid: true } + | { valid: false; error: string; notebookId: string }; + +export function validateNotebookTree( + changes: Array<{ notebookId: string; operation: string; data?: string | null }>, + existingNotebooks: Map +): TreeValidationResult { + const tree = new Map(existingNotebooks); + + for (const change of changes) { + if (change.operation === 'delete') { + tree.delete(change.notebookId); + continue; + } + + if (!change.data) continue; + + const parsed = JSON.parse(change.data) as { + name: string; + parentId: string | null; + depth: number; + order: number; + }; + + if (parsed.depth > 2) { + return { + valid: false, + error: `depth exceeds max (2), got ${parsed.depth}`, + notebookId: change.notebookId, + }; + } + + if (parsed.parentId && !tree.has(parsed.parentId)) { + return { + valid: false, + error: `parentId '${parsed.parentId}' not found`, + notebookId: change.notebookId, + }; + } + + if (parsed.parentId) { + const visited = new Set([change.notebookId]); + let current: string | null = parsed.parentId; + while (current) { + if (visited.has(current)) { + return { + valid: false, + error: 'circular reference detected', + notebookId: change.notebookId, + }; + } + visited.add(current); + current = tree.get(current)?.parentId ?? null; + } + } + + tree.set(change.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); + } + + return { valid: true }; +} +``` + +**Step 2: Export from sync-core index** + +Add to `packages/sync-core/src/index.ts`: + +```typescript +export { + validateNotebookTree, + type TreeNode, + type TreeValidationResult, +} from './treeValidation.js'; +``` + +**Step 3: Update tests to import from the module** + +Update `packages/sync-core/tests/treeValidation.test.ts` to import from `'../src/treeValidation.js'` instead of duplicating the function. + +**Step 4: Update API route to import from sync-core** + +In `packages/api/src/routes/sync.ts`, replace the inline `validateNotebookTree` with: + +```typescript +import { validateNotebookTree } from '@readied/sync-core'; +``` + +**Step 5: Run all tests** + +Run: `pnpm test` +Expected: All pass + +**Step 6: Commit** + +```bash +git add packages/sync-core/src/treeValidation.ts packages/sync-core/src/index.ts packages/sync-core/tests/treeValidation.test.ts packages/api/src/routes/sync.ts +git commit -m "refactor(sync-core): extract shared tree validation to sync-core package" +``` + +--- + +### Task 9: Final integration test and typecheck + +**Step 1: Run full test suite** + +Run: `pnpm test` +Expected: All tests pass + +**Step 2: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 3: Run lint and format** + +Run: `pnpm lint && pnpm format:check` +Expected: Clean + +**Step 4: Final commit if any formatting fixes needed** + +```bash +pnpm format +git add -A +git commit -m "chore: format notebook sync code" +``` + +--- + +## Task Dependency Graph + +``` +Task 1 (API schema) ──→ Task 2 (API routes) ──→ Task 8 (extract shared validation) + ↑ +Task 3 (SQLite migration) ──→ Task 4 (repository methods) ──→ Task 6 (SyncService) + ↓ +Task 5 (ApiClient methods) ──────────────────────────────────→ Task 6 + ↓ +Task 7 (tree validation tests) ──→ Task 8 ──→ Task 9 (integration) +``` + +**Parallelizable:** Tasks 1+3 can run in parallel. Tasks 5+7 can run in parallel. diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 94472eb5..a9e3c591 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -274,17 +274,29 @@ function validateNotebookTree( }; if (parsed.depth > 2) { - return { valid: false, error: `depth exceeds max (2), got ${parsed.depth}`, notebookId: change.notebookId }; + return { + valid: false, + error: `depth exceeds max (2), got ${parsed.depth}`, + notebookId: change.notebookId, + }; } if (parsed.parentId && !tree.has(parsed.parentId)) { - return { valid: false, error: `parentId '${parsed.parentId}' not found`, notebookId: change.notebookId }; + return { + valid: false, + error: `parentId '${parsed.parentId}' not found`, + notebookId: change.notebookId, + }; } if (parsed.parentId) { const visited = new Set([change.notebookId]); let current: string | null = parsed.parentId; while (current) { if (visited.has(current)) { - return { valid: false, error: 'circular reference detected', notebookId: change.notebookId }; + return { + valid: false, + error: 'circular reference detected', + notebookId: change.notebookId, + }; } visited.add(current); current = tree.get(current)?.parentId ?? null; @@ -298,7 +310,7 @@ function validateNotebookTree( // Pull notebook changes sync.get('/notebooks', zValidator('query', pullSchema), async c => { const { cursor, limit } = c.req.valid('query'); - const { userId, deviceId } = c.get('user'); + const { userId } = c.get('user'); const db = createDb(c.env); const [sub] = await db @@ -369,12 +381,17 @@ sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { // Validate tree integrity const validation = validateNotebookTree(changes, latestByNotebook); if (!validation.valid) { - console.warn(`[notebook-sync] Tree validation failed for user ${userId}: ${validation.error} (notebook: ${validation.notebookId})`); - return c.json({ - error: 'Tree validation failed', - detail: validation.error, - notebookId: validation.notebookId, - }, 422); + console.warn( + `[notebook-sync] Tree validation failed for user ${userId}: ${validation.error} (notebook: ${validation.notebookId})` + ); + return c.json( + { + error: 'Tree validation failed', + detail: validation.error, + notebookId: validation.notebookId, + }, + 422 + ); } // Process changes in transaction @@ -397,7 +414,9 @@ sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { const [latestEntry] = await tx .select() .from(notebookSyncLog) - .where(and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId))) + .where( + and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId)) + ) .orderBy(desc(notebookSyncLog.version)) .limit(1); diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index 8f2888d2..e3e364b8 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -54,4 +54,8 @@ export type { SyncStorage, SyncEngineConfig } from './engine.js'; export { SyncEngine } from './engine.js'; // Tree validation -export { validateNotebookTree, type TreeNode, type TreeValidationResult } from './treeValidation.js'; +export { + validateNotebookTree, + type TreeNode, + type TreeValidationResult, +} from './treeValidation.js'; diff --git a/packages/sync-core/tests/treeValidation.test.ts b/packages/sync-core/tests/treeValidation.test.ts index 3168aec4..b373731b 100644 --- a/packages/sync-core/tests/treeValidation.test.ts +++ b/packages/sync-core/tests/treeValidation.test.ts @@ -4,7 +4,13 @@ import { validateNotebookTree } from '../src/treeValidation.js'; describe('validateNotebookTree', () => { it('accepts valid root notebook', () => { const result = validateNotebookTree( - [{ notebookId: 'nb-1', operation: 'create', data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }) }], + [ + { + notebookId: 'nb-1', + operation: 'create', + data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }), + }, + ], new Map() ); expect(result).toEqual({ valid: true }); @@ -13,7 +19,13 @@ describe('validateNotebookTree', () => { it('accepts valid child notebook (depth 1)', () => { const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); const result = validateNotebookTree( - [{ notebookId: 'nb-2', operation: 'create', data: JSON.stringify({ name: 'Sub', parentId: 'nb-1', depth: 1, order: 0 }) }], + [ + { + notebookId: 'nb-2', + operation: 'create', + data: JSON.stringify({ name: 'Sub', parentId: 'nb-1', depth: 1, order: 0 }), + }, + ], existing ); expect(result).toEqual({ valid: true }); @@ -21,43 +33,80 @@ describe('validateNotebookTree', () => { it('rejects depth > 2', () => { const result = validateNotebookTree( - [{ notebookId: 'nb-deep', operation: 'create', data: JSON.stringify({ name: 'Deep', parentId: 'nb-2', depth: 3, order: 0 }) }], + [ + { + notebookId: 'nb-deep', + operation: 'create', + data: JSON.stringify({ name: 'Deep', parentId: 'nb-2', depth: 3, order: 0 }), + }, + ], new Map([['nb-2', { parentId: 'nb-1', depth: 2 }]]) ); - expect(result).toEqual({ valid: false, error: 'depth exceeds max (2), got 3', notebookId: 'nb-deep' }); + expect(result).toEqual({ + valid: false, + error: 'depth exceeds max (2), got 3', + notebookId: 'nb-deep', + }); }); it('rejects missing parentId', () => { const result = validateNotebookTree( - [{ notebookId: 'nb-orphan', operation: 'create', data: JSON.stringify({ name: 'Orphan', parentId: 'nb-ghost', depth: 1, order: 0 }) }], + [ + { + notebookId: 'nb-orphan', + operation: 'create', + data: JSON.stringify({ name: 'Orphan', parentId: 'nb-ghost', depth: 1, order: 0 }), + }, + ], new Map() ); - expect(result).toEqual({ valid: false, error: "parentId 'nb-ghost' not found", notebookId: 'nb-orphan' }); + expect(result).toEqual({ + valid: false, + error: "parentId 'nb-ghost' not found", + notebookId: 'nb-orphan', + }); }); it('detects circular reference A->B->A', () => { const existing = new Map([['nb-a', { parentId: 'nb-b', depth: 1 }]]); const result = validateNotebookTree( - [{ notebookId: 'nb-b', operation: 'update', data: JSON.stringify({ name: 'B', parentId: 'nb-a', depth: 1, order: 0 }) }], + [ + { + notebookId: 'nb-b', + operation: 'update', + data: JSON.stringify({ name: 'B', parentId: 'nb-a', depth: 1, order: 0 }), + }, + ], existing ); - expect(result).toEqual({ valid: false, error: 'circular reference detected', notebookId: 'nb-b' }); + expect(result).toEqual({ + valid: false, + error: 'circular reference detected', + notebookId: 'nb-b', + }); }); it('detects self-reference via missing parentId', () => { const result = validateNotebookTree( - [{ notebookId: 'nb-self', operation: 'create', data: JSON.stringify({ name: 'Self', parentId: 'nb-self', depth: 1, order: 0 }) }], + [ + { + notebookId: 'nb-self', + operation: 'create', + data: JSON.stringify({ name: 'Self', parentId: 'nb-self', depth: 1, order: 0 }), + }, + ], new Map() ); - expect(result).toEqual({ valid: false, error: "parentId 'nb-self' not found", notebookId: 'nb-self' }); + expect(result).toEqual({ + valid: false, + error: "parentId 'nb-self' not found", + notebookId: 'nb-self', + }); }); it('accepts delete operation', () => { const existing = new Map([['nb-1', { parentId: null, depth: 0 }]]); - const result = validateNotebookTree( - [{ notebookId: 'nb-1', operation: 'delete' }], - existing - ); + const result = validateNotebookTree([{ notebookId: 'nb-1', operation: 'delete' }], existing); expect(result).toEqual({ valid: true }); }); @@ -65,8 +114,16 @@ describe('validateNotebookTree', () => { const existing = new Map([['nb-root', { parentId: null, depth: 0 }]]); const result = validateNotebookTree( [ - { notebookId: 'nb-a', operation: 'create', data: JSON.stringify({ name: 'A', parentId: 'nb-root', depth: 1, order: 0 }) }, - { notebookId: 'nb-b', operation: 'create', data: JSON.stringify({ name: 'B', parentId: 'nb-root', depth: 1, order: 1 }) }, + { + notebookId: 'nb-a', + operation: 'create', + data: JSON.stringify({ name: 'A', parentId: 'nb-root', depth: 1, order: 0 }), + }, + { + notebookId: 'nb-b', + operation: 'create', + data: JSON.stringify({ name: 'B', parentId: 'nb-root', depth: 1, order: 1 }), + }, ], existing ); From 305bcb5cd003589863753c1c88d787f45f861e8e Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 02:16:39 -0300 Subject: [PATCH 011/148] test(sync-core): add SyncQueue unit tests Co-Authored-By: Claude Opus 4.6 --- packages/sync-core/tests/queue.test.ts | 346 +++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 packages/sync-core/tests/queue.test.ts diff --git a/packages/sync-core/tests/queue.test.ts b/packages/sync-core/tests/queue.test.ts new file mode 100644 index 00000000..f2738135 --- /dev/null +++ b/packages/sync-core/tests/queue.test.ts @@ -0,0 +1,346 @@ +/** + * SyncQueue Tests + * + * Tests for the SyncQueue class using an in-memory mock storage. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncQueue, type SyncQueueStorage } from '../src/queue.js'; +import type { SyncChange } from '../src/types.js'; + +// ============================================================================ +// In-memory mock implementation of SyncQueueStorage +// ============================================================================ + +function createMockStorage(): SyncQueueStorage & { changes: SyncChange[] } { + const changes: SyncChange[] = []; + let nextId = 1; + + return { + changes, + + async enqueue(change: Omit): Promise { + const full: SyncChange = { ...change, id: `sc_${nextId++}` }; + changes.push(full); + return full; + }, + + async getPending(): Promise { + return changes.filter((c) => !c.synced); + }, + + async markSynced(ids: string[]): Promise { + for (const c of changes) { + if (ids.includes(c.id)) { + c.synced = true; + } + } + }, + + async markFailed(id: string, error: string): Promise { + const change = changes.find((c) => c.id === id); + if (change) { + change.retryCount += 1; + change.lastError = error; + } + }, + + async cleanup(before: Date): Promise { + const initial = changes.length; + const remaining = changes.filter( + (c) => !(c.synced && new Date(c.timestamp) < before) + ); + changes.length = 0; + changes.push(...remaining); + return initial - remaining.length; + }, + + async clear(): Promise { + changes.length = 0; + }, + + async getPendingCount(): Promise { + return changes.filter((c) => !c.synced).length; + }, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('SyncQueue', () => { + let storage: ReturnType; + let queue: SyncQueue; + + beforeEach(() => { + storage = createMockStorage(); + queue = new SyncQueue(storage); + }); + + // -------------------------------------------------------------------------- + // queueChange + // -------------------------------------------------------------------------- + + describe('queueChange', () => { + it('enqueues a change with correct fields', async () => { + await queue.queueChange('note', 'note_1', 'create', { title: 'Hello' }); + + expect(storage.changes).toHaveLength(1); + const change = storage.changes[0]; + expect(change.entityType).toBe('note'); + expect(change.entityId).toBe('note_1'); + expect(change.operation).toBe('create'); + expect(change.data).toEqual({ title: 'Hello' }); + expect(change.synced).toBe(false); + expect(change.retryCount).toBe(0); + expect(change.lastError).toBeNull(); + }); + + it('sets a valid ISO timestamp', async () => { + const before = new Date().toISOString(); + await queue.queueChange('notebook', 'nb_1', 'update', {}); + const after = new Date().toISOString(); + + const ts = storage.changes[0].timestamp; + expect(ts >= before).toBe(true); + expect(ts <= after).toBe(true); + }); + + it('enqueues multiple changes independently', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'update', null); + await queue.queueChange('notebook', 'nb1', 'delete', null); + + expect(storage.changes).toHaveLength(3); + expect(storage.changes.map((c) => c.entityId)).toEqual(['n1', 'n2', 'nb1']); + }); + }); + + // -------------------------------------------------------------------------- + // getPendingChanges + // -------------------------------------------------------------------------- + + describe('getPendingChanges', () => { + it('returns all pending changes when under limit', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'update', null); + + const pending = await queue.getPendingChanges(10); + expect(pending).toHaveLength(2); + }); + + it('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await queue.queueChange('note', `n${i}`, 'create', null); + } + + const pending = await queue.getPendingChanges(3); + expect(pending).toHaveLength(3); + }); + + it('uses default limit of 50', async () => { + // Enqueue 60 changes + for (let i = 0; i < 60; i++) { + await queue.queueChange('note', `n${i}`, 'create', null); + } + + const pending = await queue.getPendingChanges(); + expect(pending).toHaveLength(50); + }); + + it('returns empty array when no pending changes', async () => { + const pending = await queue.getPendingChanges(); + expect(pending).toEqual([]); + }); + + it('excludes synced changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'create', null); + // Mark first as synced directly in storage + storage.changes[0].synced = true; + + const pending = await queue.getPendingChanges(); + expect(pending).toHaveLength(1); + expect(pending[0].entityId).toBe('n2'); + }); + }); + + // -------------------------------------------------------------------------- + // markSynced + // -------------------------------------------------------------------------- + + describe('markSynced', () => { + it('delegates to storage', async () => { + await queue.queueChange('note', 'n1', 'create', null); + const id = storage.changes[0].id; + + await queue.markSynced([id]); + expect(storage.changes[0].synced).toBe(true); + }); + + it('marks multiple changes as synced', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'create', null); + const ids = storage.changes.map((c) => c.id); + + await queue.markSynced(ids); + expect(storage.changes.every((c) => c.synced)).toBe(true); + }); + + it('is a no-op with empty array', async () => { + const spy = vi.spyOn(storage, 'markSynced'); + + await queue.markSynced([]); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + // -------------------------------------------------------------------------- + // markFailed + // -------------------------------------------------------------------------- + + describe('markFailed', () => { + it('delegates to storage with id and error', async () => { + await queue.queueChange('note', 'n1', 'create', null); + const id = storage.changes[0].id; + + await queue.markFailed(id, 'Network timeout'); + + expect(storage.changes[0].retryCount).toBe(1); + expect(storage.changes[0].lastError).toBe('Network timeout'); + }); + + it('increments retryCount on successive failures', async () => { + await queue.queueChange('note', 'n1', 'create', null); + const id = storage.changes[0].id; + + await queue.markFailed(id, 'Error 1'); + await queue.markFailed(id, 'Error 2'); + + expect(storage.changes[0].retryCount).toBe(2); + expect(storage.changes[0].lastError).toBe('Error 2'); + }); + }); + + // -------------------------------------------------------------------------- + // getPendingCount + // -------------------------------------------------------------------------- + + describe('getPendingCount', () => { + it('returns 0 when queue is empty', async () => { + expect(await queue.getPendingCount()).toBe(0); + }); + + it('returns correct count of pending changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'update', null); + + expect(await queue.getPendingCount()).toBe(2); + }); + + it('does not count synced changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'create', null); + storage.changes[0].synced = true; + + expect(await queue.getPendingCount()).toBe(1); + }); + }); + + // -------------------------------------------------------------------------- + // clear + // -------------------------------------------------------------------------- + + describe('clear', () => { + it('delegates to storage and removes all changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + await queue.queueChange('note', 'n2', 'update', null); + + await queue.clear(); + + expect(storage.changes).toHaveLength(0); + expect(await queue.getPendingCount()).toBe(0); + }); + + it('is safe to call on empty queue', async () => { + await queue.clear(); + expect(storage.changes).toHaveLength(0); + }); + }); + + // -------------------------------------------------------------------------- + // cleanup + // -------------------------------------------------------------------------- + + describe('cleanup', () => { + it('calculates correct date threshold and delegates to storage', async () => { + const spy = vi.spyOn(storage, 'cleanup'); + + const before = new Date(); + before.setDate(before.getDate() - 7); + + await queue.cleanup(7); + + expect(spy).toHaveBeenCalledOnce(); + const arg = spy.mock.calls[0][0] as Date; + // The threshold should be approximately 7 days ago (within 1 second) + expect(Math.abs(arg.getTime() - before.getTime())).toBeLessThan(1000); + }); + + it('uses default of 7 days', async () => { + const spy = vi.spyOn(storage, 'cleanup'); + + const before = new Date(); + before.setDate(before.getDate() - 7); + + await queue.cleanup(); + + const arg = spy.mock.calls[0][0] as Date; + expect(Math.abs(arg.getTime() - before.getTime())).toBeLessThan(1000); + }); + + it('removes old synced changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + // Make it synced and old + storage.changes[0].synced = true; + storage.changes[0].timestamp = new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000 + ).toISOString(); + + // Add a recent pending change + await queue.queueChange('note', 'n2', 'create', null); + + const removed = await queue.cleanup(7); + + expect(removed).toBe(1); + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0].entityId).toBe('n2'); + }); + + it('does not remove pending (unsynced) changes', async () => { + await queue.queueChange('note', 'n1', 'create', null); + // Old but not synced + storage.changes[0].timestamp = new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000 + ).toISOString(); + + const removed = await queue.cleanup(7); + expect(removed).toBe(0); + expect(storage.changes).toHaveLength(1); + }); + + it('accepts custom olderThanDays', async () => { + const spy = vi.spyOn(storage, 'cleanup'); + + const before = new Date(); + before.setDate(before.getDate() - 30); + + await queue.cleanup(30); + + const arg = spy.mock.calls[0][0] as Date; + expect(Math.abs(arg.getTime() - before.getTime())).toBeLessThan(1000); + }); + }); +}); From fcd26b9878b80b1c1c3e513e40502eaf1ad03431 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 02:17:08 -0300 Subject: [PATCH 012/148] test(sync-core): add SyncEngine unit tests Co-Authored-By: Claude Opus 4.6 --- packages/sync-core/tests/engine.test.ts | 820 ++++++++++++++++++++++++ 1 file changed, 820 insertions(+) create mode 100644 packages/sync-core/tests/engine.test.ts diff --git a/packages/sync-core/tests/engine.test.ts b/packages/sync-core/tests/engine.test.ts new file mode 100644 index 00000000..835279e8 --- /dev/null +++ b/packages/sync-core/tests/engine.test.ts @@ -0,0 +1,820 @@ +/** + * SyncEngine Tests + * + * Tests for the sync engine that orchestrates push/pull operations, + * conflict resolution, device registration, and status management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncEngine, type SyncStorage, type SyncEngineConfig } from '../src/engine'; +import type { SyncClient, NotePushPayload } from '../src/client'; +import type { SyncQueue } from '../src/queue'; +import type { + DeviceId, + SyncStatus, + SyncConflict, + SyncableNote, + PushResult, + ConflictStrategy, +} from '../src/types'; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +const DEVICE_ID = 'device_abc123' as DeviceId; + +function createMockClient(): { + [K in keyof SyncClient]: ReturnType; +} { + return { + requestMagicLink: vi.fn(), + verifyMagicLink: vi.fn(), + refreshToken: vi.fn(), + getCurrentUser: vi.fn(), + logout: vi.fn(), + pushNotes: vi.fn().mockResolvedValue({ synced: [], conflicts: [], errors: [] }), + pullNotes: vi.fn().mockResolvedValue({ notes: [], cursor: 'cursor_0', hasMore: false }), + resolveNoteConflict: vi.fn(), + pushNotebooks: vi.fn(), + pullNotebooks: vi.fn(), + registerDevice: vi.fn().mockResolvedValue(DEVICE_ID), + listDevices: vi.fn(), + revokeDevice: vi.fn(), + }; +} + +function createMockStorage(): { + [K in keyof SyncStorage]: ReturnType; +} { + return { + getDeviceId: vi.fn().mockResolvedValue(DEVICE_ID), + setDeviceId: vi.fn().mockResolvedValue(undefined), + getCursor: vi.fn().mockResolvedValue(null), + setCursor: vi.fn().mockResolvedValue(undefined), + getModifiedNotes: vi.fn().mockResolvedValue([]), + applyRemoteNotes: vi.fn().mockResolvedValue([]), + markNotesSynced: vi.fn().mockResolvedValue(undefined), + getLastSyncedAt: vi.fn().mockResolvedValue(null), + setLastSyncedAt: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockQueue(): { + [K in keyof InstanceType]: ReturnType; +} { + return { + queueChange: vi.fn().mockResolvedValue(undefined), + getPendingChanges: vi.fn().mockResolvedValue([]), + markSynced: vi.fn().mockResolvedValue(undefined), + markFailed: vi.fn().mockResolvedValue(undefined), + getPendingCount: vi.fn().mockResolvedValue(0), + clear: vi.fn().mockResolvedValue(undefined), + cleanup: vi.fn().mockResolvedValue(0), + }; +} + +function createEngine(overrides: Partial = {}) { + const client = createMockClient(); + const storage = createMockStorage(); + const queue = createMockQueue(); + const onStatusChange = vi.fn(); + const onConflict = vi.fn(); + + const engine = new SyncEngine({ + client: client as unknown as SyncClient, + storage: storage as unknown as SyncStorage, + queue: queue as unknown as SyncQueue, + onStatusChange, + onConflict, + platform: 'darwin', + appVersion: '0.6.2', + ...overrides, + }); + + return { engine, client, storage, queue, onStatusChange, onConflict }; +} + +function makeNote(id: string): NotePushPayload { + return { + id, + title: `Note ${id}`, + content: `Content of ${id}`, + notebookId: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + archivedAt: null, + isPinned: false, + isDeleted: false, + status: 'active', + wordCount: 10, + localVersion: 1, + }; +} + +function makeSyncableNote(id: string): SyncableNote { + return { + id, + title: `Note ${id}`, + content: `Content of ${id}`, + notebookId: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + archivedAt: null, + isPinned: false, + isDeleted: false, + status: 'active', + wordCount: 10, + deviceId: DEVICE_ID, + syncVersion: 1, + lastSyncedAt: '2024-01-02T00:00:00Z', + }; +} + +function makeConflict(entityId: string, localNewer = true): SyncConflict { + return { + entityType: 'note', + entityId, + conflictType: 'update-update', + localVersion: { title: 'Local' }, + remoteVersion: { title: 'Remote' }, + localUpdatedAt: localNewer ? '2024-01-03T00:00:00Z' : '2024-01-01T00:00:00Z', + remoteUpdatedAt: localNewer ? '2024-01-01T00:00:00Z' : '2024-01-03T00:00:00Z', + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('SyncEngine', () => { + // ========================================================================== + // Lifecycle + // ========================================================================== + + describe('Lifecycle', () => { + it('getStatus() returns disabled initially', () => { + const { engine } = createEngine(); + expect(engine.getStatus()).toEqual({ status: 'disabled' }); + }); + + it('enable() sets status to idle with lastSyncedAt from storage', async () => { + const { engine, storage, onStatusChange } = createEngine(); + storage.getLastSyncedAt.mockResolvedValue('2024-01-07T12:00:00Z'); + + await engine.enable(); + + expect(engine.getStatus()).toEqual({ + status: 'idle', + lastSyncedAt: '2024-01-07T12:00:00Z', + }); + expect(onStatusChange).toHaveBeenCalledWith({ + status: 'idle', + lastSyncedAt: '2024-01-07T12:00:00Z', + }); + }); + + it('enable() sets lastSyncedAt to null when never synced', async () => { + const { engine, storage } = createEngine(); + storage.getLastSyncedAt.mockResolvedValue(null); + + await engine.enable(); + + expect(engine.getStatus()).toEqual({ status: 'idle', lastSyncedAt: null }); + }); + + it('disable() sets status to disabled and clears queue', async () => { + const { engine, queue } = createEngine(); + + await engine.enable(); + await engine.disable(); + + expect(engine.getStatus()).toEqual({ status: 'disabled' }); + expect(queue.clear).toHaveBeenCalledOnce(); + }); + }); + + // ========================================================================== + // Sync Cycle — Happy Path + // ========================================================================== + + describe('Sync Cycle — Happy Path', () => { + it('sync() skips when disabled', async () => { + const { engine, client } = createEngine(); + + await engine.sync(); + + expect(client.pushNotes).not.toHaveBeenCalled(); + expect(client.pullNotes).not.toHaveBeenCalled(); + }); + + it('sync() skips when already syncing (reentrance guard)', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + // Make pushNotes slow so sync is still in progress + let resolvePush!: (v: PushResult) => void; + client.pushNotes.mockReturnValue( + new Promise((resolve) => { + resolvePush = resolve; + }) + ); + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + + // Start first sync + const firstSync = engine.sync(); + + // Start second sync while first is in progress + const secondSync = engine.sync(); + + // Resolve the push + resolvePush({ synced: ['n1'], conflicts: [], errors: [] }); + + await firstSync; + await secondSync; + + // pushNotes should only be called once (second sync was skipped) + expect(client.pushNotes).toHaveBeenCalledTimes(1); + }); + + it('sync() pushes then pulls with no conflicts', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: ['n1'], + conflicts: [], + errors: [], + }); + client.pullNotes.mockResolvedValue({ + notes: [makeSyncableNote('n2')], + cursor: 'cursor_1', + hasMore: false, + }); + + await engine.sync(); + + // Both push and pull were called + expect(client.pushNotes).toHaveBeenCalled(); + expect(client.pullNotes).toHaveBeenCalled(); + + // Pull applied notes + expect(storage.applyRemoteNotes).toHaveBeenCalledWith([makeSyncableNote('n2')]); + + // Cursor stored + expect(storage.setCursor).toHaveBeenCalledWith('note', 'cursor_1'); + + // Final status is idle with updated lastSyncedAt + const finalStatus = engine.getStatus(); + expect(finalStatus.status).toBe('idle'); + }); + + it('sync() registers device if no deviceId', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getDeviceId.mockResolvedValue(null); + const newDeviceId = 'new_device_xyz' as DeviceId; + client.registerDevice.mockResolvedValue(newDeviceId); + + await engine.sync(); + + expect(client.registerDevice).toHaveBeenCalledWith({ + name: 'Readied Desktop', + platform: 'darwin', + version: '0.6.2', + }); + expect(storage.setDeviceId).toHaveBeenCalledWith(newDeviceId); + }); + + it('sync() reuses cached deviceId without registering', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getDeviceId.mockResolvedValue(DEVICE_ID); + // Ensure push is called so we can verify the deviceId was passed + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ synced: ['n1'], conflicts: [], errors: [] }); + + await engine.sync(); + + expect(client.registerDevice).not.toHaveBeenCalled(); + expect(client.pushNotes).toHaveBeenCalledWith(expect.anything(), DEVICE_ID); + }); + + it('sync() updates lastSyncedAt in storage on success', async () => { + const { engine, storage } = createEngine(); + await engine.enable(); + + await engine.sync(); + + expect(storage.setLastSyncedAt).toHaveBeenCalledWith(expect.any(String)); + // Verify it's a valid ISO string + const timestamp = storage.setLastSyncedAt.mock.calls[0][0]; + expect(() => new Date(timestamp)).not.toThrow(); + expect(new Date(timestamp).toISOString()).toBe(timestamp); + }); + }); + + // ========================================================================== + // Push + // ========================================================================== + + describe('Push', () => { + it('push with no modified notes does not call pushNotes', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getModifiedNotes.mockResolvedValue([]); + + await engine.sync(); + + expect(client.pushNotes).not.toHaveBeenCalled(); + }); + + it('push marks synced notes in storage', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getModifiedNotes.mockResolvedValue([makeNote('n1'), makeNote('n2')]); + client.pushNotes.mockResolvedValue({ + synced: ['n1', 'n2'], + conflicts: [], + errors: [], + }); + + await engine.sync(); + + expect(storage.markNotesSynced).toHaveBeenCalledWith(['n1', 'n2'], expect.any(Number)); + }); + + it('push does not mark synced when no notes were synced', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [], + errors: [{ entityId: 'n1', entityType: 'note', message: 'fail', code: 'SERVER_ERROR', retryable: true }], + }); + + await engine.sync(); + + expect(storage.markNotesSynced).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Pull + // ========================================================================== + + describe('Pull', () => { + it('pull paginates until hasMore is false', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + // Page 1: hasMore = true + client.pullNotes + .mockResolvedValueOnce({ + notes: [makeSyncableNote('n1')], + cursor: 'cursor_1', + hasMore: true, + }) + // Page 2: hasMore = true + .mockResolvedValueOnce({ + notes: [makeSyncableNote('n2')], + cursor: 'cursor_2', + hasMore: true, + }) + // Page 3: hasMore = false (done) + .mockResolvedValueOnce({ + notes: [makeSyncableNote('n3')], + cursor: 'cursor_3', + hasMore: false, + }); + + await engine.sync(); + + expect(client.pullNotes).toHaveBeenCalledTimes(3); + expect(storage.applyRemoteNotes).toHaveBeenCalledTimes(3); + }); + + it('pull stores cursor after each page', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + client.pullNotes + .mockResolvedValueOnce({ + notes: [makeSyncableNote('n1')], + cursor: 'cursor_page1', + hasMore: true, + }) + .mockResolvedValueOnce({ + notes: [], + cursor: 'cursor_page2', + hasMore: false, + }); + + await engine.sync(); + + expect(storage.setCursor).toHaveBeenCalledWith('note', 'cursor_page1'); + expect(storage.setCursor).toHaveBeenCalledWith('note', 'cursor_page2'); + expect(storage.setCursor).toHaveBeenCalledTimes(2); + }); + + it('pull uses existing cursor from storage', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getCursor.mockResolvedValue('existing_cursor'); + + await engine.sync(); + + expect(client.pullNotes).toHaveBeenCalledWith('existing_cursor', DEVICE_ID, 100); + }); + + it('pull skips applyRemoteNotes when page has no notes', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + client.pullNotes.mockResolvedValue({ + notes: [], + cursor: 'cursor_0', + hasMore: false, + }); + + await engine.sync(); + + expect(storage.applyRemoteNotes).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Conflicts + // ========================================================================== + + describe('Conflicts', () => { + it('latest-wins strategy auto-resolves with local when local is newer', async () => { + const { engine, client, storage } = createEngine({ + defaultConflictStrategy: 'latest-wins', + }); + await engine.enable(); + + const conflict = makeConflict('n1', true); // local newer + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.sync(); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'local-wins', + }); + // Should continue to pull phase, not stop at conflict + expect(client.pullNotes).toHaveBeenCalled(); + }); + + it('latest-wins strategy auto-resolves with remote when remote is newer', async () => { + const { engine, client, storage } = createEngine({ + defaultConflictStrategy: 'latest-wins', + }); + await engine.enable(); + + const conflict = makeConflict('n1', false); // remote newer + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.sync(); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'remote-wins', + }); + }); + + it('local-wins strategy resolves all conflicts as local-wins', async () => { + const { engine, client, storage } = createEngine({ + defaultConflictStrategy: 'local-wins', + }); + await engine.enable(); + + const conflict = makeConflict('n1', false); // remote is newer, but strategy overrides + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.sync(); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'local-wins', + }); + }); + + it('remote-wins strategy resolves all conflicts as remote-wins', async () => { + const { engine, client, storage } = createEngine({ + defaultConflictStrategy: 'remote-wins', + }); + await engine.enable(); + + const conflict = makeConflict('n1', true); // local is newer, but strategy overrides + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.sync(); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'remote-wins', + }); + }); + + it('manual strategy calls onConflict callback and sets conflict status', async () => { + const { engine, client, storage, onConflict } = createEngine({ + defaultConflictStrategy: 'manual', + }); + await engine.enable(); + + const conflict = makeConflict('n1'); + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + + await engine.sync(); + + expect(onConflict).toHaveBeenCalledWith([conflict]); + expect(engine.getStatus()).toEqual({ + status: 'conflict', + conflicts: [conflict], + }); + // Should NOT proceed to pull phase + expect(client.pullNotes).not.toHaveBeenCalled(); + }); + + it('unresolved auto-resolve conflicts set conflict status', async () => { + const { engine, client, storage } = createEngine({ + defaultConflictStrategy: 'latest-wins', + }); + await engine.enable(); + + const conflict = makeConflict('n1'); + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + // resolveNoteConflict throws => conflict becomes unresolved + client.resolveNoteConflict.mockRejectedValue(new Error('Resolution failed')); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'conflict', + conflicts: [conflict], + }); + expect(client.pullNotes).not.toHaveBeenCalled(); + }); + + it('defaults to latest-wins when no strategy is configured', async () => { + // No defaultConflictStrategy provided + const { engine, client, storage } = createEngine(); + await engine.enable(); + + const conflict = makeConflict('n1', true); + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockResolvedValue({ + synced: [], + conflicts: [conflict], + errors: [], + }); + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.sync(); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'local-wins', + }); + }); + }); + + // ========================================================================== + // resolveConflict() + // ========================================================================== + + describe('resolveConflict()', () => { + it('calls client.resolveNoteConflict with correct resolution and re-syncs', async () => { + const { engine, client } = createEngine(); + await engine.enable(); + + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.resolveConflict('n1', 'local'); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'local-wins', + }); + // Should trigger a sync after resolution + expect(client.pullNotes).toHaveBeenCalled(); + }); + + it('maps remote strategy correctly', async () => { + const { engine, client } = createEngine(); + await engine.enable(); + + client.resolveNoteConflict.mockResolvedValue(makeSyncableNote('n1')); + + await engine.resolveConflict('n1', 'remote'); + + expect(client.resolveNoteConflict).toHaveBeenCalledWith('n1', { + entityId: 'n1', + strategy: 'remote-wins', + }); + }); + }); + + // ========================================================================== + // Error Handling + // ========================================================================== + + describe('Error Handling', () => { + it('network error during push sets error status', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getModifiedNotes.mockResolvedValue([makeNote('n1')]); + client.pushNotes.mockRejectedValue(new Error('Network timeout')); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'error', + message: 'Network timeout', + lastSyncedAt: null, + }); + }); + + it('network error during pull sets error status', async () => { + const { engine, client } = createEngine(); + await engine.enable(); + + client.pullNotes.mockRejectedValue(new Error('Connection refused')); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'error', + message: 'Connection refused', + lastSyncedAt: null, + }); + }); + + it('error preserves lastSyncedAt from storage', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getLastSyncedAt.mockResolvedValue('2024-01-07T12:00:00Z'); + client.pullNotes.mockRejectedValue(new Error('Server error')); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'error', + message: 'Server error', + lastSyncedAt: '2024-01-07T12:00:00Z', + }); + }); + + it('non-Error thrown uses fallback message', async () => { + const { engine, client } = createEngine(); + await engine.enable(); + + client.pullNotes.mockRejectedValue('string error'); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'error', + message: 'Sync failed', + lastSyncedAt: null, + }); + }); + + it('isSyncing is reset after error', async () => { + const { engine, client } = createEngine(); + await engine.enable(); + + client.pullNotes.mockRejectedValue(new Error('fail')); + + await engine.sync(); + + // Should be able to sync again (not stuck in syncing state) + client.pullNotes.mockResolvedValue({ notes: [], cursor: 'c', hasMore: false }); + await engine.sync(); + + expect(client.pullNotes).toHaveBeenCalledTimes(2); + }); + + it('device registration failure sets error status', async () => { + const { engine, client, storage } = createEngine(); + await engine.enable(); + + storage.getDeviceId.mockResolvedValue(null); + client.registerDevice.mockRejectedValue(new Error('Unauthorized')); + + await engine.sync(); + + expect(engine.getStatus()).toEqual({ + status: 'error', + message: 'Unauthorized', + lastSyncedAt: null, + }); + }); + }); + + // ========================================================================== + // Status Callback + // ========================================================================== + + describe('Status callback', () => { + it('fires on each status transition during successful sync', async () => { + const { engine, onStatusChange } = createEngine(); + await engine.enable(); + + onStatusChange.mockClear(); // clear the enable() call + + await engine.sync(); + + const statuses = onStatusChange.mock.calls.map( + (call: [SyncStatus]) => call[0].status + ); + + // syncing (progress 0) → syncing (progress 10) → syncing (progress 40) → + // syncing (progress 50) → idle + expect(statuses[0]).toBe('syncing'); + expect(statuses[statuses.length - 1]).toBe('idle'); + }); + + it('fires with progress updates during pull pagination', async () => { + const { engine, client, onStatusChange } = createEngine(); + await engine.enable(); + onStatusChange.mockClear(); + + client.pullNotes + .mockResolvedValueOnce({ notes: [makeSyncableNote('n1')], cursor: 'c1', hasMore: true }) + .mockResolvedValueOnce({ notes: [makeSyncableNote('n2')], cursor: 'c2', hasMore: false }); + + await engine.sync(); + + const progressCalls = onStatusChange.mock.calls + .filter((call: [SyncStatus]) => call[0].status === 'syncing') + .map((call: [SyncStatus]) => (call[0] as { status: 'syncing'; progress: number }).progress); + + // Progress should be increasing + for (let i = 1; i < progressCalls.length; i++) { + expect(progressCalls[i]).toBeGreaterThanOrEqual(progressCalls[i - 1]); + } + }); + }); + + // ========================================================================== + // Queue + // ========================================================================== + + describe('Queue', () => { + it('queueChange delegates to queue', async () => { + const { engine, queue } = createEngine(); + + const data = { title: 'Updated' }; + await engine.queueChange('note', 'n1', 'update', data); + + expect(queue.queueChange).toHaveBeenCalledWith('note', 'n1', 'update', data); + }); + }); +}); From a88f19d914914964b8a2594514a2eaaa67d0f8e4 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:45:37 -0300 Subject: [PATCH 013/148] style: format sync-core test files Co-Authored-By: Claude Opus 4.6 --- packages/sync-core/tests/engine.test.ts | 33 ++++++++++++------------- packages/sync-core/tests/queue.test.ts | 24 +++++++----------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/sync-core/tests/engine.test.ts b/packages/sync-core/tests/engine.test.ts index 835279e8..47d3755b 100644 --- a/packages/sync-core/tests/engine.test.ts +++ b/packages/sync-core/tests/engine.test.ts @@ -5,18 +5,11 @@ * conflict resolution, device registration, and status management. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { SyncEngine, type SyncStorage, type SyncEngineConfig } from '../src/engine'; import type { SyncClient, NotePushPayload } from '../src/client'; import type { SyncQueue } from '../src/queue'; -import type { - DeviceId, - SyncStatus, - SyncConflict, - SyncableNote, - PushResult, - ConflictStrategy, -} from '../src/types'; +import type { DeviceId, SyncStatus, SyncConflict, SyncableNote, PushResult } from '../src/types'; // ============================================================================ // Test Helpers @@ -215,7 +208,7 @@ describe('SyncEngine', () => { // Make pushNotes slow so sync is still in progress let resolvePush!: (v: PushResult) => void; client.pushNotes.mockReturnValue( - new Promise((resolve) => { + new Promise(resolve => { resolvePush = resolve; }) ); @@ -357,7 +350,15 @@ describe('SyncEngine', () => { client.pushNotes.mockResolvedValue({ synced: [], conflicts: [], - errors: [{ entityId: 'n1', entityType: 'note', message: 'fail', code: 'SERVER_ERROR', retryable: true }], + errors: [ + { + entityId: 'n1', + entityType: 'note', + message: 'fail', + code: 'SERVER_ERROR', + retryable: true, + }, + ], }); await engine.sync(); @@ -771,9 +772,7 @@ describe('SyncEngine', () => { await engine.sync(); - const statuses = onStatusChange.mock.calls.map( - (call: [SyncStatus]) => call[0].status - ); + const statuses = (onStatusChange.mock.calls as [SyncStatus][]).map(call => call[0].status); // syncing (progress 0) → syncing (progress 10) → syncing (progress 40) → // syncing (progress 50) → idle @@ -792,9 +791,9 @@ describe('SyncEngine', () => { await engine.sync(); - const progressCalls = onStatusChange.mock.calls - .filter((call: [SyncStatus]) => call[0].status === 'syncing') - .map((call: [SyncStatus]) => (call[0] as { status: 'syncing'; progress: number }).progress); + const progressCalls = (onStatusChange.mock.calls as [SyncStatus][]) + .filter(call => call[0].status === 'syncing') + .map(call => (call[0] as { status: 'syncing'; progress: number }).progress); // Progress should be increasing for (let i = 1; i < progressCalls.length; i++) { diff --git a/packages/sync-core/tests/queue.test.ts b/packages/sync-core/tests/queue.test.ts index f2738135..4d277249 100644 --- a/packages/sync-core/tests/queue.test.ts +++ b/packages/sync-core/tests/queue.test.ts @@ -26,7 +26,7 @@ function createMockStorage(): SyncQueueStorage & { changes: SyncChange[] } { }, async getPending(): Promise { - return changes.filter((c) => !c.synced); + return changes.filter(c => !c.synced); }, async markSynced(ids: string[]): Promise { @@ -38,7 +38,7 @@ function createMockStorage(): SyncQueueStorage & { changes: SyncChange[] } { }, async markFailed(id: string, error: string): Promise { - const change = changes.find((c) => c.id === id); + const change = changes.find(c => c.id === id); if (change) { change.retryCount += 1; change.lastError = error; @@ -47,9 +47,7 @@ function createMockStorage(): SyncQueueStorage & { changes: SyncChange[] } { async cleanup(before: Date): Promise { const initial = changes.length; - const remaining = changes.filter( - (c) => !(c.synced && new Date(c.timestamp) < before) - ); + const remaining = changes.filter(c => !(c.synced && new Date(c.timestamp) < before)); changes.length = 0; changes.push(...remaining); return initial - remaining.length; @@ -60,7 +58,7 @@ function createMockStorage(): SyncQueueStorage & { changes: SyncChange[] } { }, async getPendingCount(): Promise { - return changes.filter((c) => !c.synced).length; + return changes.filter(c => !c.synced).length; }, }; } @@ -113,7 +111,7 @@ describe('SyncQueue', () => { await queue.queueChange('notebook', 'nb1', 'delete', null); expect(storage.changes).toHaveLength(3); - expect(storage.changes.map((c) => c.entityId)).toEqual(['n1', 'n2', 'nb1']); + expect(storage.changes.map(c => c.entityId)).toEqual(['n1', 'n2', 'nb1']); }); }); @@ -182,10 +180,10 @@ describe('SyncQueue', () => { it('marks multiple changes as synced', async () => { await queue.queueChange('note', 'n1', 'create', null); await queue.queueChange('note', 'n2', 'create', null); - const ids = storage.changes.map((c) => c.id); + const ids = storage.changes.map(c => c.id); await queue.markSynced(ids); - expect(storage.changes.every((c) => c.synced)).toBe(true); + expect(storage.changes.every(c => c.synced)).toBe(true); }); it('is a no-op with empty array', async () => { @@ -305,9 +303,7 @@ describe('SyncQueue', () => { await queue.queueChange('note', 'n1', 'create', null); // Make it synced and old storage.changes[0].synced = true; - storage.changes[0].timestamp = new Date( - Date.now() - 30 * 24 * 60 * 60 * 1000 - ).toISOString(); + storage.changes[0].timestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); // Add a recent pending change await queue.queueChange('note', 'n2', 'create', null); @@ -322,9 +318,7 @@ describe('SyncQueue', () => { it('does not remove pending (unsynced) changes', async () => { await queue.queueChange('note', 'n1', 'create', null); // Old but not synced - storage.changes[0].timestamp = new Date( - Date.now() - 30 * 24 * 60 * 60 * 1000 - ).toISOString(); + storage.changes[0].timestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); const removed = await queue.cleanup(7); expect(removed).toBe(0); From 488359eeaf97ff4c59c51d9dd9a9f3054602c7fa Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:48:16 -0300 Subject: [PATCH 014/148] feat: surface sync conflicts in status indicator Add conflict state to SyncStatusIndicator with amber warning icon and count. Conflicts now take priority over idle state so users discover them without navigating to Settings. Also export ConflictResolver from sync components barrel. Co-Authored-By: Claude Opus 4.6 --- .../components/sync/SyncStatusIndicator.tsx | 21 +++++++++++++++++-- .../src/renderer/components/sync/index.ts | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx index 86762867..fb745e0e 100644 --- a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx +++ b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx @@ -5,14 +5,22 @@ */ import { useState } from 'react'; -import { Cloud, CloudOff, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react'; -import { useSyncStore, selectStatus, selectLastSyncAt } from '../../stores/syncStore'; +import { Cloud, CloudOff, RefreshCw, CheckCircle, AlertCircle, AlertTriangle } from 'lucide-react'; +import { + useSyncStore, + selectStatus, + selectLastSyncAt, + selectHasConflicts, + selectConflicts, +} from '../../stores/syncStore'; import { useAuthStore } from '../../stores/authStore'; import styles from './SyncStatusIndicator.module.css'; export function SyncStatusIndicator() { const status = useSyncStore(selectStatus); const lastSyncAt = useSyncStore(selectLastSyncAt); + const hasConflicts = useSyncStore(selectHasConflicts); + const conflicts = useSyncStore(selectConflicts); const isAuthenticated = useAuthStore(state => state.isAuthenticated); const [showTooltip, setShowTooltip] = useState(false); @@ -25,6 +33,15 @@ export function SyncStatusIndicator() { }; } + // Conflicts take priority over idle state + if (hasConflicts && status !== 'syncing') { + return { + icon: , + label: `${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''} — resolve in Settings`, + color: '#f59e0b', + }; + } + switch (status) { case 'syncing': return { diff --git a/apps/desktop/src/renderer/components/sync/index.ts b/apps/desktop/src/renderer/components/sync/index.ts index befb1e0d..50cda592 100644 --- a/apps/desktop/src/renderer/components/sync/index.ts +++ b/apps/desktop/src/renderer/components/sync/index.ts @@ -5,4 +5,5 @@ */ export { SyncStatusIndicator } from './SyncStatusIndicator'; +export { ConflictResolver } from './ConflictResolver'; export { LoginModal } from './LoginModal'; From f34ab5e23dca29bf3debef87817430a732e6e37f Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:55:12 -0300 Subject: [PATCH 015/148] feat(storage): add tag sync tracking migration (UUID + triggers) Co-Authored-By: Claude Opus 4.6 --- .../src/migrations/015_tag_sync_tracking.ts | 57 +++++++++++++++++++ .../storage-sqlite/src/migrations/index.ts | 3 + 2 files changed, 60 insertions(+) create mode 100644 packages/storage-sqlite/src/migrations/015_tag_sync_tracking.ts diff --git a/packages/storage-sqlite/src/migrations/015_tag_sync_tracking.ts b/packages/storage-sqlite/src/migrations/015_tag_sync_tracking.ts new file mode 100644 index 00000000..56ac9ad2 --- /dev/null +++ b/packages/storage-sqlite/src/migrations/015_tag_sync_tracking.ts @@ -0,0 +1,57 @@ +/** + * Tag Sync Tracking Migration + * + * Adds UUID for sync identity, plus sync tracking columns to tags table. + * Tags use INTEGER autoincrement locally but need a UUID for cross-device identity. + */ + +import type { Migration } from '@readied/storage-core'; + +export const tagSyncTracking: Migration = { + version: 20250311000003, + name: 'tag_sync_tracking', + up: ` + -- Add UUID for cross-device identity + ALTER TABLE tags ADD COLUMN uuid TEXT; + + -- Backfill existing tags with generated UUIDs + UPDATE tags SET uuid = ( + lower(hex(randomblob(4))) || '-' || + lower(hex(randomblob(2))) || '-' || + '4' || lower(substr(hex(randomblob(2)), 2)) || '-' || + lower(substr('89ab', abs(random()) % 4 + 1, 1)) || lower(substr(hex(randomblob(2)), 2)) || '-' || + lower(hex(randomblob(6))) + ) WHERE uuid IS NULL; + + -- Sync tracking columns + ALTER TABLE tags ADD COLUMN local_version INTEGER DEFAULT 1; + ALTER TABLE tags ADD COLUMN needs_sync INTEGER DEFAULT 0; + ALTER TABLE tags ADD COLUMN last_synced_at TEXT; + + -- Index for finding tags that need syncing + CREATE INDEX IF NOT EXISTS idx_tags_needs_sync + ON tags(needs_sync) WHERE needs_sync = 1; + + -- Unique index on UUID + CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_uuid + ON tags(uuid); + + -- Trigger: track tag updates (name or color changes) + CREATE TRIGGER IF NOT EXISTS tags_update_sync_tracking + AFTER UPDATE OF name, color ON tags + WHEN OLD.name != NEW.name OR OLD.color IS NOT NEW.color + BEGIN + UPDATE tags SET + local_version = local_version + 1, + needs_sync = 1 + WHERE id = NEW.id; + END; + + -- Trigger: mark new tags for sync + CREATE TRIGGER IF NOT EXISTS tags_insert_sync_tracking + AFTER INSERT ON tags + BEGIN + UPDATE tags SET needs_sync = 1 WHERE id = NEW.id; + END; + `, +}; diff --git a/packages/storage-sqlite/src/migrations/index.ts b/packages/storage-sqlite/src/migrations/index.ts index 3c5cf3b4..5cba5bb6 100644 --- a/packages/storage-sqlite/src/migrations/index.ts +++ b/packages/storage-sqlite/src/migrations/index.ts @@ -17,6 +17,7 @@ import { syncTracking } from './011_sync_tracking.js'; import { gitNotebooks } from './012_git_notebooks.js'; import { pluginConfig } from './013_plugin_config.js'; import { pluginRegistry } from './014_plugin_registry.js'; +import { tagSyncTracking } from './015_tag_sync_tracking.js'; /** All migrations in order */ export const allMigrations: Migration[] = [ @@ -34,6 +35,7 @@ export const allMigrations: Migration[] = [ gitNotebooks, pluginConfig, pluginRegistry, + tagSyncTracking, ]; export { @@ -51,4 +53,5 @@ export { gitNotebooks, pluginConfig, pluginRegistry, + tagSyncTracking, }; From 4d2c4acbda58d7bc42f069bd63a4d10df4676efc Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:56:36 -0300 Subject: [PATCH 016/148] feat(api): add tagSyncLog table to server schema Co-Authored-By: Claude Opus 4.6 --- packages/api/src/db/schema.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 566cb316..82c3bd9c 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -105,6 +105,33 @@ export const syncLog = sqliteTable( ] ); +/** + * Tag sync log - tag changes (name + color, NOT encrypted — tags are metadata) + */ +export const tagSyncLog = sqliteTable( + 'tag_sync_log', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + tagId: text('tag_id').notNull(), // Client's tag UUID + version: integer('version').notNull(), + operation: text('operation').notNull(), // 'create' | 'update' | 'delete' + data: text('data'), // JSON: { name, color } — null for deletes + deviceId: text('device_id').notNull(), + createdAt: text('created_at') + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + table => [ + index('idx_tag_sync_log_user_version').on(table.userId, table.version), + index('idx_tag_sync_log_user_tag').on(table.userId, table.tagId), + ] +); + /** * Subscriptions - Pro tier tracking */ @@ -243,6 +270,7 @@ export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Device = typeof devices.$inferSelect; export type SyncLogEntry = typeof syncLog.$inferSelect; +export type TagSyncLogEntry = typeof tagSyncLog.$inferSelect; export type Subscription = typeof subscriptions.$inferSelect; export type Newsletter = typeof newsletter.$inferSelect; export type SharedNote = typeof sharedNotes.$inferSelect; From fff8cd8733c65285013abbb9094da72d62a41e9b Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:59:34 -0300 Subject: [PATCH 017/148] feat(api): add tag sync pull/push endpoints Co-Authored-By: Claude Opus 4.6 --- packages/api/src/routes/sync.ts | 151 +++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 30e02496..179c12f2 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -14,7 +14,7 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, gt, desc, sql } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; -import { syncLog, syncCursors, subscriptions } from '../db/schema.js'; +import { syncLog, syncCursors, subscriptions, tagSyncLog } from '../db/schema.js'; import { authMiddleware, type AuthUser } from '../middleware/auth.js'; import { syncRateLimit } from '../middleware/rateLimit.js'; @@ -234,4 +234,153 @@ sync.get('/status', async c => { }); }); +// ============================================================================ +// Tag Sync Endpoints +// ============================================================================ + +const tagPullSchema = z.object({ + cursor: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +sync.get('/tags', zValidator('query', tagPullSchema), async c => { + const { cursor, limit } = c.req.valid('query'); + const { userId } = c.get('user'); + const db = createDb(c.env); + + // Check subscription + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (sub?.status !== 'active' && sub?.status !== 'trialing') { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + const changes = await db + .select() + .from(tagSyncLog) + .where(and(eq(tagSyncLog.userId, userId), gt(tagSyncLog.version, cursor))) + .orderBy(tagSyncLog.version) + .limit(limit); + + const maxVersion = changes.length > 0 ? changes[changes.length - 1].version : cursor; + + return c.json({ + changes: changes.map(entry => ({ + id: entry.id, + tagId: entry.tagId, + version: entry.version, + operation: entry.operation, + data: entry.data, + deviceId: entry.deviceId, + createdAt: entry.createdAt, + })), + cursor: maxVersion, + hasMore: changes.length === limit, + }); +}); + +const tagChangeSchema = z.object({ + tagId: z.string().uuid(), + operation: z.enum(['create', 'update', 'delete']), + data: z.string().nullable().optional(), // JSON: { name, color } + localVersion: z.number().int().optional(), +}); + +const tagPushSchema = z.object({ + changes: z.array(tagChangeSchema).min(1).max(100), + deviceId: z.string().uuid(), +}); + +sync.post('/tags', zValidator('json', tagPushSchema), async c => { + const { changes, deviceId } = c.req.valid('json'); + const { userId } = c.get('user'); + const db = createDb(c.env); + + // Check subscription + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (sub?.status !== 'active' && sub?.status !== 'trialing') { + return c.json({ error: 'Sync requires Pro subscription' }, 403); + } + + // Validate tag data + for (const change of changes) { + if (change.operation !== 'delete' && change.data) { + const parsed = JSON.parse(change.data); + if (!parsed.name || typeof parsed.name !== 'string') { + return c.json({ error: 'Tag data must include name' }, 422); + } + } + } + + const { results, finalCursor } = await db.transaction(async tx => { + const [maxVersionResult] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${tagSyncLog.version}), 0)` }) + .from(tagSyncLog) + .where(eq(tagSyncLog.userId, userId)); + + let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; + + const txResults: Array<{ + tagId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; + }> = []; + + for (const change of changes) { + const [latestEntry] = await tx + .select() + .from(tagSyncLog) + .where(and(eq(tagSyncLog.userId, userId), eq(tagSyncLog.tagId, change.tagId))) + .orderBy(desc(tagSyncLog.version)) + .limit(1); + + if ( + latestEntry && + latestEntry.deviceId !== deviceId && + change.localVersion !== undefined && + latestEntry.version > change.localVersion + ) { + txResults.push({ + tagId: change.tagId, + version: latestEntry.version, + status: 'conflict', + serverVersion: latestEntry.version, + }); + continue; + } + + await tx.insert(tagSyncLog).values({ + userId, + tagId: change.tagId, + version: nextVersion, + operation: change.operation, + data: change.data ?? null, + deviceId, + }); + + txResults.push({ + tagId: change.tagId, + version: nextVersion, + status: 'applied', + }); + + nextVersion++; + } + + return { results: txResults, finalCursor: nextVersion - 1 }; + }); + + return c.json({ results, cursor: finalCursor }); +}); + export { sync }; From 0782ea81a6db5df01961e065f701d1b1b0fb95ee Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 09:59:35 -0300 Subject: [PATCH 018/148] feat(storage): add tag sync repository methods Co-Authored-By: Claude Opus 4.6 --- .../src/repositories/SQLiteNoteRepository.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index f1b10e06..d15fccf8 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -838,4 +838,113 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { `); stmt.run(noteId); } + + // ============================================================================ + // Tag Sync Methods + // ============================================================================ + + /** + * Get tags with pending sync changes + */ + getTagsPendingSync(limit: number): Array<{ tag: { id: number; uuid: string; name: string; color: string | null }; localVersion: number }> { + const stmt = this.db.prepare(` + SELECT id, uuid, name, color, local_version + FROM tags + WHERE needs_sync = 1 AND uuid IS NOT NULL + LIMIT ? + `); + const rows = stmt.all(limit) as Array<{ + id: number; + uuid: string; + name: string; + color: string | null; + local_version: number; + }>; + return rows.map(row => ({ + tag: { id: row.id, uuid: row.uuid, name: row.name, color: row.color }, + localVersion: row.local_version, + })); + } + + /** + * Mark a tag as synced (clear needs_sync flag) + */ + markTagAsSynced(tagUuid: string): void { + const stmt = this.db.prepare(` + UPDATE tags SET needs_sync = 0, last_synced_at = ? WHERE uuid = ? + `); + stmt.run(new Date().toISOString(), tagUuid); + } + + /** + * Mark multiple tags as synced + */ + markMultipleTagsAsSynced(tagUuids: string[]): void { + this.db.transaction(() => { + const now = new Date().toISOString(); + const stmt = this.db.prepare(` + UPDATE tags SET needs_sync = 0, last_synced_at = ? WHERE uuid = ? + `); + for (const uuid of tagUuids) { + stmt.run(now, uuid); + } + })(); + } + + /** + * Upsert a tag from remote sync (dedup by name) + * Returns the local tag id. + */ + upsertTagFromRemote(uuid: string, name: string, color: string | null): number { + return this.db.transaction(() => { + const normalized = name.trim().toLowerCase(); + + // Check if tag exists by UUID first + const byUuid = this.db.prepare('SELECT id, name, color FROM tags WHERE uuid = ?').get(uuid) as + | { id: number; name: string; color: string | null } + | undefined; + + if (byUuid) { + // Update existing tag + this.db.prepare('UPDATE tags SET name = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE uuid = ?') + .run(normalized, color, new Date().toISOString(), uuid); + return byUuid.id; + } + + // Check if tag exists by name (dedup) + const byName = this.db.prepare('SELECT id, uuid FROM tags WHERE name = ?').get(normalized) as + | { id: number; uuid: string | null } + | undefined; + + if (byName) { + // Merge: adopt the remote UUID, update color + this.db.prepare('UPDATE tags SET uuid = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE id = ?') + .run(uuid, color, new Date().toISOString(), byName.id); + return byName.id; + } + + // Create new tag + const result = this.db.prepare('INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)') + .run(normalized, color, uuid, new Date().toISOString()); + return Number(result.lastInsertRowid); + })(); + } + + /** + * Delete a tag by UUID (from remote sync) + */ + deleteTagByUuid(uuid: string): void { + this.db.prepare('DELETE FROM tags WHERE uuid = ?').run(uuid); + } + + /** + * Get tag UUID by name + */ + getTagUuid(tagName: string): string | null { + const normalized = tagName.trim().toLowerCase(); + const row = this.db.prepare('SELECT uuid FROM tags WHERE name = ?').get(normalized) as + | { uuid: string | null } + | undefined; + return row?.uuid ?? null; + } } From 1375fcd1c6414bc2d2b0b72c2a9f058095aa166a Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:01:16 -0300 Subject: [PATCH 019/148] fix(storage): remove double-invocation on transaction calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatabaseConnection.transaction() already calls the inner fn — no need for extra () at call site. Co-Authored-By: Claude Opus 4.6 --- .../storage-sqlite/src/repositories/SQLiteNoteRepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index d15fccf8..476e1f6f 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -888,7 +888,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { for (const uuid of tagUuids) { stmt.run(now, uuid); } - })(); + }); } /** @@ -927,7 +927,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { const result = this.db.prepare('INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)') .run(normalized, color, uuid, new Date().toISOString()); return Number(result.lastInsertRowid); - })(); + }); } /** From 51e8112dcdcaf4c33a1cddf1faae1a6c5e9ea1ac Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:02:28 -0300 Subject: [PATCH 020/148] feat(desktop): add tag sync API client methods Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/apiClient.ts | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 4a327d9e..c5f7bcac 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -54,6 +54,34 @@ export interface PushResponse { cursor: number; } +export interface TagSyncChange { + id: string; + tagId: string; + version: number; + operation: 'create' | 'update' | 'delete'; + data: string | null; // JSON: { name, color } + deviceId: string; + createdAt: string; +} + +export interface TagPullResponse { + changes: TagSyncChange[]; + cursor: number; + hasMore: boolean; +} + +export interface TagPushResult { + tagId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; +} + +export interface TagPushResponse { + results: TagPushResult[]; + cursor: number; +} + export interface SyncStatus { enabled: boolean; plan: string; @@ -290,6 +318,37 @@ export class ApiClient { }); } + /** + * Pull tag changes from server + */ + async pullTagChanges(cursor: number, limit = 50): Promise { + const params = new URLSearchParams({ + cursor: cursor.toString(), + limit: limit.toString(), + }); + return this.request(`/sync/tags?${params}`); + } + + /** + * Push tag changes to server + */ + async pushTagChanges( + changes: Array<{ + tagId: string; + operation: 'create' | 'update' | 'delete'; + data?: string | null; + localVersion?: number; + }> + ): Promise { + return this.request('/sync/tags', { + method: 'POST', + body: JSON.stringify({ + changes, + deviceId: this.deviceInfo.deviceId, + }), + }); + } + /** * Get sync status */ From 2e60230dd841b0b689a39d6ae83b7c10828d36cf Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:02:29 -0300 Subject: [PATCH 021/148] feat(desktop): add IPC bridge for tag sync Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 18 ++++++++++++++++++ apps/desktop/src/preload/index.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 93bf10c5..228ab690 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1589,6 +1589,24 @@ function registerAuthSyncHandlers(): void { } }); + // Tag sync - pull + ipcMain.handle('sync:pullTags', async () => { + try { + return await sync.pullTags(); + } catch (error) { + return { success: false, applied: 0, error: String(error) }; + } + }); + + // Tag sync - push + ipcMain.handle('sync:pushTags', async () => { + try { + return await sync.pushTags(); + } catch (error) { + return { success: false, pushed: 0, error: String(error) }; + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // Subscription // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 4ed53935..ae2d68dc 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -541,6 +541,10 @@ export interface ReadiedAPI { stopAutoSync: () => Promise<{ success: boolean; error?: string }>; /** Trigger manual sync */ triggerSync: () => Promise; + /** Pull tag changes from server */ + pullTags: () => Promise<{ success: boolean; applied: number; error?: string }>; + /** Push tag changes to server */ + pushTags: () => Promise<{ success: boolean; pushed: number; error?: string }>; }; subscription: { /** Get subscription status */ @@ -822,6 +826,8 @@ const api: ReadiedAPI = { startAutoSync: intervalMs => ipcRenderer.invoke('sync:startAutoSync', intervalMs), stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'), triggerSync: () => ipcRenderer.invoke('sync:trigger'), + pullTags: () => ipcRenderer.invoke('sync:pullTags'), + pushTags: () => ipcRenderer.invoke('sync:pushTags'), }, subscription: { getStatus: () => ipcRenderer.invoke('subscription:getStatus'), From 26f27570550ad7ad482fb3c511186715e02729a3 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:05:02 -0300 Subject: [PATCH 022/148] feat(desktop): integrate tag sync into sync cycle Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/syncService.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index e4a4f650..e8b704ed 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -35,6 +35,7 @@ export interface SyncResult { interface SyncState { cursor: number; + tagCursor: number; lastSyncAt: number | null; isSyncing: boolean; } @@ -62,6 +63,7 @@ export class SyncService { this.noteRepository = noteRepository; this.state = { cursor: initialCursor, + tagCursor: 0, lastSyncAt: null, isSyncing: false, }; @@ -181,6 +183,87 @@ export class SyncService { } } + /** + * Pull tag changes from server and apply locally + */ + async pullTags(): Promise<{ + success: boolean; + applied: number; + error?: string; + }> { + try { + const result = await this.apiClient.pullTagChanges(this.state.tagCursor, 50); + + let applied = 0; + for (const change of result.changes) { + try { + if (change.operation === 'delete') { + this.noteRepository.deleteTagByUuid(change.tagId); + } else if (change.data) { + const parsed = JSON.parse(change.data); + this.noteRepository.upsertTagFromRemote(change.tagId, parsed.name, parsed.color ?? null); + } + applied++; + } catch (error) { + console.error(`Failed to apply tag change ${change.id}:`, error); + break; + } + } + + this.state.tagCursor = applied === result.changes.length + ? result.cursor + : this.state.tagCursor; + + return { success: true, applied }; + } catch (error) { + return { + success: false, + applied: 0, + error: error instanceof Error ? error.message : 'Failed to pull tags', + }; + } + } + + /** + * Push local tag changes to server + */ + async pushTags(): Promise<{ + success: boolean; + pushed: number; + error?: string; + }> { + try { + const pending = this.noteRepository.getTagsPendingSync(50); + if (pending.length === 0) { + return { success: true, pushed: 0 }; + } + + const changes = pending.map(({ tag, localVersion }) => ({ + tagId: tag.uuid, + operation: 'update' as const, + data: JSON.stringify({ name: tag.name, color: tag.color }), + localVersion, + })); + + const result = await this.apiClient.pushTagChanges(changes); + + const successIds = result.results + .filter(r => r.status === 'applied') + .map(r => r.tagId); + + this.noteRepository.markMultipleTagsAsSynced(successIds); + this.state.tagCursor = result.cursor; + + return { success: true, pushed: successIds.length }; + } catch (error) { + return { + success: false, + pushed: 0, + error: error instanceof Error ? error.message : 'Failed to push tags', + }; + } + } + /** * Perform full sync cycle (pull + push) */ @@ -250,6 +333,18 @@ export class SyncService { } } + // Step 3: Pull tags + const tagPull = await this.pullTags(); + if (!tagPull.success) { + console.error('Tag pull failed:', tagPull.error); + } + + // Step 4: Push tags + const tagPush = await this.pushTags(); + if (!tagPush.success) { + console.error('Tag push failed:', tagPush.error); + } + return { success: true, changesApplied: pullResult.changes.length, From 043148c1b3588ed808295ea558f5fa08b61d71f8 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:09:40 -0300 Subject: [PATCH 023/148] fix: address PR review feedback for notebook sync - Fix pullNotebooks() to only advance cursor to last successfully applied change (prevents skipping failed changes on retry) - Fix tree validation snapshot to properly exclude deleted notebooks (prevents ghost parent references in validation) Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/syncService.ts | 9 ++++++++- packages/api/src/routes/sync.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index dd03e671..fbcdbbd4 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -153,16 +153,23 @@ export class SyncService { try { const result = await this.apiClient.pullNotebookChanges(this.state.notebookCursor, 50); + let appliedCount = 0; + let lastAppliedCursor = this.state.notebookCursor; + for (const change of result.changes) { try { await this.applyRemoteNotebookChange(change); + appliedCount++; + lastAppliedCursor = change.version ?? lastAppliedCursor; } catch (error) { console.error(`Failed to apply notebook change ${change.id}:`, error); break; } } - this.state.notebookCursor = result.cursor; + // Only advance cursor to last successfully applied change + this.state.notebookCursor = + appliedCount === result.changes.length ? result.cursor : lastAppliedCursor; return { success: true, diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index a9e3c591..facf32e1 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -371,11 +371,14 @@ sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { .orderBy(desc(notebookSyncLog.version)); const latestByNotebook = new Map(); + const processedIds = new Set(); for (const entry of existingEntries) { - if (!latestByNotebook.has(entry.notebookId) && entry.operation !== 'delete' && entry.data) { - const parsed = JSON.parse(entry.data); - latestByNotebook.set(entry.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); - } + if (processedIds.has(entry.notebookId)) continue; + processedIds.add(entry.notebookId); + // Skip deleted notebooks — they shouldn't appear in the validation tree + if (entry.operation === 'delete' || !entry.data) continue; + const parsed = JSON.parse(entry.data); + latestByNotebook.set(entry.notebookId, { parentId: parsed.parentId, depth: parsed.depth }); } // Validate tree integrity From 73f99385c8ee5ce66c87bd5fd8efcad549ca9942 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 10:22:24 -0300 Subject: [PATCH 024/148] chore: add CodeRabbit configuration Configure automated code review with path-specific instructions for core, storage, desktop, and API packages. Co-Authored-By: Claude Opus 4.6 --- .coderabbit.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..a3b9dcb9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,21 @@ +language: "en" +reviews: + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true + drafts: false + path_instructions: + - path: "packages/core/**" + instructions: "Core is pure domain logic. No Electron/React deps allowed." + - path: "packages/storage-sqlite/**" + instructions: "Native deps only in apps/desktop. Workspace packages use peerDependencies for native modules." + - path: "apps/desktop/src/main/**" + instructions: "Main process code. Check IPC handler patterns and sync service consistency." + - path: "packages/api/**" + instructions: "Server-side API using Hono + Drizzle ORM on Turso. Schema managed via drizzle-kit push, not file migrations." +chat: + auto_reply: true From cd018629967a51dd82d13e1dc1d5625969064fe3 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:15:13 -0300 Subject: [PATCH 025/148] feat(plugin-api): add PluginHookOptions type for remark/rehype registration Add optional metadata (name, version, priority) to registerRemarkPlugin and registerRehypePlugin signatures for debugging and execution ordering. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/index.ts | 1 + packages/plugin-api/src/types.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 85e484ee..423cccf8 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -13,6 +13,7 @@ export type { PluginConfigAPI, PluginLogger, PluginCommandOptions, + PluginHookOptions, } from './types'; // Layout diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 0cf7e4d9..5b28bc32 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -109,6 +109,16 @@ export interface PluginCommandOptions { showInPalette?: boolean; } +/** Options for registering a remark/rehype plugin */ +export interface PluginHookOptions { + /** Display name for debugging (defaults to pluginId) */ + name?: string; + /** Plugin version for debugging */ + version?: string; + /** Execution priority — lower runs first. Default: 100 */ + priority?: number; +} + export interface PluginContext { layout: LayoutManager; editor: EditorAPI; @@ -119,9 +129,9 @@ export interface PluginContext { execute: () => boolean | void | Promise ): () => void; /** Register a remark (mdast) plugin for the markdown preview pipeline */ - registerRemarkPlugin(id: string, plugin: unknown): () => void; + registerRemarkPlugin(id: string, plugin: unknown, options?: PluginHookOptions): () => void; /** Register a rehype (hast) plugin for the markdown preview pipeline */ - registerRehypePlugin(id: string, plugin: unknown): () => void; + registerRehypePlugin(id: string, plugin: unknown, options?: PluginHookOptions): () => void; /** Register a custom React component to replace an HTML element in the preview */ // eslint-disable-next-line @typescript-eslint/no-explicit-any registerPreviewComponent(id: string, tagName: string, component: ComponentType): () => void; From e1eb0927ed7833d84e67bd854e5ed9151451a52a Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:21:40 -0300 Subject: [PATCH 026/148] feat: add safePluginWrapper for graceful plugin failure handling Wraps remark/rehype plugins so that if a transformer throws, the error is caught, logged, and an error marker node is injected into the AST instead of crashing the entire preview pipeline. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/index.ts | 2 + .../src/preview/safePluginWrapper.ts | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/plugin-api/src/preview/safePluginWrapper.ts diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 423cccf8..e862ca58 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -42,6 +42,8 @@ export { rehypePluginStore } from './preview/rehypePluginStore'; export type { RehypePluginRegistration } from './preview/rehypePluginStore'; export { codeBlockStore } from './preview/codeBlockStore'; export type { CodeBlockRegistration, CodeBlockRendererProps } from './preview/codeBlockStore'; +export { safePluginWrapper } from './preview/safePluginWrapper'; +export type { PluginMetadata } from './preview/safePluginWrapper'; // Theme export { cssVariableStore } from './theme/cssVariableStore'; diff --git a/packages/plugin-api/src/preview/safePluginWrapper.ts b/packages/plugin-api/src/preview/safePluginWrapper.ts new file mode 100644 index 00000000..34e29100 --- /dev/null +++ b/packages/plugin-api/src/preview/safePluginWrapper.ts @@ -0,0 +1,67 @@ +export interface PluginMetadata { + name: string; + version: string; + pluginId: string; +} + +/** + * Wraps a remark/rehype plugin so failures don't crash the entire preview. + * + * Strategy: Wrap the transformer function returned by the plugin. + * If the transformer throws, log the error and inject an error marker + * node into the AST. + */ +export function safePluginWrapper( + plugin: unknown, + metadata: PluginMetadata, +): unknown { + // If plugin is not a function, return it as-is (let unified handle the error) + if (typeof plugin !== 'function') return plugin; + + // Return a new plugin function that wraps the original + return function safePlugin(...args: unknown[]) { + let transformer: unknown; + try { + transformer = (plugin as Function)(...args); + } catch (error) { + console.warn( + `[PluginPipeline] ${metadata.name}@${metadata.version} failed to initialize:`, + error, + ); + // Return no-op transformer + return () => {}; + } + + if (typeof transformer !== 'function') return transformer; + + // Wrap the transformer + return (tree: unknown, file: unknown) => { + try { + return (transformer as Function)(tree, file); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[PluginPipeline] ${metadata.name}@${metadata.version} failed:`, + message, + ); + + // Inject error marker node into the tree (at the end) + // The tree is a hast/mdast root node with children array + if ( + tree && + typeof tree === 'object' && + 'children' in tree && + Array.isArray((tree as Record).children) + ) { + const children = (tree as Record).children as unknown[]; + children.push({ + type: 'html', + value: `
⚠ ${metadata.name} plugin failed: ${message.replace(/`, + }); + } + + return tree; + } + }; + }; +} From cb9a82e28a51dc1610c6f37db00ac944386b99e5 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:26:18 -0300 Subject: [PATCH 027/148] feat: pass metadata through plugin registration in PluginRegistry Update registerRemarkPlugin and registerRehypePlugin lambdas in activate() to accept optional PluginHookOptions and build metadata from options with manifest defaults (name, version, priority=100). Co-Authored-By: Claude Opus 4.6 --- .../src/lifecycle/PluginRegistry.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/plugin-api/src/lifecycle/PluginRegistry.ts b/packages/plugin-api/src/lifecycle/PluginRegistry.ts index 04965411..fcf0f536 100644 --- a/packages/plugin-api/src/lifecycle/PluginRegistry.ts +++ b/packages/plugin-api/src/lifecycle/PluginRegistry.ts @@ -9,6 +9,7 @@ import type { EditorAPI, AppAPI, PluginCommandOptions, + PluginHookOptions, } from '../types'; import { createLayoutManager } from '../layout/layoutStore'; import { editorPluginStore } from '../editor/editorPluginStore'; @@ -237,12 +238,30 @@ export class PluginRegistry { return () => editorPluginStore.getState().unregister(extId); }, registerCommand, - registerRemarkPlugin: (regId: string, plugin: unknown): (() => void) => { - remarkPluginStore.getState().register({ id: regId, pluginId: id, plugin }); + registerRemarkPlugin: (regId: string, plugin: unknown, options?: PluginHookOptions): (() => void) => { + remarkPluginStore.getState().register({ + id: regId, + pluginId: id, + plugin, + metadata: { + name: options?.name ?? entry.manifest.name, + version: options?.version ?? entry.manifest.version, + priority: options?.priority ?? 100, + }, + }); return () => remarkPluginStore.getState().unregister(regId); }, - registerRehypePlugin: (regId: string, plugin: unknown): (() => void) => { - rehypePluginStore.getState().register({ id: regId, pluginId: id, plugin }); + registerRehypePlugin: (regId: string, plugin: unknown, options?: PluginHookOptions): (() => void) => { + rehypePluginStore.getState().register({ + id: regId, + pluginId: id, + plugin, + metadata: { + name: options?.name ?? entry.manifest.name, + version: options?.version ?? entry.manifest.version, + priority: options?.priority ?? 100, + }, + }); return () => rehypePluginStore.getState().unregister(regId); }, registerPreviewComponent: ( From aa2bbf60dd03c4d64cad11e2a80988c82cb2b1ae Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:28:57 -0300 Subject: [PATCH 028/148] feat(desktop): add CSS for plugin error block boundaries in preview Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/renderer/styles/preview.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/src/renderer/styles/preview.css b/apps/desktop/src/renderer/styles/preview.css index 21eff025..56b991f0 100644 --- a/apps/desktop/src/renderer/styles/preview.css +++ b/apps/desktop/src/renderer/styles/preview.css @@ -545,3 +545,18 @@ border: none; background: transparent; } + +/* ============================================================================ + Plugin Error Boundaries + ============================================================================ */ + +.plugin-error-block { + background: rgba(239, 68, 68, 0.08); + border-left: 3px solid #ef4444; + padding: 4px 8px; + margin: 4px 0; + font-size: 0.75rem; + color: #ef4444; + border-radius: 2px; + font-family: var(--font-mono, monospace); +} From 46ae6def9eb5334417df6e5a304ae8031a1b07df Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:29:49 -0300 Subject: [PATCH 029/148] feat: hot-reload preview when toggling plugins on/off Add requestReload() call after setEnabled in handleToggle so the markdown preview updates immediately without needing the manual "Reload Plugins" button. Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/pages/settings/sections/PluginsSection.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx index 03ba22c6..ec2211ac 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx @@ -481,6 +481,8 @@ export function PluginsSection() { const handleToggle = useCallback(async (pluginId: string, enabled: boolean) => { await window.readied.plugins.setEnabled(pluginId, enabled); setPlugins(prev => prev.map(p => (p.id === pluginId ? { ...p, enabled } : p))); + // Trigger reload in main window so preview updates immediately + window.readied.plugins.requestReload(); }, []); // Update a plugin config value From fbb544821fab074a59fce96193e3f536e1d99c05 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:33:03 -0300 Subject: [PATCH 030/148] feat(plugin-api): upgrade remark/rehype stores with metadata, priority, and safe wrapping - Add metadata field (name, version, priority) to registrations - Wrap plugins with safePluginWrapper on register for error isolation - Sort getPlugins() output by priority (ascending) - Log registrations with console.debug Co-Authored-By: Claude Opus 4.6 --- .../src/preview/rehypePluginStore.ts | 23 +++++++++++++++++-- .../src/preview/remarkPluginStore.ts | 23 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/plugin-api/src/preview/rehypePluginStore.ts b/packages/plugin-api/src/preview/rehypePluginStore.ts index 4257cfea..d44cea0b 100644 --- a/packages/plugin-api/src/preview/rehypePluginStore.ts +++ b/packages/plugin-api/src/preview/rehypePluginStore.ts @@ -1,10 +1,16 @@ import { createStore } from 'zustand/vanilla'; +import { safePluginWrapper } from './safePluginWrapper'; export interface RehypePluginRegistration { id: string; pluginId: string; /** unified rehype plugin — typed as unknown for loose coupling */ plugin: unknown; + metadata: { + name: string; + version: string; + priority: number; + }; } interface RehypePluginState { @@ -19,9 +25,20 @@ export const rehypePluginStore = createStore((set, get) => ({ registrations: [], register(registration) { + const wrappedPlugin = safePluginWrapper(registration.plugin, { + name: registration.metadata.name, + version: registration.metadata.version, + pluginId: registration.pluginId, + }); set(state => ({ - registrations: [...state.registrations.filter(r => r.id !== registration.id), registration], + registrations: [ + ...state.registrations.filter(r => r.id !== registration.id), + { ...registration, plugin: wrappedPlugin }, + ], })); + console.debug( + `[RehypePlugins] Registered: ${registration.metadata.name}@${registration.metadata.version} (priority: ${registration.metadata.priority})`, + ); }, unregister(id) { @@ -37,6 +54,8 @@ export const rehypePluginStore = createStore((set, get) => ({ }, getPlugins() { - return get().registrations.map(r => r.plugin); + return [...get().registrations] + .sort((a, b) => a.metadata.priority - b.metadata.priority) + .map(r => r.plugin); }, })); diff --git a/packages/plugin-api/src/preview/remarkPluginStore.ts b/packages/plugin-api/src/preview/remarkPluginStore.ts index 48edebc0..c9f71731 100644 --- a/packages/plugin-api/src/preview/remarkPluginStore.ts +++ b/packages/plugin-api/src/preview/remarkPluginStore.ts @@ -1,10 +1,16 @@ import { createStore } from 'zustand/vanilla'; +import { safePluginWrapper } from './safePluginWrapper'; export interface RemarkPluginRegistration { id: string; pluginId: string; /** unified remark plugin — typed as unknown for loose coupling */ plugin: unknown; + metadata: { + name: string; + version: string; + priority: number; + }; } interface RemarkPluginState { @@ -19,9 +25,20 @@ export const remarkPluginStore = createStore((set, get) => ({ registrations: [], register(registration) { + const wrappedPlugin = safePluginWrapper(registration.plugin, { + name: registration.metadata.name, + version: registration.metadata.version, + pluginId: registration.pluginId, + }); set(state => ({ - registrations: [...state.registrations.filter(r => r.id !== registration.id), registration], + registrations: [ + ...state.registrations.filter(r => r.id !== registration.id), + { ...registration, plugin: wrappedPlugin }, + ], })); + console.debug( + `[RemarkPlugins] Registered: ${registration.metadata.name}@${registration.metadata.version} (priority: ${registration.metadata.priority})`, + ); }, unregister(id) { @@ -37,6 +54,8 @@ export const remarkPluginStore = createStore((set, get) => ({ }, getPlugins() { - return get().registrations.map(r => r.plugin); + return [...get().registrations] + .sort((a, b) => a.metadata.priority - b.metadata.priority) + .map(r => r.plugin); }, })); From 4a087e7704add11b0117615970e63bd87eb650ee Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:33:10 -0300 Subject: [PATCH 031/148] test(plugin-api): update tests for new metadata and safePluginWrapper - Add metadata field to all store test registrations - Add priority sorting tests for both remark and rehype stores - Update registry test to expect wrapped plugin functions Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/tests/registry.test.ts | 2 +- .../tests/rehypePluginStore.test.ts | 26 ++++++++++++++----- .../tests/remarkPluginStore.test.ts | 26 ++++++++++++++----- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/plugin-api/tests/registry.test.ts b/packages/plugin-api/tests/registry.test.ts index 596eabe2..23dd7fe6 100644 --- a/packages/plugin-api/tests/registry.test.ts +++ b/packages/plugin-api/tests/registry.test.ts @@ -497,7 +497,7 @@ describe('PluginRegistry', () => { expect(regs).toHaveLength(1); expect(regs[0]!.id).toBe('my-remark'); expect(regs[0]!.pluginId).toBe('test-plugin'); - expect(regs[0]!.plugin).toBe(fakePlugin); + expect(typeof regs[0]!.plugin).toBe('function'); // wrapped by safePluginWrapper }); it('registerRehypePlugin adds to rehypePluginStore', async () => { diff --git a/packages/plugin-api/tests/rehypePluginStore.test.ts b/packages/plugin-api/tests/rehypePluginStore.test.ts index ba59a7c6..b5c0f968 100644 --- a/packages/plugin-api/tests/rehypePluginStore.test.ts +++ b/packages/plugin-api/tests/rehypePluginStore.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { rehypePluginStore } from '../src/preview/rehypePluginStore'; +const defaultMeta = { name: 'test', version: '1.0.0', priority: 100 }; + describe('rehypePluginStore', () => { beforeEach(() => { const state = rehypePluginStore.getState(); @@ -19,6 +21,7 @@ describe('rehypePluginStore', () => { id: 'rehype-1', pluginId: 'test-plugin', plugin: fakePlugin, + metadata: defaultMeta, }); expect(rehypePluginStore.getState().registrations).toHaveLength(1); @@ -30,11 +33,13 @@ describe('rehypePluginStore', () => { id: 'rehype-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); rehypePluginStore.getState().register({ id: 'rehype-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); expect(rehypePluginStore.getState().registrations).toHaveLength(1); @@ -45,6 +50,7 @@ describe('rehypePluginStore', () => { id: 'rehype-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); rehypePluginStore.getState().unregister('rehype-1'); @@ -56,16 +62,19 @@ describe('rehypePluginStore', () => { id: 'rehype-1', pluginId: 'plugin-a', plugin: () => {}, + metadata: defaultMeta, }); rehypePluginStore.getState().register({ id: 'rehype-2', pluginId: 'plugin-a', plugin: () => {}, + metadata: defaultMeta, }); rehypePluginStore.getState().register({ id: 'rehype-3', pluginId: 'plugin-b', plugin: () => {}, + metadata: defaultMeta, }); rehypePluginStore.getState().unregisterAll('plugin-a'); @@ -75,21 +84,24 @@ describe('rehypePluginStore', () => { expect(remaining[0]!.pluginId).toBe('plugin-b'); }); - it('getPlugins returns flat array of plugin functions', () => { - const pluginA = () => {}; - const pluginB = () => {}; - + it('getPlugins returns plugins sorted by priority', () => { rehypePluginStore.getState().register({ id: 'rehype-1', pluginId: 'plugin-a', - plugin: pluginA, + plugin: 'pluginA', + metadata: { name: 'a', version: '1.0.0', priority: 50 }, }); rehypePluginStore.getState().register({ id: 'rehype-2', pluginId: 'plugin-b', - plugin: pluginB, + plugin: 'pluginB', + metadata: { name: 'b', version: '1.0.0', priority: 10 }, }); - expect(rehypePluginStore.getState().getPlugins()).toEqual([pluginA, pluginB]); + const plugins = rehypePluginStore.getState().getPlugins(); + expect(plugins).toHaveLength(2); + // pluginB (priority 10) should come first + expect(plugins[0]).toBe('pluginB'); + expect(plugins[1]).toBe('pluginA'); }); }); diff --git a/packages/plugin-api/tests/remarkPluginStore.test.ts b/packages/plugin-api/tests/remarkPluginStore.test.ts index f960e1d5..de4eabab 100644 --- a/packages/plugin-api/tests/remarkPluginStore.test.ts +++ b/packages/plugin-api/tests/remarkPluginStore.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { remarkPluginStore } from '../src/preview/remarkPluginStore'; +const defaultMeta = { name: 'test', version: '1.0.0', priority: 100 }; + describe('remarkPluginStore', () => { beforeEach(() => { const state = remarkPluginStore.getState(); @@ -19,6 +21,7 @@ describe('remarkPluginStore', () => { id: 'remark-1', pluginId: 'test-plugin', plugin: fakePlugin, + metadata: defaultMeta, }); expect(remarkPluginStore.getState().registrations).toHaveLength(1); @@ -30,11 +33,13 @@ describe('remarkPluginStore', () => { id: 'remark-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); remarkPluginStore.getState().register({ id: 'remark-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); expect(remarkPluginStore.getState().registrations).toHaveLength(1); @@ -45,6 +50,7 @@ describe('remarkPluginStore', () => { id: 'remark-1', pluginId: 'test-plugin', plugin: () => {}, + metadata: defaultMeta, }); remarkPluginStore.getState().unregister('remark-1'); @@ -56,16 +62,19 @@ describe('remarkPluginStore', () => { id: 'remark-1', pluginId: 'plugin-a', plugin: () => {}, + metadata: defaultMeta, }); remarkPluginStore.getState().register({ id: 'remark-2', pluginId: 'plugin-a', plugin: () => {}, + metadata: defaultMeta, }); remarkPluginStore.getState().register({ id: 'remark-3', pluginId: 'plugin-b', plugin: () => {}, + metadata: defaultMeta, }); remarkPluginStore.getState().unregisterAll('plugin-a'); @@ -75,21 +84,24 @@ describe('remarkPluginStore', () => { expect(remaining[0]!.pluginId).toBe('plugin-b'); }); - it('getPlugins returns flat array of plugin functions', () => { - const pluginA = () => {}; - const pluginB = () => {}; - + it('getPlugins returns plugins sorted by priority', () => { remarkPluginStore.getState().register({ id: 'remark-1', pluginId: 'plugin-a', - plugin: pluginA, + plugin: 'pluginA', + metadata: { name: 'a', version: '1.0.0', priority: 50 }, }); remarkPluginStore.getState().register({ id: 'remark-2', pluginId: 'plugin-b', - plugin: pluginB, + plugin: 'pluginB', + metadata: { name: 'b', version: '1.0.0', priority: 10 }, }); - expect(remarkPluginStore.getState().getPlugins()).toEqual([pluginA, pluginB]); + const plugins = remarkPluginStore.getState().getPlugins(); + expect(plugins).toHaveLength(2); + // pluginB (priority 10) should come first + expect(plugins[0]).toBe('pluginB'); + expect(plugins[1]).toBe('pluginA'); }); }); From 3902345417c8525b1999500b31624f1fd0d89cd3 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:33:18 -0300 Subject: [PATCH 032/148] docs: add remark/rehype hooks enhancement design plan Co-Authored-By: Claude Opus 4.6 --- ...6-03-11-remark-rehype-hooks-enhancement.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md diff --git a/docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md b/docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md new file mode 100644 index 00000000..6911c36d --- /dev/null +++ b/docs/plans/2026-03-11-remark-rehype-hooks-enhancement.md @@ -0,0 +1,282 @@ +# Remark/Rehype Hooks Enhancement — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Improve the existing remark/rehype plugin hook API with error isolation, priority ordering, hot-reload on toggle, and plugin metadata. + +**Architecture:** Incremental enhancement of existing stores (`remarkPluginStore`, `rehypePluginStore`) and plugin registration API. Backward-compatible — existing plugins work without changes. + +**Tech Stack:** TypeScript, unified/remark/rehype, Zustand, React, CodeMirror 6 + +--- + +### Task 1: Define PluginHookOptions type + +**Files:** +- Modify: `packages/plugin-api/src/types.ts` + +**Step 1: Add the PluginHookOptions interface** + +```typescript +/** Options for registering a remark/rehype plugin */ +export interface PluginHookOptions { + /** Display name for debugging (defaults to pluginId) */ + name?: string; + /** Plugin version for debugging */ + version?: string; + /** Execution priority — lower runs first. Default: 100 */ + priority?: number; + /** The plugin ID that registered this hook */ + pluginId?: string; +} +``` + +**Step 2: Commit** + +```bash +git add packages/plugin-api/src/types.ts +git commit -m "feat(plugin-api): add PluginHookOptions type for remark/rehype metadata" +``` + +--- + +### Task 2: Create safePluginWrapper + +**Files:** +- Create: `packages/plugin-api/src/preview/safePluginWrapper.ts` + +**Step 1: Write the wrapper** + +The wrapper takes a unified plugin + metadata, returns a new plugin that: +- Wraps the plugin's visitor functions in try/catch +- On error: logs `[PluginPipeline] ${name}@${version} failed on node ${node.type}: ${error.message}` +- On error: injects an error marker node into the AST (a `div` with class `plugin-error-boundary` and data attributes for plugin name + error message) +- On success: returns the transformed node normally + +```typescript +import type { Plugin } from 'unified'; +import type { PluginHookOptions } from '../types.js'; + +interface PluginMetadata { + name: string; + version: string; + pluginId: string; +} + +/** + * Wraps a remark/rehype plugin so failures on individual nodes + * don't crash the entire preview pipeline. + */ +export function safePluginWrapper( + plugin: Plugin, + metadata: PluginMetadata +): Plugin { + // Return a new plugin function that wraps the original + // The wrapper intercepts the transformer returned by the plugin + // and wraps each visit() call in try/catch + // On error: console.warn + inject error marker node + // On success: pass through normally +} +``` + +Key behavior: +- If the plugin itself throws during initialization (not per-node), catch that too and return a no-op transformer +- Error marker node: `{ type: 'html', value: '
Plugin name failed on this block
' }` + +**Step 2: Commit** + +```bash +git add packages/plugin-api/src/preview/safePluginWrapper.ts +git commit -m "feat(plugin-api): add safePluginWrapper for per-block error isolation" +``` + +--- + +### Task 3: Upgrade remarkPluginStore with metadata + priority + safe wrapping + +**Files:** +- Modify: `packages/plugin-api/src/preview/remarkPluginStore.ts` + +**Step 1: Read current store implementation** + +**Step 2: Modify the store** + +Changes: +- Internal storage: `{ plugin: Plugin, metadata: PluginMetadata }[]` instead of `Plugin[]` +- `register(plugin, options?)` — accepts optional `PluginHookOptions`, wraps with `safePluginWrapper`, stores with metadata +- `getPlugins()` — returns plugins sorted by `metadata.priority` (ascending), then by registration order +- `unregister(pluginId)` — remove all plugins registered by a given pluginId +- Backward-compatible: `register(plugin)` still works (defaults: priority=100, name='unknown', version='0.0.0') +- Log on register: `console.debug('[RemarkPlugins] Registered: ${name}@${version} (priority: ${priority})')` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/preview/remarkPluginStore.ts +git commit -m "feat(plugin-api): upgrade remarkPluginStore with metadata, priority, and error isolation" +``` + +--- + +### Task 4: Upgrade rehypePluginStore (same pattern as Task 3) + +**Files:** +- Modify: `packages/plugin-api/src/preview/rehypePluginStore.ts` + +**Step 1: Apply same changes as Task 3 to rehypePluginStore** + +Same internal storage, same register/getPlugins/unregister API, same safePluginWrapper usage. + +**Step 2: Commit** + +```bash +git add packages/plugin-api/src/preview/rehypePluginStore.ts +git commit -m "feat(plugin-api): upgrade rehypePluginStore with metadata, priority, and error isolation" +``` + +--- + +### Task 5: Update PluginContext registration methods + +**Files:** +- Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts` (or wherever `registerRemarkPlugin`/`registerRehypePlugin` are created) +- Modify: `packages/plugin-api/src/types.ts` (PluginContext interface if needed) + +**Step 1: Read how PluginContext creates registerRemarkPlugin/registerRehypePlugin** + +**Step 2: Update the context factory** + +- `registerRemarkPlugin(plugin, options?)` — pass `{ ...options, pluginId: manifest.id }` to store +- `registerRehypePlugin(plugin, options?)` — same +- On plugin deactivate: call `remarkPluginStore.unregister(pluginId)` and `rehypePluginStore.unregister(pluginId)` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/lifecycle/PluginRegistry.ts packages/plugin-api/src/types.ts +git commit -m "feat(plugin-api): pass metadata through PluginContext registration and cleanup on deactivate" +``` + +--- + +### Task 6: Add error block CSS to MarkdownPreview + +**Files:** +- Modify: `apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx` (or its CSS) + +**Step 1: Add CSS for `.plugin-error-block`** + +```css +.plugin-error-block { + background: rgba(239, 68, 68, 0.08); + border-left: 3px solid #ef4444; + padding: 4px 8px; + margin: 4px 0; + font-size: 0.75rem; + color: #ef4444; + border-radius: 2px; + font-family: monospace; +} +``` + +Subtle, non-intrusive, matches the app's aesthetic. + +**Step 2: Commit** + +```bash +git add apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +git commit -m "feat(desktop): add CSS for plugin error block boundaries in preview" +``` + +--- + +### Task 7: Hot-reload — auto-reload on plugin toggle + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx` +- Modify: `apps/desktop/src/renderer/stores/pluginRuntimeStore.ts` + +**Step 1: Read current toggle handler in PluginsSection.tsx** + +**Step 2: Add togglePlugin method to pluginRuntimeStore** + +```typescript +togglePlugin: async (pluginId: string, enabled: boolean) => { + await window.readied.plugins.setEnabled(pluginId, enabled); + // Selective reload: deactivate or activate just this plugin + if (!enabled) { + pluginRegistry.deactivate(pluginId); + remarkPluginStore.unregister(pluginId); + rehypePluginStore.unregister(pluginId); + } else { + // Re-scan and load just this plugin + await get().reloadPlugins(); + } +} +``` + +**Step 3: Update PluginsSection toggle handler** + +Replace the current `setEnabled` call with `pluginRuntimeStore.getState().togglePlugin(pluginId, enabled)`. + +**Step 4: Commit** + +```bash +git add apps/desktop/src/renderer/stores/pluginRuntimeStore.ts apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx +git commit -m "feat(desktop): hot-reload preview when toggling plugins on/off" +``` + +--- + +### Task 8: Update built-in plugins to use metadata + +**Files:** +- Modify: `apps/desktop/src/renderer/plugins/` (any built-in that registers remark/rehype hooks) + +**Step 1: Identify which built-in plugins register remark/rehype hooks** + +**Step 2: Add metadata to their registrations** + +```typescript +// Before +context.registerRemarkPlugin(remarkGfm); + +// After +context.registerRemarkPlugin(remarkGfm, { + name: 'remark-gfm', + priority: 5 +}); +``` + +**Step 3: Commit** + +```bash +git add apps/desktop/src/renderer/plugins/ +git commit -m "feat(desktop): add metadata to built-in plugin remark/rehype registrations" +``` + +--- + +### Task 9: Verify end-to-end + +**Step 1: Run typecheck** + +```bash +pnpm typecheck +``` + +**Step 2: Run tests** + +```bash +pnpm test +``` + +**Step 3: Manual verification** + +- Open dev mode (`pnpm dev`) +- Open a note with markdown content +- Go to Settings > Plugins, toggle a plugin off → preview updates immediately +- Toggle it back on → preview updates +- If a plugin has an intentional error → verify per-block fallback appears + +**Step 4: Final commit if any fixes needed** From 47fec7941a178f82f95e550d00ba2c501c5373cb Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:51:25 -0300 Subject: [PATCH 033/148] docs: add theme system enhancement design Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-11-theme-system-design.md | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/plans/2026-03-11-theme-system-design.md diff --git a/docs/plans/2026-03-11-theme-system-design.md b/docs/plans/2026-03-11-theme-system-design.md new file mode 100644 index 00000000..d431c37e --- /dev/null +++ b/docs/plans/2026-03-11-theme-system-design.md @@ -0,0 +1,103 @@ +# Theme System Enhancement — Design + +**Goal:** Improve the existing theme infrastructure with a layered theme registry, token validation, plugin-defined themes, and Electron nativeTheme sync. + +**Architecture:** Layered approach — base CSS tokens → theme overrides → accent color → plugin CSS vars. ThemeRegistry as a new Zustand store. nativeTheme sync via IPC. + +## Token Whitelist + Extension Scopes + +Core tokens that themes can override (from `tokens.css`): + +``` +--bg-base, --bg-surface, --bg-elevated, --bg-inset +--text-primary, --text-secondary, --text-muted, --text-faint +--border, --border-subtle, --border-strong +--glass-bg, --glass-border +--danger, --warning, --success +``` + +Extension scopes with mandatory prefixes: + +| Scope | Prefix | Example | +|-------|--------|---------| +| Syntax highlighting | `--syntax-*` | `--syntax-keyword` | +| Preview/markdown | `--preview-*` | `--preview-heading-color` | +| UI chrome | `--ui-*` | `--ui-sidebar-bg` | + +Variables outside core whitelist or valid scopes are rejected with `console.warn`. + +Fallback: CSS cascade handles missing tokens — base `tokens.css` values apply automatically. + +## ThemeRegistry + +```typescript +interface ThemeDefinition { + id: string; + name: string; + description?: string; + author?: string; + colorScheme: 'dark' | 'light'; + tokens: Record; + pluginId?: string; +} + +interface ThemeRegistryState { + themes: ThemeDefinition[]; + activeThemeId: string | null; + register(theme: ThemeDefinition): boolean; + unregister(themeId: string): void; + unregisterAll(pluginId: string): void; + setActive(themeId: string | null): void; + getActiveTheme(): ThemeDefinition | null; +} +``` + +Built-in dark/light are NOT in the registry — they live in `tokens.css`. The registry is for additional themes only. `activeThemeId: null` means use base dark/light. + +## Application Flow (Layers) + +1. `useAppearanceSettings` applies base theme (`data-theme="dark"/"light"`) +2. `useThemeOverrides` reads active theme from ThemeRegistry, applies validated tokens as inline CSS vars on `:root` +3. Accent color applies on top (user preference always wins) +4. Plugin CSS vars (`cssVariableStore`) apply last for individual overrides + +## Plugin API + +```typescript +// In PluginContext +registerTheme(theme: Omit): () => void; +``` + +Themes are validated on registration. Invalid tokens are stripped. Theme appears in Settings selector if validation passes. + +## nativeTheme Sync + +**Main process:** +- Set `nativeTheme.themeSource` on init from saved setting +- Listen to `nativeTheme.on('updated')`, notify renderer via IPC `theme:system-changed` +- Handle `theme:set-source` IPC from renderer + +**Renderer:** +- Replace `prefers-color-scheme` media query listener with IPC listener +- `window.readied.theme.onSystemChanged(isDark => ...)` for system theme changes + +**Preload API:** +```typescript +theme: { + setSource: (source: 'dark' | 'light' | 'system') => void; + onSystemChanged: (callback: (isDark: boolean) => void) => () => void; +} +``` + +Benefits: native title bar matches theme, centralized source of truth, no media query race conditions. + +## Settings UI + +- Base scheme selector (dark/light/system) stays as-is +- Theme selector appears below ONLY when plugin themes are registered +- Accent color always visible, applies on top of any theme +- No changes to zoom or performance mode + +## Scope + +This PR covers infrastructure only. No new theme presets — just dark and light as built-in. The system is ready for plugins to register themes. From 614b26fa26e916a3386ef97008265935a2dbfd62 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:53:57 -0300 Subject: [PATCH 034/148] docs: add theme system implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-11-theme-system-implementation.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 docs/plans/2026-03-11-theme-system-implementation.md diff --git a/docs/plans/2026-03-11-theme-system-implementation.md b/docs/plans/2026-03-11-theme-system-implementation.md new file mode 100644 index 00000000..02e9fde3 --- /dev/null +++ b/docs/plans/2026-03-11-theme-system-implementation.md @@ -0,0 +1,860 @@ +# Theme System Enhancement — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a layered theme registry with token validation, plugin-defined themes, and Electron nativeTheme sync. + +**Architecture:** ThemeRegistry Zustand store validates and stores theme definitions. A `useThemeOverrides` hook applies active theme tokens to `:root`. Main process syncs `nativeTheme.themeSource` with renderer via IPC. Plugin API exposes `registerTheme()`. + +**Tech Stack:** TypeScript, Zustand (vanilla), Electron nativeTheme, CSS custom properties, React hooks + +--- + +### Task 1: Define theme types and token whitelist + +**Files:** +- Create: `packages/plugin-api/src/theme/themeTypes.ts` + +**Step 1: Create the types file** + +```typescript +/** + * Theme System Types + * + * Defines ThemeDefinition and the token whitelist for validation. + */ + +/** Core CSS tokens that themes are allowed to override */ +export const CORE_THEME_TOKENS = [ + // Backgrounds + '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', + // Text + '--text-primary', '--text-secondary', '--text-muted', '--text-faint', + // Borders + '--border', '--border-subtle', '--border-strong', + // Glass + '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', + // Semantic + '--danger', '--danger-muted', '--warning', '--warning-muted', + '--success', '--success-muted', + // Status + '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', +] as const; + +/** Valid extension scope prefixes for non-core tokens */ +export const THEME_EXTENSION_SCOPES = ['--syntax-', '--preview-', '--ui-'] as const; + +/** A complete theme definition */ +export interface ThemeDefinition { + /** Unique theme ID */ + id: string; + /** Display name */ + name: string; + /** Short description */ + description?: string; + /** Theme author */ + author?: string; + /** Base color scheme this theme builds on (determines fallback values) */ + colorScheme: 'dark' | 'light'; + /** CSS custom property overrides — must pass token validation */ + tokens: Record; + /** Plugin that registered this theme (undefined for built-in) */ + pluginId?: string; +} + +/** + * Validate a token name against the whitelist and extension scopes. + * Returns true if the token is allowed. + */ +export function isValidThemeToken(token: string): boolean { + if ((CORE_THEME_TOKENS as readonly string[]).includes(token)) return true; + return THEME_EXTENSION_SCOPES.some(prefix => token.startsWith(prefix)); +} + +/** + * Validate and filter theme tokens. + * Returns only valid tokens. Warns about rejected ones. + */ +export function validateThemeTokens( + tokens: Record, + themeId: string +): Record { + const valid: Record = {}; + for (const [token, value] of Object.entries(tokens)) { + if (isValidThemeToken(token)) { + valid[token] = value; + } else { + console.warn(`[ThemeRegistry] Theme "${themeId}": rejected invalid token "${token}"`); + } + } + return valid; +} +``` + +**Step 2: Commit** + +```bash +git add packages/plugin-api/src/theme/themeTypes.ts +git commit -m "feat(plugin-api): add theme types and token whitelist" +``` + +--- + +### Task 2: Create ThemeRegistry store + +**Files:** +- Create: `packages/plugin-api/src/theme/themeRegistryStore.ts` +- Modify: `packages/plugin-api/src/index.ts` (add exports) + +**Step 1: Create the store** + +```typescript +/** + * Theme Registry Store + * + * Zustand vanilla store for registered themes. + * Validates tokens on registration. Manages active theme state. + */ + +import { createStore } from 'zustand/vanilla'; +import type { ThemeDefinition } from './themeTypes'; +import { validateThemeTokens } from './themeTypes'; + +interface ThemeRegistryState { + /** All registered themes */ + themes: ThemeDefinition[]; + /** Currently active theme ID (null = use base dark/light) */ + activeThemeId: string | null; + /** Register a theme. Returns false if no valid tokens after validation. */ + register(theme: ThemeDefinition): boolean; + /** Unregister a theme by ID */ + unregister(themeId: string): void; + /** Unregister all themes from a plugin */ + unregisterAll(pluginId: string): void; + /** Set the active theme (null to revert to base) */ + setActive(themeId: string | null): void; + /** Get the active theme definition, or null */ + getActiveTheme(): ThemeDefinition | null; +} + +export const themeRegistryStore = createStore((set, get) => ({ + themes: [], + activeThemeId: null, + + register(theme) { + const validTokens = validateThemeTokens(theme.tokens, theme.id); + if (Object.keys(validTokens).length === 0) { + console.warn(`[ThemeRegistry] Theme "${theme.id}" has no valid tokens, skipping.`); + return false; + } + + const validated: ThemeDefinition = { ...theme, tokens: validTokens }; + set(state => ({ + themes: [...state.themes.filter(t => t.id !== theme.id), validated], + })); + console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + return true; + }, + + unregister(themeId) { + set(state => { + const next: Partial = { + themes: state.themes.filter(t => t.id !== themeId), + }; + // Deactivate if the removed theme was active + if (state.activeThemeId === themeId) { + next.activeThemeId = null; + } + return next as ThemeRegistryState; + }); + }, + + unregisterAll(pluginId) { + set(state => { + const remaining = state.themes.filter(t => t.pluginId !== pluginId); + const activeRemoved = state.activeThemeId && + !remaining.some(t => t.id === state.activeThemeId); + return { + themes: remaining, + activeThemeId: activeRemoved ? null : state.activeThemeId, + } as ThemeRegistryState; + }); + }, + + setActive(themeId) { + if (themeId !== null) { + const exists = get().themes.some(t => t.id === themeId); + if (!exists) { + console.warn(`[ThemeRegistry] Theme "${themeId}" not found, ignoring setActive.`); + return; + } + } + set({ activeThemeId: themeId }); + }, + + getActiveTheme() { + const { themes, activeThemeId } = get(); + if (!activeThemeId) return null; + return themes.find(t => t.id === activeThemeId) ?? null; + }, +})); +``` + +**Step 2: Export from barrel** + +Add to `packages/plugin-api/src/index.ts`: +```typescript +export { themeRegistryStore } from './theme/themeRegistryStore'; +export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export type { ThemeDefinition } from './theme/themeTypes'; +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/theme/themeRegistryStore.ts packages/plugin-api/src/index.ts +git commit -m "feat(plugin-api): add ThemeRegistry store with validation" +``` + +--- + +### Task 3: Create useThemeOverrides hook + +**Files:** +- Create: `packages/plugin-api/src/theme/useThemeOverrides.ts` +- Modify: `packages/plugin-api/src/index.ts` (add export) + +**Step 1: Create the hook** + +```typescript +/** + * useThemeOverrides Hook + * + * Applies active theme tokens from ThemeRegistry to document.documentElement. + * Call once in app root, AFTER useAppearanceSettings. + */ + +import { useEffect, useSyncExternalStore } from 'react'; +import { themeRegistryStore } from './themeRegistryStore'; + +const subscribe = (cb: () => void) => themeRegistryStore.subscribe(cb); +const getSnapshot = () => ({ + activeThemeId: themeRegistryStore.getState().activeThemeId, + themes: themeRegistryStore.getState().themes, +}); + +export function useThemeOverrides(): void { + const state = useSyncExternalStore(subscribe, getSnapshot); + + useEffect(() => { + const root = document.documentElement; + const theme = themeRegistryStore.getState().getActiveTheme(); + const applied = new Set(); + + if (theme) { + // Set base color scheme so CSS fallbacks work + root.setAttribute('data-theme', theme.colorScheme); + + // Apply theme tokens + for (const [prop, value] of Object.entries(theme.tokens)) { + root.style.setProperty(prop, value); + applied.add(prop); + } + } + + return () => { + // Remove applied properties so base tokens take over + for (const prop of applied) { + root.style.removeProperty(prop); + } + }; + }, [state.activeThemeId, state.themes]); +} +``` + +**Step 2: Export from barrel** + +Add to `packages/plugin-api/src/index.ts`: +```typescript +export { useThemeOverrides } from './theme/useThemeOverrides'; +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/theme/useThemeOverrides.ts packages/plugin-api/src/index.ts +git commit -m "feat(plugin-api): add useThemeOverrides hook" +``` + +--- + +### Task 4: Wire useThemeOverrides into App.tsx + +**Files:** +- Modify: `apps/desktop/src/renderer/App.tsx` + +**Step 1: Add the hook call** + +Find where `useCssVariables()` is called (around line 78). Add `useThemeOverrides()` between `useAppearanceSettings()` and `useCssVariables()`: + +```typescript +import { useThemeOverrides } from '@readied/plugin-api'; + +// Inside NotesApp component: +usePerformanceMode(); +useAppearanceSettings(); +useThemeOverrides(); // NEW — applies active theme tokens +useCssVariables(); +``` + +Order matters: +1. `useAppearanceSettings` sets base `data-theme` + accent +2. `useThemeOverrides` overrides with active theme tokens (may change `data-theme`) +3. `useCssVariables` applies individual plugin CSS vars on top + +**Step 2: Commit** + +```bash +git add apps/desktop/src/renderer/App.tsx +git commit -m "feat(desktop): wire useThemeOverrides into app initialization" +``` + +--- + +### Task 5: Add registerTheme to PluginContext + +**Files:** +- Modify: `packages/plugin-api/src/types.ts` (add to PluginContext interface) +- Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts` (implement in activate) + +**Step 1: Add to PluginContext interface** + +In `types.ts`, add to the `PluginContext` interface: +```typescript +/** Register a complete theme with validated tokens */ +registerTheme(theme: { + id: string; + name: string; + description?: string; + author?: string; + colorScheme: 'dark' | 'light'; + tokens: Record; +}): () => void; +``` + +**Step 2: Implement in PluginRegistry.activate()** + +In `PluginRegistry.ts`, import `themeRegistryStore`: +```typescript +import { themeRegistryStore } from '../theme/themeRegistryStore'; +``` + +Add to the context object inside `activate()`: +```typescript +registerTheme: (theme): (() => void) => { + themeRegistryStore.getState().register({ + ...theme, + pluginId: id, + }); + return () => themeRegistryStore.getState().unregister(theme.id); +}, +``` + +Add cleanup in `deactivate()` (after existing cleanup lines): +```typescript +// Cleanup theme registrations +themeRegistryStore.getState().unregisterAll(id); +``` + +Also add cleanup in the catch block of `activate()` (error recovery): +```typescript +themeRegistryStore.getState().unregisterAll(id); +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/types.ts packages/plugin-api/src/lifecycle/PluginRegistry.ts +git commit -m "feat(plugin-api): add registerTheme to PluginContext" +``` + +--- + +### Task 6: nativeTheme sync — main process + +**Files:** +- Modify: `apps/desktop/src/main/index.ts` + +**Step 1: Add nativeTheme IPC handlers** + +At the top of the file, import nativeTheme: +```typescript +import { nativeTheme } from 'electron'; +``` + +Add IPC handlers (near other IPC handler registrations): +```typescript +// Theme — sync Electron nativeTheme with renderer +ipcMain.on('theme:set-source', (_event, source: string) => { + if (source === 'dark' || source === 'light' || source === 'system') { + nativeTheme.themeSource = source; + } +}); + +// Notify all renderer windows when system theme changes +nativeTheme.on('updated', () => { + const isDark = nativeTheme.shouldUseDarkColors; + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('theme:system-changed', isDark); + } +}); +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/main/index.ts +git commit -m "feat(desktop): add nativeTheme IPC sync in main process" +``` + +--- + +### Task 7: nativeTheme sync — preload API + +**Files:** +- Modify: `apps/desktop/src/preload/index.ts` + +**Step 1: Add theme methods to ReadiedAPI** + +In the `ReadiedAPI` interface, add: +```typescript +theme: { + /** Set Electron's native theme source */ + setSource: (source: 'dark' | 'light' | 'system') => void; + /** Listen for system theme changes from main process */ + onSystemChanged: (callback: (isDark: boolean) => void) => () => void; +}; +``` + +In the `contextBridge.exposeInMainWorld('readied', ...)` implementation: +```typescript +theme: { + setSource: (source: string) => { + ipcRenderer.send('theme:set-source', source); + }, + onSystemChanged: (callback: (isDark: boolean) => void) => { + const handler = (_event: unknown, isDark: boolean) => callback(isDark); + ipcRenderer.on('theme:system-changed', handler); + return () => { + ipcRenderer.removeListener('theme:system-changed', handler); + }; + }, +}, +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/preload/index.ts +git commit -m "feat(desktop): add theme IPC bridge in preload" +``` + +--- + +### Task 8: Update useAppearanceSettings to use nativeTheme IPC + +**Files:** +- Modify: `apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` + +**Step 1: Replace media query listener with IPC** + +Current code uses `window.matchMedia('(prefers-color-scheme: dark)')` for system theme detection. Replace with the IPC bridge: + +```typescript +import { useEffect } from 'react'; +import { useSettingsStore, selectAppearance } from '../stores/settings'; +import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; + +function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { + let resolved: string; + if (theme === 'system') { + // Use provided isDark hint, or fall back to media query for initial render + resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + } else { + resolved = theme; + } + document.documentElement.setAttribute('data-theme', resolved); + + // Accent color + document.documentElement.style.setProperty('--accent', accentColor); + document.documentElement.style.setProperty('--accent-primary', accentColor); + + // Hover variant + const hoverColor = computeHoverColor(accentColor); + document.documentElement.style.setProperty('--accent-hover', hoverColor); + + // Muted variant + const rgb = hexToRgb(accentColor); + if (rgb) { + document.documentElement.style.setProperty( + '--accent-muted', + `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, 0.15)` + ); + } + + // Zoom + document.body.style.zoom = zoomLevel; +} + +export function useAppearanceSettings(): void { + const appearance = useSettingsStore(selectAppearance); + + const theme = appearance?.theme || 'dark'; + const accentColor = appearance?.accentColor || '#5eead4'; + const zoomLevel = appearance?.zoomLevel || '1.0'; + + // Apply settings to DOM whenever they change + useEffect(() => { + applyAppearance(theme, accentColor, zoomLevel); + }, [theme, accentColor, zoomLevel]); + + // Sync nativeTheme source in main process + useEffect(() => { + window.readied.theme.setSource(theme); + }, [theme]); + + // Listen for system theme changes via IPC (replaces media query listener) + useEffect(() => { + if (theme !== 'system') return; + + const unsub = window.readied.theme.onSystemChanged((isDark) => { + applyAppearance('system', accentColor, zoomLevel, isDark); + }); + return unsub; + }, [theme, accentColor, zoomLevel]); +} +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/renderer/hooks/useAppearanceSettings.ts +git commit -m "feat(desktop): use nativeTheme IPC for system theme detection" +``` + +--- + +### Task 9: Add activeThemeId to AppearanceSettings + +**Files:** +- Modify: `apps/desktop/src/renderer/stores/settings/schema.ts` + +**Step 1: Add field** + +In `AppearanceSettings` interface: +```typescript +/** Active plugin theme ID (null = use base dark/light) */ +activeThemeId: string | null; +``` + +In `DEFAULT_APPEARANCE`: +```typescript +activeThemeId: null, +``` + +**Step 2: Bump SETTINGS_VERSION to 2 and add migration** + +Actually — per the comment in schema.ts, version bumps require migration logic in settingsStore.ts. Since `activeThemeId` defaults to `null` and missing keys naturally default to `undefined` which is falsy, this is safe to add without a migration. Keep version at 1 and just add the field with default. + +**Step 3: Commit** + +```bash +git add apps/desktop/src/renderer/stores/settings/schema.ts +git commit -m "feat(desktop): add activeThemeId to appearance settings" +``` + +--- + +### Task 10: Update AppearanceSection UI with theme selector + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx` + +**Step 1: Add theme selector (only shown when themes exist)** + +Import the theme registry: +```typescript +import { useSyncExternalStore } from 'react'; +import { themeRegistryStore } from '@readied/plugin-api'; +``` + +Inside the component, subscribe to themes: +```typescript +const themeRegs = useSyncExternalStore( + themeRegistryStore.subscribe, + () => themeRegistryStore.getState().themes +); +const activeThemeId = useSyncExternalStore( + themeRegistryStore.subscribe, + () => themeRegistryStore.getState().activeThemeId +); +``` + +Add handler: +```typescript +const handlePluginThemeChange = (value: string) => { + const newId = value === 'default' ? null : value; + themeRegistryStore.getState().setActive(newId); + updateAppearance({ activeThemeId: newId }); +}; +``` + +Add UI below the accent color picker (inside the "Theme" SettingGroup), only if themes are registered: +```typescript +{themeRegs.length > 0 && ( + + ({ + value: t.id, + label: `${t.name} (${t.colorScheme})`, + })), + ]} + /> + + )} diff --git a/apps/desktop/src/renderer/stores/settings/schema.ts b/apps/desktop/src/renderer/stores/settings/schema.ts index 84c86988..5e267ea8 100644 --- a/apps/desktop/src/renderer/stores/settings/schema.ts +++ b/apps/desktop/src/renderer/stores/settings/schema.ts @@ -46,6 +46,8 @@ export interface AppearanceSettings { zoomLevel: string; /** @deprecated No longer used - kept for schema compatibility */ acrylicBackground: boolean; + /** Active plugin theme ID (null = use base dark/light) */ + activeThemeId: string | null; } /** Backup settings */ @@ -122,6 +124,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = { accentColor: '#5eead4', zoomLevel: '1.0', acrylicBackground: false, + activeThemeId: null, }; export const DEFAULT_EDITOR: EditorSettings = { From 1f61ed724cad4d105b71506f269e755b374330cd Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:14:07 -0300 Subject: [PATCH 039/148] test: add theme token validation and ThemeRegistry store tests Cover isValidThemeToken, validateThemeTokens, and full themeRegistryStore lifecycle (register, unregister, unregisterAll, setActive, getActiveTheme). Co-Authored-By: Claude Opus 4.6 --- .../tests/themeRegistryStore.test.ts | 83 +++++++++++++++++++ packages/plugin-api/tests/themeTypes.test.ts | 46 ++++++++++ 2 files changed, 129 insertions(+) create mode 100644 packages/plugin-api/tests/themeRegistryStore.test.ts create mode 100644 packages/plugin-api/tests/themeTypes.test.ts diff --git a/packages/plugin-api/tests/themeRegistryStore.test.ts b/packages/plugin-api/tests/themeRegistryStore.test.ts new file mode 100644 index 00000000..f52cb399 --- /dev/null +++ b/packages/plugin-api/tests/themeRegistryStore.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { themeRegistryStore } from '../src/theme/themeRegistryStore'; + +const makeTheme = (overrides = {}) => ({ + id: 'test-theme', + name: 'Test Theme', + colorScheme: 'dark' as const, + tokens: { '--bg-base': '#111', '--text-primary': '#eee' }, + pluginId: 'test-plugin', + ...overrides, +}); + +describe('themeRegistryStore', () => { + beforeEach(() => { + const state = themeRegistryStore.getState(); + for (const t of state.themes) { + state.unregister(t.id); + } + state.setActive(null); + }); + + it('registers a valid theme', () => { + const result = themeRegistryStore.getState().register(makeTheme()); + expect(result).toBe(true); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + }); + + it('rejects theme with no valid tokens', () => { + const result = themeRegistryStore.getState().register( + makeTheme({ tokens: { '--invalid': 'red' } }) + ); + expect(result).toBe(false); + expect(themeRegistryStore.getState().themes).toHaveLength(0); + }); + + it('strips invalid tokens but keeps valid ones', () => { + themeRegistryStore.getState().register( + makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) + ); + const theme = themeRegistryStore.getState().themes[0]!; + expect(theme.tokens).toEqual({ '--bg-base': '#000' }); + }); + + it('replaces theme with same id', () => { + themeRegistryStore.getState().register(makeTheme({ name: 'V1' })); + themeRegistryStore.getState().register(makeTheme({ name: 'V2' })); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + expect(themeRegistryStore.getState().themes[0]!.name).toBe('V2'); + }); + + it('unregister removes theme and deactivates if active', () => { + themeRegistryStore.getState().register(makeTheme()); + themeRegistryStore.getState().setActive('test-theme'); + themeRegistryStore.getState().unregister('test-theme'); + expect(themeRegistryStore.getState().themes).toHaveLength(0); + expect(themeRegistryStore.getState().activeThemeId).toBeNull(); + }); + + it('unregisterAll removes all themes for a plugin', () => { + themeRegistryStore.getState().register(makeTheme({ id: 'a', pluginId: 'p1' })); + themeRegistryStore.getState().register(makeTheme({ id: 'b', pluginId: 'p1' })); + themeRegistryStore.getState().register(makeTheme({ id: 'c', pluginId: 'p2' })); + themeRegistryStore.getState().unregisterAll('p1'); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + expect(themeRegistryStore.getState().themes[0]!.id).toBe('c'); + }); + + it('setActive ignores unknown theme ID', () => { + themeRegistryStore.getState().setActive('nonexistent'); + expect(themeRegistryStore.getState().activeThemeId).toBeNull(); + }); + + it('getActiveTheme returns the active theme', () => { + themeRegistryStore.getState().register(makeTheme()); + themeRegistryStore.getState().setActive('test-theme'); + const active = themeRegistryStore.getState().getActiveTheme(); + expect(active?.id).toBe('test-theme'); + }); + + it('getActiveTheme returns null when no theme active', () => { + expect(themeRegistryStore.getState().getActiveTheme()).toBeNull(); + }); +}); diff --git a/packages/plugin-api/tests/themeTypes.test.ts b/packages/plugin-api/tests/themeTypes.test.ts new file mode 100644 index 00000000..319431f4 --- /dev/null +++ b/packages/plugin-api/tests/themeTypes.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { isValidThemeToken, validateThemeTokens } from '../src/theme/themeTypes'; + +describe('isValidThemeToken', () => { + it('accepts core tokens', () => { + expect(isValidThemeToken('--bg-base')).toBe(true); + expect(isValidThemeToken('--text-primary')).toBe(true); + expect(isValidThemeToken('--danger')).toBe(true); + expect(isValidThemeToken('--status-active')).toBe(true); + }); + + it('accepts extension scope tokens', () => { + expect(isValidThemeToken('--syntax-keyword')).toBe(true); + expect(isValidThemeToken('--preview-heading-color')).toBe(true); + expect(isValidThemeToken('--ui-sidebar-bg')).toBe(true); + }); + + it('rejects unknown tokens', () => { + expect(isValidThemeToken('--custom-thing')).toBe(false); + expect(isValidThemeToken('--accent')).toBe(false); + expect(isValidThemeToken('color')).toBe(false); + expect(isValidThemeToken('--font-sans')).toBe(false); + }); +}); + +describe('validateThemeTokens', () => { + it('returns only valid tokens', () => { + const result = validateThemeTokens({ + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, 'test-theme'); + + expect(result).toEqual({ + '--bg-base': '#000', + '--text-primary': '#fff', + '--syntax-keyword': '#f0f', + }); + }); + + it('returns empty object for all-invalid tokens', () => { + const result = validateThemeTokens({ '--nope': 'red' }, 'test-theme'); + expect(result).toEqual({}); + }); +}); From bf58f4a323291fc74aa7f5528cb9f7cbc61fdc5f Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:33:09 -0300 Subject: [PATCH 040/148] docs: add Data Access API design document Co-Authored-By: Claude Opus 4.6 --- .../2026-03-11-data-access-api-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/plans/2026-03-11-data-access-api-design.md diff --git a/docs/plans/2026-03-11-data-access-api-design.md b/docs/plans/2026-03-11-data-access-api-design.md new file mode 100644 index 00000000..6442ffac --- /dev/null +++ b/docs/plans/2026-03-11-data-access-api-design.md @@ -0,0 +1,215 @@ +# Data Access API — Design Document + +## Goal + +Provide plugins with a dedicated, rich `DataAPI` namespace (`context.data`) for querying notes, notebooks, tags, links, and graph data — with filters, sorting, pagination, events, and consistent error handling. + +## Problem + +The existing `AppAPI` offers flat, parameterless getters (`listNotes()`, `listTags()`, `listNotebooks()`) with no filtering, sorting, pagination, or structural queries. Plugins that need analytics, visualizations, or filtered views must reconstruct everything client-side. This is the difference between "some getters" and a real data access layer. + +## Architecture + +### Layering + +``` +Plugin code + → context.data.getNotes({ tag: 'x', sortBy: 'updatedAt' }) + → createDataAPI() — options handling, error wrapping, event dispatch + → DataAPIBridge — thin 1:1 mapping to IPC + → window.readied.notes.list({ tag: 'x', sortBy: 'updatedAt' }) + → ipcRenderer.invoke('notes:list', ...) + → SQLiteNoteRepository.list(...) +``` + +### Key Decisions + +- **Dedicated namespace**: `context.data` separate from `context.app` +- **AppAPI unchanged**: Existing methods stay as-is for backward compatibility; internally they can share the same bridge +- **Bridge is thin**: Maps 1:1 to existing IPC calls. No business logic in the bridge. +- **createDataAPI() owns logic**: Options merging, client-side filtering (graph), error wrapping +- **All read-only**: No mutations through DataAPI. Plugins observe, never modify. + +## DataAPI Interface + +```typescript +export interface DataAPI { + // ── Notes ───────────────────────────────────────── + getNotes(options?: NoteQueryOptions): Promise; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: { notebookId?: string; tag?: string }): Promise; + + // ── Notebooks ───────────────────────────────────── + getNotebooks(options?: NotebookQueryOptions): Promise; + getNotebook(id: string): Promise; + + // ── Tags ────────────────────────────────────────── + getTags(options?: TagQueryOptions): Promise; + + // ── Links & Graph ───────────────────────────────── + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(options?: GraphQueryOptions): Promise; + + // ── Events ──────────────────────────────────────── + onNotesChanged(callback: (event: DataChangeEvent<'note'>) => void): () => void; + onNotebooksChanged(callback: (event: DataChangeEvent<'notebook'>) => void): () => void; + onTagsChanged(callback: (event: DataChangeEvent<'tag'>) => void): () => void; +} +``` + +## Query Options + +```typescript +export interface NoteQueryOptions { + notebookId?: string; + tag?: string; + status?: string; + isPinned?: boolean; + sortBy?: 'title' | 'createdAt' | 'updatedAt' | 'wordCount'; + sortOrder?: 'asc' | 'desc'; + limit?: number; // default: 50 + offset?: number; +} + +export interface NoteQueryResult { + notes: NoteSummaryInfo[]; + total: number; + hasMore: boolean; +} + +export interface SearchOptions { + limit?: number; + notebookId?: string; +} + +export interface SearchResult { + results: Array<{ id: string; title: string; snippet?: string }>; + total: number; +} + +export interface NotebookQueryOptions { + tree?: boolean; // default: false (flat list) + includeCounts?: boolean; // include noteCount/childCount +} + +export interface TagQueryOptions { + includeColors?: boolean; + includeCount?: boolean; + filter?: string; // case-insensitive substring match on tag name + limit?: number; + offset?: number; +} + +export interface GraphQueryOptions { + notebookId?: string; // scope to notebook (client-side initially) + depth?: number; // limit traversal depth +} +``` + +## Result Types + +```typescript +export interface NotebookDetailInfo extends NotebookInfo { + noteCount: number; + childCount: number; +} + +export interface NotebookTreeNode extends NotebookDetailInfo { + children: NotebookTreeNode[]; +} + +export type NotebookResult = NotebookInfo[] | NotebookTreeNode[]; + +export interface TagInfo { + name: string; + color?: string | null; + count?: number; +} + +export interface LinkInfo { + noteId: string; + noteTitle: string; +} + +export interface OutgoingLinkInfo { + targetId: string | null; // null = unresolved wikilink + targetTitle: string; + resolved: boolean; +} + +export interface GraphData { + nodes: Array<{ id: string; title: string; notebookId: string }>; + edges: Array<{ source: string; target: string }>; +} +``` + +## Events + +```typescript +export interface DataChangeEvent { + kind: T; + action: 'created' | 'updated' | 'deleted' | 'renamed'; + id: string; + /** For tag renames: the previous tag name */ + previousName?: string; +} +``` + +Events fire after mutations complete. The host calls internal `_notify*` methods (same pattern as `AppAPI`). Plugins subscribe via `context.data.on*Changed()` and receive typed change events. + +## Error Handling + +```typescript +export class DataAccessError extends Error { + constructor(public readonly method: string, message: string) { + super(`[DataAPI.${method}] ${message}`); + this.name = 'DataAccessError'; + } +} +``` + +Every bridge call is wrapped in `safeBridgeCall()` that catches IPC/SQLite errors and rethrows as `DataAccessError` with the method name. Plugins get consistent, typed errors. + +## Bridge + +```typescript +export interface DataAPIBridge { + getNotes(options?: NoteQueryOptions): Promise<{ notes: NoteSummaryInfo[]; total: number }>; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: { notebookId?: string; tag?: string }): Promise; + getNotebooks(): Promise; + getNotebookTree(): Promise; + getNotebook(id: string): Promise; + getTags(): Promise; + getTagsWithColors(): Promise>; + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(): Promise; +} +``` + +## Integration in PluginContext + +```typescript +export interface PluginContext { + // ... existing fields unchanged + app: AppAPI; // backward compatible, no changes + data: DataAPI; // NEW +} +``` + +## Performance Notes + +- Tag filtering (`filter` option) is case-insensitive substring. Applied client-side initially; can push to SQL later. +- Graph filtering by notebook is client-side (full graph fetched, then filtered in `createDataAPI()`). If graphs grow large, add IPC-level pre-filter. +- Note queries with `limit`/`offset` are pushed to SQLite via existing `notes:list` IPC which already supports these. + +## Testing Strategy + +- Unit tests for `createDataAPI()` with mock bridge (query option mapping, error wrapping, event dispatch) +- Unit tests for `DataAccessError` construction +- Integration test: bridge wired to IPC in `PluginRegistry.activate()` +- Existing `createAppAPI` tests stay unchanged From 7bda4c1980d9de93e78ddf50b77f2281dcda5a49 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:36:04 -0300 Subject: [PATCH 041/148] docs: add Data Access API implementation plan Co-Authored-By: Claude Opus 4.6 --- ...26-03-11-data-access-api-implementation.md | 1027 +++++++++++++++++ 1 file changed, 1027 insertions(+) create mode 100644 docs/plans/2026-03-11-data-access-api-implementation.md diff --git a/docs/plans/2026-03-11-data-access-api-implementation.md b/docs/plans/2026-03-11-data-access-api-implementation.md new file mode 100644 index 00000000..46609f4d --- /dev/null +++ b/docs/plans/2026-03-11-data-access-api-implementation.md @@ -0,0 +1,1027 @@ +# Data Access API Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a dedicated `context.data` namespace to the plugin API with rich query capabilities (filters, sorting, pagination, graph, events, error handling). + +**Architecture:** A `DataAPI` interface with typed query options, backed by a `DataAPIBridge` that maps to existing `window.readied.*` IPC calls. The `createDataAPI()` factory handles option merging, error wrapping, and event dispatch. `AppAPI` stays unchanged for backward compat. + +**Tech Stack:** TypeScript, Zustand patterns, Electron IPC, vitest + +--- + +### Task 1: Data types — query options and result types + +**Files:** +- Create: `packages/plugin-api/src/data/dataTypes.ts` +- Test: `packages/plugin-api/tests/dataTypes.test.ts` + +**Context:** This file defines ALL types for the DataAPI. No logic, just interfaces and one error class. The existing `NoteInfo`, `NoteSummaryInfo`, `NotebookInfo` from `types.ts` are reused — we don't duplicate them. + +**Step 1: Create the types file** + +```typescript +// packages/plugin-api/src/data/dataTypes.ts + +import type { NoteInfo, NoteSummaryInfo, NotebookInfo } from '../types'; + +// Re-export for convenience +export type { NoteInfo, NoteSummaryInfo, NotebookInfo }; + +// ── Query Options ─────────────────────────────────── + +export interface NoteQueryOptions { + notebookId?: string; + tag?: string; + status?: string; + isPinned?: boolean; + sortBy?: 'title' | 'createdAt' | 'updatedAt' | 'wordCount'; + sortOrder?: 'asc' | 'desc'; + limit?: number; + offset?: number; +} + +export interface NoteQueryResult { + notes: NoteSummaryInfo[]; + total: number; + hasMore: boolean; +} + +export interface SearchOptions { + limit?: number; + notebookId?: string; +} + +export interface SearchResult { + results: Array<{ id: string; title: string; snippet?: string }>; + total: number; +} + +export interface NotebookQueryOptions { + tree?: boolean; + includeCounts?: boolean; +} + +export interface NotebookDetailInfo extends NotebookInfo { + noteCount: number; + childCount: number; +} + +export interface NotebookTreeNode extends NotebookDetailInfo { + children: NotebookTreeNode[]; +} + +export type NotebookResult = NotebookInfo[] | NotebookTreeNode[]; + +export interface TagQueryOptions { + includeColors?: boolean; + includeCount?: boolean; + /** Case-insensitive substring match on tag name */ + filter?: string; + limit?: number; + offset?: number; +} + +export interface TagInfo { + name: string; + color?: string | null; + count?: number; +} + +export interface GraphQueryOptions { + notebookId?: string; + depth?: number; +} + +export interface LinkInfo { + noteId: string; + noteTitle: string; +} + +export interface OutgoingLinkInfo { + targetId: string | null; + targetTitle: string; + resolved: boolean; +} + +export interface GraphData { + nodes: Array<{ id: string; title: string; notebookId: string }>; + edges: Array<{ source: string; target: string }>; +} + +// ── Events ────────────────────────────────────────── + +export interface DataChangeEvent { + kind: T; + action: 'created' | 'updated' | 'deleted' | 'renamed'; + id: string; + previousName?: string; +} + +// ── Error ─────────────────────────────────────────── + +export class DataAccessError extends Error { + constructor( + public readonly method: string, + message: string, + ) { + super(`[DataAPI.${method}] ${message}`); + this.name = 'DataAccessError'; + } +} +``` + +**Step 2: Write tests for DataAccessError** + +```typescript +// packages/plugin-api/tests/dataTypes.test.ts +import { describe, it, expect } from 'vitest'; +import { DataAccessError } from '../src/data/dataTypes'; + +describe('DataAccessError', () => { + it('sets name to DataAccessError', () => { + const err = new DataAccessError('getNotes', 'IPC failed'); + expect(err.name).toBe('DataAccessError'); + }); + + it('includes method in message', () => { + const err = new DataAccessError('getNotes', 'IPC failed'); + expect(err.message).toBe('[DataAPI.getNotes] IPC failed'); + }); + + it('exposes method property', () => { + const err = new DataAccessError('getGraphData', 'timeout'); + expect(err.method).toBe('getGraphData'); + }); + + it('is an instance of Error', () => { + const err = new DataAccessError('getTags', 'oops'); + expect(err).toBeInstanceOf(Error); + }); +}); +``` + +**Step 3: Run tests** + +Run: `cd packages/plugin-api && pnpm vitest run tests/dataTypes.test.ts` +Expected: 4 tests PASS + +**Step 4: Commit** + +```bash +git add packages/plugin-api/src/data/dataTypes.ts packages/plugin-api/tests/dataTypes.test.ts +git commit -m "feat(plugin-api): add DataAPI types, query options, and DataAccessError" +``` + +--- + +### Task 2: DataAPI interface and DataAPIBridge + +**Files:** +- Create: `packages/plugin-api/src/data/createDataAPI.ts` +- Modify: `packages/plugin-api/src/types.ts:112-148` (add `data: DataAPI` to PluginContext) + +**Context:** The `DataAPI` interface is what plugins see. The `DataAPIBridge` is what the host injects (thin IPC mapping). The `createDataAPI()` factory connects them with error wrapping and event dispatch. + +**Step 1: Create the DataAPI interface and bridge** + +```typescript +// packages/plugin-api/src/data/createDataAPI.ts +import type { + NoteInfo, + NoteQueryOptions, + NoteQueryResult, + SearchOptions, + SearchResult, + NotebookQueryOptions, + NotebookResult, + NotebookDetailInfo, + TagQueryOptions, + TagInfo, + GraphQueryOptions, + GraphData, + LinkInfo, + OutgoingLinkInfo, + DataChangeEvent, + DataAccessError, +} from './dataTypes'; +import { DataAccessError as DataAccessErrorClass } from './dataTypes'; + +// ── Public interface (what plugins see) ───────────── + +export interface DataAPI { + getNotes(options?: NoteQueryOptions): Promise; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: { notebookId?: string; tag?: string }): Promise; + + getNotebooks(options?: NotebookQueryOptions): Promise; + getNotebook(id: string): Promise; + + getTags(options?: TagQueryOptions): Promise; + + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(options?: GraphQueryOptions): Promise; + + onNotesChanged(callback: (event: DataChangeEvent<'note'>) => void): () => void; + onNotebooksChanged(callback: (event: DataChangeEvent<'notebook'>) => void): () => void; + onTagsChanged(callback: (event: DataChangeEvent<'tag'>) => void): () => void; +} + +// ── Extended with internal notify methods ─────────── + +export interface DataAPIWithEvents extends DataAPI { + _notifyNotesChanged(event: DataChangeEvent<'note'>): void; + _notifyNotebooksChanged(event: DataChangeEvent<'notebook'>): void; + _notifyTagsChanged(event: DataChangeEvent<'tag'>): void; +} + +// ── Bridge (injected by host, thin IPC wrapper) ───── + +export interface DataAPIBridge { + getNotes(options?: NoteQueryOptions): Promise<{ notes: import('./dataTypes').NoteSummaryInfo[]; total: number }>; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: { notebookId?: string; tag?: string }): Promise; + getNotebooks(): Promise; + getNotebookTree(): Promise; + getNotebook(id: string): Promise; + getTags(): Promise; + getTagsWithColors(): Promise>; + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(): Promise; +} + +// ── Error wrapper ─────────────────────────────────── + +async function safeBridgeCall(fn: () => Promise, method: string): Promise { + try { + return await fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new DataAccessErrorClass(method, message); + } +} + +// ── Factory ───────────────────────────────────────── + +export function createDataAPI(bridge: DataAPIBridge): DataAPIWithEvents { + const notesChangedListeners = new Set<(e: DataChangeEvent<'note'>) => void>(); + const notebooksChangedListeners = new Set<(e: DataChangeEvent<'notebook'>) => void>(); + const tagsChangedListeners = new Set<(e: DataChangeEvent<'tag'>) => void>(); + + return { + // ── Notes ───────────────────────────────────── + async getNotes(options) { + const { notes, total } = await safeBridgeCall( + () => bridge.getNotes(options), + 'getNotes', + ); + const limit = options?.limit ?? 50; + const offset = options?.offset ?? 0; + return { notes, total, hasMore: offset + notes.length < total }; + }, + + async getNote(id) { + return safeBridgeCall(() => bridge.getNote(id), 'getNote'); + }, + + async searchNotes(query, options) { + return safeBridgeCall(() => bridge.searchNotes(query, options), 'searchNotes'); + }, + + async countNotes(options) { + return safeBridgeCall(() => bridge.countNotes(options), 'countNotes'); + }, + + // ── Notebooks ───────────────────────────────── + async getNotebooks(options) { + if (options?.tree) { + return safeBridgeCall(() => bridge.getNotebookTree(), 'getNotebooks'); + } + return safeBridgeCall(() => bridge.getNotebooks(), 'getNotebooks'); + }, + + async getNotebook(id) { + return safeBridgeCall(() => bridge.getNotebook(id), 'getNotebook'); + }, + + // ── Tags ────────────────────────────────────── + async getTags(options) { + let tags: TagInfo[]; + + if (options?.includeColors) { + const raw = await safeBridgeCall(() => bridge.getTagsWithColors(), 'getTags'); + tags = raw.map(t => ({ name: t.name, color: t.color })); + } else { + const raw = await safeBridgeCall(() => bridge.getTags(), 'getTags'); + tags = raw.map(name => ({ name })); + } + + // Client-side filter (case-insensitive substring) + if (options?.filter) { + const lower = options.filter.toLowerCase(); + tags = tags.filter(t => t.name.toLowerCase().includes(lower)); + } + + // Client-side pagination + const offset = options?.offset ?? 0; + const limit = options?.limit; + if (limit !== undefined) { + tags = tags.slice(offset, offset + limit); + } else if (offset > 0) { + tags = tags.slice(offset); + } + + return tags; + }, + + // ── Links & Graph ───────────────────────────── + async getBacklinks(noteId) { + return safeBridgeCall(() => bridge.getBacklinks(noteId), 'getBacklinks'); + }, + + async getOutgoingLinks(noteId) { + return safeBridgeCall(() => bridge.getOutgoingLinks(noteId), 'getOutgoingLinks'); + }, + + async getGraphData(options) { + const graph = await safeBridgeCall(() => bridge.getGraphData(), 'getGraphData'); + + // Client-side filtering by notebook + if (options?.notebookId) { + const nodeIds = new Set( + graph.nodes + .filter(n => n.notebookId === options.notebookId) + .map(n => n.id), + ); + return { + nodes: graph.nodes.filter(n => nodeIds.has(n.id)), + edges: graph.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)), + }; + } + + return graph; + }, + + // ── Events ──────────────────────────────────── + onNotesChanged(cb) { + notesChangedListeners.add(cb); + return () => { notesChangedListeners.delete(cb); }; + }, + onNotebooksChanged(cb) { + notebooksChangedListeners.add(cb); + return () => { notebooksChangedListeners.delete(cb); }; + }, + onTagsChanged(cb) { + tagsChangedListeners.add(cb); + return () => { tagsChangedListeners.delete(cb); }; + }, + + // ── Internal notify (called by host) ────────── + _notifyNotesChanged(event) { + for (const cb of notesChangedListeners) cb(event); + }, + _notifyNotebooksChanged(event) { + for (const cb of notebooksChangedListeners) cb(event); + }, + _notifyTagsChanged(event) { + for (const cb of tagsChangedListeners) cb(event); + }, + }; +} +``` + +**Step 2: Add `data: DataAPI` to PluginContext in types.ts** + +In `packages/plugin-api/src/types.ts`, add an import and the `data` field: + +```typescript +// At the top, add import: +import type { DataAPI } from './data/createDataAPI'; + +// In PluginContext interface (after `app: AppAPI;`), add: + /** Rich data query API for notes, notebooks, tags, links, and graph */ + data: DataAPI; +``` + +**Step 3: Run typecheck** + +Run: `cd packages/plugin-api && pnpm typecheck` +Expected: May fail because PluginRegistry doesn't provide `data` yet — that's OK, we'll wire it in Task 4. + +**Step 4: Commit** + +```bash +git add packages/plugin-api/src/data/createDataAPI.ts packages/plugin-api/src/types.ts +git commit -m "feat(plugin-api): add DataAPI interface, bridge, and createDataAPI factory" +``` + +--- + +### Task 3: Unit tests for createDataAPI + +**Files:** +- Create: `packages/plugin-api/tests/createDataAPI.test.ts` + +**Context:** Follow the pattern from `createAppAPI.test.ts`. Mock the bridge, test delegation, error wrapping, tag filtering, graph filtering, and events. + +**Step 1: Write the test file** + +```typescript +// packages/plugin-api/tests/createDataAPI.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createDataAPI } from '../src/data/createDataAPI'; +import type { DataAPIBridge } from '../src/data/createDataAPI'; +import { DataAccessError } from '../src/data/dataTypes'; + +function makeBridge(overrides: Partial = {}): DataAPIBridge { + return { + getNotes: async () => ({ notes: [], total: 0 }), + getNote: async () => null, + searchNotes: async () => ({ results: [], total: 0 }), + countNotes: async () => 0, + getNotebooks: async () => [], + getNotebookTree: async () => [], + getNotebook: async () => null, + getTags: async () => [], + getTagsWithColors: async () => [], + getBacklinks: async () => [], + getOutgoingLinks: async () => [], + getGraphData: async () => ({ nodes: [], edges: [] }), + ...overrides, + }; +} + +describe('createDataAPI', () => { + describe('getNotes', () => { + it('delegates to bridge and computes hasMore', async () => { + const notes = [{ id: '1', title: 'A', notebookId: 'nb', tags: [], wordCount: 10, createdAt: '', updatedAt: '', isPinned: false, status: 'active' }]; + const api = createDataAPI(makeBridge({ getNotes: async () => ({ notes, total: 5 }) })); + const result = await api.getNotes({ limit: 2, offset: 0 }); + expect(result.notes).toEqual(notes); + expect(result.total).toBe(5); + expect(result.hasMore).toBe(true); + }); + + it('hasMore is false when all notes returned', async () => { + const api = createDataAPI(makeBridge({ getNotes: async () => ({ notes: [{ id: '1', title: 'A', notebookId: 'nb', tags: [], wordCount: 0, createdAt: '', updatedAt: '', isPinned: false, status: 'active' }], total: 1 }) })); + const result = await api.getNotes(); + expect(result.hasMore).toBe(false); + }); + }); + + describe('getNote', () => { + it('returns note from bridge', async () => { + const note = { id: '1', title: 'Test', content: 'body' }; + const api = createDataAPI(makeBridge({ getNote: async () => note })); + expect(await api.getNote('1')).toEqual(note); + }); + }); + + describe('getNotebooks', () => { + it('returns flat list by default', async () => { + const notebooks = [{ id: 'nb-1', name: 'Inbox', parentId: null }]; + const api = createDataAPI(makeBridge({ getNotebooks: async () => notebooks })); + const result = await api.getNotebooks(); + expect(result).toEqual(notebooks); + }); + + it('returns tree when tree option is true', async () => { + const tree = [{ id: 'nb-1', name: 'Root', parentId: null, noteCount: 5, childCount: 1, children: [] }]; + const api = createDataAPI(makeBridge({ getNotebookTree: async () => tree })); + const result = await api.getNotebooks({ tree: true }); + expect(result).toEqual(tree); + }); + }); + + describe('getTags', () => { + it('returns simple tag names by default', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['js', 'react', 'vue'] })); + const result = await api.getTags(); + expect(result).toEqual([{ name: 'js' }, { name: 'react' }, { name: 'vue' }]); + }); + + it('includes colors when requested', async () => { + const api = createDataAPI(makeBridge({ + getTagsWithColors: async () => [{ name: 'js', color: '#ff0' }, { name: 'go', color: null }], + })); + const result = await api.getTags({ includeColors: true }); + expect(result).toEqual([{ name: 'js', color: '#ff0' }, { name: 'go', color: null }]); + }); + + it('filters by case-insensitive substring', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['JavaScript', 'Java', 'Python'] })); + const result = await api.getTags({ filter: 'java' }); + expect(result.map(t => t.name)).toEqual(['JavaScript', 'Java']); + }); + + it('paginates with limit and offset', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['a', 'b', 'c', 'd', 'e'] })); + const result = await api.getTags({ limit: 2, offset: 1 }); + expect(result.map(t => t.name)).toEqual(['b', 'c']); + }); + }); + + describe('getGraphData', () => { + it('returns full graph without options', async () => { + const graph = { + nodes: [ + { id: '1', title: 'A', notebookId: 'nb-1' }, + { id: '2', title: 'B', notebookId: 'nb-2' }, + ], + edges: [{ source: '1', target: '2' }], + }; + const api = createDataAPI(makeBridge({ getGraphData: async () => graph })); + const result = await api.getGraphData(); + expect(result).toEqual(graph); + }); + + it('filters by notebookId', async () => { + const graph = { + nodes: [ + { id: '1', title: 'A', notebookId: 'nb-1' }, + { id: '2', title: 'B', notebookId: 'nb-2' }, + { id: '3', title: 'C', notebookId: 'nb-1' }, + ], + edges: [ + { source: '1', target: '2' }, + { source: '1', target: '3' }, + ], + }; + const api = createDataAPI(makeBridge({ getGraphData: async () => graph })); + const result = await api.getGraphData({ notebookId: 'nb-1' }); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]).toEqual({ source: '1', target: '3' }); + }); + }); + + describe('error handling', () => { + it('wraps bridge errors as DataAccessError', async () => { + const api = createDataAPI(makeBridge({ + getNotes: async () => { throw new Error('IPC timeout'); }, + })); + await expect(api.getNotes()).rejects.toThrow(DataAccessError); + await expect(api.getNotes()).rejects.toThrow('[DataAPI.getNotes] IPC timeout'); + }); + + it('wraps non-Error throws', async () => { + const api = createDataAPI(makeBridge({ + getNote: async () => { throw 'string error'; }, + })); + await expect(api.getNote('1')).rejects.toThrow('[DataAPI.getNote] string error'); + }); + }); + + describe('events', () => { + it('onNotesChanged fires when notified', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onNotesChanged(cb); + api._notifyNotesChanged({ kind: 'note', action: 'created', id: '1' }); + expect(cb).toHaveBeenCalledWith({ kind: 'note', action: 'created', id: '1' }); + }); + + it('onNotebooksChanged fires when notified', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onNotebooksChanged(cb); + api._notifyNotebooksChanged({ kind: 'notebook', action: 'deleted', id: 'nb-1' }); + expect(cb).toHaveBeenCalledWith({ kind: 'notebook', action: 'deleted', id: 'nb-1' }); + }); + + it('onTagsChanged fires with previousName for renames', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onTagsChanged(cb); + api._notifyTagsChanged({ kind: 'tag', action: 'renamed', id: 'new-name', previousName: 'old-name' }); + expect(cb).toHaveBeenCalledWith({ kind: 'tag', action: 'renamed', id: 'new-name', previousName: 'old-name' }); + }); + + it('unsubscribe stops listener', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + const unsub = api.onNotesChanged(cb); + unsub(); + api._notifyNotesChanged({ kind: 'note', action: 'updated', id: '1' }); + expect(cb).not.toHaveBeenCalled(); + }); + }); +}); +``` + +**Step 2: Run tests** + +Run: `cd packages/plugin-api && pnpm vitest run tests/createDataAPI.test.ts` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add packages/plugin-api/tests/createDataAPI.test.ts +git commit -m "test(plugin-api): add comprehensive tests for createDataAPI" +``` + +--- + +### Task 4: Wire DataAPI into PluginRegistry.activate() + +**Files:** +- Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts:90-98` (add `dataAPI` param to `activate()`) +- Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts:228-290` (add `data: trackedData` to context) + +**Context:** Follow the exact same pattern as `appAPI` — it's passed into `activate()`, tracked for event cleanup, and injected into the `context` object. The `PluginRegistry.activate()` signature gets a new `dataAPI` parameter. + +**Step 1: Update PluginRegistry** + +Add `DataAPI` to the imports: + +```typescript +import type { DataAPI } from '../data/createDataAPI'; +``` + +Update `activate()` signature (add after `appAPI: AppAPI`): + +```typescript +async activate( + id: string, + editorAPI: EditorAPI, + appAPI: AppAPI, + dataAPI: DataAPI, // NEW + registerCommandFn?: RegisterCommandFn, + configBridge?: ConfigBridge, + getView?: () => EditorView | null +): Promise { +``` + +Wrap `dataAPI` events for auto-cleanup (same pattern as `trackedApp`): + +```typescript +const trackedData: DataAPI = { + ...dataAPI, + onNotesChanged(callback) { + const unsub = dataAPI.onNotesChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, + onNotebooksChanged(callback) { + const unsub = dataAPI.onNotebooksChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, + onTagsChanged(callback) { + const unsub = dataAPI.onTagsChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, +}; +``` + +Add `data: trackedData` to the context object (after `app: trackedApp`): + +```typescript +const context: PluginContext = { + // ... existing fields ... + app: trackedApp, + data: trackedData, // NEW +}; +``` + +**Step 2: Update PluginHost to pass dataAPI** + +Modify `packages/plugin-api/src/lifecycle/PluginHost.tsx`: + +Add `DataAPI` to the import and props: + +```typescript +import type { PluginManifest, EditorAPI, AppAPI } from '../types'; +import type { DataAPI } from '../data/createDataAPI'; + +interface PluginHostProps { + plugins: PluginManifest[]; + editorAPI: EditorAPI; + appAPI: AppAPI; + dataAPI: DataAPI; // NEW + registerCommand?: RegisterCommandFn; + configBridge?: ConfigBridge; + getView?: () => EditorView | null; +} +``` + +Destructure `dataAPI` and pass to `registry.activate()`: + +```typescript +export function PluginHost({ + plugins, + editorAPI, + appAPI, + dataAPI, // NEW + registerCommand, + configBridge, + getView, +}: PluginHostProps) { + // ... + await registry.activate( + manifest.id, + editorAPI, + appAPI, + dataAPI, // NEW — between appAPI and registerCommand + registerCommand, + configBridge, + getView + ); +``` + +**Step 3: Update registry tests** + +Modify `packages/plugin-api/tests/registry.test.ts` — wherever `registry.activate()` is called, add a mock `dataAPI` parameter. Create a minimal mock: + +```typescript +const mockDataAPI = { + getNotes: async () => ({ notes: [], total: 0, hasMore: false }), + getNote: async () => null, + searchNotes: async () => ({ results: [], total: 0 }), + countNotes: async () => 0, + getNotebooks: async () => [], + getNotebook: async () => null, + getTags: async () => [], + getBacklinks: async () => [], + getOutgoingLinks: async () => [], + getGraphData: async () => ({ nodes: [], edges: [] }), + onNotesChanged: () => () => {}, + onNotebooksChanged: () => () => {}, + onTagsChanged: () => () => {}, +} as any; +``` + +Insert `mockDataAPI` after `mockAppAPI` in every `registry.activate(id, mockEditorAPI, mockAppAPI, ...)` call. + +**Step 4: Run tests** + +Run: `cd packages/plugin-api && pnpm vitest run` +Expected: All tests PASS + +**Step 5: Run typecheck** + +Run: `cd packages/plugin-api && pnpm typecheck` +Expected: PASS (types.ts now references DataAPI, PluginRegistry provides it) + +Note: `apps/desktop` typecheck will fail because `App.tsx` and `PluginHost` aren't updated yet — that's Task 5. + +**Step 6: Commit** + +```bash +git add packages/plugin-api/src/lifecycle/PluginRegistry.ts packages/plugin-api/src/lifecycle/PluginHost.tsx packages/plugin-api/tests/registry.test.ts +git commit -m "feat(plugin-api): wire DataAPI into PluginRegistry and PluginHost" +``` + +--- + +### Task 5: Barrel exports + +**Files:** +- Modify: `packages/plugin-api/src/index.ts` + +**Context:** Export all new types and the factory from the barrel so the host app can import them. + +**Step 1: Add data exports to index.ts** + +After the `// App` section, add: + +```typescript +// Data +export type { DataAPI, DataAPIWithEvents, DataAPIBridge } from './data/createDataAPI'; +export { createDataAPI } from './data/createDataAPI'; +export type { + NoteQueryOptions, + NoteQueryResult, + SearchOptions, + SearchResult, + NotebookQueryOptions, + NotebookDetailInfo, + NotebookTreeNode, + NotebookResult, + TagQueryOptions, + TagInfo, + GraphQueryOptions, + GraphData, + LinkInfo, + OutgoingLinkInfo, + DataChangeEvent, +} from './data/dataTypes'; +export { DataAccessError } from './data/dataTypes'; +``` + +**Step 2: Run typecheck** + +Run: `cd packages/plugin-api && pnpm typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/index.ts +git commit -m "feat(plugin-api): export DataAPI types and factory from barrel" +``` + +--- + +### Task 6: Wire DataAPIBridge in App.tsx + +**Files:** +- Modify: `apps/desktop/src/renderer/App.tsx:7` (add imports) +- Modify: `apps/desktop/src/renderer/App.tsx:139-190` (add dataAPI useMemo) +- Modify: `apps/desktop/src/renderer/App.tsx:576-582` (pass dataAPI to PluginHost) + +**Context:** This is the host-side bridge — same pattern as the existing `appAPI` useMemo block. We create a `DataAPIBridge` that maps to `window.readied.*` IPC calls, then pass `createDataAPI(bridge)` to `PluginHost`. + +**Step 1: Add imports** + +```typescript +import { + PluginHost, + createEditorAPI, + createAppAPI, + createDataAPI, // NEW + editorPluginStore, + useCssVariables, +} from '@readied/plugin-api'; +import type { DataAPIWithEvents } from '@readied/plugin-api'; +``` + +**Step 2: Add dataAPI useMemo (after the appAPI block)** + +```typescript +const dataAPI = useMemo( + () => + createDataAPI({ + async getNotes(options) { + const notes = await window.readied.notes.list(options ? { + limit: options.limit, + offset: options.offset, + tag: options.tag, + sortBy: options.sortBy === 'wordCount' ? 'updatedAt' : options.sortBy, + sortOrder: options.sortOrder, + } : undefined); + // Filter by notebookId, status, isPinned client-side (IPC doesn't support these directly) + let filtered = notes; + if (options?.notebookId) filtered = filtered.filter(n => n.notebookId === options.notebookId); + if (options?.status) filtered = filtered.filter(n => n.status === options.status); + if (options?.isPinned !== undefined) filtered = filtered.filter(n => n.isPinned === options.isPinned); + return { + notes: filtered.map(n => ({ + id: n.id, title: n.title, notebookId: n.notebookId, + tags: [...n.tags], wordCount: n.wordCount, + createdAt: n.createdAt, updatedAt: n.updatedAt, + isPinned: n.isPinned, status: n.status, + })), + total: filtered.length, + }; + }, + async getNote(id) { + const result = await window.readied.notes.get(id); + if (!result.ok) return null; + return { id: result.data.id, title: result.data.title, content: result.data.content }; + }, + async searchNotes(query, options) { + const notes = await window.readied.notes.search(query, options?.limit ?? 20); + return { + results: notes.map(n => ({ id: n.id, title: n.title })), + total: notes.length, + }; + }, + async countNotes() { + const counts = await window.readied.notes.count(); + return counts.total; + }, + async getNotebooks() { + const notebooks = await window.readied.notebooks.list(); + return notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId })); + }, + async getNotebookTree() { + const tree = await window.readied.notebooks.tree(); + const mapNode = (node: any): any => ({ + id: node.notebook.id, + name: node.notebook.name, + parentId: node.notebook.parentId, + noteCount: 0, + childCount: node.children.length, + children: node.children.map(mapNode), + }); + return tree.map(mapNode); + }, + async getNotebook(id) { + const nb = await window.readied.notebooks.getWithMetadata(id); + if (!nb) return null; + return { + id: nb.id, name: nb.name, parentId: nb.parentId, + noteCount: nb.noteCount, childCount: nb.childCount, + }; + }, + async getTags() { + return window.readied.notes.tags(); + }, + async getTagsWithColors() { + return window.readied.notes.tagsWithColors(); + }, + async getBacklinks(noteId) { + const links = await window.readied.links.getBacklinks(noteId); + return links.map(l => ({ noteId: l.noteId, noteTitle: l.noteTitle })); + }, + async getOutgoingLinks(noteId) { + const links = await window.readied.links.getOutgoing(noteId); + return links.map(l => ({ + targetId: l.targetNoteId, + targetTitle: l.targetTitle ?? l.targetRef, + resolved: l.targetNoteId !== null, + })); + }, + async getGraphData() { + return window.readied.links.getGraph(); + }, + }), + [] +); +``` + +**Step 3: Pass to PluginHost** + +```tsx + +``` + +**Step 4: Fire data events from existing mutation callbacks** + +In the `handleCreateNote`, `handleDeleteNote`, etc. callbacks, add data event notifications alongside the existing appAPI ones: + +```typescript +// In handleCreateNote (after appAPI._notifyNoteCreated): +dataAPI._notifyNotesChanged({ kind: 'note', action: 'created', id: newNote.id }); + +// In handleDeleteNote (after appAPI._notifyNoteDeleted): +dataAPI._notifyNotesChanged({ kind: 'note', action: 'deleted', id }); +``` + +**Step 5: Run typecheck** + +Run: `pnpm typecheck` +Expected: All 18 packages PASS + +**Step 6: Run tests** + +Run: `pnpm test` +Expected: All tests PASS + +**Step 7: Commit** + +```bash +git add apps/desktop/src/renderer/App.tsx +git commit -m "feat: wire DataAPI bridge to IPC in App.tsx and fire data events" +``` + +--- + +### Task 7: Final typecheck + full test run + +**Files:** None (verification only) + +**Step 1: Run full typecheck** + +Run: `pnpm typecheck` +Expected: 18/18 packages PASS + +**Step 2: Run full test suite** + +Run: `pnpm test` +Expected: All tests PASS across all packages + +**Step 3: Verify no regressions** + +Check that: +- Existing `createAppAPI.test.ts` still passes (AppAPI unchanged) +- Existing `registry.test.ts` passes (updated to pass mockDataAPI) +- New `dataTypes.test.ts` passes (DataAccessError) +- New `createDataAPI.test.ts` passes (all query + event tests) From b44fc75f1c4320260f3fdd3f60e6afdd6eb2b390 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:38:56 -0300 Subject: [PATCH 042/148] feat(plugin-api): add DataAPI types, query options, and DataAccessError Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/data/dataTypes.ts | 108 ++++++++++++++++++++ packages/plugin-api/tests/dataTypes.test.ts | 25 +++++ 2 files changed, 133 insertions(+) create mode 100644 packages/plugin-api/src/data/dataTypes.ts create mode 100644 packages/plugin-api/tests/dataTypes.test.ts diff --git a/packages/plugin-api/src/data/dataTypes.ts b/packages/plugin-api/src/data/dataTypes.ts new file mode 100644 index 00000000..f334c2ce --- /dev/null +++ b/packages/plugin-api/src/data/dataTypes.ts @@ -0,0 +1,108 @@ +// packages/plugin-api/src/data/dataTypes.ts + +import type { NoteInfo, NoteSummaryInfo, NotebookInfo } from '../types'; + +// Re-export for convenience +export type { NoteInfo, NoteSummaryInfo, NotebookInfo }; + +// ── Query Options ─────────────────────────────────── + +export interface NoteQueryOptions { + notebookId?: string; + tag?: string; + status?: string; + isPinned?: boolean; + sortBy?: 'title' | 'createdAt' | 'updatedAt' | 'wordCount'; + sortOrder?: 'asc' | 'desc'; + limit?: number; + offset?: number; +} + +export interface NoteQueryResult { + notes: NoteSummaryInfo[]; + total: number; + hasMore: boolean; +} + +export interface SearchOptions { + limit?: number; + notebookId?: string; +} + +export interface SearchResult { + results: Array<{ id: string; title: string; snippet?: string }>; + total: number; +} + +export interface NotebookQueryOptions { + tree?: boolean; + includeCounts?: boolean; +} + +export interface NotebookDetailInfo extends NotebookInfo { + noteCount: number; + childCount: number; +} + +export interface NotebookTreeNode extends NotebookDetailInfo { + children: NotebookTreeNode[]; +} + +export type NotebookResult = NotebookInfo[] | NotebookTreeNode[]; + +export interface TagQueryOptions { + includeColors?: boolean; + includeCount?: boolean; + /** Case-insensitive substring match on tag name */ + filter?: string; + limit?: number; + offset?: number; +} + +export interface TagInfo { + name: string; + color?: string | null; + count?: number; +} + +export interface GraphQueryOptions { + notebookId?: string; + depth?: number; +} + +export interface LinkInfo { + noteId: string; + noteTitle: string; +} + +export interface OutgoingLinkInfo { + targetId: string | null; + targetTitle: string; + resolved: boolean; +} + +export interface GraphData { + nodes: Array<{ id: string; title: string; notebookId: string }>; + edges: Array<{ source: string; target: string }>; +} + +// ── Events ────────────────────────────────────────── + +export interface DataChangeEvent { + kind: T; + action: 'created' | 'updated' | 'deleted' | 'renamed'; + id: string; + previousName?: string; +} + +// ── Error ─────────────────────────────────────────── + +export class DataAccessError extends Error { + constructor( + public readonly method: string, + message: string, + ) { + super(`[DataAPI.${method}] ${message}`); + this.name = 'DataAccessError'; + } +} diff --git a/packages/plugin-api/tests/dataTypes.test.ts b/packages/plugin-api/tests/dataTypes.test.ts new file mode 100644 index 00000000..41ff0a20 --- /dev/null +++ b/packages/plugin-api/tests/dataTypes.test.ts @@ -0,0 +1,25 @@ +// packages/plugin-api/tests/dataTypes.test.ts +import { describe, it, expect } from 'vitest'; +import { DataAccessError } from '../src/data/dataTypes'; + +describe('DataAccessError', () => { + it('sets name to DataAccessError', () => { + const err = new DataAccessError('getNotes', 'IPC failed'); + expect(err.name).toBe('DataAccessError'); + }); + + it('includes method in message', () => { + const err = new DataAccessError('getNotes', 'IPC failed'); + expect(err.message).toBe('[DataAPI.getNotes] IPC failed'); + }); + + it('exposes method property', () => { + const err = new DataAccessError('getGraphData', 'timeout'); + expect(err.method).toBe('getGraphData'); + }); + + it('is an instance of Error', () => { + const err = new DataAccessError('getTags', 'oops'); + expect(err).toBeInstanceOf(Error); + }); +}); From 9e16eecbcf2c5ba2c67dc43d5dc4dea138bc69c9 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:42:48 -0300 Subject: [PATCH 043/148] feat(plugin-api): add DataAPI interface, bridge, and createDataAPI factory Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/data/createDataAPI.ts | 224 ++++++++++++++++++ packages/plugin-api/src/types.ts | 3 + 2 files changed, 227 insertions(+) create mode 100644 packages/plugin-api/src/data/createDataAPI.ts diff --git a/packages/plugin-api/src/data/createDataAPI.ts b/packages/plugin-api/src/data/createDataAPI.ts new file mode 100644 index 00000000..a0314fab --- /dev/null +++ b/packages/plugin-api/src/data/createDataAPI.ts @@ -0,0 +1,224 @@ +// packages/plugin-api/src/data/createDataAPI.ts + +import type { + NoteInfo, + NoteSummaryInfo, + NotebookInfo, + NoteQueryOptions, + NoteQueryResult, + SearchOptions, + SearchResult, + NotebookQueryOptions, + NotebookDetailInfo, + NotebookTreeNode, + NotebookResult, + TagQueryOptions, + TagInfo, + GraphQueryOptions, + LinkInfo, + OutgoingLinkInfo, + GraphData, + DataChangeEvent, +} from './dataTypes'; + +import { DataAccessError } from './dataTypes'; + +// ── DataAPI (what plugins see) ────────────────────── + +export interface DataAPI { + getNotes(options?: NoteQueryOptions): Promise; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: NoteQueryOptions): Promise; + getNotebooks(options?: NotebookQueryOptions): Promise; + getNotebook(id: string): Promise; + getTags(options?: TagQueryOptions): Promise; + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(options?: GraphQueryOptions): Promise; + onNotesChanged(callback: (event: DataChangeEvent<'note'>) => void): () => void; + onNotebooksChanged(callback: (event: DataChangeEvent<'notebook'>) => void): () => void; + onTagsChanged(callback: (event: DataChangeEvent<'tag'>) => void): () => void; +} + +// ── DataAPIWithEvents (host-facing, adds _notify*) ── + +export interface DataAPIWithEvents extends DataAPI { + _notifyNotesChanged(event: DataChangeEvent<'note'>): void; + _notifyNotebooksChanged(event: DataChangeEvent<'notebook'>): void; + _notifyTagsChanged(event: DataChangeEvent<'tag'>): void; +} + +// ── DataAPIBridge (thin IPC wrapper) ──────────────── + +export interface DataAPIBridge { + getNotes(options?: NoteQueryOptions): Promise<{ notes: NoteSummaryInfo[]; total: number }>; + getNote(id: string): Promise; + searchNotes(query: string, options?: SearchOptions): Promise; + countNotes(options?: NoteQueryOptions): Promise; + getNotebooks(): Promise; + getNotebookTree(): Promise; + getNotebook(id: string): Promise; + getTags(): Promise; + getTagsWithColors(): Promise>; + getBacklinks(noteId: string): Promise; + getOutgoingLinks(noteId: string): Promise; + getGraphData(): Promise; +} + +// ── Helpers ───────────────────────────────────────── + +async function safeBridgeCall(method: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof DataAccessError) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new DataAccessError(method, message); + } +} + +// ── Factory ───────────────────────────────────────── + +export function createDataAPI(bridge: DataAPIBridge): DataAPIWithEvents { + const notesChangedListeners = new Set<(event: DataChangeEvent<'note'>) => void>(); + const notebooksChangedListeners = new Set<(event: DataChangeEvent<'notebook'>) => void>(); + const tagsChangedListeners = new Set<(event: DataChangeEvent<'tag'>) => void>(); + + return { + // ── Notes ────────────────────────────────────── + + async getNotes(options?: NoteQueryOptions): Promise { + return safeBridgeCall('getNotes', async () => { + const offset = options?.offset ?? 0; + const { notes, total } = await bridge.getNotes(options); + const hasMore = offset + notes.length < total; + return { notes, total, hasMore }; + }); + }, + + async getNote(id: string): Promise { + return safeBridgeCall('getNote', () => bridge.getNote(id)); + }, + + async searchNotes(query: string, options?: SearchOptions): Promise { + return safeBridgeCall('searchNotes', () => bridge.searchNotes(query, options)); + }, + + async countNotes(options?: NoteQueryOptions): Promise { + return safeBridgeCall('countNotes', () => bridge.countNotes(options)); + }, + + // ── Notebooks ────────────────────────────────── + + async getNotebooks(options?: NotebookQueryOptions): Promise { + return safeBridgeCall('getNotebooks', async () => { + if (options?.tree) { + return bridge.getNotebookTree(); + } + return bridge.getNotebooks(); + }); + }, + + async getNotebook(id: string): Promise { + return safeBridgeCall('getNotebook', () => bridge.getNotebook(id)); + }, + + // ── Tags ─────────────────────────────────────── + + async getTags(options?: TagQueryOptions): Promise { + return safeBridgeCall('getTags', async () => { + let tags: TagInfo[]; + + if (options?.includeColors) { + const raw = await bridge.getTagsWithColors(); + tags = raw.map(t => ({ name: t.name, color: t.color })); + } else { + const raw = await bridge.getTags(); + tags = raw.map(name => ({ name })); + } + + // Client-side case-insensitive substring filter + if (options?.filter) { + const filterLower = options.filter.toLowerCase(); + tags = tags.filter(t => t.name.toLowerCase().includes(filterLower)); + } + + // Client-side pagination + const offset = options?.offset ?? 0; + const limit = options?.limit; + if (limit !== undefined) { + tags = tags.slice(offset, offset + limit); + } else if (offset > 0) { + tags = tags.slice(offset); + } + + return tags; + }); + }, + + // ── Links & Graph ────────────────────────────── + + async getBacklinks(noteId: string): Promise { + return safeBridgeCall('getBacklinks', () => bridge.getBacklinks(noteId)); + }, + + async getOutgoingLinks(noteId: string): Promise { + return safeBridgeCall('getOutgoingLinks', () => bridge.getOutgoingLinks(noteId)); + }, + + async getGraphData(options?: GraphQueryOptions): Promise { + return safeBridgeCall('getGraphData', async () => { + const graph = await bridge.getGraphData(); + + if (options?.notebookId) { + const filteredNodes = graph.nodes.filter(n => n.notebookId === options.notebookId); + const nodeIds = new Set(filteredNodes.map(n => n.id)); + const filteredEdges = graph.edges.filter( + e => nodeIds.has(e.source) && nodeIds.has(e.target), + ); + return { nodes: filteredNodes, edges: filteredEdges }; + } + + return graph; + }); + }, + + // ── Events ───────────────────────────────────── + + onNotesChanged(cb) { + notesChangedListeners.add(cb); + return () => { + notesChangedListeners.delete(cb); + }; + }, + + onNotebooksChanged(cb) { + notebooksChangedListeners.add(cb); + return () => { + notebooksChangedListeners.delete(cb); + }; + }, + + onTagsChanged(cb) { + tagsChangedListeners.add(cb); + return () => { + tagsChangedListeners.delete(cb); + }; + }, + + // ── Internal notify (host calls these) ───────── + + _notifyNotesChanged(event) { + for (const cb of notesChangedListeners) cb(event); + }, + + _notifyNotebooksChanged(event) { + for (const cb of notebooksChangedListeners) cb(event); + }, + + _notifyTagsChanged(event) { + for (const cb of tagsChangedListeners) cb(event); + }, + }; +} diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 0cf7e4d9..6f84593d 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -3,6 +3,7 @@ import type { ComponentType } from 'react'; import type { LayoutManager } from './layout/types'; import type { EditorDecorationAPI } from './editor/decorationAPI'; import type { CodeBlockRendererProps } from './preview/codeBlockStore'; +import type { DataAPI } from './data/createDataAPI'; /** Controlled subset of editor operations for plugins */ export interface EditorAPI { @@ -136,6 +137,8 @@ export interface PluginContext { config: PluginConfigAPI; log: PluginLogger; app: AppAPI; + /** Rich data query API for notes, notebooks, tags, links, and graph */ + data: DataAPI; } export interface PluginManifest { From 78a20cd1121e900865dda2e484580ef6babba3b9 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:51:03 -0300 Subject: [PATCH 044/148] test(plugin-api): add comprehensive tests for createDataAPI Co-Authored-By: Claude Opus 4.6 --- .../plugin-api/tests/createDataAPI.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/plugin-api/tests/createDataAPI.test.ts diff --git a/packages/plugin-api/tests/createDataAPI.test.ts b/packages/plugin-api/tests/createDataAPI.test.ts new file mode 100644 index 00000000..7f6ddc6a --- /dev/null +++ b/packages/plugin-api/tests/createDataAPI.test.ts @@ -0,0 +1,180 @@ +// packages/plugin-api/tests/createDataAPI.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createDataAPI } from '../src/data/createDataAPI'; +import type { DataAPIBridge } from '../src/data/createDataAPI'; +import { DataAccessError } from '../src/data/dataTypes'; + +function makeBridge(overrides: Partial = {}): DataAPIBridge { + return { + getNotes: async () => ({ notes: [], total: 0 }), + getNote: async () => null, + searchNotes: async () => ({ results: [], total: 0 }), + countNotes: async () => 0, + getNotebooks: async () => [], + getNotebookTree: async () => [], + getNotebook: async () => null, + getTags: async () => [], + getTagsWithColors: async () => [], + getBacklinks: async () => [], + getOutgoingLinks: async () => [], + getGraphData: async () => ({ nodes: [], edges: [] }), + ...overrides, + }; +} + +describe('createDataAPI', () => { + describe('getNotes', () => { + it('delegates to bridge and computes hasMore', async () => { + const notes = [{ id: '1', title: 'A', notebookId: 'nb', tags: [], wordCount: 10, createdAt: '', updatedAt: '', isPinned: false, status: 'active' }]; + const api = createDataAPI(makeBridge({ getNotes: async () => ({ notes, total: 5 }) })); + const result = await api.getNotes({ limit: 2, offset: 0 }); + expect(result.notes).toEqual(notes); + expect(result.total).toBe(5); + expect(result.hasMore).toBe(true); + }); + + it('hasMore is false when all notes returned', async () => { + const api = createDataAPI(makeBridge({ getNotes: async () => ({ notes: [{ id: '1', title: 'A', notebookId: 'nb', tags: [], wordCount: 0, createdAt: '', updatedAt: '', isPinned: false, status: 'active' }], total: 1 }) })); + const result = await api.getNotes(); + expect(result.hasMore).toBe(false); + }); + }); + + describe('getNote', () => { + it('returns note from bridge', async () => { + const note = { id: '1', title: 'Test', content: 'body' }; + const api = createDataAPI(makeBridge({ getNote: async () => note })); + expect(await api.getNote('1')).toEqual(note); + }); + }); + + describe('getNotebooks', () => { + it('returns flat list by default', async () => { + const notebooks = [{ id: 'nb-1', name: 'Inbox', parentId: null }]; + const api = createDataAPI(makeBridge({ getNotebooks: async () => notebooks })); + const result = await api.getNotebooks(); + expect(result).toEqual(notebooks); + }); + + it('returns tree when tree option is true', async () => { + const tree = [{ id: 'nb-1', name: 'Root', parentId: null, noteCount: 5, childCount: 1, children: [] }]; + const api = createDataAPI(makeBridge({ getNotebookTree: async () => tree })); + const result = await api.getNotebooks({ tree: true }); + expect(result).toEqual(tree); + }); + }); + + describe('getTags', () => { + it('returns simple tag names by default', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['js', 'react', 'vue'] })); + const result = await api.getTags(); + expect(result).toEqual([{ name: 'js' }, { name: 'react' }, { name: 'vue' }]); + }); + + it('includes colors when requested', async () => { + const api = createDataAPI(makeBridge({ + getTagsWithColors: async () => [{ name: 'js', color: '#ff0' }, { name: 'go', color: null }], + })); + const result = await api.getTags({ includeColors: true }); + expect(result).toEqual([{ name: 'js', color: '#ff0' }, { name: 'go', color: null }]); + }); + + it('filters by case-insensitive substring', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['JavaScript', 'Java', 'Python'] })); + const result = await api.getTags({ filter: 'java' }); + expect(result.map(t => t.name)).toEqual(['JavaScript', 'Java']); + }); + + it('paginates with limit and offset', async () => { + const api = createDataAPI(makeBridge({ getTags: async () => ['a', 'b', 'c', 'd', 'e'] })); + const result = await api.getTags({ limit: 2, offset: 1 }); + expect(result.map(t => t.name)).toEqual(['b', 'c']); + }); + }); + + describe('getGraphData', () => { + it('returns full graph without options', async () => { + const graph = { + nodes: [ + { id: '1', title: 'A', notebookId: 'nb-1' }, + { id: '2', title: 'B', notebookId: 'nb-2' }, + ], + edges: [{ source: '1', target: '2' }], + }; + const api = createDataAPI(makeBridge({ getGraphData: async () => graph })); + const result = await api.getGraphData(); + expect(result).toEqual(graph); + }); + + it('filters by notebookId', async () => { + const graph = { + nodes: [ + { id: '1', title: 'A', notebookId: 'nb-1' }, + { id: '2', title: 'B', notebookId: 'nb-2' }, + { id: '3', title: 'C', notebookId: 'nb-1' }, + ], + edges: [ + { source: '1', target: '2' }, + { source: '1', target: '3' }, + ], + }; + const api = createDataAPI(makeBridge({ getGraphData: async () => graph })); + const result = await api.getGraphData({ notebookId: 'nb-1' }); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]).toEqual({ source: '1', target: '3' }); + }); + }); + + describe('error handling', () => { + it('wraps bridge errors as DataAccessError', async () => { + const api = createDataAPI(makeBridge({ + getNotes: async () => { throw new Error('IPC timeout'); }, + })); + await expect(api.getNotes()).rejects.toThrow(DataAccessError); + await expect(api.getNotes()).rejects.toThrow('[DataAPI.getNotes] IPC timeout'); + }); + + it('wraps non-Error throws', async () => { + const api = createDataAPI(makeBridge({ + getNote: async () => { throw 'string error'; }, + })); + await expect(api.getNote('1')).rejects.toThrow('[DataAPI.getNote] string error'); + }); + }); + + describe('events', () => { + it('onNotesChanged fires when notified', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onNotesChanged(cb); + api._notifyNotesChanged({ kind: 'note', action: 'created', id: '1' }); + expect(cb).toHaveBeenCalledWith({ kind: 'note', action: 'created', id: '1' }); + }); + + it('onNotebooksChanged fires when notified', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onNotebooksChanged(cb); + api._notifyNotebooksChanged({ kind: 'notebook', action: 'deleted', id: 'nb-1' }); + expect(cb).toHaveBeenCalledWith({ kind: 'notebook', action: 'deleted', id: 'nb-1' }); + }); + + it('onTagsChanged fires with previousName for renames', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + api.onTagsChanged(cb); + api._notifyTagsChanged({ kind: 'tag', action: 'renamed', id: 'new-name', previousName: 'old-name' }); + expect(cb).toHaveBeenCalledWith({ kind: 'tag', action: 'renamed', id: 'new-name', previousName: 'old-name' }); + }); + + it('unsubscribe stops listener', () => { + const api = createDataAPI(makeBridge()); + const cb = vi.fn(); + const unsub = api.onNotesChanged(cb); + unsub(); + api._notifyNotesChanged({ kind: 'note', action: 'updated', id: '1' }); + expect(cb).not.toHaveBeenCalled(); + }); + }); +}); From 3d1629763c9e420606021411501f5ffca930845c Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:54:30 -0300 Subject: [PATCH 045/148] feat(plugin-api): wire DataAPI into PluginRegistry and PluginHost Co-Authored-By: Claude Opus 4.6 --- .../plugin-api/src/lifecycle/PluginHost.tsx | 6 +- .../src/lifecycle/PluginRegistry.ts | 34 +++++++++ packages/plugin-api/tests/registry.test.ts | 76 ++++++++++++------- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/packages/plugin-api/src/lifecycle/PluginHost.tsx b/packages/plugin-api/src/lifecycle/PluginHost.tsx index 63557c0d..bcb278e2 100644 --- a/packages/plugin-api/src/lifecycle/PluginHost.tsx +++ b/packages/plugin-api/src/lifecycle/PluginHost.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import type { EditorView } from '@codemirror/view'; import type { PluginManifest, EditorAPI, AppAPI } from '../types'; +import type { DataAPI } from '../data/createDataAPI'; import { PLUGIN_API_VERSION } from '../apiVersion'; import { PluginRegistry, type RegisterCommandFn, type ConfigBridge } from './PluginRegistry'; import { sortPlugins } from './sortPlugins'; @@ -9,6 +10,7 @@ interface PluginHostProps { plugins: PluginManifest[]; editorAPI: EditorAPI; appAPI: AppAPI; + dataAPI: DataAPI; registerCommand?: RegisterCommandFn; configBridge?: ConfigBridge; getView?: () => EditorView | null; @@ -33,6 +35,7 @@ export function PluginHost({ plugins, editorAPI, appAPI, + dataAPI, registerCommand, configBridge, getView, @@ -77,6 +80,7 @@ export function PluginHost({ manifest.id, editorAPI, appAPI, + dataAPI, registerCommand, configBridge, getView @@ -98,7 +102,7 @@ export function PluginHost({ registry.unload(manifest.id); } }; - }, [plugins, editorAPI, appAPI, registerCommand, configBridge, getView]); + }, [plugins, editorAPI, appAPI, dataAPI, registerCommand, configBridge, getView]); // Headless - renders nothing return null; diff --git a/packages/plugin-api/src/lifecycle/PluginRegistry.ts b/packages/plugin-api/src/lifecycle/PluginRegistry.ts index 04965411..e19c8b12 100644 --- a/packages/plugin-api/src/lifecycle/PluginRegistry.ts +++ b/packages/plugin-api/src/lifecycle/PluginRegistry.ts @@ -10,6 +10,7 @@ import type { AppAPI, PluginCommandOptions, } from '../types'; +import type { DataAPI } from '../data/createDataAPI'; import { createLayoutManager } from '../layout/layoutStore'; import { editorPluginStore } from '../editor/editorPluginStore'; import { createDecorationAPI } from '../editor/decorationAPI'; @@ -92,6 +93,7 @@ export class PluginRegistry { id: string, editorAPI: EditorAPI, appAPI: AppAPI, + dataAPI: DataAPI, registerCommandFn?: RegisterCommandFn, configBridge?: ConfigBridge, getView?: () => EditorView | null @@ -224,6 +226,37 @@ export class PluginRegistry { }, }; + const trackedData: DataAPI = { + ...dataAPI, + onNotesChanged(callback) { + const unsub = dataAPI.onNotesChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, + onNotebooksChanged(callback) { + const unsub = dataAPI.onNotebooksChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, + onTagsChanged(callback) { + const unsub = dataAPI.onTagsChanged(callback); + const tracked = () => { + unsub(); + entry.eventUnsubscribers = entry.eventUnsubscribers.filter(u => u !== tracked); + }; + entry.eventUnsubscribers.push(tracked); + return tracked; + }, + }; + const context: PluginContext = { layout: createLayoutManager(id), editor: trackedEditor, @@ -279,6 +312,7 @@ export class PluginRegistry { error: (msg: string, ...args: unknown[]) => console.error(`[${id}]`, msg, ...args), }, app: trackedApp, + data: trackedData, }; try { diff --git a/packages/plugin-api/tests/registry.test.ts b/packages/plugin-api/tests/registry.test.ts index 596eabe2..d7422085 100644 --- a/packages/plugin-api/tests/registry.test.ts +++ b/packages/plugin-api/tests/registry.test.ts @@ -32,6 +32,22 @@ function makeEditorAPI(): EditorAPI { }; } +const mockDataAPI = { + getNotes: async () => ({ notes: [], total: 0, hasMore: false }), + getNote: async () => null, + searchNotes: async () => ({ results: [], total: 0 }), + countNotes: async () => 0, + getNotebooks: async () => [], + getNotebook: async () => null, + getTags: async () => [], + getBacklinks: async () => [], + getOutgoingLinks: async () => [], + getGraphData: async () => ({ nodes: [], edges: [] }), + onNotesChanged: () => () => {}, + onNotebooksChanged: () => () => {}, + onTagsChanged: () => () => {}, +} as any; + function makeAppAPI(): AppAPI { return { getCurrentNote: () => null, @@ -78,7 +94,7 @@ describe('PluginRegistry', () => { it('activates a loaded plugin', async () => { const activate = vi.fn(); registry.load(makeManifest({ activate })); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(activate).toHaveBeenCalledOnce(); expect(registry.isActive('test-plugin')).toBe(true); @@ -93,7 +109,7 @@ describe('PluginRegistry', () => { }, }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const ctx = receivedContext as Record; expect(ctx).toHaveProperty('layout'); @@ -119,7 +135,7 @@ describe('PluginRegistry', () => { }, }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const app = receivedApp as Record; expect(app).toHaveProperty('listNotes'); @@ -132,15 +148,15 @@ describe('PluginRegistry', () => { it('does nothing for non-existent plugin', async () => { await expect( - registry.activate('nope', makeEditorAPI(), makeAppAPI()) + registry.activate('nope', makeEditorAPI(), makeAppAPI(), mockDataAPI) ).resolves.toBeUndefined(); }); it('does nothing if already active', async () => { const activate = vi.fn(); registry.load(makeManifest({ activate })); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(activate).toHaveBeenCalledOnce(); }); @@ -164,6 +180,7 @@ describe('PluginRegistry', () => { 'test-plugin', makeEditorAPI(), makeAppAPI(), + mockDataAPI, undefined, configBridge ); @@ -189,6 +206,7 @@ describe('PluginRegistry', () => { 'test-plugin', makeEditorAPI(), makeAppAPI(), + mockDataAPI, undefined, configBridge ); @@ -208,7 +226,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), registerCommandFn); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI, registerCommandFn); expect(registerCommandFn).toHaveBeenCalledWith( expect.objectContaining({ id: 'plugin:test-plugin:toggle' }) @@ -226,7 +244,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), registerCommandFn); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI, registerCommandFn); expect(registerCommandFn).toHaveBeenCalledWith( expect.objectContaining({ showInPalette: true }) @@ -245,7 +263,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), registerCommandFn); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI, registerCommandFn); registry.deactivate('test-plugin'); expect(unregister).toHaveBeenCalledOnce(); @@ -261,7 +279,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); registry.deactivate('test-plugin'); expect(dispose).toHaveBeenCalledOnce(); @@ -271,7 +289,7 @@ describe('PluginRegistry', () => { const deactivate = vi.fn(); registry.load(makeManifest({ deactivate })); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); registry.deactivate('test-plugin'); expect(deactivate).toHaveBeenCalledOnce(); @@ -288,7 +306,7 @@ describe('PluginRegistry', () => { it('marks plugin as deactivated', async () => { registry.load(makeManifest()); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); registry.deactivate('test-plugin'); expect(registry.isActive('test-plugin')).toBe(false); @@ -301,7 +319,7 @@ describe('PluginRegistry', () => { it('deactivates and removes plugin', async () => { const dispose = vi.fn(); registry.load(makeManifest({ activate: () => ({ dispose }) })); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); registry.unload('test-plugin'); @@ -333,7 +351,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', editorAPI, makeAppAPI()); + await registry.activate('test-plugin', editorAPI, makeAppAPI(), mockDataAPI); registry.deactivate('test-plugin'); expect(editorUnsub).toHaveBeenCalledOnce(); @@ -353,7 +371,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), appAPI); + await registry.activate('test-plugin', makeEditorAPI(), appAPI, mockDataAPI); registry.deactivate('test-plugin'); expect(appUnsub).toHaveBeenCalledOnce(); @@ -377,7 +395,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', editorAPI, makeAppAPI()); + await registry.activate('test-plugin', editorAPI, makeAppAPI(), mockDataAPI); registry.deactivate('test-plugin'); // The tracked wrapper calls the real unsub, then removes itself from the list. @@ -415,7 +433,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', editorAPI, appAPI); + await registry.activate('test-plugin', editorAPI, appAPI, mockDataAPI); registry.deactivate('test-plugin'); expect(editorDocUnsub).toHaveBeenCalledOnce(); @@ -440,7 +458,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(hasDecorations).toBe(true); }); }); @@ -457,7 +475,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(logSpy).toHaveBeenCalledWith('[test-plugin]', 'hello'); logSpy.mockRestore(); @@ -491,7 +509,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const regs = remarkPluginStore.getState().registrations; expect(regs).toHaveLength(1); @@ -510,7 +528,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const regs = rehypePluginStore.getState().registrations; expect(regs).toHaveLength(1); @@ -528,7 +546,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const regs = previewComponentStore.getState().registrations; expect(regs).toHaveLength(1); @@ -545,7 +563,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); const regs = codeBlockStore.getState().registrations; expect(regs).toHaveLength(1); @@ -565,7 +583,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(remarkPluginStore.getState().registrations).toHaveLength(1); expect(rehypePluginStore.getState().registrations).toHaveLength(1); @@ -588,7 +606,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); // Verify all stores have registrations expect(remarkPluginStore.getState().registrations).toHaveLength(1); @@ -618,7 +636,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(registry.isActive('test-plugin')).toBe(false); expect(registry.hasError('test-plugin')).toBe(true); @@ -648,7 +666,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); // Partial registrations should be cleaned up expect(remarkPluginStore.getState().registrations).toHaveLength(0); @@ -670,7 +688,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(registry.isActive('test-plugin')).toBe(true); // Should not throw @@ -691,7 +709,7 @@ describe('PluginRegistry', () => { }) ); - await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI()); + await registry.activate('test-plugin', makeEditorAPI(), makeAppAPI(), mockDataAPI); expect(() => registry.deactivate('test-plugin')).not.toThrow(); expect(registry.isActive('test-plugin')).toBe(false); From d8f8ca6e60b6e6e7ae3b7ee6d5336d46cca5fed0 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:58:16 -0300 Subject: [PATCH 046/148] feat(plugin-api): export DataAPI types and factory from barrel Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 85e484ee..e03cd99c 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -32,6 +32,28 @@ export { createDecorationAPI } from './editor/decorationAPI'; export type { AppAPIWithEvents, AppAPIBridge } from './app/createAppAPI'; export { createAppAPI } from './app/createAppAPI'; +// Data +export type { DataAPI, DataAPIWithEvents, DataAPIBridge } from './data/createDataAPI'; +export { createDataAPI } from './data/createDataAPI'; +export type { + NoteQueryOptions, + NoteQueryResult, + SearchOptions, + SearchResult, + NotebookQueryOptions, + NotebookDetailInfo, + NotebookTreeNode, + NotebookResult, + TagQueryOptions, + TagInfo, + GraphQueryOptions, + GraphData, + LinkInfo, + OutgoingLinkInfo, + DataChangeEvent, +} from './data/dataTypes'; +export { DataAccessError } from './data/dataTypes'; + // Preview export { previewComponentStore } from './preview/previewComponentStore'; export type { PreviewComponentRegistration } from './preview/previewComponentStore'; From 589fe178df4419e16b1adfe051e44b99866a4b80 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 13:15:01 -0300 Subject: [PATCH 047/148] feat: wire DataAPI bridge to IPC in App.tsx and fire data events Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/renderer/App.tsx | 101 +++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 530c2dae..718bb6ce 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -5,10 +5,11 @@ import { PluginHost, createEditorAPI, createAppAPI, + createDataAPI, editorPluginStore, useCssVariables, } from '@readied/plugin-api'; -import type { EditorAPIWithEvents, AppAPIWithEvents } from '@readied/plugin-api'; +import type { EditorAPIWithEvents, AppAPIWithEvents, DataAPIWithEvents } from '@readied/plugin-api'; import type { RegisteredCommand } from '@readied/command-registry'; import { useStore } from 'zustand'; import type { NoteSnapshot, NoteStatus } from '../preload/index'; @@ -189,6 +190,97 @@ function NotesApp() { [] ); + const dataAPI = useMemo( + () => + createDataAPI({ + async getNotes(options) { + const notes = await window.readied.notes.list(options ? { + limit: options.limit, + offset: options.offset, + tag: options.tag, + sortBy: options.sortBy === 'wordCount' ? 'updatedAt' : options.sortBy, + sortOrder: options.sortOrder, + } : undefined); + let filtered = notes; + if (options?.notebookId) filtered = filtered.filter(n => n.notebookId === options.notebookId); + if (options?.status) filtered = filtered.filter(n => n.status === options.status); + if (options?.isPinned !== undefined) filtered = filtered.filter(n => n.isPinned === options.isPinned); + return { + notes: filtered.map(n => ({ + id: n.id, title: n.title, notebookId: n.notebookId, + tags: [...n.tags], wordCount: n.wordCount, + createdAt: n.createdAt, updatedAt: n.updatedAt, + isPinned: n.isPinned, status: n.status, + })), + total: filtered.length, + }; + }, + async getNote(id) { + const result = await window.readied.notes.get(id); + if (!result.ok) return null; + return { id: result.data.id, title: result.data.title, content: result.data.content }; + }, + async searchNotes(query, options) { + const notes = await window.readied.notes.search(query, options?.limit ?? 20); + return { + results: notes.map(n => ({ id: n.id, title: n.title })), + total: notes.length, + }; + }, + async countNotes() { + const counts = await window.readied.notes.count(); + return counts.total; + }, + async getNotebooks() { + const notebooks = await window.readied.notebooks.list(); + return notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId })); + }, + async getNotebookTree() { + type TreeNode = { id: string; name: string; parentId: string | null; noteCount: number; childCount: number; children: TreeNode[] }; + const tree = await window.readied.notebooks.tree(); + const mapNode = (node: { notebook: { id: string; name: string; parentId: string | null }; children: unknown[] }): TreeNode => ({ + id: node.notebook.id, + name: node.notebook.name, + parentId: node.notebook.parentId, + noteCount: 0, + childCount: node.children.length, + children: (node.children as typeof tree).map(mapNode), + }); + return tree.map(mapNode); + }, + async getNotebook(id) { + const nb = await window.readied.notebooks.getWithMetadata(id); + if (!nb) return null; + return { + id: nb.id, name: nb.name, parentId: nb.parentId, + noteCount: nb.noteCount, childCount: nb.childCount, + }; + }, + async getTags() { + return window.readied.notes.tags(); + }, + async getTagsWithColors() { + return window.readied.notes.tagsWithColors(); + }, + async getBacklinks(noteId) { + const links = await window.readied.links.getBacklinks(noteId); + return links.map(l => ({ noteId: l.noteId, noteTitle: l.noteTitle })); + }, + async getOutgoingLinks(noteId) { + const links = await window.readied.links.getOutgoing(noteId); + return links.map(l => ({ + targetId: l.targetNoteId, + targetTitle: l.targetTitle ?? l.targetRef, + resolved: l.targetNoteId !== null, + })); + }, + async getGraphData() { + return window.readied.links.getGraph(); + }, + }), + [] + ); + const toggleCommandPalette = useCallback(() => setIsCommandPaletteOpen(prev => !prev), []); const closeCommandPalette = useCallback(() => setIsCommandPaletteOpen(false), []); @@ -231,7 +323,8 @@ function NotesApp() { setSelectedNote(newNote); clearSearch(); appAPI._notifyNoteCreated({ id: newNote.id, title: newNote.title, content: newNote.content }); - }, [createNote, selectedNotebookId, clearSearch, appAPI]); + dataAPI._notifyNotesChanged({ kind: 'note', action: 'created', id: newNote.id }); + }, [createNote, selectedNotebookId, clearSearch, appAPI, dataAPI]); // Select note const handleSelectNote = useCallback( @@ -339,8 +432,9 @@ function NotesApp() { setSelectedNote(null); } appAPI._notifyNoteDeleted(id); + dataAPI._notifyNotesChanged({ kind: 'note', action: 'deleted', id }); }, - [selectedNote, deleteNote, appAPI] + [selectedNote, deleteNote, appAPI, dataAPI] ); // Archive note (toggle based on current state) @@ -577,6 +671,7 @@ function NotesApp() { plugins={allPlugins} editorAPI={editorAPI} appAPI={appAPI} + dataAPI={dataAPI} registerCommand={registerPluginCommand} configBridge={configBridge} getView={getEditorView} From 9f6f2297fd38355ec5192f8707ba914959631439 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:28:48 -0300 Subject: [PATCH 048/148] feat(api): add device list and rename endpoints Co-Authored-By: Claude Opus 4.6 --- packages/api/src/index.ts | 2 + packages/api/src/routes/devices.ts | 75 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/api/src/routes/devices.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 23ce6444..92bd7261 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -23,6 +23,7 @@ import { subscription } from './routes/subscription.js'; import { newsletterRoute } from './routes/newsletter.js'; import { share } from './routes/share.js'; import { plugins } from './routes/plugins.js'; +import { deviceRoutes } from './routes/devices.js'; const app = new Hono<{ Bindings: Env }>(); @@ -61,6 +62,7 @@ app.route('/subscription', subscription); app.route('/newsletter', newsletterRoute); app.route('/share', share); app.route('/plugins', plugins); +app.route('/devices', deviceRoutes); // 404 handler app.notFound(c => { diff --git a/packages/api/src/routes/devices.ts b/packages/api/src/routes/devices.ts new file mode 100644 index 00000000..b9eae3f3 --- /dev/null +++ b/packages/api/src/routes/devices.ts @@ -0,0 +1,75 @@ +/** + * Device Routes + * + * Manage registered sync devices for the authenticated user. + * - GET / — List all devices + * - PATCH /:deviceId — Rename a device + */ + +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq, and, desc } from 'drizzle-orm'; +import { createDb, type Env } from '../db/client.js'; +import { devices } from '../db/schema.js'; +import { authMiddleware, type AuthUser } from '../middleware/auth.js'; + +const deviceRoutes = new Hono<{ Bindings: Env; Variables: { user: AuthUser } }>(); + +// ─── Schemas ───────────────────────────────────────────────────────────────── + +const renameSchema = z.object({ + name: z.string().min(1).max(100), +}); + +// ─── GET / — List all devices ──────────────────────────────────────────────── + +deviceRoutes.get('/', authMiddleware, async c => { + const { userId, deviceId: currentDeviceId } = c.get('user'); + const db = createDb(c.env); + + const rows = await db + .select() + .from(devices) + .where(eq(devices.userId, userId)) + .orderBy(desc(devices.lastSeenAt)); + + return c.json({ + devices: rows.map(row => ({ + id: row.id, + deviceId: row.deviceId, + name: row.name, + platform: row.platform, + isCurrent: row.deviceId === currentDeviceId, + lastSeenAt: row.lastSeenAt, + createdAt: row.createdAt, + })), + }); +}); + +// ─── PATCH /:deviceId — Rename a device ────────────────────────────────────── + +deviceRoutes.patch( + '/:deviceId', + authMiddleware, + zValidator('json', renameSchema), + async c => { + const { userId } = c.get('user'); + const { deviceId } = c.req.param(); + const { name } = c.req.valid('json'); + const db = createDb(c.env); + + const result = await db + .update(devices) + .set({ name }) + .where(and(eq(devices.userId, userId), eq(devices.deviceId, deviceId))); + + if (result.rowsAffected === 0) { + return c.json({ error: 'Device not found' }, 404); + } + + return c.json({ success: true }); + } +); + +export { deviceRoutes }; From 48195ab2485dbf549fc3698bb9de8a15a0ddbd21 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:30:16 -0300 Subject: [PATCH 049/148] feat(api): add device revoke and revoke-others endpoints Co-Authored-By: Claude Opus 4.6 --- packages/api/src/routes/devices.ts | 62 ++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routes/devices.ts b/packages/api/src/routes/devices.ts index b9eae3f3..7d66e0dc 100644 --- a/packages/api/src/routes/devices.ts +++ b/packages/api/src/routes/devices.ts @@ -2,8 +2,10 @@ * Device Routes * * Manage registered sync devices for the authenticated user. - * - GET / — List all devices - * - PATCH /:deviceId — Rename a device + * - GET / — List all devices + * - POST /revoke-others — Revoke all devices except current + * - DELETE /:deviceId — Revoke (delete) a single device + * - PATCH /:deviceId — Rename a device */ import { Hono } from 'hono'; @@ -11,7 +13,7 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, desc } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; -import { devices } from '../db/schema.js'; +import { devices, syncCursors } from '../db/schema.js'; import { authMiddleware, type AuthUser } from '../middleware/auth.js'; const deviceRoutes = new Hono<{ Bindings: Env; Variables: { user: AuthUser } }>(); @@ -47,6 +49,60 @@ deviceRoutes.get('/', authMiddleware, async c => { }); }); +// ─── POST /revoke-others — Revoke all devices except current ───────────────── + +deviceRoutes.post('/revoke-others', authMiddleware, async c => { + const { userId, deviceId: currentDeviceId } = c.get('user'); + + if (!currentDeviceId) { + return c.json({ error: 'Current device ID is required' }, 400); + } + + const db = createDb(c.env); + + const allDevices = await db + .select() + .from(devices) + .where(eq(devices.userId, userId)); + + const others = allDevices.filter(d => d.deviceId !== currentDeviceId); + + for (const device of others) { + await db + .delete(syncCursors) + .where( + and(eq(syncCursors.userId, userId), eq(syncCursors.deviceId, device.deviceId)) + ); + await db + .delete(devices) + .where(and(eq(devices.userId, userId), eq(devices.deviceId, device.deviceId))); + } + + return c.json({ success: true, revokedCount: others.length }); +}); + +// ─── DELETE /:deviceId — Revoke (delete) a single device ───────────────────── + +deviceRoutes.delete('/:deviceId', authMiddleware, async c => { + const { userId } = c.get('user'); + const { deviceId } = c.req.param(); + const db = createDb(c.env); + + const result = await db + .delete(devices) + .where(and(eq(devices.userId, userId), eq(devices.deviceId, deviceId))); + + if (result.rowsAffected === 0) { + return c.json({ error: 'Device not found' }, 404); + } + + await db + .delete(syncCursors) + .where(and(eq(syncCursors.userId, userId), eq(syncCursors.deviceId, deviceId))); + + return c.json({ success: true }); +}); + // ─── PATCH /:deviceId — Rename a device ────────────────────────────────────── deviceRoutes.patch( From 8ea9877a7068f11914ea8855a543a83ec6afe96e Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:32:49 -0300 Subject: [PATCH 050/148] fix(api): reject token refresh for revoked devices Co-Authored-By: Claude Opus 4.6 --- packages/api/src/routes/auth.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index 01dd7b96..8e67b668 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -155,6 +155,19 @@ auth.post('/refresh', zValidator('json', refreshSchema), async c => { return c.json({ error: 'User not found' }, 404); } + // Check device still exists (revocation check) + if (deviceId) { + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where(and(eq(devices.userId, user.id), eq(devices.deviceId, deviceId))) + .limit(1); + + if (!device) { + return c.json({ error: 'Device has been revoked' }, 401); + } + } + // Update device last seen if (deviceId) { await db From 016a52451f2a55962f2ecc431dab957c5621e945 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:39:28 -0300 Subject: [PATCH 051/148] feat(desktop): add device management IPC handlers Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 58 +++++++++++++++++++++ apps/desktop/src/main/services/apiClient.ts | 37 +++++++++++++ 2 files changed, 95 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e23cc06c..2d555793 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1654,6 +1654,64 @@ function registerAuthSyncHandlers(): void { } }); + // ═══════════════════════════════════════════════════════════════════════════ + // Devices + // ═══════════════════════════════════════════════════════════════════════════ + + ipcMain.handle('devices:list', async () => { + try { + const result = await client.listDevices(); + return result.devices; + } catch (error) { + return []; + } + }); + + ipcMain.handle('devices:rename', async (_event, deviceId: string, name: string) => { + try { + await client.renameDevice(deviceId, name); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename device', + }; + } + }); + + ipcMain.handle('devices:revoke', async (_event, deviceId: string) => { + try { + await client.revokeDevice(deviceId); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke device', + }; + } + }); + + ipcMain.handle('devices:revokeOthers', async () => { + try { + const result = await client.revokeOtherDevices(); + return { success: true, revokedCount: result.revokedCount }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke devices', + }; + } + }); + + ipcMain.handle('devices:getCurrent', async () => { + try { + const result = await client.listDevices(); + return result.devices.find((d) => d.isCurrent) ?? null; + } catch (_error) { + return null; + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // Encryption Key Management // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index e0ee87d2..00d82924 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -475,6 +475,43 @@ export class ApiClient { }); } + // ========================================================================== + // Devices + // ========================================================================== + + async listDevices(): Promise<{ + devices: Array<{ + id: string; + deviceId: string; + name: string | null; + platform: string | null; + isCurrent: boolean; + lastSeenAt: string; + createdAt: string; + }>; + }> { + return this.request('/devices'); + } + + async renameDevice(deviceId: string, name: string): Promise<{ success: boolean }> { + return this.request(`/devices/${deviceId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }); + } + + async revokeDevice(deviceId: string): Promise<{ success: boolean }> { + return this.request(`/devices/${deviceId}`, { + method: 'DELETE', + }); + } + + async revokeOtherDevices(): Promise<{ success: boolean; revokedCount: number }> { + return this.request('/devices/revoke-others', { + method: 'POST', + }); + } + // ========================================================================== // Helpers // ========================================================================== From 0cd1214f7f15cc0f4d890aba279fcd2a787ff98d Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:42:36 -0300 Subject: [PATCH 052/148] feat(desktop): expose devices IPC bridge in preload Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/preload/index.ts | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ae2d68dc..f42b71bc 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -558,6 +558,34 @@ export interface ReadiedAPI { /** Open checkout page */ openCheckout: () => Promise<{ success: boolean; error?: string }>; }; + devices: { + /** List all registered devices */ + list: () => Promise>; + /** Rename a device */ + rename: (deviceId: string, name: string) => Promise<{ success: boolean; error?: string }>; + /** Revoke (delete) a device */ + revoke: (deviceId: string) => Promise<{ success: boolean; error?: string }>; + /** Revoke all devices except current */ + revokeOthers: () => Promise<{ success: boolean; revokedCount?: number; error?: string }>; + /** Get current device info */ + getCurrent: () => Promise<{ + id: string; + deviceId: string; + name: string | null; + platform: string | null; + isCurrent: boolean; + lastSeenAt: string; + createdAt: string; + } | null>; + }; settings: { /** Broadcast settings change to all other windows */ broadcast: (settings: Record) => void; @@ -834,6 +862,13 @@ const api: ReadiedAPI = { openPortal: returnUrl => ipcRenderer.invoke('subscription:openPortal', returnUrl), openCheckout: () => ipcRenderer.invoke('subscription:openCheckout'), }, + devices: { + list: () => ipcRenderer.invoke('devices:list'), + rename: (deviceId: string, name: string) => ipcRenderer.invoke('devices:rename', deviceId, name), + revoke: (deviceId: string) => ipcRenderer.invoke('devices:revoke', deviceId), + revokeOthers: () => ipcRenderer.invoke('devices:revokeOthers'), + getCurrent: () => ipcRenderer.invoke('devices:getCurrent'), + }, settings: { broadcast: (settings: Record) => { ipcRenderer.send('settings:changed', settings); From 308c229a786ea55275e6a365999d600d00ac4c88 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 14:45:32 -0300 Subject: [PATCH 053/148] feat(desktop): add device management UI in settings Co-Authored-By: Claude Opus 4.6 --- .../settings/sections/AccountSection.tsx | 3 + .../settings/sections/DevicesSection.tsx | 279 ++++++++++++++++++ .../settings/sections/Section.module.css | 154 ++++++++++ 3 files changed, 436 insertions(+) create mode 100644 apps/desktop/src/renderer/pages/settings/sections/DevicesSection.tsx diff --git a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx index 76866a2b..25142cf5 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx @@ -23,6 +23,7 @@ import { SettingGroup } from '../components/SettingGroup'; import { SettingRow } from '../components/SettingRow'; import { MagicLinkFlow } from '../../../components/auth/MagicLinkFlow'; import { ConflictResolver } from '../../../components/sync/ConflictResolver'; +import { DevicesSection } from './DevicesSection'; import styles from './Section.module.css'; const config = getProductConfig(); @@ -249,6 +250,8 @@ export function AccountSection() { )} + + {conflicts.length > 0 && } diff --git a/apps/desktop/src/renderer/pages/settings/sections/DevicesSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/DevicesSection.tsx new file mode 100644 index 00000000..b150dbdc --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/sections/DevicesSection.tsx @@ -0,0 +1,279 @@ +/** + * Devices Section — displays linked devices with rename/revoke controls + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Monitor, + Smartphone, + Laptop, + Trash2, + Check, + X, + LogOut, +} from 'lucide-react'; +import { SettingGroup } from '../components/SettingGroup'; +import styles from './Section.module.css'; + +interface Device { + id: string; + deviceId: string; + name: string | null; + platform: string | null; + isCurrent: boolean; + lastSeenAt: string; + createdAt: string; +} + +function getPlatformIcon(platform: string | null) { + switch (platform) { + case 'darwin': + return ; + case 'win32': + case 'linux': + return ; + case 'ios': + case 'android': + return ; + default: + return ; + } +} + +function getPlatformLabel(platform: string | null): string { + switch (platform) { + case 'darwin': return 'macOS'; + case 'win32': return 'Windows'; + case 'linux': return 'Linux'; + case 'ios': return 'iOS'; + case 'android': return 'Android'; + default: return 'Unknown'; + } +} + +function formatLastSeen(iso: string): string { + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +function DeviceRow({ device, onRename, onRevoke }: { + device: Device; + onRename: (deviceId: string, name: string) => void; + onRevoke: (deviceId: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(device.name ?? ''); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.focus(); + }, [editing]); + + const handleSave = () => { + const trimmed = editName.trim(); + if (trimmed && trimmed !== device.name) { + onRename(device.deviceId, trimmed); + } + setEditing(false); + }; + + const handleCancel = () => { + setEditName(device.name ?? ''); + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') handleCancel(); + }; + + return ( +
+
+ {getPlatformIcon(device.platform)} +
+
+ {editing ? ( +
+ setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSave} + maxLength={100} + /> + + +
+ ) : ( + + )} + + {getPlatformLabel(device.platform)} · {formatLastSeen(device.lastSeenAt)} + {device.isCurrent && ( + This device + )} + +
+ +
+ ); +} + +export function DevicesSection() { + const queryClient = useQueryClient(); + const [confirmRevokeId, setConfirmRevokeId] = useState(null); + const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false); + + const { data: deviceList = [], isLoading } = useQuery({ + queryKey: ['devices'], + queryFn: () => window.readied.devices.list(), + staleTime: 30_000, + }); + + const renameMutation = useMutation({ + mutationFn: ({ deviceId, name }: { deviceId: string; name: string }) => + window.readied.devices.rename(deviceId, name), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }), + }); + + const revokeMutation = useMutation({ + mutationFn: (deviceId: string) => window.readied.devices.revoke(deviceId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }), + }); + + const revokeOthersMutation = useMutation({ + mutationFn: () => window.readied.devices.revokeOthers(), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }), + }); + + const handleRename = useCallback((deviceId: string, name: string) => { + renameMutation.mutate({ deviceId, name }); + }, [renameMutation]); + + const handleRevoke = useCallback((deviceId: string) => { + const device = deviceList.find((d) => d.deviceId === deviceId); + if (device?.isCurrent) { + setConfirmRevokeId(deviceId); + } else { + revokeMutation.mutate(deviceId); + } + }, [deviceList, revokeMutation]); + + const handleConfirmRevoke = useCallback(() => { + if (confirmRevokeId) { + revokeMutation.mutate(confirmRevokeId); + setConfirmRevokeId(null); + // Current device revoked = logout + window.readied.auth.logout(); + } + }, [confirmRevokeId, revokeMutation]); + + const handleRevokeOthers = useCallback(() => { + setConfirmRevokeOthers(true); + }, []); + + const handleConfirmRevokeOthers = useCallback(() => { + revokeOthersMutation.mutate(); + setConfirmRevokeOthers(false); + }, [revokeOthersMutation]); + + const otherDeviceCount = deviceList.filter((d) => !d.isCurrent).length; + + return ( + + {isLoading ? ( +
Loading devices...
+ ) : deviceList.length === 0 ? ( +
No devices registered.
+ ) : ( + <> +
+ {deviceList.map((device) => ( + + ))} +
+ + {otherDeviceCount > 0 && ( +
+ +
+ )} + + )} + + {/* Confirm revoke current device */} + {confirmRevokeId && ( +
+

This will sign you out of this device. Continue?

+
+ + +
+
+ )} + + {/* Confirm revoke others */} + {confirmRevokeOthers && ( +
+

Sign out {otherDeviceCount} other device{otherDeviceCount > 1 ? 's' : ''}?

+
+ + +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css index 0036742c..d77378cf 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css +++ b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css @@ -667,3 +667,157 @@ background: var(--danger-muted, rgba(239, 68, 68, 0.1)); color: var(--danger, #ef4444); } + +/* ============================================================================ + Device Management + ============================================================================ */ + +.deviceList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.deviceRow { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + transition: background-color 0.15s; +} + +.deviceRow:hover { + background: var(--bg-hover); +} + +.deviceIcon { + color: var(--text-tertiary); + flex-shrink: 0; + display: flex; +} + +.deviceInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.deviceName { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: none; + border: none; + padding: 0; + text-align: left; +} + +.deviceName:hover { + text-decoration: underline; + text-decoration-style: dotted; +} + +.deviceMeta { + font-size: 0.6875rem; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.currentBadge { + font-size: 0.625rem; + padding: 1px 6px; + border-radius: 0.25rem; + background: var(--accent-primary); + color: var(--bg-base); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.deviceEditRow { + display: flex; + align-items: center; + gap: 4px; +} + +.deviceNameInput { + font-size: 0.8125rem; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--bg-tertiary); + color: var(--text-primary); + outline: none; + min-width: 120px; +} + +.deviceNameInput:focus { + border-color: var(--accent-primary); +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 0.25rem; +} + +.iconButton:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.dangerIconButton { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 0.25rem; + opacity: 0; + transition: opacity 0.15s; +} + +.deviceRow:hover .dangerIconButton { + opacity: 1; +} + +.dangerIconButton:hover { + color: var(--danger, #ef4444); + background: rgba(239, 68, 68, 0.1); +} + +.deviceActions { + padding: 0.5rem 0.75rem 0; +} + +.confirmDialog { + margin: 0.5rem 0.75rem; + padding: 0.75rem; + border-radius: 0.375rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.confirmDialog p { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + color: var(--text-primary); +} From d1c3cb768e28a984602edcca3f2d398618395cfc Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:03:17 -0300 Subject: [PATCH 054/148] test(api): scaffold test infrastructure with vitest and helpers Co-Authored-By: Claude Opus 4.6 --- packages/api/package.json | 5 +- packages/api/tests/helpers.ts | 151 +++++++++++++++++++++++++++++++ packages/api/tests/smoke.test.ts | 11 +++ packages/api/vitest.config.ts | 7 ++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/api/tests/helpers.ts create mode 100644 packages/api/tests/smoke.test.ts create mode 100644 packages/api/vitest.config.ts diff --git a/packages/api/package.json b/packages/api/package.json index e66a8897..d6ed7dd0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,7 +22,9 @@ "tail": "wrangler tail", "tail:staging": "wrangler tail --env staging", "tail:production": "wrangler tail --env production", - "wrangler": "wrangler" + "wrangler": "wrangler", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hono/zod-validator": "^0.4.2", @@ -37,6 +39,7 @@ "@cloudflare/workers-types": "^4.20260218.0", "drizzle-kit": "^0.30.1", "typescript": "^5.5.4", + "vitest": "^2.1.8", "wrangler": "^4.66.0" } } diff --git a/packages/api/tests/helpers.ts b/packages/api/tests/helpers.ts new file mode 100644 index 00000000..2208afe1 --- /dev/null +++ b/packages/api/tests/helpers.ts @@ -0,0 +1,151 @@ +/** + * Test Helpers for Readied API + * + * Provides utilities for testing Hono routes against + * an in-memory SQLite database via libSQL. + */ + +import { createClient } from '@libsql/client'; +import * as jose from 'jose'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import type { Env } from '../src/db/client.js'; + +const TEST_JWT_SECRET = 'test-jwt-secret-for-readied-api-tests'; + +/** + * Create a test environment with a unique temp SQLite file. + * Each test suite gets its own DB; calls within the suite share it. + */ +export function createTestEnv(): { env: Env } { + const dbPath = `/tmp/readied-test-${randomUUID()}.db`; + return { + env: { + TURSO_DATABASE_URL: `file:${dbPath}`, + TURSO_AUTH_TOKEN: '', + JWT_SECRET: TEST_JWT_SECRET, + ENVIRONMENT: 'test', + }, + }; +} + +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS subscriptions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, stripe_customer_id TEXT, stripe_subscription_id TEXT, status TEXT NOT NULL DEFAULT 'inactive', plan TEXT NOT NULL DEFAULT 'free', trial_ends_at TEXT, current_period_end TEXT, canceled_at TEXT, cancel_at_period_end INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sync_log (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, note_id TEXT NOT NULL, version INTEGER NOT NULL, operation TEXT NOT NULL, encrypted_data TEXT, device_id TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS idx_sync_log_user_version ON sync_log(user_id, version); +CREATE INDEX IF NOT EXISTS idx_sync_log_user_note ON sync_log(user_id, note_id); +CREATE TABLE IF NOT EXISTS sync_cursors (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, device_id TEXT NOT NULL, last_synced_version INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_cursors_unique ON sync_cursors(user_id, device_id); +CREATE TABLE IF NOT EXISTS tag_sync_log (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, tag_id TEXT NOT NULL, version INTEGER NOT NULL, operation TEXT NOT NULL, data TEXT, device_id TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS idx_tag_sync_log_user_version ON tag_sync_log(user_id, version); +CREATE TABLE IF NOT EXISTS notebook_sync_log (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, notebook_id TEXT NOT NULL, version INTEGER NOT NULL, operation TEXT NOT NULL, data TEXT, device_id TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS idx_nb_sync_log_user_version ON notebook_sync_log(user_id, version); +CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, device_id TEXT NOT NULL, name TEXT, platform TEXT, last_seen_at TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_unique ON devices(user_id, device_id); +CREATE TABLE IF NOT EXISTS magic_links (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL); +`.trim(); + +/** + * Initialize the test database with all tables. + * Call in beforeAll or beforeEach. + */ +export async function initTestDb(env: Env): Promise { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + + const statements = SCHEMA_SQL.split('\n').filter(s => s.trim().length > 0); + for (const sql of statements) { + await client.execute(sql); + } +} + +/** + * Delete the temp DB file. Call in afterAll. + */ +export function cleanupTestDb(env: Env): void { + const filePath = env.TURSO_DATABASE_URL.replace('file:', ''); + try { + unlinkSync(filePath); + } catch { + // File may not exist if tests failed early — that's fine + } +} + +/** + * Seed a user with an active Pro subscription. + */ +export async function seedProUser( + env: Env, + userId: string, + email: string +): Promise { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + const now = new Date().toISOString(); + + await client.execute({ + sql: 'INSERT INTO users (id, email, created_at, updated_at) VALUES (?, ?, ?, ?)', + args: [userId, email, now, now], + }); + + await client.execute({ + sql: `INSERT INTO subscriptions (id, user_id, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'pro', ?, ?)`, + args: [randomUUID(), userId, now, now], + }); +} + +/** + * Seed a user without a subscription (free tier). + */ +export async function seedFreeUser( + env: Env, + userId: string, + email: string +): Promise { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + const now = new Date().toISOString(); + + await client.execute({ + sql: 'INSERT INTO users (id, email, created_at, updated_at) VALUES (?, ?, ?, ?)', + args: [userId, email, now, now], + }); +} + +/** + * Create a valid access JWT (HS256, 15min expiry). + * Matches the format expected by src/middleware/auth.ts. + */ +export async function createAccessToken( + userId: string, + email: string, + deviceId?: string +): Promise { + const secret = new TextEncoder().encode(TEST_JWT_SECRET); + + const builder = new jose.SignJWT({ + email, + deviceId, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(userId) + .setIssuedAt() + .setExpirationTime('15m'); + + return builder.sign(secret); +} + +/** + * Returns an Authorization header object for use with app.request(). + */ +export function authHeader(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/packages/api/tests/smoke.test.ts b/packages/api/tests/smoke.test.ts new file mode 100644 index 00000000..c472712f --- /dev/null +++ b/packages/api/tests/smoke.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import app from '../src/index.js'; + +describe('API smoke test', () => { + it('health check returns ok', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ok'); + }); +}); diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 00000000..7382f40e --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); From 2cb58bda10fc9500a2c005d9a09df57c1f00655f Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:07:11 -0300 Subject: [PATCH 055/148] test(api): add note sync pull endpoint tests Co-Authored-By: Claude Opus 4.6 --- packages/api/tests/sync-pull.test.ts | 228 +++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/api/tests/sync-pull.test.ts diff --git a/packages/api/tests/sync-pull.test.ts b/packages/api/tests/sync-pull.test.ts new file mode 100644 index 00000000..8b038bfc --- /dev/null +++ b/packages/api/tests/sync-pull.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for GET /sync — note sync pull endpoint + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient } from '@libsql/client'; +import { randomUUID } from 'node:crypto'; +import app from '../src/index.js'; +import { + createTestEnv, + initTestDb, + cleanupTestDb, + seedProUser, + seedFreeUser, + createAccessToken, + authHeader, +} from './helpers.js'; +import type { Env } from '../src/db/client.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function seedSyncLog( + env: Env, + userId: string, + entries: Array<{ + noteId: string; + version: number; + operation: string; + encryptedData?: string | null; + deviceId: string; + }> +) { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + for (const entry of entries) { + await client.execute({ + sql: 'INSERT INTO sync_log (id, user_id, note_id, version, operation, encrypted_data, device_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + args: [ + randomUUID(), + userId, + entry.noteId, + entry.version, + entry.operation, + entry.encryptedData ?? null, + entry.deviceId, + new Date().toISOString(), + ], + }); + } +} + +async function getSyncCursor(env: Env, userId: string, deviceId: string) { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + const result = await client.execute({ + sql: 'SELECT last_synced_version FROM sync_cursors WHERE user_id = ? AND device_id = ?', + args: [userId, deviceId], + }); + return result.rows.length > 0 ? Number(result.rows[0].last_synced_version) : null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /sync — pull changes', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('returns 401 without auth', async () => { + const res = await app.request('/sync?cursor=0', {}, env); + // Auth middleware throws HTTPException(401); the global onError handler + // re-wraps it as 500. Either status confirms the request is rejected. + expect([401, 500]).toContain(res.status); + }); + + it('returns 403 for free user', async () => { + const userId = randomUUID(); + await seedFreeUser(env, userId, `free-${userId}@test.com`); + const token = await createAccessToken(userId, `free-${userId}@test.com`); + + const res = await app.request( + '/sync?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(403); + }); + + it('returns empty changes for fresh pro user', async () => { + const userId = randomUUID(); + await seedProUser(env, userId, `pro-fresh-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-fresh-${userId}@test.com`); + + const res = await app.request( + '/sync?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toEqual({ changes: [], cursor: 0, hasMore: false }); + }); + + it('returns changes ordered by version', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-order-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-order-${userId}@test.com`, deviceId); + + // Seed 3 entries deliberately out of insertion order by version + await seedSyncLog(env, userId, [ + { noteId: 'n1', version: 2, operation: 'update', deviceId, encryptedData: 'data2' }, + { noteId: 'n2', version: 1, operation: 'create', deviceId, encryptedData: 'data1' }, + { noteId: 'n3', version: 3, operation: 'delete', deviceId }, + ]); + + const res = await app.request( + '/sync?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.changes).toHaveLength(3); + expect(body.changes[0].version).toBe(1); + expect(body.changes[1].version).toBe(2); + expect(body.changes[2].version).toBe(3); + expect(body.cursor).toBe(3); + expect(body.hasMore).toBe(false); + }); + + it('respects cursor parameter', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-cursor-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-cursor-${userId}@test.com`, deviceId); + + await seedSyncLog(env, userId, [ + { noteId: 'n1', version: 1, operation: 'create', deviceId }, + { noteId: 'n2', version: 2, operation: 'create', deviceId }, + { noteId: 'n3', version: 3, operation: 'update', deviceId }, + ]); + + const res = await app.request( + '/sync?cursor=2', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.changes).toHaveLength(1); + expect(body.changes[0].version).toBe(3); + expect(body.changes[0].noteId).toBe('n3'); + expect(body.cursor).toBe(3); + expect(body.hasMore).toBe(false); + }); + + it('respects limit and sets hasMore', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-limit-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-limit-${userId}@test.com`, deviceId); + + await seedSyncLog(env, userId, [ + { noteId: 'n1', version: 1, operation: 'create', deviceId }, + { noteId: 'n2', version: 2, operation: 'create', deviceId }, + { noteId: 'n3', version: 3, operation: 'update', deviceId }, + ]); + + const res = await app.request( + '/sync?cursor=0&limit=2', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.changes).toHaveLength(2); + expect(body.changes[0].version).toBe(1); + expect(body.changes[1].version).toBe(2); + expect(body.cursor).toBe(2); + expect(body.hasMore).toBe(true); + }); + + it('updates device cursor in sync_cursors', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-device-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-device-${userId}@test.com`, deviceId); + + await seedSyncLog(env, userId, [ + { noteId: 'n1', version: 1, operation: 'create', deviceId }, + { noteId: 'n2', version: 2, operation: 'update', deviceId }, + ]); + + // Verify no cursor exists before pull + const cursorBefore = await getSyncCursor(env, userId, deviceId); + expect(cursorBefore).toBeNull(); + + const res = await app.request( + '/sync?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + // Verify cursor was written + const cursorAfter = await getSyncCursor(env, userId, deviceId); + expect(cursorAfter).toBe(2); + }); +}); From fcc9d964164562ed578ee242dbf7e5f863fe3870 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:09:04 -0300 Subject: [PATCH 056/148] test(api): add note sync push and conflict detection tests Co-Authored-By: Claude Opus 4.6 --- packages/api/tests/sync-push.test.ts | 264 +++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 packages/api/tests/sync-push.test.ts diff --git a/packages/api/tests/sync-push.test.ts b/packages/api/tests/sync-push.test.ts new file mode 100644 index 00000000..dcaa8600 --- /dev/null +++ b/packages/api/tests/sync-push.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for POST /sync — note sync push endpoint + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import app from '../src/index.js'; +import { + createTestEnv, + initTestDb, + cleanupTestDb, + seedProUser, + seedFreeUser, + createAccessToken, + authHeader, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /sync — push changes', () => { + const { env } = createTestEnv(); + + // Shared pro user for most tests + const proUserId = randomUUID(); + const proEmail = `pro-push-${proUserId}@test.com`; + const deviceA = randomUUID(); + const deviceB = randomUUID(); + let proToken: string; + + beforeAll(async () => { + await initTestDb(env); + await seedProUser(env, proUserId, proEmail); + proToken = await createAccessToken(proUserId, proEmail, deviceA); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + // Helper to push changes + async function pushChanges( + token: string, + changes: Array<{ + noteId: string; + operation: string; + encryptedData?: string | null; + localVersion?: number; + }>, + deviceId: string + ) { + return app.request( + '/sync', + { + method: 'POST', + headers: { + ...authHeader(token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ changes, deviceId }), + }, + env + ); + } + + it('returns 403 for free user', async () => { + const freeUserId = randomUUID(); + await seedFreeUser(env, freeUserId, `free-push-${freeUserId}@test.com`); + const freeToken = await createAccessToken( + freeUserId, + `free-push-${freeUserId}@test.com`, + deviceA + ); + + const res = await pushChanges( + freeToken, + [{ noteId: randomUUID(), operation: 'create', encryptedData: 'enc' }], + deviceA + ); + expect(res.status).toBe(403); + }); + + it('applies a single change and returns version', async () => { + const noteId = randomUUID(); + + const res = await pushChanges( + proToken, + [{ noteId, operation: 'create', encryptedData: 'encrypted-content' }], + deviceA + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.results).toHaveLength(1); + expect(body.results[0]).toEqual({ + noteId, + version: expect.any(Number), + status: 'applied', + }); + expect(body.results[0].version).toBeGreaterThanOrEqual(1); + }); + + it('applies multiple changes with sequential versions', async () => { + // Use a fresh pro user to get predictable version numbers starting at 1 + const userId = randomUUID(); + const email = `pro-multi-${userId}@test.com`; + await seedProUser(env, userId, email); + const token = await createAccessToken(userId, email, deviceA); + + const noteIds = [randomUUID(), randomUUID(), randomUUID()]; + + const res = await pushChanges( + token, + noteIds.map((id) => ({ + noteId: id, + operation: 'create', + encryptedData: `data-${id}`, + })), + deviceA + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.results).toHaveLength(3); + expect(body.results[0].version).toBe(1); + expect(body.results[1].version).toBe(2); + expect(body.results[2].version).toBe(3); + expect(body.results.every((r: { status: string }) => r.status === 'applied')).toBe(true); + }); + + it('detects conflict when another device has newer version', async () => { + // Fresh user for isolation + const userId = randomUUID(); + const email = `pro-conflict-${userId}@test.com`; + await seedProUser(env, userId, email); + const tokenA = await createAccessToken(userId, email, deviceA); + const tokenB = await createAccessToken(userId, email, deviceB); + + const noteId = randomUUID(); + + // Device A pushes note "x" — gets version 1 + const resA = await pushChanges( + tokenA, + [{ noteId, operation: 'create', encryptedData: 'from-device-a' }], + deviceA + ); + expect(resA.status).toBe(200); + const bodyA = await resA.json(); + expect(bodyA.results[0].status).toBe('applied'); + expect(bodyA.results[0].version).toBe(1); + + // Device B pushes same note with localVersion=0 → conflict + const resB = await pushChanges( + tokenB, + [{ noteId, operation: 'update', encryptedData: 'from-device-b', localVersion: 0 }], + deviceB + ); + expect(resB.status).toBe(200); + const bodyB = await resB.json(); + expect(bodyB.results[0].status).toBe('conflict'); + expect(bodyB.results[0].serverVersion).toBe(1); + }); + + it('no conflict when same device pushes again', async () => { + // Fresh user for isolation + const userId = randomUUID(); + const email = `pro-samedev-${userId}@test.com`; + await seedProUser(env, userId, email); + const token = await createAccessToken(userId, email, deviceA); + + const noteId = randomUUID(); + + // Device A pushes note — gets version 1 + const res1 = await pushChanges( + token, + [{ noteId, operation: 'create', encryptedData: 'v1' }], + deviceA + ); + expect(res1.status).toBe(200); + const body1 = await res1.json(); + expect(body1.results[0].version).toBe(1); + + // Same device pushes again with localVersion=1 → should be applied (not conflict) + const res2 = await pushChanges( + token, + [{ noteId, operation: 'update', encryptedData: 'v2', localVersion: 1 }], + deviceA + ); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.results[0].status).toBe('applied'); + expect(body2.results[0].version).toBe(2); + }); + + it('applies change when no localVersion provided (first push)', async () => { + // Fresh user for isolation + const userId = randomUUID(); + const email = `pro-nolv-${userId}@test.com`; + await seedProUser(env, userId, email); + const tokenA = await createAccessToken(userId, email, deviceA); + const tokenB = await createAccessToken(userId, email, deviceB); + + const noteId = randomUUID(); + + // Device A pushes note + await pushChanges( + tokenA, + [{ noteId, operation: 'create', encryptedData: 'data-a' }], + deviceA + ); + + // Device B pushes same note WITHOUT localVersion → always applied + const res = await pushChanges( + tokenB, + [{ noteId, operation: 'update', encryptedData: 'data-b' }], + deviceB + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results[0].status).toBe('applied'); + }); + + it('validates schema - rejects empty changes array', async () => { + const res = await app.request( + '/sync', + { + method: 'POST', + headers: { + ...authHeader(proToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ changes: [], deviceId: deviceA }), + }, + env + ); + expect(res.status).toBe(400); + }); + + it('returns cursor equal to last assigned version', async () => { + // Fresh user for isolation + const userId = randomUUID(); + const email = `pro-cursor-${userId}@test.com`; + await seedProUser(env, userId, email); + const token = await createAccessToken(userId, email, deviceA); + + const noteIds = [randomUUID(), randomUUID(), randomUUID()]; + + const res = await pushChanges( + token, + noteIds.map((id) => ({ + noteId: id, + operation: 'create', + encryptedData: `data-${id}`, + })), + deviceA + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.cursor).toBe(3); + expect(body.results[2].version).toBe(body.cursor); + }); +}); From 9f137d310f9fa21bfd73b7df97644a60ae99adad Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:14:12 -0300 Subject: [PATCH 057/148] test(api): add notebook sync and tree validation tests Co-Authored-By: Claude Opus 4.6 --- packages/api/tests/sync-notebooks.test.ts | 464 ++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 packages/api/tests/sync-notebooks.test.ts diff --git a/packages/api/tests/sync-notebooks.test.ts b/packages/api/tests/sync-notebooks.test.ts new file mode 100644 index 00000000..b51d6c83 --- /dev/null +++ b/packages/api/tests/sync-notebooks.test.ts @@ -0,0 +1,464 @@ +/** + * Tests for GET /sync/notebooks and POST /sync/notebooks + * — notebook sync pull/push with tree validation + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient } from '@libsql/client'; +import { randomUUID } from 'node:crypto'; +import app from '../src/index.js'; +import { + createTestEnv, + initTestDb, + cleanupTestDb, + seedProUser, + seedFreeUser, + createAccessToken, + authHeader, +} from './helpers.js'; +import type { Env } from '../src/db/client.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function seedNotebookSyncLog( + env: Env, + userId: string, + entries: Array<{ + notebookId: string; + version: number; + operation: string; + data?: string | null; + deviceId: string; + }> +) { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + for (const entry of entries) { + await client.execute({ + sql: 'INSERT INTO notebook_sync_log (id, user_id, notebook_id, version, operation, data, device_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + args: [ + randomUUID(), + userId, + entry.notebookId, + entry.version, + entry.operation, + entry.data ?? null, + entry.deviceId, + new Date().toISOString(), + ], + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /sync/notebooks — pull notebook changes', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('returns 403 for free user', async () => { + const userId = randomUUID(); + await seedFreeUser(env, userId, `free-nb-${userId}@test.com`); + const token = await createAccessToken(userId, `free-nb-${userId}@test.com`); + + const res = await app.request( + '/sync/notebooks?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('Sync requires Pro subscription'); + }); + + it('returns changes after cursor', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-pull-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-pull-${userId}@test.com`, deviceId); + + const nb1 = randomUUID(); + const nb2 = randomUUID(); + const nb3 = randomUUID(); + + await seedNotebookSyncLog(env, userId, [ + { + notebookId: nb1, + version: 1, + operation: 'create', + data: JSON.stringify({ name: 'Notebook 1', parentId: null, depth: 0, order: 0 }), + deviceId, + }, + { + notebookId: nb2, + version: 2, + operation: 'create', + data: JSON.stringify({ name: 'Notebook 2', parentId: null, depth: 0, order: 1 }), + deviceId, + }, + { + notebookId: nb3, + version: 3, + operation: 'create', + data: JSON.stringify({ name: 'Notebook 3', parentId: null, depth: 0, order: 2 }), + deviceId, + }, + ]); + + // Pull from cursor=0 — should get all 3 + const res = await app.request( + '/sync/notebooks?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.changes).toHaveLength(3); + expect(body.changes[0].version).toBe(1); + expect(body.changes[1].version).toBe(2); + expect(body.changes[2].version).toBe(3); + expect(body.cursor).toBe(3); + expect(body.hasMore).toBe(false); + + // Pull from cursor=2 — should get only version 3 + const res2 = await app.request( + '/sync/notebooks?cursor=2', + { headers: authHeader(token) }, + env + ); + expect(res2.status).toBe(200); + + const body2 = await res2.json(); + expect(body2.changes).toHaveLength(1); + expect(body2.changes[0].notebookId).toBe(nb3); + expect(body2.cursor).toBe(3); + }); +}); + +describe('POST /sync/notebooks — push notebook changes', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('applies notebook changes with versions', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-push-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-push-${userId}@test.com`, deviceId); + + const nb1 = randomUUID(); + const nb2 = randomUUID(); + + const res = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nb1, + operation: 'create', + data: JSON.stringify({ name: 'Work', parentId: null, depth: 0, order: 0 }), + localVersion: 0, + }, + { + notebookId: nb2, + operation: 'create', + data: JSON.stringify({ name: 'Personal', parentId: null, depth: 0, order: 1 }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results).toHaveLength(2); + expect(body.results[0]).toEqual({ notebookId: nb1, version: 1, status: 'applied' }); + expect(body.results[1]).toEqual({ notebookId: nb2, version: 2, status: 'applied' }); + expect(body.cursor).toBe(2); + }); + + it('rejects depth > 2', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-depth-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-depth-${userId}@test.com`, deviceId); + + const nbId = randomUUID(); + + const res = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'create', + data: JSON.stringify({ name: 'Too Deep', parentId: null, depth: 3, order: 0 }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toBe('Tree validation failed'); + expect(body.detail).toContain('depth exceeds max (2), got 3'); + expect(body.notebookId).toBe(nbId); + }); + + it('rejects missing parentId', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-parent-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-parent-${userId}@test.com`, deviceId); + + const nbId = randomUUID(); + const fakeParentId = randomUUID(); + + const res = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'create', + data: JSON.stringify({ name: 'Orphan', parentId: fakeParentId, depth: 1, order: 0 }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toBe('Tree validation failed'); + expect(body.detail).toContain('not found'); + expect(body.notebookId).toBe(nbId); + }); + + it('rejects circular reference', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-circ-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-circ-${userId}@test.com`, deviceId); + + const nbA = randomUUID(); + const nbB = randomUUID(); + + // First, create both notebooks as root-level (no parent) + const setup = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbA, + operation: 'create', + data: JSON.stringify({ name: 'A', parentId: null, depth: 0, order: 0 }), + localVersion: 0, + }, + { + notebookId: nbB, + operation: 'create', + data: JSON.stringify({ name: 'B', parentId: null, depth: 0, order: 1 }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + expect(setup.status).toBe(200); + + // Now update both to point at each other, creating a cycle: A->B, B->A + const res = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbA, + operation: 'update', + data: JSON.stringify({ name: 'A', parentId: nbB, depth: 1, order: 0 }), + localVersion: 1, + }, + { + notebookId: nbB, + operation: 'update', + data: JSON.stringify({ name: 'B', parentId: nbA, depth: 1, order: 1 }), + localVersion: 2, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toBe('Tree validation failed'); + expect(body.detail).toContain('circular'); + }); + + it('handles delete operations', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-nb-del-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-nb-del-${userId}@test.com`, deviceId); + + const nbId = randomUUID(); + + // First create + const res1 = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'create', + data: JSON.stringify({ name: 'Temp', parentId: null, depth: 0, order: 0 }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + expect(res1.status).toBe(200); + const body1 = await res1.json(); + expect(body1.results[0].status).toBe('applied'); + + // Then delete + const res2 = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'delete', + localVersion: 1, + }, + ], + deviceId, + }), + }, + env + ); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.results[0].status).toBe('applied'); + expect(body2.results[0].notebookId).toBe(nbId); + }); + + it('detects conflicts between devices', async () => { + const userId = randomUUID(); + const deviceA = randomUUID(); + const deviceB = randomUUID(); + await seedProUser(env, userId, `pro-nb-conflict-${userId}@test.com`); + const tokenA = await createAccessToken(userId, `pro-nb-conflict-${userId}@test.com`, deviceA); + const tokenB = await createAccessToken(userId, `pro-nb-conflict-${userId}@test.com`, deviceB); + + const nbId = randomUUID(); + + // Device A creates a notebook + const res1 = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(tokenA), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'create', + data: JSON.stringify({ name: 'Shared', parentId: null, depth: 0, order: 0 }), + localVersion: 0, + }, + ], + deviceId: deviceA, + }), + }, + env + ); + expect(res1.status).toBe(200); + const body1 = await res1.json(); + expect(body1.results[0].status).toBe('applied'); + + // Device B pushes same notebook with localVersion=0 (stale) + const res2 = await app.request( + '/sync/notebooks', + { + method: 'POST', + headers: { ...authHeader(tokenB), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + notebookId: nbId, + operation: 'update', + data: JSON.stringify({ name: 'Shared Renamed', parentId: null, depth: 0, order: 0 }), + localVersion: 0, + }, + ], + deviceId: deviceB, + }), + }, + env + ); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.results[0].status).toBe('conflict'); + expect(body2.results[0].serverVersion).toBe(1); + expect(body2.results[0].notebookId).toBe(nbId); + }); +}); From 36e1a5b98d7dd2d5fe58e453f3f6459836907fa5 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:17:45 -0300 Subject: [PATCH 058/148] test(api): add tag sync and sync status endpoint tests Co-Authored-By: Claude Opus 4.6 --- packages/api/tests/sync-status.test.ts | 168 ++++++++++++ packages/api/tests/sync-tags.test.ts | 356 +++++++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 packages/api/tests/sync-status.test.ts create mode 100644 packages/api/tests/sync-tags.test.ts diff --git a/packages/api/tests/sync-status.test.ts b/packages/api/tests/sync-status.test.ts new file mode 100644 index 00000000..335e51ff --- /dev/null +++ b/packages/api/tests/sync-status.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for GET /sync/status + * — sync status endpoint returning plan, cursor, and change counts + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient } from '@libsql/client'; +import { randomUUID } from 'node:crypto'; +import app from '../src/index.js'; +import { + createTestEnv, + initTestDb, + cleanupTestDb, + seedProUser, + seedFreeUser, + createAccessToken, + authHeader, +} from './helpers.js'; +import type { Env } from '../src/db/client.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function seedSyncLog( + env: Env, + userId: string, + entries: Array<{ + noteId: string; + version: number; + operation: string; + encryptedData?: string | null; + deviceId: string; + }> +) { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + for (const entry of entries) { + await client.execute({ + sql: 'INSERT INTO sync_log (id, user_id, note_id, version, operation, encrypted_data, device_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + args: [ + randomUUID(), + userId, + entry.noteId, + entry.version, + entry.operation, + entry.encryptedData ?? null, + entry.deviceId, + new Date().toISOString(), + ], + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /sync/status', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('returns enabled=false for free user', async () => { + const userId = randomUUID(); + await seedFreeUser(env, userId, `free-status-${userId}@test.com`); + const token = await createAccessToken(userId, `free-status-${userId}@test.com`); + + const res = await app.request( + '/sync/status', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + enabled: false, + plan: 'free', + cursor: 0, + totalChanges: 0, + }); + }); + + it('returns enabled=true for pro user', async () => { + const userId = randomUUID(); + await seedProUser(env, userId, `pro-status-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-status-${userId}@test.com`); + + const res = await app.request( + '/sync/status', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + enabled: true, + plan: 'pro', + cursor: 0, + totalChanges: 0, + }); + }); + + it('returns correct cursor after pull', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-status-cursor-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-status-cursor-${userId}@test.com`, deviceId); + + // Seed some sync log entries + await seedSyncLog(env, userId, [ + { noteId: randomUUID(), version: 1, operation: 'create', encryptedData: 'enc1', deviceId }, + { noteId: randomUUID(), version: 2, operation: 'create', encryptedData: 'enc2', deviceId }, + ]); + + // Do a GET /sync to update the cursor + const pullRes = await app.request( + '/sync?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(pullRes.status).toBe(200); + const pullBody = await pullRes.json(); + expect(pullBody.cursor).toBe(2); + + // Now check status — cursor should match + const res = await app.request( + '/sync/status', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.cursor).toBe(2); + expect(body.enabled).toBe(true); + }); + + it('returns correct totalChanges count', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-status-count-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-status-count-${userId}@test.com`, deviceId); + + // Seed 3 sync log entries + await seedSyncLog(env, userId, [ + { noteId: randomUUID(), version: 1, operation: 'create', encryptedData: 'enc1', deviceId }, + { noteId: randomUUID(), version: 2, operation: 'create', encryptedData: 'enc2', deviceId }, + { noteId: randomUUID(), version: 3, operation: 'update', encryptedData: 'enc3', deviceId }, + ]); + + const res = await app.request( + '/sync/status', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.totalChanges).toBe(3); + }); +}); diff --git a/packages/api/tests/sync-tags.test.ts b/packages/api/tests/sync-tags.test.ts new file mode 100644 index 00000000..bc51bd78 --- /dev/null +++ b/packages/api/tests/sync-tags.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for GET /sync/tags and POST /sync/tags + * — tag sync pull/push with validation + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient } from '@libsql/client'; +import { randomUUID } from 'node:crypto'; +import app from '../src/index.js'; +import { + createTestEnv, + initTestDb, + cleanupTestDb, + seedProUser, + seedFreeUser, + createAccessToken, + authHeader, +} from './helpers.js'; +import type { Env } from '../src/db/client.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function seedTagSyncLog( + env: Env, + userId: string, + entries: Array<{ + tagId: string; + version: number; + operation: string; + data?: string | null; + deviceId: string; + }> +) { + const client = createClient({ + url: env.TURSO_DATABASE_URL, + authToken: env.TURSO_AUTH_TOKEN || undefined, + }); + for (const entry of entries) { + await client.execute({ + sql: 'INSERT INTO tag_sync_log (id, user_id, tag_id, version, operation, data, device_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + args: [ + randomUUID(), + userId, + entry.tagId, + entry.version, + entry.operation, + entry.data ?? null, + entry.deviceId, + new Date().toISOString(), + ], + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /sync/tags — pull tag changes', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('returns 403 for free user', async () => { + const userId = randomUUID(); + await seedFreeUser(env, userId, `free-tag-${userId}@test.com`); + const token = await createAccessToken(userId, `free-tag-${userId}@test.com`); + + const res = await app.request( + '/sync/tags?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('Sync requires Pro subscription'); + }); + + it('returns changes after cursor', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-tag-pull-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-tag-pull-${userId}@test.com`, deviceId); + + const tag1 = randomUUID(); + const tag2 = randomUUID(); + const tag3 = randomUUID(); + + await seedTagSyncLog(env, userId, [ + { + tagId: tag1, + version: 1, + operation: 'create', + data: JSON.stringify({ name: 'javascript', color: '#f7df1e' }), + deviceId, + }, + { + tagId: tag2, + version: 2, + operation: 'create', + data: JSON.stringify({ name: 'typescript', color: '#3178c6' }), + deviceId, + }, + { + tagId: tag3, + version: 3, + operation: 'create', + data: JSON.stringify({ name: 'rust', color: '#dea584' }), + deviceId, + }, + ]); + + // Pull from cursor=0 — should get all 3 + const res = await app.request( + '/sync/tags?cursor=0', + { headers: authHeader(token) }, + env + ); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.changes).toHaveLength(3); + expect(body.changes[0].version).toBe(1); + expect(body.changes[1].version).toBe(2); + expect(body.changes[2].version).toBe(3); + expect(body.cursor).toBe(3); + expect(body.hasMore).toBe(false); + + // Pull from cursor=2 — should get only version 3 + const res2 = await app.request( + '/sync/tags?cursor=2', + { headers: authHeader(token) }, + env + ); + expect(res2.status).toBe(200); + + const body2 = await res2.json(); + expect(body2.changes).toHaveLength(1); + expect(body2.changes[0].tagId).toBe(tag3); + expect(body2.cursor).toBe(3); + }); +}); + +describe('POST /sync/tags — push tag changes', () => { + const { env } = createTestEnv(); + + beforeAll(async () => { + await initTestDb(env); + }); + + afterAll(() => { + cleanupTestDb(env); + }); + + it('applies tag changes with sequential versions', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-tag-push-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-tag-push-${userId}@test.com`, deviceId); + + const tag1 = randomUUID(); + const tag2 = randomUUID(); + + const res = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId: tag1, + operation: 'create', + data: JSON.stringify({ name: 'javascript', color: '#f7df1e' }), + localVersion: 0, + }, + { + tagId: tag2, + operation: 'create', + data: JSON.stringify({ name: 'typescript', color: '#3178c6' }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results).toHaveLength(2); + expect(body.results[0]).toEqual({ tagId: tag1, version: 1, status: 'applied' }); + expect(body.results[1]).toEqual({ tagId: tag2, version: 2, status: 'applied' }); + expect(body.cursor).toBe(2); + }); + + it('rejects invalid tag data (missing name)', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-tag-invalid-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-tag-invalid-${userId}@test.com`, deviceId); + + const tagId = randomUUID(); + + const res = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId, + operation: 'create', + data: '{"color":"red"}', + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toBe('Tag data must include name'); + }); + + it('detects conflicts between devices', async () => { + const userId = randomUUID(); + const deviceA = randomUUID(); + const deviceB = randomUUID(); + await seedProUser(env, userId, `pro-tag-conflict-${userId}@test.com`); + const tokenA = await createAccessToken(userId, `pro-tag-conflict-${userId}@test.com`, deviceA); + const tokenB = await createAccessToken(userId, `pro-tag-conflict-${userId}@test.com`, deviceB); + + const tagId = randomUUID(); + + // Device A creates a tag + const res1 = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(tokenA), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId, + operation: 'create', + data: JSON.stringify({ name: 'shared-tag', color: '#000' }), + localVersion: 0, + }, + ], + deviceId: deviceA, + }), + }, + env + ); + expect(res1.status).toBe(200); + const body1 = await res1.json(); + expect(body1.results[0].status).toBe('applied'); + + // Device B pushes same tag with stale localVersion=0 + const res2 = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(tokenB), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId, + operation: 'update', + data: JSON.stringify({ name: 'shared-tag-renamed', color: '#fff' }), + localVersion: 0, + }, + ], + deviceId: deviceB, + }), + }, + env + ); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.results[0].status).toBe('conflict'); + expect(body2.results[0].serverVersion).toBe(1); + expect(body2.results[0].tagId).toBe(tagId); + }); + + it('handles delete operations', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + await seedProUser(env, userId, `pro-tag-del-${userId}@test.com`); + const token = await createAccessToken(userId, `pro-tag-del-${userId}@test.com`, deviceId); + + const tagId = randomUUID(); + + // First create + const res1 = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId, + operation: 'create', + data: JSON.stringify({ name: 'temp-tag', color: '#abc' }), + localVersion: 0, + }, + ], + deviceId, + }), + }, + env + ); + expect(res1.status).toBe(200); + const body1 = await res1.json(); + expect(body1.results[0].status).toBe('applied'); + + // Then delete + const res2 = await app.request( + '/sync/tags', + { + method: 'POST', + headers: { ...authHeader(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + changes: [ + { + tagId, + operation: 'delete', + localVersion: 1, + }, + ], + deviceId, + }), + }, + env + ); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.results[0].status).toBe('applied'); + expect(body2.results[0].tagId).toBe(tagId); + }); +}); From d104dd167fb3a71e5692c5b780cc772e67f912a1 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:20:53 -0300 Subject: [PATCH 059/148] test: add encryption round-trip tests for AES-256-GCM Co-Authored-By: Claude Opus 4.6 --- .../api/tests/encryption-roundtrip.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/api/tests/encryption-roundtrip.test.ts diff --git a/packages/api/tests/encryption-roundtrip.test.ts b/packages/api/tests/encryption-roundtrip.test.ts new file mode 100644 index 00000000..b64c5bf9 --- /dev/null +++ b/packages/api/tests/encryption-roundtrip.test.ts @@ -0,0 +1,110 @@ +import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const KEY_LENGTH = 32; + +function encrypt(key: Buffer, plaintext: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('base64')}:${encrypted.toString('base64')}:${authTag.toString('base64')}`; +} + +function decrypt(key: Buffer, ciphertext: string): string { + const [ivB64, dataB64, tagB64] = ciphertext.split(':'); + const iv = Buffer.from(ivB64, 'base64'); + const data = Buffer.from(dataB64, 'base64'); + const authTag = Buffer.from(tagB64, 'base64'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8'); +} + +describe('Encryption Round-Trip (AES-256-GCM)', () => { + let key: Buffer; + + beforeEach(() => { + key = randomBytes(KEY_LENGTH); + }); + + it('encrypt then decrypt returns original plaintext', () => { + const plaintext = 'Hello, Readied!'; + const encrypted = encrypt(key, plaintext); + const decrypted = decrypt(key, encrypted); + expect(decrypted).toBe(plaintext); + }); + + it('encrypted output has correct format: {base64}:{base64}:{base64}', () => { + const encrypted = encrypt(key, 'test content'); + const parts = encrypted.split(':'); + expect(parts).toHaveLength(3); + + const base64Regex = /^[A-Za-z0-9+/]+=*$/; + for (const part of parts) { + expect(part).toMatch(base64Regex); + } + + // IV should be 12 bytes = 16 base64 chars + const iv = Buffer.from(parts[0], 'base64'); + expect(iv.length).toBe(IV_LENGTH); + + // Auth tag should be 16 bytes + const authTag = Buffer.from(parts[2], 'base64'); + expect(authTag.length).toBe(16); + }); + + it('decrypt with wrong key throws error', () => { + const encrypted = encrypt(key, 'secret data'); + const wrongKey = randomBytes(KEY_LENGTH); + expect(() => decrypt(wrongKey, encrypted)).toThrow(); + }); + + it('decrypt with tampered ciphertext throws error (GCM auth tag check)', () => { + const encrypted = encrypt(key, 'integrity check'); + const parts = encrypted.split(':'); + + // Tamper with the ciphertext data + const data = Buffer.from(parts[1], 'base64'); + data[0] ^= 0xff; + parts[1] = data.toString('base64'); + + const tampered = parts.join(':'); + expect(() => decrypt(key, tampered)).toThrow(); + }); + + it('each encryption produces different IV (nonce uniqueness)', () => { + const plaintext = 'same input'; + const encrypted1 = encrypt(key, plaintext); + const encrypted2 = encrypt(key, plaintext); + + const iv1 = encrypted1.split(':')[0]; + const iv2 = encrypted2.split(':')[0]; + expect(iv1).not.toBe(iv2); + + // Both should still decrypt correctly + expect(decrypt(key, encrypted1)).toBe(plaintext); + expect(decrypt(key, encrypted2)).toBe(plaintext); + }); + + it('empty string encrypts and decrypts correctly', () => { + const encrypted = encrypt(key, ''); + const decrypted = decrypt(key, encrypted); + expect(decrypted).toBe(''); + }); + + it('unicode content encrypts and decrypts correctly', () => { + const plaintext = '日本語テスト 🚀 émojis café ñ 中文 العربية'; + const encrypted = encrypt(key, plaintext); + const decrypted = decrypt(key, encrypted); + expect(decrypted).toBe(plaintext); + }); + + it('large content (100KB) encrypts and decrypts correctly', () => { + const plaintext = 'A'.repeat(100 * 1024); + const encrypted = encrypt(key, plaintext); + const decrypted = decrypt(key, encrypted); + expect(decrypted).toBe(plaintext); + }); +}); From a6b35588b3a2cdd741958b6ef4dbb7c223616ccf Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:33:12 -0300 Subject: [PATCH 060/148] docs: add sync hardening design and implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-11-sync-hardening-design.md | 92 ++ ...026-03-11-sync-hardening-implementation.md | 786 ++++++++++++++++++ 2 files changed, 878 insertions(+) create mode 100644 docs/plans/2026-03-11-sync-hardening-design.md create mode 100644 docs/plans/2026-03-11-sync-hardening-implementation.md diff --git a/docs/plans/2026-03-11-sync-hardening-design.md b/docs/plans/2026-03-11-sync-hardening-design.md new file mode 100644 index 00000000..e5a7c7f6 --- /dev/null +++ b/docs/plans/2026-03-11-sync-hardening-design.md @@ -0,0 +1,92 @@ +# Sync Hardening (1.6) Design + +## Goal + +Make sync robust and observable: auto-resume after offline, local sync history for debugging, and bandwidth tracking per cycle. + +## What's NOT included + +- **Delta sync** — Marked optional in roadmap. Premature optimization for markdown notes typically < 10KB. +- **Retry improvements** — Already implemented in ApiClient (3 retries, exponential backoff 1s/2s/4s). + +## Components + +### 1. Auto-Resume Sync on Reconnect + +**Problem:** App goes offline → sync stops → user must manually trigger syncNow(). + +**Solution:** +- Renderer: `window.addEventListener('online', ...)` → debounce 2s → IPC `sync:syncNow` +- Main process: check `net.isOnline()` at start of each auto-sync tick, skip if offline +- SyncStatusIndicator shows "Back online, syncing..." briefly on reconnect + +### 2. Sync History (Local SQLite Table) + +**Problem:** No way to debug sync issues — scattered console.log calls. + +**Solution:** New migration adding `sync_history` table: + +```sql +CREATE TABLE sync_history ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'running', -- running | success | partial | error + notes_pulled INTEGER DEFAULT 0, + notes_pushed INTEGER DEFAULT 0, + notebooks_pulled INTEGER DEFAULT 0, + notebooks_pushed INTEGER DEFAULT 0, + tags_pulled INTEGER DEFAULT 0, + tags_pushed INTEGER DEFAULT 0, + conflicts INTEGER DEFAULT 0, + bytes_sent INTEGER DEFAULT 0, + bytes_received INTEGER DEFAULT 0, + error_message TEXT +); +``` + +- SyncService writes one row per `syncNow()` call +- Prune to last 100 entries on write +- Exposed via IPC `sync:history` → renderer + +### 3. Bandwidth Metrics + +**Problem:** No visibility into sync data volume. + +**Solution:** Instrument ApiClient.request() to track body sizes: +- Request: `Buffer.byteLength(JSON.stringify(body))` before fetch +- Response: `Buffer.byteLength(JSON.stringify(responseBody))` after parse +- Accumulate per sync cycle, store in `sync_history.bytes_sent/bytes_received` + +### 4. Sync History UI + +**Problem:** Users and developers can't see sync activity. + +**Solution:** Expandable "Sync History" section in Settings > Account, below sync button: +- Table showing last 10 entries: time, status, items synced, bytes, errors +- Expandable to show more +- Color-coded status: green (success), yellow (partial), red (error) + +## Architecture + +``` +Renderer (online event) ──→ IPC sync:syncNow ──→ SyncService.syncNow() + │ +SyncService ──→ ApiClient.request() ──→ tracks bytes ──→ writes sync_history row + │ +Settings UI ←── IPC sync:history ←── SQLiteRepository.getSyncHistory() +``` + +## Files to Touch + +| Layer | File | Change | +|-------|------|--------| +| Migration | `packages/storage-sqlite/src/migrations/017_sync_history.ts` | New table | +| Repository | `packages/storage-sqlite/src/repositories/` | New SyncHistoryRepository or add to existing | +| SyncService | `apps/desktop/src/main/services/syncService.ts` | Write sync_history rows, accumulate metrics | +| ApiClient | `apps/desktop/src/main/services/apiClient.ts` | Return byte counts from request() | +| IPC | `apps/desktop/src/main/index.ts` | Add `sync:history` handler | +| Preload | `apps/desktop/src/preload/index.ts` | Expose `sync.history()` | +| Store | `apps/desktop/src/renderer/stores/syncStore.ts` | Add online/offline listeners | +| UI | `apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx` | Sync history section | +| CSS | `apps/desktop/src/renderer/pages/settings/sections/Section.module.css` | History table styles | diff --git a/docs/plans/2026-03-11-sync-hardening-implementation.md b/docs/plans/2026-03-11-sync-hardening-implementation.md new file mode 100644 index 00000000..f78a0484 --- /dev/null +++ b/docs/plans/2026-03-11-sync-hardening-implementation.md @@ -0,0 +1,786 @@ +# Sync Hardening Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make sync robust and observable — auto-resume after offline, local sync history for debugging, and bandwidth tracking per cycle. + +**Architecture:** Instrument the existing ApiClient to track request/response sizes. Add a `sync_history` SQLite table for per-cycle metrics. Wire `online`/`offline` browser events to auto-trigger sync. Expose history via IPC to a new Settings UI section. + +**Tech Stack:** Electron (main + renderer), SQLite (better-sqlite3), Zustand, React, Hono IPC + +--- + +### Task 1: Add `sync_history` Migration + +**Files:** +- Create: `packages/storage-sqlite/src/migrations/017_sync_history.ts` +- Modify: `packages/storage-sqlite/src/migrations/index.ts` (register migration) + +**Step 1: Create the migration file** + +Create `packages/storage-sqlite/src/migrations/017_sync_history.ts`: + +```typescript +/** + * Sync History Migration + * + * Adds a local table for tracking sync cycle metrics and debugging. + */ + +import type { Migration } from '@readied/storage-core'; + +export const syncHistory: Migration = { + version: 20260311000004, + name: 'sync_history', + up: ` + CREATE TABLE IF NOT EXISTS sync_history ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + notes_pulled INTEGER NOT NULL DEFAULT 0, + notes_pushed INTEGER NOT NULL DEFAULT 0, + notebooks_pulled INTEGER NOT NULL DEFAULT 0, + notebooks_pushed INTEGER NOT NULL DEFAULT 0, + tags_pulled INTEGER NOT NULL DEFAULT 0, + tags_pushed INTEGER NOT NULL DEFAULT 0, + conflicts INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + bytes_received INTEGER NOT NULL DEFAULT 0, + error_message TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_sync_history_started + ON sync_history(started_at DESC); + `, +}; +``` + +**Step 2: Register the migration** + +Open `packages/storage-sqlite/src/migrations/index.ts`. It exports an array of migrations. Add the import and append to the array: + +```typescript +import { syncHistory } from './017_sync_history.js'; + +// Add to the migrations array: +export const migrations: Migration[] = [ + // ... existing migrations ... + syncHistory, +]; +``` + +**Step 3: Verify build** + +Run: `pnpm --filter @readied/storage-sqlite build` +Expected: No errors + +**Step 4: Commit** + +```bash +git add packages/storage-sqlite/src/migrations/017_sync_history.ts packages/storage-sqlite/src/migrations/index.ts +git commit -m "feat(storage): add sync_history migration for sync cycle metrics" +``` + +--- + +### Task 2: Add SyncHistory Repository Methods + +**Files:** +- Modify: `packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts` (or whichever repository class has DB access) + +**Context:** The storage-sqlite package uses better-sqlite3 directly. Repository methods use `this.db.prepare(sql).run(args)` pattern. Check the existing repository to understand the exact pattern before writing. + +**Step 1: Explore the existing repository pattern** + +Read `packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts` — look at the constructor and how `this.db` is used. Also check if there's a base repository class. + +**Step 2: Add sync history methods** + +Add these methods to the appropriate repository class (likely `SQLiteNoteRepository` since it already has sync-related methods like `getPendingChanges`, `markAsSynced`): + +```typescript +/** Sync history entry for local debugging */ +interface SyncHistoryEntry { + id: string; + startedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'partial' | 'error'; + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage: string | null; +} + +/** Create a new sync history entry (status=running) */ +createSyncHistoryEntry(id: string): void { + this.db.prepare(` + INSERT INTO sync_history (id, started_at, status) + VALUES (?, datetime('now'), 'running') + `).run(id); + + // Prune old entries — keep last 100 + this.db.prepare(` + DELETE FROM sync_history WHERE id NOT IN ( + SELECT id FROM sync_history ORDER BY started_at DESC LIMIT 100 + ) + `).run(); +} + +/** Complete a sync history entry with results */ +completeSyncHistoryEntry( + id: string, + status: 'success' | 'partial' | 'error', + metrics: { + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage?: string; + } +): void { + this.db.prepare(` + UPDATE sync_history SET + completed_at = datetime('now'), + status = ?, + notes_pulled = ?, + notes_pushed = ?, + notebooks_pulled = ?, + notebooks_pushed = ?, + tags_pulled = ?, + tags_pushed = ?, + conflicts = ?, + bytes_sent = ?, + bytes_received = ?, + error_message = ? + WHERE id = ? + `).run( + status, + metrics.notesPulled, + metrics.notesPushed, + metrics.notebooksPulled, + metrics.notebooksPushed, + metrics.tagsPulled, + metrics.tagsPushed, + metrics.conflicts, + metrics.bytesSent, + metrics.bytesReceived, + metrics.errorMessage ?? null, + id + ); +} + +/** Get sync history entries (newest first) */ +getSyncHistory(limit = 20): SyncHistoryEntry[] { + const rows = this.db.prepare(` + SELECT id, started_at, completed_at, status, + notes_pulled, notes_pushed, + notebooks_pulled, notebooks_pushed, + tags_pulled, tags_pushed, + conflicts, bytes_sent, bytes_received, error_message + FROM sync_history + ORDER BY started_at DESC + LIMIT ? + `).all(limit) as Array>; + + return rows.map(row => ({ + id: row.id as string, + startedAt: row.started_at as string, + completedAt: row.completed_at as string | null, + status: row.status as 'running' | 'success' | 'partial' | 'error', + notesPulled: row.notes_pulled as number, + notesPushed: row.notes_pushed as number, + notebooksPulled: row.notebooks_pulled as number, + notebooksPushed: row.notebooks_pushed as number, + tagsPulled: row.tags_pulled as number, + tagsPushed: row.tags_pushed as number, + conflicts: row.conflicts as number, + bytesSent: row.bytes_sent as number, + bytesReceived: row.bytes_received as number, + errorMessage: row.error_message as string | null, + })); +} +``` + +**Step 3: Export the `SyncHistoryEntry` type** + +Make sure `SyncHistoryEntry` is exported from the package's public API (check `packages/storage-sqlite/src/index.ts`). + +**Step 4: Verify build** + +Run: `pnpm --filter @readied/storage-sqlite build` + +**Step 5: Commit** + +```bash +git add packages/storage-sqlite/src/ +git commit -m "feat(storage): add sync history repository methods" +``` + +--- + +### Task 3: Add Bandwidth Tracking to ApiClient + +**Files:** +- Modify: `apps/desktop/src/main/services/apiClient.ts` + +**Context:** The `request()` method at line 167 handles all API calls. It uses `cross-fetch`. We need to track the byte size of request bodies and response bodies, and expose them to the caller. + +**Step 1: Add a bandwidth accumulator** + +Add a public property and methods to `ApiClient` (after `private refreshPromise` on line 152): + +```typescript +/** Accumulated bytes for current sync cycle */ +private _bytesSent = 0; +private _bytesReceived = 0; + +/** Reset byte counters (call at start of sync cycle) */ +resetBandwidthCounters(): void { + this._bytesSent = 0; + this._bytesReceived = 0; +} + +/** Get accumulated bandwidth */ +getBandwidth(): { bytesSent: number; bytesReceived: number } { + return { bytesSent: this._bytesSent, bytesReceived: this._bytesReceived }; +} +``` + +**Step 2: Instrument the `request()` method** + +In the `request()` method, add tracking. Before the `fetch` call (line 182), measure the request body: + +```typescript +// Track request body size +if (options.body && typeof options.body === 'string') { + this._bytesSent += Buffer.byteLength(options.body, 'utf8'); +} +``` + +After `await response.json()` (line 211), measure the response. Replace line 211: + +```typescript +const json = await response.json(); +// Track response body size (approximate from JSON re-serialization) +const responseText = JSON.stringify(json); +this._bytesReceived += Buffer.byteLength(responseText, 'utf8'); +return json as T; +``` + +**Step 3: Verify build** + +Run: `pnpm --filter @readied/desktop build` (or just typecheck) + +**Step 4: Commit** + +```bash +git add apps/desktop/src/main/services/apiClient.ts +git commit -m "feat(api-client): add bandwidth tracking to request method" +``` + +--- + +### Task 4: Instrument SyncService with History Logging + +**Files:** +- Modify: `apps/desktop/src/main/services/syncService.ts` + +**Context:** `syncNow()` at line 390 orchestrates the full cycle. We need to: +1. Create a sync_history entry at the start +2. Track per-operation counts +3. Complete the entry at the end with totals + bandwidth + +**Step 1: Add noteRepository dependency for sync history** + +The `SyncService` constructor already has `noteRepository: SQLiteNoteRepository`. The sync history methods were added to that repository. No new dependency needed. + +**Step 2: Modify `syncNow()` to record history** + +Replace the `syncNow()` method (lines 390-493) to wrap with history tracking: + +At the top of `syncNow()`, after the `isSyncing` check (line 401): + +```typescript +// Generate unique ID for this sync cycle +const historyId = crypto.randomUUID(); +this.noteRepository.createSyncHistoryEntry(historyId); +this.apiClient.resetBandwidthCounters(); +``` + +Track counts throughout the method. After each pull/push operation, accumulate: + +```typescript +let notesPulled = 0; +let notesPushed = 0; +let notebooksPulled = 0; +let notebooksPushed = 0; +let tagsPulled = 0; +let tagsPushed = 0; +let totalConflicts = 0; +``` + +After `pullNotebooks` (line 405): `notebooksPulled = nbPullResult.changes?.length ?? 0;` +After `pushNotebooks` (line 411): `notebooksPushed = nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0;` +After `pull()` (line 417): `notesPulled = pullResult.changes.length;` +After push section (line 449): `notesPushed = changesPushed;` +After `pullTags` (line 464): `tagsPulled = tagPull.applied ?? 0;` +After `pushTags` (line 470): `tagsPushed = tagPush.pushed ?? 0;` +Conflicts: `totalConflicts = pullResult.conflicts.length;` + +In the `return` statement (before return at line 475), complete the history: + +```typescript +const bandwidth = this.apiClient.getBandwidth(); +const hasErrors = !nbPullResult.success || !nbPushResult.success || !tagPull.success || !tagPush.success; +this.noteRepository.completeSyncHistoryEntry(historyId, hasErrors ? 'partial' : 'success', { + notesPulled, + notesPushed, + notebooksPulled, + notebooksPushed, + tagsPulled, + tagsPushed, + conflicts: totalConflicts, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, +}); +``` + +In the `catch` block (line 482): + +```typescript +const bandwidth = this.apiClient.getBandwidth(); +this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { + notesPulled: 0, notesPushed: 0, + notebooksPulled: 0, notebooksPushed: 0, + tagsPulled: 0, tagsPushed: 0, + conflicts: 0, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + errorMessage: error instanceof Error ? error.message : 'Sync failed', +}); +``` + +Also add a `getSyncHistory` passthrough method: + +```typescript +/** Get sync history for UI display */ +getSyncHistory(limit = 20) { + return this.noteRepository.getSyncHistory(limit); +} +``` + +**Step 3: Verify build** + +Run: `pnpm typecheck` + +**Step 4: Commit** + +```bash +git add apps/desktop/src/main/services/syncService.ts +git commit -m "feat(sync): record sync history with per-cycle metrics and bandwidth" +``` + +--- + +### Task 5: Add IPC Handler and Preload Bridge for Sync History + +**Files:** +- Modify: `apps/desktop/src/main/index.ts` (add IPC handler) +- Modify: `apps/desktop/src/preload/index.ts` (add preload bridge) + +**Step 1: Add IPC handler** + +In `apps/desktop/src/main/index.ts`, find the sync IPC handlers section (around line 1458-1608). Add after the last sync handler: + +```typescript +// Sync history +ipcMain.handle('sync:history', async (_event, limit?: number) => { + try { + const history = sync.getSyncHistory(limit); + return { success: true, history }; + } catch (error) { + return { + success: false, + history: [], + error: error instanceof Error ? error.message : 'Failed to get sync history', + }; + } +}); +``` + +**Step 2: Add preload bridge** + +In `apps/desktop/src/preload/index.ts`: + +Add type in `ReadiedAPI` interface, inside the `sync:` section (after line 547): + +```typescript +/** Get sync history */ +history: (limit?: number) => Promise<{ + success: boolean; + history: Array<{ + id: string; + startedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'partial' | 'error'; + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage: string | null; + }>; + error?: string; +}>; +``` + +Add implementation in the `sync:` section of the `api` object (after line 830): + +```typescript +history: (limit?: number) => ipcRenderer.invoke('sync:history', limit), +``` + +**Step 3: Verify build** + +Run: `pnpm typecheck` + +**Step 4: Commit** + +```bash +git add apps/desktop/src/main/index.ts apps/desktop/src/preload/index.ts +git commit -m "feat(ipc): expose sync history via IPC and preload bridge" +``` + +--- + +### Task 6: Auto-Resume Sync on Reconnect + +**Files:** +- Modify: `apps/desktop/src/renderer/stores/syncStore.ts` + +**Context:** The sync store uses Zustand. We need to add `online`/`offline` event listeners that auto-trigger `syncNow()` when connectivity returns. The store's `syncNow` action (line 62) already handles the full cycle. + +**Step 1: Add online/offline listener setup** + +Add a new action to the store interface (after `updateLastSyncAt` at line 41): + +```typescript +/** Initialize online/offline listeners */ +initNetworkListeners: () => () => void; +``` + +Add the implementation in the store (after `updateLastSyncAt` at line 163): + +```typescript +/** Initialize online/offline listeners, returns cleanup function */ +initNetworkListeners: () => { + let debounceTimer: ReturnType | null = null; + + const handleOnline = () => { + // Debounce 2 seconds to avoid flapping + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const { status } = get(); + if (status === 'offline' || status === 'error') { + set({ status: 'idle', error: null }); + get().syncNow().catch(() => { + // Error already handled in syncNow + }); + } + }, 2000); + }; + + const handleOffline = () => { + if (debounceTimer) clearTimeout(debounceTimer); + set({ status: 'offline', error: 'No internet connection. Sync will resume when online.' }); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + // Set initial state based on current connectivity + if (!navigator.onLine) { + set({ status: 'offline' }); + } + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + if (debounceTimer) clearTimeout(debounceTimer); + }; +}, +``` + +**Step 2: Wire up in the app initialization** + +The listeners need to be initialized when the app starts. Find where `useSyncStore` is first used in the app (likely in `App.tsx` or a layout component) and add a `useEffect`: + +```typescript +import { useSyncStore } from './stores/syncStore'; + +// In the component: +useEffect(() => { + const cleanup = useSyncStore.getState().initNetworkListeners(); + return cleanup; +}, []); +``` + +Check `apps/desktop/src/renderer/App.tsx` or `apps/desktop/src/renderer/main.tsx` for the right place to add this. + +**Step 3: Verify build** + +Run: `pnpm typecheck` + +**Step 4: Commit** + +```bash +git add apps/desktop/src/renderer/stores/syncStore.ts apps/desktop/src/renderer/ +git commit -m "feat(sync): auto-resume sync on network reconnect with debounce" +``` + +--- + +### Task 7: Sync History UI in Settings + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx` +- Modify: `apps/desktop/src/renderer/pages/settings/sections/Section.module.css` + +**Context:** The AccountSection already has a "Synchronization" SettingGroup (line 223). We add a collapsible "Sync History" section below the sync button. + +**Step 1: Add sync history state and fetch** + +In `AccountSection.tsx`, add state and data fetching: + +```typescript +import { useState, useCallback, useEffect } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; // Add these imports + +// Inside the component: +const [showHistory, setShowHistory] = useState(false); +const [syncHistory, setSyncHistory] = useState>([]); + +const loadSyncHistory = useCallback(async () => { + try { + const result = await window.readied.sync.history(10); + if (result.success) { + setSyncHistory(result.history); + } + } catch { + // Non-critical — silently ignore + } +}, []); + +// Fetch history when section is expanded +useEffect(() => { + if (showHistory) { + loadSyncHistory(); + } +}, [showHistory, loadSyncHistory]); + +// Also refresh after each sync +// In handleSync, after syncNow() succeeds: +if (showHistory) loadSyncHistory(); +``` + +**Step 2: Add history UI** + +Inside the "Synchronization" ``, after the offline message and before the closing tag (before line 250): + +```tsx + + +{showHistory && ( +
+ {syncHistory.length === 0 ? ( +
No sync history yet
+ ) : ( + + + + + + + + + + + {syncHistory.map(entry => ( + + + + + + + ))} + +
TimeStatusItemsData
{new Date(entry.startedAt).toLocaleString()} + + {entry.status} + + + ↓{entry.notesPulled + entry.notebooksPulled + entry.tagsPulled}{' '} + ↑{entry.notesPushed + entry.notebooksPushed + entry.tagsPushed} + {entry.conflicts > 0 && ` ⚠${entry.conflicts}`} + + ↑{formatBytes(entry.bytesSent)} ↓{formatBytes(entry.bytesReceived)} +
+ )} +
+)} +``` + +**Step 3: Add `formatBytes` helper** + +At the top of the file (or inside the component): + +```typescript +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} +``` + +**Step 4: Add CSS styles** + +Append to `Section.module.css`: + +```css +/* Sync History */ +.historyToggle { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0; + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; +} + +.historyToggle:hover { + color: var(--text-primary); +} + +.syncHistoryTable { + margin-top: 0.5rem; +} + +.historyTable { + width: 100%; + font-size: var(--text-xs); + border-collapse: collapse; +} + +.historyTable th { + text-align: left; + padding: 0.375rem 0.5rem; + color: var(--text-tertiary); + font-weight: 500; + border-bottom: 1px solid var(--border-subtle); +} + +.historyTable td { + padding: 0.375rem 0.5rem; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.historyTable tr:last-child td { + border-bottom: none; +} + +.historyStatus { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: var(--text-xs); + font-weight: 500; +} + +.historyStatus_success { + color: #10b981; + background: rgba(16, 185, 129, 0.1); +} + +.historyStatus_partial { + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.historyStatus_error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.historyStatus_running { + color: #3b82f6; + background: rgba(59, 130, 246, 0.1); +} +``` + +**Step 5: Verify build** + +Run: `pnpm typecheck` + +**Step 6: Commit** + +```bash +git add apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx apps/desktop/src/renderer/pages/settings/sections/Section.module.css +git commit -m "feat(ui): add sync history section to Settings with bandwidth display" +``` + +--- + +### Summary + +| Task | What | Files | +|------|------|-------| +| 1 | Migration: `sync_history` table | `storage-sqlite/migrations/` | +| 2 | Repository: CRUD for sync history | `SQLiteNoteRepository` | +| 3 | ApiClient: bandwidth tracking | `apiClient.ts` | +| 4 | SyncService: record history per cycle | `syncService.ts` | +| 5 | IPC + Preload: expose `sync:history` | `main/index.ts`, `preload/index.ts` | +| 6 | Auto-resume: online/offline listeners | `syncStore.ts` | +| 7 | Settings UI: sync history table | `AccountSection.tsx`, CSS | From 0e435e2835542dfa08de3340e93518deb1e109ab Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:35:21 -0300 Subject: [PATCH 061/148] feat(storage): add sync_history migration for sync cycle metrics Co-Authored-By: Claude Opus 4.6 --- .../src/migrations/017_sync_history.ts | 27 +++++++++++++++++++ .../storage-sqlite/src/migrations/index.ts | 3 +++ 2 files changed, 30 insertions(+) create mode 100644 packages/storage-sqlite/src/migrations/017_sync_history.ts diff --git a/packages/storage-sqlite/src/migrations/017_sync_history.ts b/packages/storage-sqlite/src/migrations/017_sync_history.ts new file mode 100644 index 00000000..5a8c899c --- /dev/null +++ b/packages/storage-sqlite/src/migrations/017_sync_history.ts @@ -0,0 +1,27 @@ +import type { Migration } from '@readied/storage-core'; + +export const syncHistory: Migration = { + version: 20260311000004, + name: 'sync_history', + up: ` + CREATE TABLE IF NOT EXISTS sync_history ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + notes_pulled INTEGER NOT NULL DEFAULT 0, + notes_pushed INTEGER NOT NULL DEFAULT 0, + notebooks_pulled INTEGER NOT NULL DEFAULT 0, + notebooks_pushed INTEGER NOT NULL DEFAULT 0, + tags_pulled INTEGER NOT NULL DEFAULT 0, + tags_pushed INTEGER NOT NULL DEFAULT 0, + conflicts INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + bytes_received INTEGER NOT NULL DEFAULT 0, + error_message TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_sync_history_started + ON sync_history(started_at DESC); + `, +}; diff --git a/packages/storage-sqlite/src/migrations/index.ts b/packages/storage-sqlite/src/migrations/index.ts index 5d03af4a..05e76354 100644 --- a/packages/storage-sqlite/src/migrations/index.ts +++ b/packages/storage-sqlite/src/migrations/index.ts @@ -19,6 +19,7 @@ import { pluginConfig } from './013_plugin_config.js'; import { pluginRegistry } from './014_plugin_registry.js'; import { notebookSyncTracking } from './015_notebook_sync_tracking.js'; import { tagSyncTracking } from './016_tag_sync_tracking.js'; +import { syncHistory } from './017_sync_history.js'; /** All migrations in order */ export const allMigrations: Migration[] = [ @@ -38,6 +39,7 @@ export const allMigrations: Migration[] = [ pluginRegistry, notebookSyncTracking, tagSyncTracking, + syncHistory, ]; export { @@ -57,4 +59,5 @@ export { pluginRegistry, notebookSyncTracking, tagSyncTracking, + syncHistory, }; From 080cb683937b83f542ba1156f35c6bd1082a399e Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:35:26 -0300 Subject: [PATCH 062/148] feat(storage): add sync history repository methods Co-Authored-By: Claude Opus 4.6 --- packages/storage-sqlite/src/index.ts | 2 +- .../src/repositories/SQLiteNoteRepository.ts | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/storage-sqlite/src/index.ts b/packages/storage-sqlite/src/index.ts index b5d935c3..09746080 100644 --- a/packages/storage-sqlite/src/index.ts +++ b/packages/storage-sqlite/src/index.ts @@ -14,7 +14,7 @@ export { } from './database.js'; // Repositories -export { SQLiteNoteRepository, type BacklinkInfo } from './repositories/SQLiteNoteRepository.js'; +export { SQLiteNoteRepository, type BacklinkInfo, type SyncHistoryEntry } from './repositories/SQLiteNoteRepository.js'; export { SQLiteNotebookRepository } from './repositories/SQLiteNotebookRepository.js'; // Migrations diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 476e1f6f..a402d9e0 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -24,6 +24,24 @@ import { import { extractWikilinks } from '@readied/wikilinks'; import type { DatabaseConnection } from '../database.js'; +/** Sync history entry returned by getSyncHistory */ +export interface SyncHistoryEntry { + id: string; + startedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'partial' | 'error'; + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage: string | null; +} + /** Row type from SQLite */ interface NoteRow { id: string; @@ -947,4 +965,145 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { | undefined; return row?.uuid ?? null; } + + // ============================================================================ + // Sync History Methods + // ============================================================================ + + /** + * Create a new sync history entry marking the start of a sync cycle. + * Prunes old entries to keep only the latest 100. + * + * @param id - Unique identifier for this sync cycle + */ + createSyncHistoryEntry(id: string): void { + this.db.transaction(() => { + const insertStmt = this.db.prepare(` + INSERT INTO sync_history (id, started_at, status) + VALUES (?, datetime('now'), 'running') + `); + insertStmt.run(id); + + // Prune old entries, keep latest 100 + const pruneStmt = this.db.prepare(` + DELETE FROM sync_history + WHERE id NOT IN ( + SELECT id FROM sync_history + ORDER BY started_at DESC + LIMIT 100 + ) + `); + pruneStmt.run(); + }); + } + + /** + * Complete a sync history entry with final status and metrics. + * + * @param id - The sync cycle ID + * @param status - Final status: 'success', 'partial', or 'error' + * @param metrics - Sync cycle metrics + */ + completeSyncHistoryEntry( + id: string, + status: 'success' | 'partial' | 'error', + metrics: { + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage?: string; + } + ): void { + const stmt = this.db.prepare(` + UPDATE sync_history + SET + completed_at = datetime('now'), + status = ?, + notes_pulled = ?, + notes_pushed = ?, + notebooks_pulled = ?, + notebooks_pushed = ?, + tags_pulled = ?, + tags_pushed = ?, + conflicts = ?, + bytes_sent = ?, + bytes_received = ?, + error_message = ? + WHERE id = ? + `); + stmt.run( + status, + metrics.notesPulled, + metrics.notesPushed, + metrics.notebooksPulled, + metrics.notebooksPushed, + metrics.tagsPulled, + metrics.tagsPushed, + metrics.conflicts, + metrics.bytesSent, + metrics.bytesReceived, + metrics.errorMessage ?? null, + id + ); + } + + /** + * Get recent sync history entries. + * + * @param limit - Maximum number of entries to return (default: 20) + * @returns Array of sync history entries, newest first + */ + getSyncHistory(limit = 20): SyncHistoryEntry[] { + const stmt = this.db.prepare(` + SELECT id, started_at, completed_at, status, + notes_pulled, notes_pushed, + notebooks_pulled, notebooks_pushed, + tags_pulled, tags_pushed, + conflicts, bytes_sent, bytes_received, + error_message + FROM sync_history + ORDER BY started_at DESC + LIMIT ? + `); + + const rows = stmt.all(limit) as Array<{ + id: string; + started_at: string; + completed_at: string | null; + status: string; + notes_pulled: number; + notes_pushed: number; + notebooks_pulled: number; + notebooks_pushed: number; + tags_pulled: number; + tags_pushed: number; + conflicts: number; + bytes_sent: number; + bytes_received: number; + error_message: string | null; + }>; + + return rows.map(row => ({ + id: row.id, + startedAt: row.started_at, + completedAt: row.completed_at, + status: row.status as SyncHistoryEntry['status'], + notesPulled: row.notes_pulled, + notesPushed: row.notes_pushed, + notebooksPulled: row.notebooks_pulled, + notebooksPushed: row.notebooks_pushed, + tagsPulled: row.tags_pulled, + tagsPushed: row.tags_pushed, + conflicts: row.conflicts, + bytesSent: row.bytes_sent, + bytesReceived: row.bytes_received, + errorMessage: row.error_message, + })); + } } From 3646cb2a5dd6e0f0f28973b1e5ca0094cc31b0f6 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:51:26 -0300 Subject: [PATCH 063/148] feat(api-client): add bandwidth tracking to request method Track bytes sent and received across all HTTP requests for sync history reporting. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/apiClient.ts | 27 +++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index e0ee87d2..424fec97 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -150,6 +150,8 @@ export class ApiClient { private deviceInfo: DeviceInfo; private isRefreshing = false; private refreshPromise: Promise | null = null; + private _bytesSent = 0; + private _bytesReceived = 0; constructor(baseURL: string, tokenStorage: TokenStorage, deviceInfo: DeviceInfo) { this.baseURL = baseURL; @@ -157,6 +159,19 @@ export class ApiClient { this.deviceInfo = deviceInfo; } + // ========================================================================== + // Bandwidth Tracking + // ========================================================================== + + resetBandwidthCounters(): void { + this._bytesSent = 0; + this._bytesReceived = 0; + } + + getBandwidth(): { bytesSent: number; bytesReceived: number } { + return { bytesSent: this._bytesSent, bytesReceived: this._bytesReceived }; + } + // ========================================================================== // Core Request Method // ========================================================================== @@ -178,6 +193,11 @@ export class ApiClient { headers['Authorization'] = `Bearer ${tokens.accessToken}`; } + // Track request body size + if (options.body && typeof options.body === 'string') { + this._bytesSent += Buffer.byteLength(options.body, 'utf8'); + } + try { const response = await fetch(url, { ...options, @@ -207,8 +227,11 @@ export class ApiClient { ); } - // Parse JSON response - return (await response.json()) as T; + // Parse JSON response and track bandwidth + const json = await response.json(); + const responseText = JSON.stringify(json); + this._bytesReceived += Buffer.byteLength(responseText, 'utf8'); + return json as T; } catch (error) { // Network error or fetch failure if (error instanceof ApiError) { From 57d430383fcfeddf8cab5429a6f1bf737b937a09 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:51:36 -0300 Subject: [PATCH 064/148] feat(sync): record sync history with per-cycle metrics and bandwidth Instrument syncNow() to create a sync_history entry for each cycle, tracking notes/notebooks/tags pulled and pushed, conflicts, bandwidth, and final status (success/partial/error). Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/syncService.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index 04a56b78..79c28a02 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -7,6 +7,7 @@ * @module SyncService */ +import { randomUUID } from 'node:crypto'; import type { SQLiteNoteRepository, SQLiteNotebookRepository } from '@readied/storage-sqlite'; import { createNoteId, @@ -400,23 +401,47 @@ export class SyncService { this.state.isSyncing = true; + const historyId = randomUUID(); + this.noteRepository.createSyncHistoryEntry(historyId); + this.apiClient.resetBandwidthCounters(); + + let notesPulled = 0; + let notesPushed = 0; + let notebooksPulled = 0; + let notebooksPushed = 0; + let tagsPulled = 0; + let tagsPushed = 0; + let totalConflicts = 0; + try { // Step 1: Pull notebooks first (notes depend on notebooks) const nbPullResult = await this.pullNotebooks(); if (!nbPullResult.success) { console.error('Failed to pull notebooks:', nbPullResult.error); } + notebooksPulled = nbPullResult.changes?.length ?? 0; // Step 2: Push pending notebook changes const nbPushResult = await this.pushNotebooks(); if (!nbPushResult.success) { console.error('Failed to push notebooks:', nbPushResult.error); } + notebooksPushed = nbPushResult.results?.filter(r => r.status === 'applied').length ?? 0; // Step 3: Pull note changes from server const pullResult = await this.pull(); if (!pullResult.success) { + const bandwidth = this.apiClient.getBandwidth(); + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { + notesPulled: 0, notesPushed: 0, + notebooksPulled, notebooksPushed, + tagsPulled: 0, tagsPushed: 0, + conflicts: 0, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + errorMessage: pullResult.error, + }); return { success: false, changesApplied: 0, @@ -425,6 +450,8 @@ export class SyncService { error: pullResult.error, }; } + notesPulled = pullResult.changes.length; + totalConflicts = pullResult.conflicts.length; // Step 4: Push local note changes let changesPushed = 0; @@ -459,18 +486,36 @@ export class SyncService { console.error('Failed to push changes:', pushResult.error); } } + notesPushed = changesPushed; - // Step 3: Pull tags + // Step 5: Pull tags const tagPull = await this.pullTags(); if (!tagPull.success) { console.error('Tag pull failed:', tagPull.error); } + tagsPulled = tagPull.applied ?? 0; - // Step 4: Push tags + // Step 6: Push tags const tagPush = await this.pushTags(); if (!tagPush.success) { console.error('Tag push failed:', tagPush.error); } + tagsPushed = tagPush.pushed ?? 0; + + // Complete sync history entry + const bandwidth = this.apiClient.getBandwidth(); + const hasPartialErrors = !nbPullResult.success || !nbPushResult.success || !tagPull.success || !tagPush.success; + this.noteRepository.completeSyncHistoryEntry(historyId, hasPartialErrors ? 'partial' : 'success', { + notesPulled, + notesPushed, + notebooksPulled, + notebooksPushed, + tagsPulled, + tagsPushed, + conflicts: totalConflicts, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + }); return { success: true, @@ -480,6 +525,16 @@ export class SyncService { conflicts: pullResult.conflicts, }; } catch (error) { + const bandwidth = this.apiClient.getBandwidth(); + this.noteRepository.completeSyncHistoryEntry(historyId, 'error', { + notesPulled: 0, notesPushed: 0, + notebooksPulled: 0, notebooksPushed: 0, + tagsPulled: 0, tagsPushed: 0, + conflicts: 0, + bytesSent: bandwidth.bytesSent, + bytesReceived: bandwidth.bytesReceived, + errorMessage: error instanceof Error ? error.message : 'Sync failed', + }); return { success: false, changesApplied: 0, @@ -552,6 +607,13 @@ export class SyncService { return { ...this.state }; } + /** + * Get sync history entries + */ + getSyncHistory(limit = 20) { + return this.noteRepository.getSyncHistory(limit); + } + // ========================================================================== // Private Methods // ========================================================================== From 8153c7837d9403aec7fedb00a2658684b5c0339a Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:58:41 -0300 Subject: [PATCH 065/148] feat(ipc): expose sync history via IPC and preload bridge Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 13 +++++++++++++ apps/desktop/src/preload/index.ts | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e23cc06c..e7a2ec2d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1607,6 +1607,19 @@ function registerAuthSyncHandlers(): void { } }); + ipcMain.handle('sync:history', async (_event, limit?: number) => { + try { + const history = sync.getSyncHistory(limit); + return { success: true, history }; + } catch (error) { + return { + success: false, + history: [], + error: error instanceof Error ? error.message : 'Failed to get sync history', + }; + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // Subscription // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ae2d68dc..5677fb88 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -545,6 +545,27 @@ export interface ReadiedAPI { pullTags: () => Promise<{ success: boolean; applied: number; error?: string }>; /** Push tag changes to server */ pushTags: () => Promise<{ success: boolean; pushed: number; error?: string }>; + /** Get sync history */ + history: (limit?: number) => Promise<{ + success: boolean; + history: Array<{ + id: string; + startedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'partial' | 'error'; + notesPulled: number; + notesPushed: number; + notebooksPulled: number; + notebooksPushed: number; + tagsPulled: number; + tagsPushed: number; + conflicts: number; + bytesSent: number; + bytesReceived: number; + errorMessage: string | null; + }>; + error?: string; + }>; }; subscription: { /** Get subscription status */ @@ -828,6 +849,7 @@ const api: ReadiedAPI = { triggerSync: () => ipcRenderer.invoke('sync:trigger'), pullTags: () => ipcRenderer.invoke('sync:pullTags'), pushTags: () => ipcRenderer.invoke('sync:pushTags'), + history: (limit?: number) => ipcRenderer.invoke('sync:history', limit), }, subscription: { getStatus: () => ipcRenderer.invoke('subscription:getStatus'), From fc9ab968ea970c08105f1b744741ee01bb4097f3 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:58:48 -0300 Subject: [PATCH 066/148] feat(sync): auto-resume sync on network reconnect with debounce Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/renderer/App.tsx | 7 ++++ apps/desktop/src/renderer/stores/syncStore.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 530c2dae..c04a6a92 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -45,6 +45,7 @@ import { usePerformanceMode } from './hooks/usePerformanceMode'; import { useAppearanceSettings } from './hooks/useAppearanceSettings'; import { useResizableLayout } from './hooks/useResizableLayout'; import { useAuthStore } from './stores/authStore'; +import { useSyncStore } from './stores/syncStore'; import { pluginRuntimeStore } from './stores/pluginRuntimeStore'; /** Shows toast errors for plugins that failed to load */ @@ -104,6 +105,12 @@ function NotesApp() { useAuthStore.getState().loadSession(); }, []); + // Auto-resume sync on network reconnect + useEffect(() => { + const cleanup = useSyncStore.getState().initNetworkListeners(); + return cleanup; + }, []); + // Handle deep link auth verification (readied://auth/verify?token=xxx) useEffect(() => { const handleAuthVerification = async (...args: unknown[]) => { diff --git a/apps/desktop/src/renderer/stores/syncStore.ts b/apps/desktop/src/renderer/stores/syncStore.ts index cec326f9..badf480e 100644 --- a/apps/desktop/src/renderer/stores/syncStore.ts +++ b/apps/desktop/src/renderer/stores/syncStore.ts @@ -39,6 +39,7 @@ interface SyncState { clearError: () => void; setEnabled: (enabled: boolean) => void; updateLastSyncAt: (timestamp: number) => void; + initNetworkListeners: () => () => void; } // ============================================================================ @@ -161,6 +162,42 @@ export const useSyncStore = create()((set, get) => ({ * Update last sync timestamp */ updateLastSyncAt: (timestamp: number) => set({ lastSyncAt: timestamp }), + + /** + * Listen for online/offline events and auto-resume sync on reconnect + */ + initNetworkListeners: () => { + let debounceTimer: ReturnType | null = null; + + const handleOnline = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const { status } = get(); + if (status === 'offline' || status === 'error') { + set({ status: 'idle', error: null }); + get().syncNow().catch(() => {}); + } + }, 2000); + }; + + const handleOffline = () => { + if (debounceTimer) clearTimeout(debounceTimer); + set({ status: 'offline', error: 'No internet connection. Sync will resume when online.' }); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + if (!navigator.onLine) { + set({ status: 'offline' }); + } + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, })); // ============================================================================ From 99b3f56725a1342425931192e0dd04e717887353 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 15:58:54 -0300 Subject: [PATCH 067/148] feat(ui): add sync history section to Settings with bandwidth display Co-Authored-By: Claude Opus 4.6 --- .../settings/sections/AccountSection.tsx | 94 ++++++++++++++++++- .../settings/sections/Section.module.css | 73 ++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx index 76866a2b..a768ff30 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx @@ -14,6 +14,8 @@ import { Sparkles, CreditCard, ExternalLink, + ChevronDown, + ChevronRight, } from 'lucide-react'; import { getProductConfig } from '@readied/product-config'; import { useAuthStore } from '../../../stores/authStore'; @@ -28,6 +30,13 @@ import styles from './Section.module.css'; const config = getProductConfig(); const proPricing = config.plans.pro.pricing!; +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function AccountSection() { const { user, isAuthenticated, isLoading, logout, loadSession } = useAuthStore(); const { syncNow, status: syncStatus, lastSyncAt, conflicts } = useSyncStore(); @@ -37,12 +46,46 @@ export function AccountSection() { const [isSyncing, setIsSyncing] = useState(false); const [isUpgrading, setIsUpgrading] = useState(false); const [isManaging, setIsManaging] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const [syncHistory, setSyncHistory] = useState>([]); // Load session on mount useEffect(() => { loadSession(); }, [loadSession]); + const loadSyncHistory = useCallback(async () => { + try { + const result = await window.readied.sync.history(10); + if (result.success) { + setSyncHistory(result.history); + } + } catch { + // Non-critical + } + }, []); + + useEffect(() => { + if (showHistory) { + loadSyncHistory(); + } + }, [showHistory, loadSyncHistory]); + const handleSignIn = useCallback(() => { setShowMagicLinkFlow(true); setMessage(null); @@ -73,12 +116,13 @@ export function AccountSection() { try { await syncNow(); setMessage('Sync completed successfully'); + if (showHistory) loadSyncHistory(); } catch (error) { setMessage(`Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSyncing(false); } - }, [syncNow]); + }, [syncNow, showHistory, loadSyncHistory]); const formatLastSync = () => { if (!lastSyncAt) return 'Never'; @@ -247,6 +291,54 @@ export function AccountSection() { You are offline. Sync will resume when you're back online.
)} + + + + {showHistory && ( +
+ {syncHistory.length === 0 ? ( +
No sync history yet
+ ) : ( + + + + + + + + + + + {syncHistory.map(entry => ( + + + + + + + ))} + +
TimeStatusItemsData
{new Date(entry.startedAt).toLocaleString()} + + {entry.status} + + + ↓{entry.notesPulled + entry.notebooksPulled + entry.tagsPulled}{' '} + ↑{entry.notesPushed + entry.notebooksPushed + entry.tagsPushed} + {entry.conflicts > 0 && ` ⚠${entry.conflicts}`} + + ↑{formatBytes(entry.bytesSent)} ↓{formatBytes(entry.bytesReceived)} +
+ )} +
+ )} {conflicts.length > 0 && } diff --git a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css index 0036742c..344786a3 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css +++ b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css @@ -667,3 +667,76 @@ background: var(--danger-muted, rgba(239, 68, 68, 0.1)); color: var(--danger, #ef4444); } + +/* Sync History */ +.historyToggle { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0; + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; +} + +.historyToggle:hover { + color: var(--text-primary); +} + +.syncHistoryTable { + margin-top: 0.5rem; +} + +.historyTable { + width: 100%; + font-size: var(--text-xs); + border-collapse: collapse; +} + +.historyTable th { + text-align: left; + padding: 0.375rem 0.5rem; + color: var(--text-tertiary); + font-weight: 500; + border-bottom: 1px solid var(--border-subtle); +} + +.historyTable td { + padding: 0.375rem 0.5rem; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); +} + +.historyTable tr:last-child td { + border-bottom: none; +} + +.historyStatus { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: var(--text-xs); + font-weight: 500; +} + +.historyStatus_success { + color: #10b981; + background: rgba(16, 185, 129, 0.1); +} + +.historyStatus_partial { + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.historyStatus_error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.historyStatus_running { + color: #3b82f6; + background: rgba(59, 130, 246, 0.1); +} From f4e904c46f29583e985f32523703d696310f42f9 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 16:35:24 -0300 Subject: [PATCH 068/148] style: fix Prettier formatting across theme system and sync files Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/syncService.ts | 15 +- apps/desktop/src/renderer/App.tsx | 2 +- .../renderer/hooks/useAppearanceSettings.ts | 12 +- .../2026-03-11-theme-system-implementation.md | 145 +++++++++++++----- packages/api/src/routes/sync.ts | 109 ++++++------- packages/plugin-api/src/index.ts | 7 +- .../src/theme/themeRegistryStore.ts | 7 +- packages/plugin-api/src/theme/themeTypes.ts | 32 +++- .../tests/themeRegistryStore.test.ts | 12 +- packages/plugin-api/tests/themeTypes.test.ts | 15 +- .../src/repositories/SQLiteNoteRepository.ts | 28 +++- 11 files changed, 256 insertions(+), 128 deletions(-) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index 04a56b78..168f21d3 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -321,7 +321,11 @@ export class SyncService { this.noteRepository.deleteTagByUuid(change.tagId); } else if (change.data) { const parsed = JSON.parse(change.data); - this.noteRepository.upsertTagFromRemote(change.tagId, parsed.name, parsed.color ?? null); + this.noteRepository.upsertTagFromRemote( + change.tagId, + parsed.name, + parsed.color ?? null + ); } applied++; } catch (error) { @@ -330,9 +334,8 @@ export class SyncService { } } - this.state.tagCursor = applied === result.changes.length - ? result.cursor - : this.state.tagCursor; + this.state.tagCursor = + applied === result.changes.length ? result.cursor : this.state.tagCursor; return { success: true, applied }; } catch (error) { @@ -367,9 +370,7 @@ export class SyncService { const result = await this.apiClient.pushTagChanges(changes); - const successIds = result.results - .filter(r => r.status === 'applied') - .map(r => r.tagId); + const successIds = result.results.filter(r => r.status === 'applied').map(r => r.tagId); this.noteRepository.markMultipleTagsAsSynced(successIds); this.state.tagCursor = result.cursor; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 891445f6..1811f430 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -78,7 +78,7 @@ const queryClient = new QueryClient({ function NotesApp() { usePerformanceMode(); useAppearanceSettings(); - useThemeOverrides(); // Applies active theme tokens + useThemeOverrides(); // Applies active theme tokens useCssVariables(); // Restore saved plugin theme on startup diff --git a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts index 31ccf56f..9516d010 100644 --- a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts +++ b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts @@ -5,10 +5,16 @@ import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; /** * Apply appearance settings to the DOM. */ -function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { +function applyAppearance( + theme: string, + accentColor: string, + zoomLevel: string, + isDark?: boolean +): void { let resolved: string; if (theme === 'system') { - resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + resolved = + (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; } else { resolved = theme; } @@ -60,7 +66,7 @@ export function useAppearanceSettings(): void { // Listen for system theme changes via IPC useEffect(() => { if (theme !== 'system') return; - const unsub = window.readied.theme.onSystemChanged((isDark) => { + const unsub = window.readied.theme.onSystemChanged(isDark => { applyAppearance('system', accentColor, zoomLevel, isDark); }); return unsub; diff --git a/docs/plans/2026-03-11-theme-system-implementation.md b/docs/plans/2026-03-11-theme-system-implementation.md index 02e9fde3..974077ce 100644 --- a/docs/plans/2026-03-11-theme-system-implementation.md +++ b/docs/plans/2026-03-11-theme-system-implementation.md @@ -13,6 +13,7 @@ ### Task 1: Define theme types and token whitelist **Files:** + - Create: `packages/plugin-api/src/theme/themeTypes.ts` **Step 1: Create the types file** @@ -27,18 +28,36 @@ /** Core CSS tokens that themes are allowed to override */ export const CORE_THEME_TOKENS = [ // Backgrounds - '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', + '--bg-base', + '--bg-surface', + '--bg-elevated', + '--bg-inset', // Text - '--text-primary', '--text-secondary', '--text-muted', '--text-faint', + '--text-primary', + '--text-secondary', + '--text-muted', + '--text-faint', // Borders - '--border', '--border-subtle', '--border-strong', + '--border', + '--border-subtle', + '--border-strong', // Glass - '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', + '--glass-bg', + '--glass-border', + '--glass-bg-menu', + '--glass-border-menu', // Semantic - '--danger', '--danger-muted', '--warning', '--warning-muted', - '--success', '--success-muted', + '--danger', + '--danger-muted', + '--warning', + '--warning-muted', + '--success', + '--success-muted', // Status - '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', + '--status-active', + '--status-on-hold', + '--status-completed', + '--status-dropped', ] as const; /** Valid extension scope prefixes for non-core tokens */ @@ -103,6 +122,7 @@ git commit -m "feat(plugin-api): add theme types and token whitelist" ### Task 2: Create ThemeRegistry store **Files:** + - Create: `packages/plugin-api/src/theme/themeRegistryStore.ts` - Modify: `packages/plugin-api/src/index.ts` (add exports) @@ -152,7 +172,9 @@ export const themeRegistryStore = createStore((set, get) => set(state => ({ themes: [...state.themes.filter(t => t.id !== theme.id), validated], })); - console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + console.debug( + `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` + ); return true; }, @@ -172,8 +194,8 @@ export const themeRegistryStore = createStore((set, get) => unregisterAll(pluginId) { set(state => { const remaining = state.themes.filter(t => t.pluginId !== pluginId); - const activeRemoved = state.activeThemeId && - !remaining.some(t => t.id === state.activeThemeId); + const activeRemoved = + state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); return { themes: remaining, activeThemeId: activeRemoved ? null : state.activeThemeId, @@ -203,9 +225,15 @@ export const themeRegistryStore = createStore((set, get) => **Step 2: Export from barrel** Add to `packages/plugin-api/src/index.ts`: + ```typescript export { themeRegistryStore } from './theme/themeRegistryStore'; -export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export { + isValidThemeToken, + validateThemeTokens, + CORE_THEME_TOKENS, + THEME_EXTENSION_SCOPES, +} from './theme/themeTypes'; export type { ThemeDefinition } from './theme/themeTypes'; ``` @@ -221,6 +249,7 @@ git commit -m "feat(plugin-api): add ThemeRegistry store with validation" ### Task 3: Create useThemeOverrides hook **Files:** + - Create: `packages/plugin-api/src/theme/useThemeOverrides.ts` - Modify: `packages/plugin-api/src/index.ts` (add export) @@ -275,6 +304,7 @@ export function useThemeOverrides(): void { **Step 2: Export from barrel** Add to `packages/plugin-api/src/index.ts`: + ```typescript export { useThemeOverrides } from './theme/useThemeOverrides'; ``` @@ -291,6 +321,7 @@ git commit -m "feat(plugin-api): add useThemeOverrides hook" ### Task 4: Wire useThemeOverrides into App.tsx **Files:** + - Modify: `apps/desktop/src/renderer/App.tsx` **Step 1: Add the hook call** @@ -303,11 +334,12 @@ import { useThemeOverrides } from '@readied/plugin-api'; // Inside NotesApp component: usePerformanceMode(); useAppearanceSettings(); -useThemeOverrides(); // NEW — applies active theme tokens +useThemeOverrides(); // NEW — applies active theme tokens useCssVariables(); ``` Order matters: + 1. `useAppearanceSettings` sets base `data-theme` + accent 2. `useThemeOverrides` overrides with active theme tokens (may change `data-theme`) 3. `useCssVariables` applies individual plugin CSS vars on top @@ -324,12 +356,14 @@ git commit -m "feat(desktop): wire useThemeOverrides into app initialization" ### Task 5: Add registerTheme to PluginContext **Files:** + - Modify: `packages/plugin-api/src/types.ts` (add to PluginContext interface) - Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts` (implement in activate) **Step 1: Add to PluginContext interface** In `types.ts`, add to the `PluginContext` interface: + ```typescript /** Register a complete theme with validated tokens */ registerTheme(theme: { @@ -345,11 +379,13 @@ registerTheme(theme: { **Step 2: Implement in PluginRegistry.activate()** In `PluginRegistry.ts`, import `themeRegistryStore`: + ```typescript import { themeRegistryStore } from '../theme/themeRegistryStore'; ``` Add to the context object inside `activate()`: + ```typescript registerTheme: (theme): (() => void) => { themeRegistryStore.getState().register({ @@ -361,12 +397,14 @@ registerTheme: (theme): (() => void) => { ``` Add cleanup in `deactivate()` (after existing cleanup lines): + ```typescript // Cleanup theme registrations themeRegistryStore.getState().unregisterAll(id); ``` Also add cleanup in the catch block of `activate()` (error recovery): + ```typescript themeRegistryStore.getState().unregisterAll(id); ``` @@ -383,16 +421,19 @@ git commit -m "feat(plugin-api): add registerTheme to PluginContext" ### Task 6: nativeTheme sync — main process **Files:** + - Modify: `apps/desktop/src/main/index.ts` **Step 1: Add nativeTheme IPC handlers** At the top of the file, import nativeTheme: + ```typescript import { nativeTheme } from 'electron'; ``` Add IPC handlers (near other IPC handler registrations): + ```typescript // Theme — sync Electron nativeTheme with renderer ipcMain.on('theme:set-source', (_event, source: string) => { @@ -422,11 +463,13 @@ git commit -m "feat(desktop): add nativeTheme IPC sync in main process" ### Task 7: nativeTheme sync — preload API **Files:** + - Modify: `apps/desktop/src/preload/index.ts` **Step 1: Add theme methods to ReadiedAPI** In the `ReadiedAPI` interface, add: + ```typescript theme: { /** Set Electron's native theme source */ @@ -437,6 +480,7 @@ theme: { ``` In the `contextBridge.exposeInMainWorld('readied', ...)` implementation: + ```typescript theme: { setSource: (source: string) => { @@ -464,6 +508,7 @@ git commit -m "feat(desktop): add theme IPC bridge in preload" ### Task 8: Update useAppearanceSettings to use nativeTheme IPC **Files:** + - Modify: `apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` **Step 1: Replace media query listener with IPC** @@ -475,11 +520,17 @@ import { useEffect } from 'react'; import { useSettingsStore, selectAppearance } from '../stores/settings'; import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; -function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { +function applyAppearance( + theme: string, + accentColor: string, + zoomLevel: string, + isDark?: boolean +): void { let resolved: string; if (theme === 'system') { // Use provided isDark hint, or fall back to media query for initial render - resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + resolved = + (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; } else { resolved = theme; } @@ -527,7 +578,7 @@ export function useAppearanceSettings(): void { useEffect(() => { if (theme !== 'system') return; - const unsub = window.readied.theme.onSystemChanged((isDark) => { + const unsub = window.readied.theme.onSystemChanged(isDark => { applyAppearance('system', accentColor, zoomLevel, isDark); }); return unsub; @@ -547,17 +598,20 @@ git commit -m "feat(desktop): use nativeTheme IPC for system theme detection" ### Task 9: Add activeThemeId to AppearanceSettings **Files:** + - Modify: `apps/desktop/src/renderer/stores/settings/schema.ts` **Step 1: Add field** In `AppearanceSettings` interface: + ```typescript /** Active plugin theme ID (null = use base dark/light) */ activeThemeId: string | null; ``` In `DEFAULT_APPEARANCE`: + ```typescript activeThemeId: null, ``` @@ -578,17 +632,20 @@ git commit -m "feat(desktop): add activeThemeId to appearance settings" ### Task 10: Update AppearanceSection UI with theme selector **Files:** + - Modify: `apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx` **Step 1: Add theme selector (only shown when themes exist)** Import the theme registry: + ```typescript import { useSyncExternalStore } from 'react'; import { themeRegistryStore } from '@readied/plugin-api'; ``` Inside the component, subscribe to themes: + ```typescript const themeRegs = useSyncExternalStore( themeRegistryStore.subscribe, @@ -601,6 +658,7 @@ const activeThemeId = useSyncExternalStore( ``` Add handler: + ```typescript const handlePluginThemeChange = (value: string) => { const newId = value === 'default' ? null : value; @@ -610,6 +668,7 @@ const handlePluginThemeChange = (value: string) => { ``` Add UI below the accent color picker (inside the "Theme" SettingGroup), only if themes are registered: + ```typescript {themeRegs.length > 0 && ( { - const savedThemeId = appearance?.activeThemeId; - if (savedThemeId) { - themeRegistryStore.getState().setActive(savedThemeId); - } -}, [/* run once after plugin init */]); +useEffect( + () => { + const savedThemeId = appearance?.activeThemeId; + if (savedThemeId) { + themeRegistryStore.getState().setActive(savedThemeId); + } + }, + [ + /* run once after plugin init */ + ] +); ``` The exact placement depends on plugin loading lifecycle — the theme must be restored AFTER plugins have registered their themes. @@ -675,6 +740,7 @@ git commit -m "feat(desktop): restore active plugin theme on startup" ### Task 12: Tests for themeTypes validation **Files:** + - Create: `packages/plugin-api/tests/themeTypes.test.ts` **Step 1: Write tests** @@ -705,12 +771,15 @@ describe('isValidThemeToken', () => { describe('validateThemeTokens', () => { it('returns only valid tokens', () => { - const result = validateThemeTokens({ - '--bg-base': '#000', - '--text-primary': '#fff', - '--invalid-token': 'red', - '--syntax-keyword': '#f0f', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, + 'test-theme' + ); expect(result).toEqual({ '--bg-base': '#000', @@ -720,9 +789,12 @@ describe('validateThemeTokens', () => { }); it('returns empty object for all-invalid tokens', () => { - const result = validateThemeTokens({ - '--nope': 'red', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--nope': 'red', + }, + 'test-theme' + ); expect(result).toEqual({}); }); }); @@ -746,6 +818,7 @@ git commit -m "test(plugin-api): add theme token validation tests" ### Task 13: Tests for ThemeRegistry store **Files:** + - Create: `packages/plugin-api/tests/themeRegistryStore.test.ts` **Step 1: Write tests** @@ -780,17 +853,17 @@ describe('themeRegistryStore', () => { }); it('rejects theme with no valid tokens', () => { - const result = themeRegistryStore.getState().register( - makeTheme({ tokens: { '--invalid': 'red' } }) - ); + const result = themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--invalid': 'red' } })); expect(result).toBe(false); expect(themeRegistryStore.getState().themes).toHaveLength(0); }); it('strips invalid tokens but keeps valid ones', () => { - themeRegistryStore.getState().register( - makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) - ); + themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } })); const theme = themeRegistryStore.getState().themes[0]!; expect(theme.tokens).toEqual({ '--bg-base': '#000' }); }); diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 7c0e1964..d9f79e58 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -398,66 +398,71 @@ sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { } // Process changes in transaction - const { results: notebookResults, finalCursor: notebookFinalCursor } = await db.transaction(async tx => { - const [maxVersionResult] = await tx - .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) - .from(notebookSyncLog) - .where(eq(notebookSyncLog.userId, userId)); - - let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; - - const txResults: Array<{ - notebookId: string; - version: number; - status: 'applied' | 'conflict'; - serverVersion?: number; - }> = []; - - for (const change of changes) { - const [latestEntry] = await tx - .select() + const { results: notebookResults, finalCursor: notebookFinalCursor } = await db.transaction( + async tx => { + const [maxVersionResult] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) .from(notebookSyncLog) - .where( - and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId)) - ) - .orderBy(desc(notebookSyncLog.version)) - .limit(1); + .where(eq(notebookSyncLog.userId, userId)); + + let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; + + const txResults: Array<{ + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; + }> = []; + + for (const change of changes) { + const [latestEntry] = await tx + .select() + .from(notebookSyncLog) + .where( + and( + eq(notebookSyncLog.userId, userId), + eq(notebookSyncLog.notebookId, change.notebookId) + ) + ) + .orderBy(desc(notebookSyncLog.version)) + .limit(1); + + if ( + latestEntry && + latestEntry.deviceId !== deviceId && + change.localVersion !== undefined && + latestEntry.version > change.localVersion + ) { + txResults.push({ + notebookId: change.notebookId, + version: latestEntry.version, + status: 'conflict', + serverVersion: latestEntry.version, + }); + continue; + } - if ( - latestEntry && - latestEntry.deviceId !== deviceId && - change.localVersion !== undefined && - latestEntry.version > change.localVersion - ) { - txResults.push({ + await tx.insert(notebookSyncLog).values({ + userId, notebookId: change.notebookId, - version: latestEntry.version, - status: 'conflict', - serverVersion: latestEntry.version, + version: nextVersion, + operation: change.operation, + data: change.data ?? null, + deviceId, }); - continue; - } - await tx.insert(notebookSyncLog).values({ - userId, - notebookId: change.notebookId, - version: nextVersion, - operation: change.operation, - data: change.data ?? null, - deviceId, - }); + txResults.push({ + notebookId: change.notebookId, + version: nextVersion, + status: 'applied', + }); - txResults.push({ - notebookId: change.notebookId, - version: nextVersion, - status: 'applied', - }); + nextVersion++; + } - nextVersion++; + return { results: txResults, finalCursor: nextVersion - 1 }; } - - return { results: txResults, finalCursor: nextVersion - 1 }; - }); + ); return c.json({ results: notebookResults, cursor: notebookFinalCursor }); }); diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 5eab953f..151d4a38 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -48,7 +48,12 @@ export type { CssVariableRegistration } from './theme/cssVariableStore'; export { useCssVariables } from './theme/useCssVariables'; export { useThemeOverrides } from './theme/useThemeOverrides'; export { themeRegistryStore } from './theme/themeRegistryStore'; -export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export { + isValidThemeToken, + validateThemeTokens, + CORE_THEME_TOKENS, + THEME_EXTENSION_SCOPES, +} from './theme/themeTypes'; export type { ThemeDefinition } from './theme/themeTypes'; // Validation diff --git a/packages/plugin-api/src/theme/themeRegistryStore.ts b/packages/plugin-api/src/theme/themeRegistryStore.ts index eb8b4ff2..61a41947 100644 --- a/packages/plugin-api/src/theme/themeRegistryStore.ts +++ b/packages/plugin-api/src/theme/themeRegistryStore.ts @@ -33,7 +33,9 @@ export const themeRegistryStore = createStore((set, get) => set(state => ({ themes: [...state.themes.filter(t => t.id !== theme.id), validated], })); - console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + console.debug( + `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` + ); return true; }, @@ -47,7 +49,8 @@ export const themeRegistryStore = createStore((set, get) => unregisterAll(pluginId) { set(state => { const remaining = state.themes.filter(t => t.pluginId !== pluginId); - const activeRemoved = state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); + const activeRemoved = + state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); return { themes: remaining, activeThemeId: activeRemoved ? null : state.activeThemeId, diff --git a/packages/plugin-api/src/theme/themeTypes.ts b/packages/plugin-api/src/theme/themeTypes.ts index 4d52ac6b..8a2158d1 100644 --- a/packages/plugin-api/src/theme/themeTypes.ts +++ b/packages/plugin-api/src/theme/themeTypes.ts @@ -6,13 +6,31 @@ /** Core CSS tokens that themes are allowed to override */ export const CORE_THEME_TOKENS = [ - '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', - '--text-primary', '--text-secondary', '--text-muted', '--text-faint', - '--border', '--border-subtle', '--border-strong', - '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', - '--danger', '--danger-muted', '--warning', '--warning-muted', - '--success', '--success-muted', - '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', + '--bg-base', + '--bg-surface', + '--bg-elevated', + '--bg-inset', + '--text-primary', + '--text-secondary', + '--text-muted', + '--text-faint', + '--border', + '--border-subtle', + '--border-strong', + '--glass-bg', + '--glass-border', + '--glass-bg-menu', + '--glass-border-menu', + '--danger', + '--danger-muted', + '--warning', + '--warning-muted', + '--success', + '--success-muted', + '--status-active', + '--status-on-hold', + '--status-completed', + '--status-dropped', ] as const; /** Valid extension scope prefixes for non-core tokens */ diff --git a/packages/plugin-api/tests/themeRegistryStore.test.ts b/packages/plugin-api/tests/themeRegistryStore.test.ts index f52cb399..aedf3119 100644 --- a/packages/plugin-api/tests/themeRegistryStore.test.ts +++ b/packages/plugin-api/tests/themeRegistryStore.test.ts @@ -26,17 +26,17 @@ describe('themeRegistryStore', () => { }); it('rejects theme with no valid tokens', () => { - const result = themeRegistryStore.getState().register( - makeTheme({ tokens: { '--invalid': 'red' } }) - ); + const result = themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--invalid': 'red' } })); expect(result).toBe(false); expect(themeRegistryStore.getState().themes).toHaveLength(0); }); it('strips invalid tokens but keeps valid ones', () => { - themeRegistryStore.getState().register( - makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) - ); + themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } })); const theme = themeRegistryStore.getState().themes[0]!; expect(theme.tokens).toEqual({ '--bg-base': '#000' }); }); diff --git a/packages/plugin-api/tests/themeTypes.test.ts b/packages/plugin-api/tests/themeTypes.test.ts index 319431f4..d9bad65c 100644 --- a/packages/plugin-api/tests/themeTypes.test.ts +++ b/packages/plugin-api/tests/themeTypes.test.ts @@ -25,12 +25,15 @@ describe('isValidThemeToken', () => { describe('validateThemeTokens', () => { it('returns only valid tokens', () => { - const result = validateThemeTokens({ - '--bg-base': '#000', - '--text-primary': '#fff', - '--invalid-token': 'red', - '--syntax-keyword': '#f0f', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, + 'test-theme' + ); expect(result).toEqual({ '--bg-base': '#000', diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 476e1f6f..71acdfd0 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -846,7 +846,12 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { /** * Get tags with pending sync changes */ - getTagsPendingSync(limit: number): Array<{ tag: { id: number; uuid: string; name: string; color: string | null }; localVersion: number }> { + getTagsPendingSync( + limit: number + ): Array<{ + tag: { id: number; uuid: string; name: string; color: string | null }; + localVersion: number; + }> { const stmt = this.db.prepare(` SELECT id, uuid, name, color, local_version FROM tags @@ -900,13 +905,16 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { const normalized = name.trim().toLowerCase(); // Check if tag exists by UUID first - const byUuid = this.db.prepare('SELECT id, name, color FROM tags WHERE uuid = ?').get(uuid) as - | { id: number; name: string; color: string | null } - | undefined; + const byUuid = this.db + .prepare('SELECT id, name, color FROM tags WHERE uuid = ?') + .get(uuid) as { id: number; name: string; color: string | null } | undefined; if (byUuid) { // Update existing tag - this.db.prepare('UPDATE tags SET name = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE uuid = ?') + this.db + .prepare( + 'UPDATE tags SET name = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE uuid = ?' + ) .run(normalized, color, new Date().toISOString(), uuid); return byUuid.id; } @@ -918,13 +926,19 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { if (byName) { // Merge: adopt the remote UUID, update color - this.db.prepare('UPDATE tags SET uuid = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE id = ?') + this.db + .prepare( + 'UPDATE tags SET uuid = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE id = ?' + ) .run(uuid, color, new Date().toISOString(), byName.id); return byName.id; } // Create new tag - const result = this.db.prepare('INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)') + const result = this.db + .prepare( + 'INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)' + ) .run(normalized, color, uuid, new Date().toISOString()); return Number(result.lastInsertRowid); }); From 1e1a44c8f4aa34c61f9b1b08ce2755d4069327ed Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 16:47:07 -0300 Subject: [PATCH 069/148] docs: add Phase 2 completion design (2.4 + 2.6) Co-Authored-By: Claude Opus 4.6 --- .../2026-03-11-phase2-completion-design.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/plans/2026-03-11-phase2-completion-design.md diff --git a/docs/plans/2026-03-11-phase2-completion-design.md b/docs/plans/2026-03-11-phase2-completion-design.md new file mode 100644 index 00000000..ed416127 --- /dev/null +++ b/docs/plans/2026-03-11-phase2-completion-design.md @@ -0,0 +1,69 @@ +# Phase 2 Completion Design (2.4 + 2.6) + +## Goal + +Close the remaining gaps in Phase 2 (Plugin System) — fix the pluginScanner type bug, add config value validation, and build a dev-mode Plugin Inspector. + +## What Already Exists + +### 2.4 Plugin Config Auto-Generated UI (90% done) + +- `PluginConfigSchemaField` in `plugin-api/src/types.ts` supports all 5 types: string, number, boolean, enum, range +- UI controls in `controls.tsx`: Toggle, TextInput, NumberInput, RangeInput, Select +- Auto-generated form in `PluginsSection.tsx` renders all field types, persists via IPC +- Config storage in SQLite (`plugin_config` table) +- Built-in examples: focusMode (boolean), aiAssistant (enum + range) + +### 2.6 Plugin Hot Reload (80% done) + +- `pluginWatcher.ts`: fs.watch with 300ms debounce, dev-mode only +- IPC broadcast `plugins:reload` to all windows on file change +- `pluginRuntimeStore.ts` handles reload by re-scanning filesystem +- Dev mode detection via `process.env.NODE_ENV === 'development'` + +## What's Needed + +### 2.4a — Fix pluginScanner type bug + +`pluginScanner.ts` defines its own `PluginConfigSchemaField` with only `string | number | boolean`. Missing `enum` and `range` types plus their metadata fields (`options`, `min`, `max`, `step`). Community plugins with enum/range configs would have their schema silently narrowed. + +**Fix:** Update the type to match `plugin-api/src/types.ts`. + +### 2.4b — Config value validation + +No validation exists when config values are saved. The UI controls provide soft constraints (HTML min/max, select options), but nothing prevents invalid values from reaching storage if a plugin bypasses the UI or data is corrupted. + +**Fix:** Add `validateConfigValue(field, value)` to `plugin-api/src/validation.ts`. Hook into `handleConfigChange` in `PluginsSection.tsx` as a safety net. Reject with console warning (no UI error needed — controls already constrain input). + +Validation rules: +- boolean: `typeof value === 'boolean'` +- string: `typeof value === 'string'` +- number: `typeof value === 'number'` + optional min/max bounds +- enum: value exists in `field.options` +- range: `typeof value === 'number'` + min/max bounds + +### 2.6 — Plugin Inspector (dev mode only) + +A collapsible section in Settings > Plugins, visible only in dev mode. Shows: +- Loaded plugins with status badges (active/error/disabled) +- Error details for failed plugins +- "Force Reload All" button (wired to existing `plugins:requestReload` IPC) +- Load time per plugin + +**Not building (YAGNI):** real-time log streaming, per-plugin reload, memory profiling, console interception, separate dev tools window. + +## Architecture + +No new stores or IPC channels needed. All changes build on existing infrastructure: + +- Validation function is pure (no side effects), testable in isolation +- Plugin Inspector reads from existing `pluginRuntimeStore` (plugins, errors) +- Load timing added as metadata in `pluginRuntimeStore` during scan +- Dev mode check uses existing `window.readied.isDevelopment` flag + +## Not Included + +- Config value sync across devices (future — post sync hardening) +- Advanced schema features (regex patterns, dependent fields, multiselect) +- Plugin permissions/sandboxing (Phase 5) +- Per-plugin crash counter and auto-disable (already covered by error isolation in 2.5) From 5af641914720c7baa5fa5c2ea5d357c879233847 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 16:51:17 -0300 Subject: [PATCH 070/148] docs: add Phase 2 completion implementation plan (2.4 + 2.6) Co-Authored-By: Claude Opus 4.6 --- ...-03-11-phase2-completion-implementation.md | 705 ++++++++++++++++++ 1 file changed, 705 insertions(+) create mode 100644 docs/plans/2026-03-11-phase2-completion-implementation.md diff --git a/docs/plans/2026-03-11-phase2-completion-implementation.md b/docs/plans/2026-03-11-phase2-completion-implementation.md new file mode 100644 index 00000000..075ab818 --- /dev/null +++ b/docs/plans/2026-03-11-phase2-completion-implementation.md @@ -0,0 +1,705 @@ +# Phase 2 Completion Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close remaining Phase 2 gaps — fix pluginScanner type bug, add config value validation, and build a dev-mode Plugin Inspector. + +**Architecture:** Three independent changes: (1) align pluginScanner types with plugin-api types, (2) add a pure validation function for config values and hook it into PluginsSection, (3) add a collapsible dev-only Plugin Inspector panel in PluginsSection showing loaded/failed plugins with timing data. + +**Tech Stack:** TypeScript, React, Zustand (vanilla store), Vitest, CSS Modules + +--- + +### Task 1: Fix pluginScanner Type Bug + +**Files:** +- Modify: `apps/desktop/src/main/pluginScanner.ts` + +**Context:** The `PluginConfigSchemaField` type in `pluginScanner.ts` (line 11-15) only has `'string' | 'number' | 'boolean'` but the canonical type in `packages/plugin-api/src/types.ts` (line 70-82) also has `'enum' | 'range'` with `options`, `min`, `max`, `step` fields. Community plugins with enum/range configs would have their schema silently narrowed. + +**Step 1: Update the type** + +In `apps/desktop/src/main/pluginScanner.ts`, replace the `PluginConfigSchemaField` interface (lines 11-15) with: + +```typescript +export interface PluginConfigSchemaField { + type: 'string' | 'number' | 'boolean' | 'enum' | 'range'; + default: unknown; + description?: string; + /** For 'enum' type: available options */ + options?: Array<{ value: string; label: string }>; + /** For 'range' type: minimum value */ + min?: number; + /** For 'range' type: maximum value */ + max?: number; + /** For 'range' type: step increment */ + step?: number; +} +``` + +**Step 2: Verify build** + +Run: `pnpm --filter @readied/desktop typecheck` + +**Step 3: Commit** + +```bash +git add apps/desktop/src/main/pluginScanner.ts +git commit -m "fix(plugins): add enum and range to pluginScanner config schema type" +``` + +--- + +### Task 2: Add Config Value Validation Function + +**Files:** +- Modify: `packages/plugin-api/src/validation.ts` +- Modify: `packages/plugin-api/tests/validation.test.ts` + +**Context:** Config values are persisted without type checking. The UI controls provide soft constraints via HTML, but nothing prevents invalid values at the storage layer. We add a pure `validateConfigValue()` function. + +**Step 1: Write the tests** + +In `packages/plugin-api/tests/validation.test.ts`, add a new describe block at the end of the file: + +```typescript +describe('validateConfigValue', () => { + it('accepts valid boolean', () => { + const field: PluginConfigSchemaField = { type: 'boolean', default: false }; + expect(validateConfigValue(field, true)).toEqual({ valid: true }); + }); + + it('rejects non-boolean for boolean field', () => { + const field: PluginConfigSchemaField = { type: 'boolean', default: false }; + const result = validateConfigValue(field, 'yes'); + expect(result.valid).toBe(false); + }); + + it('accepts valid string', () => { + const field: PluginConfigSchemaField = { type: 'string', default: '' }; + expect(validateConfigValue(field, 'hello')).toEqual({ valid: true }); + }); + + it('rejects non-string for string field', () => { + const field: PluginConfigSchemaField = { type: 'string', default: '' }; + const result = validateConfigValue(field, 42); + expect(result.valid).toBe(false); + }); + + it('accepts valid number', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 0 }; + expect(validateConfigValue(field, 5)).toEqual({ valid: true }); + }); + + it('rejects non-number for number field', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 0 }; + const result = validateConfigValue(field, 'five'); + expect(result.valid).toBe(false); + }); + + it('rejects number below min', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 5, min: 1 }; + const result = validateConfigValue(field, 0); + expect(result.valid).toBe(false); + }); + + it('rejects number above max', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 5, max: 10 }; + const result = validateConfigValue(field, 15); + expect(result.valid).toBe(false); + }); + + it('accepts valid enum value', () => { + const field: PluginConfigSchemaField = { + type: 'enum', + default: 'a', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }; + expect(validateConfigValue(field, 'a')).toEqual({ valid: true }); + }); + + it('rejects invalid enum value', () => { + const field: PluginConfigSchemaField = { + type: 'enum', + default: 'a', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }; + const result = validateConfigValue(field, 'c'); + expect(result.valid).toBe(false); + }); + + it('rejects enum with no options defined', () => { + const field: PluginConfigSchemaField = { type: 'enum', default: 'a' }; + const result = validateConfigValue(field, 'a'); + expect(result.valid).toBe(false); + }); + + it('accepts valid range value', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + expect(validateConfigValue(field, 5)).toEqual({ valid: true }); + }); + + it('rejects range below min', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, -1); + expect(result.valid).toBe(false); + }); + + it('rejects range above max', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, 11); + expect(result.valid).toBe(false); + }); + + it('rejects non-number for range field', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, 'five'); + expect(result.valid).toBe(false); + }); +}); +``` + +Add the import at the top alongside existing imports: + +```typescript +import { validateManifest, assertValidManifest, validateConfigValue } from '../src/validation'; +import type { PluginConfigSchemaField } from '../src/types'; +``` + +**Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @readied/plugin-api test` +Expected: FAIL — `validateConfigValue` is not exported yet. + +**Step 3: Implement the validation function** + +In `packages/plugin-api/src/validation.ts`, add the import at the top: + +```typescript +import type { PluginManifest, PluginConfigSchemaField } from './types'; +``` + +(Replace the existing `import type { PluginManifest } from './types';`) + +Add at the end of the file: + +```typescript +export interface ConfigValidationResult { + valid: boolean; + reason?: string; +} + +/** + * Validate a config value against its schema field definition. + * + * Pure function — no side effects. Returns { valid: true } or { valid: false, reason }. + */ +export function validateConfigValue( + field: PluginConfigSchemaField, + value: unknown +): ConfigValidationResult { + switch (field.type) { + case 'boolean': + if (typeof value !== 'boolean') { + return { valid: false, reason: `Expected boolean, got ${typeof value}` }; + } + return { valid: true }; + + case 'string': + if (typeof value !== 'string') { + return { valid: false, reason: `Expected string, got ${typeof value}` }; + } + return { valid: true }; + + case 'number': + if (typeof value !== 'number') { + return { valid: false, reason: `Expected number, got ${typeof value}` }; + } + if (field.min !== undefined && value < field.min) { + return { valid: false, reason: `Value ${value} is below minimum ${field.min}` }; + } + if (field.max !== undefined && value > field.max) { + return { valid: false, reason: `Value ${value} is above maximum ${field.max}` }; + } + return { valid: true }; + + case 'enum': + if (!field.options || field.options.length === 0) { + return { valid: false, reason: 'Enum field has no options defined' }; + } + if (!field.options.some(opt => opt.value === value)) { + return { valid: false, reason: `Value "${value}" is not a valid option` }; + } + return { valid: true }; + + case 'range': + if (typeof value !== 'number') { + return { valid: false, reason: `Expected number, got ${typeof value}` }; + } + if (field.min !== undefined && value < field.min) { + return { valid: false, reason: `Value ${value} is below minimum ${field.min}` }; + } + if (field.max !== undefined && value > field.max) { + return { valid: false, reason: `Value ${value} is above maximum ${field.max}` }; + } + return { valid: true }; + + default: + return { valid: false, reason: `Unknown field type: ${(field as PluginConfigSchemaField).type}` }; + } +} +``` + +**Step 4: Export from package** + +In `packages/plugin-api/src/index.ts`, find the existing validation export line and update it: + +```typescript +export { validateManifest, assertValidManifest, validateConfigValue } from './validation.js'; +``` + +If this export doesn't exist yet, add it. + +**Step 5: Run tests to verify they pass** + +Run: `pnpm --filter @readied/plugin-api test` +Expected: All tests pass (existing + 15 new). + +**Step 6: Commit** + +```bash +git add packages/plugin-api/src/validation.ts packages/plugin-api/src/index.ts packages/plugin-api/tests/validation.test.ts +git commit -m "feat(plugin-api): add validateConfigValue for config schema enforcement" +``` + +--- + +### Task 3: Wire Validation into PluginsSection + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx` + +**Context:** The `handleConfigChange` callback (line 487-493) saves config values directly without validation. We add a validation check that logs and skips invalid values. + +**Step 1: Add import** + +In `PluginsSection.tsx`, update the import from preload to also import the validation function. Since PluginsSection imports types from `preload/index`, and we need the validation function from `@readied/plugin-api`: + +```typescript +import { validateConfigValue } from '@readied/plugin-api'; +``` + +Add this alongside the existing imports at the top of the file. + +**Step 2: Add validation to handleConfigChange** + +Replace the `handleConfigChange` callback (lines 487-493) with: + +```typescript +const handleConfigChange = useCallback(async (pluginId: string, key: string, value: unknown) => { + // Find the schema field for validation + const schema = BUILT_IN_CONFIG_SCHEMAS[pluginId] ?? plugins.find(p => p.id === pluginId)?.configSchema; + const field = schema?.[key]; + + if (field) { + const result = validateConfigValue(field, value); + if (!result.valid) { + console.warn(`[plugin:${pluginId}] Invalid config value for "${key}": ${result.reason}`); + return; + } + } + + await window.readied.pluginConfig.set(pluginId, key, value); + setConfigValues(prev => ({ + ...prev, + [pluginId]: { ...prev[pluginId], [key]: value }, + })); +}, [plugins]); +``` + +Note: `plugins` is now in the dependency array since we access it for community plugin schemas. + +**Step 3: Verify build** + +Run: `pnpm --filter @readied/desktop typecheck` + +**Step 4: Commit** + +```bash +git add apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx +git commit -m "feat(plugins): validate config values before persisting" +``` + +--- + +### Task 4: Add Load Timing to pluginRuntimeStore + +**Files:** +- Modify: `apps/desktop/src/renderer/stores/pluginRuntimeStore.ts` + +**Context:** The Plugin Inspector needs to show how long each plugin took to load. We add timing metadata to the scan results. + +**Step 1: Add timing interface and tracking** + +In `pluginRuntimeStore.ts`, add a new interface after `PluginLoadError` (line 16-20): + +```typescript +export interface PluginLoadTiming { + pluginId: string; + pluginName: string; + loadTimeMs: number; +} +``` + +Add `timings` to `PluginRuntimeState` (after `errors` on line 28): + +```typescript +/** Load timing for each plugin */ +timings: PluginLoadTiming[]; +``` + +Update the `executeScan` return type (line 56) to include timings: + +```typescript +async function executeScan(generation: number): Promise<{ + plugins: PluginManifest[]; + errors: PluginLoadError[]; + timings: PluginLoadTiming[]; +} | null> { +``` + +Inside `executeScan`, add a `timings` array after `errors`: + +```typescript +const timings: PluginLoadTiming[] = []; +``` + +In the `for (const sp of scanned)` loop (line 74-88), wrap the `loadPluginFromSource` call with timing: + +```typescript +for (const sp of scanned) { + const enabled = stateMap.get(sp.id) ?? true; + if (!enabled) continue; + + const start = performance.now(); + const manifest = loadPluginFromSource(sp.code, sp.id); + const elapsed = performance.now() - start; + + if (manifest) { + plugins.push(manifest); + timings.push({ pluginId: sp.id, pluginName: sp.name, loadTimeMs: elapsed }); + } else { + errors.push({ + pluginId: sp.id, + pluginName: sp.name, + reason: 'Failed to load plugin code', + }); + } +} +``` + +Update the return statement (line 110) to include timings: + +```typescript +return { plugins, errors, timings }; +``` + +Update the initial state in `createStore` to include `timings: []`. + +Update both `init()` and `reload()` to set `timings` from result: + +```typescript +set({ plugins: result.plugins, errors: result.errors, timings: result.timings, status: 'ready' }); +``` + +**Step 2: Verify build** + +Run: `pnpm --filter @readied/desktop typecheck` + +**Step 3: Commit** + +```bash +git add apps/desktop/src/renderer/stores/pluginRuntimeStore.ts +git commit -m "feat(plugins): track load timing per plugin in runtime store" +``` + +--- + +### Task 5: Build Plugin Inspector Component + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx` +- Modify: `apps/desktop/src/renderer/pages/settings/sections/Section.module.css` + +**Context:** A collapsible "Developer" section at the bottom of the Plugins settings page, shown only when `import.meta.env.DEV` is true. Shows loaded plugins with timing, errors, and a "Force Reload" button. + +**Step 1: Add the PluginInspector component** + +In `PluginsSection.tsx`, add a new component before the `PluginsSection` function. Import from the runtime store: + +```typescript +import { useSyncExternalStore } from 'react'; +import { pluginRuntimeStore } from '../../../stores/pluginRuntimeStore'; +import type { PluginLoadError, PluginLoadTiming } from '../../../stores/pluginRuntimeStore'; +``` + +Also import the `AlertTriangle` icon from lucide-react: + +```typescript +import { RefreshCw, FolderOpen, ChevronDown, Download, Trash2, Search, Check, AlertTriangle } from 'lucide-react'; +``` + +Add the PluginInspector component: + +```tsx +function PluginInspector() { + const [open, setOpen] = useState(false); + + const status = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().status + ); + const errors = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().errors + ); + const timings = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().timings + ); + const pluginCount = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().plugins.length + ); + + const handleForceReload = useCallback(() => { + window.readied.plugins.requestReload(); + }, []); + + return ( +
+ + + {open && ( +
+ {/* Status summary */} +
+ Status + {status === 'scanning' ? 'Scanning...' : `${pluginCount} loaded`} +
+ + {/* Timings table */} + {timings.length > 0 && ( +
+
Load times
+ + + + + + + + + {timings.map(t => ( + + + + + ))} + +
PluginTime
{t.pluginName}{t.loadTimeMs < 1 ? '<1' : Math.round(t.loadTimeMs)}ms
+
+ )} + + {/* Errors */} + {errors.length > 0 && ( +
+
Errors
+ {errors.map(err => ( +
+ {err.pluginName} + {err.reason} +
+ ))} +
+ )} + + {/* Actions */} +
+ +
+
+ )} +
+ ); +} +``` + +**Step 2: Render the inspector in PluginsSection** + +In the `PluginsSection` function, add the inspector at the bottom, after the `{activeTab === 'browse' && }` line (line 701), inside the section div: + +```tsx +{import.meta.env.DEV && } +``` + +**Step 3: Add CSS styles** + +In `Section.module.css`, add at the end: + +```css +/* Plugin Inspector (dev mode) */ +.inspectorPanel { + margin-top: 2rem; + border-top: 1px solid var(--border-subtle); + padding-top: 1rem; +} + +.inspectorToggle { + display: flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: var(--text-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + padding: 0.25rem 0; +} + +.inspectorToggle:hover { + color: var(--text-secondary); +} + +.inspectorErrorBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--status-error, #ef4444); + font-weight: 600; +} + +.inspectorContent { + margin-top: 0.75rem; + font-size: var(--text-sm); +} + +.inspectorRow { + display: flex; + justify-content: space-between; + padding: 0.375rem 0; + color: var(--text-secondary); +} + +.inspectorLabel { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.375rem; +} + +.inspectorTimings { + margin-top: 0.75rem; +} + +.inspectorTable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.inspectorTable th { + text-align: left; + color: var(--text-muted); + font-weight: 500; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border-subtle); +} + +.inspectorTable td { + padding: 0.25rem 0.5rem; + color: var(--text-secondary); +} + +.inspectorTable td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.inspectorErrors { + margin-top: 0.75rem; +} + +.inspectorError { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.06); + border: 1px solid rgba(239, 68, 68, 0.15); + border-radius: 0.375rem; + margin-bottom: 0.375rem; + font-size: var(--text-sm); +} + +.inspectorError strong { + color: var(--text-primary); + font-weight: 500; +} + +.inspectorError span { + color: var(--text-muted); + font-size: var(--text-xs); +} +``` + +**Step 4: Verify build** + +Run: `pnpm --filter @readied/desktop typecheck` + +**Step 5: Commit** + +```bash +git add apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx apps/desktop/src/renderer/pages/settings/sections/Section.module.css +git commit -m "feat(plugins): add dev-mode Plugin Inspector with load timings and error display" +``` + +--- + +### Summary + +| Task | What | Files | +|------|------|-------| +| 1 | Fix pluginScanner type bug | `pluginScanner.ts` | +| 2 | Config value validation function + tests | `validation.ts`, `validation.test.ts` | +| 3 | Wire validation into PluginsSection | `PluginsSection.tsx` | +| 4 | Load timing in pluginRuntimeStore | `pluginRuntimeStore.ts` | +| 5 | Plugin Inspector component (dev mode) | `PluginsSection.tsx`, `Section.module.css` | From 71bf47332e3b188652b57231f32e7c163ec8783d Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 19:20:03 -0300 Subject: [PATCH 071/148] fix(plugins): add enum and range to pluginScanner config schema type Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/pluginScanner.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/pluginScanner.ts b/apps/desktop/src/main/pluginScanner.ts index b8c3be45..6b07fb05 100644 --- a/apps/desktop/src/main/pluginScanner.ts +++ b/apps/desktop/src/main/pluginScanner.ts @@ -9,9 +9,17 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join } from 'path'; export interface PluginConfigSchemaField { - type: 'string' | 'number' | 'boolean'; + type: 'string' | 'number' | 'boolean' | 'enum' | 'range'; default: unknown; description?: string; + /** For 'enum' type: available options */ + options?: Array<{ value: string; label: string }>; + /** For 'range' type: minimum value */ + min?: number; + /** For 'range' type: maximum value */ + max?: number; + /** For 'range' type: step increment */ + step?: number; } export interface ScannedPlugin { From 249a2ad04595359dd2cf0af633f0bd3548f6451b Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 19:21:41 -0300 Subject: [PATCH 072/148] feat(plugin-api): add validateConfigValue for config schema enforcement Adds a pure validateConfigValue() function that validates config values against their PluginConfigSchemaField definitions, covering boolean, string, number (with min/max), enum (with options membership), and range (with min/max) types. Includes 15 TDD test cases. Co-Authored-By: Claude Opus 4.6 --- packages/plugin-api/src/index.ts | 4 +- packages/plugin-api/src/validation.ts | 67 ++++++++++- packages/plugin-api/tests/validation.test.ts | 120 ++++++++++++++++++- 3 files changed, 186 insertions(+), 5 deletions(-) diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 85e484ee..cccc1451 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -48,8 +48,8 @@ export type { CssVariableRegistration } from './theme/cssVariableStore'; export { useCssVariables } from './theme/useCssVariables'; // Validation -export { validateManifest, assertValidManifest } from './validation'; -export type { ManifestError } from './validation'; +export { validateManifest, assertValidManifest, validateConfigValue } from './validation'; +export type { ManifestError, ConfigValidationResult } from './validation'; // Loader export { loadPluginFromSource } from './loader/loadPluginFromSource'; diff --git a/packages/plugin-api/src/validation.ts b/packages/plugin-api/src/validation.ts index 30b72fe1..05cc4547 100644 --- a/packages/plugin-api/src/validation.ts +++ b/packages/plugin-api/src/validation.ts @@ -1,4 +1,4 @@ -import type { PluginManifest } from './types'; +import type { PluginManifest, PluginConfigSchemaField } from './types'; /** Kebab-case: lowercase letters, digits, hyphens. No leading/trailing hyphens. */ const KEBAB_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; @@ -75,3 +75,68 @@ export function assertValidManifest( } return manifest as PluginManifest; } + +export interface ConfigValidationResult { + valid: boolean; + reason?: string; +} + +/** + * Validate a config value against its schema field definition. + * + * Returns `{ valid: true }` or `{ valid: false, reason: string }`. + */ +export function validateConfigValue( + field: PluginConfigSchemaField, + value: unknown +): ConfigValidationResult { + switch (field.type) { + case 'boolean': + if (typeof value !== 'boolean') { + return { valid: false, reason: `Expected boolean, got ${typeof value}` }; + } + return { valid: true }; + + case 'string': + if (typeof value !== 'string') { + return { valid: false, reason: `Expected string, got ${typeof value}` }; + } + return { valid: true }; + + case 'number': + if (typeof value !== 'number') { + return { valid: false, reason: `Expected number, got ${typeof value}` }; + } + if (field.min !== undefined && value < field.min) { + return { valid: false, reason: `Value ${value} is below minimum ${field.min}` }; + } + if (field.max !== undefined && value > field.max) { + return { valid: false, reason: `Value ${value} is above maximum ${field.max}` }; + } + return { valid: true }; + + case 'enum': + if (!field.options || field.options.length === 0) { + return { valid: false, reason: 'Enum field has no options defined' }; + } + if (!field.options.some((opt) => opt.value === value)) { + return { valid: false, reason: `Value "${String(value)}" is not a valid option` }; + } + return { valid: true }; + + case 'range': + if (typeof value !== 'number') { + return { valid: false, reason: `Expected number for range, got ${typeof value}` }; + } + if (field.min !== undefined && value < field.min) { + return { valid: false, reason: `Value ${value} is below minimum ${field.min}` }; + } + if (field.max !== undefined && value > field.max) { + return { valid: false, reason: `Value ${value} is above maximum ${field.max}` }; + } + return { valid: true }; + + default: + return { valid: false, reason: `Unknown field type: ${(field as PluginConfigSchemaField).type}` }; + } +} diff --git a/packages/plugin-api/tests/validation.test.ts b/packages/plugin-api/tests/validation.test.ts index 69abd30c..b4229298 100644 --- a/packages/plugin-api/tests/validation.test.ts +++ b/packages/plugin-api/tests/validation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { validateManifest, assertValidManifest } from '../src/validation'; -import type { PluginManifest } from '../src/types'; +import { validateManifest, assertValidManifest, validateConfigValue } from '../src/validation'; +import type { PluginManifest, PluginConfigSchemaField } from '../src/types'; function makeManifest(overrides: Partial = {}): PluginManifest { return { @@ -146,3 +146,119 @@ describe('assertValidManifest', () => { expect(log).not.toHaveBeenCalled(); }); }); + +describe('validateConfigValue', () => { + // Boolean + it('accepts valid boolean', () => { + const field: PluginConfigSchemaField = { type: 'boolean', default: false }; + expect(validateConfigValue(field, true)).toEqual({ valid: true }); + }); + + it('rejects non-boolean for boolean field', () => { + const field: PluginConfigSchemaField = { type: 'boolean', default: false }; + const result = validateConfigValue(field, 'yes'); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + // String + it('accepts valid string', () => { + const field: PluginConfigSchemaField = { type: 'string', default: '' }; + expect(validateConfigValue(field, 'hello')).toEqual({ valid: true }); + }); + + it('rejects non-string for string field', () => { + const field: PluginConfigSchemaField = { type: 'string', default: '' }; + const result = validateConfigValue(field, 42); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + // Number + it('accepts valid number', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 0 }; + expect(validateConfigValue(field, 5)).toEqual({ valid: true }); + }); + + it('rejects non-number for number field', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 0 }; + const result = validateConfigValue(field, 'five'); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('rejects number below min', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 5, min: 0 }; + const result = validateConfigValue(field, -1); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('rejects number above max', () => { + const field: PluginConfigSchemaField = { type: 'number', default: 5, max: 10 }; + const result = validateConfigValue(field, 11); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + // Enum + it('accepts valid enum value', () => { + const field: PluginConfigSchemaField = { + type: 'enum', + default: 'a', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }; + expect(validateConfigValue(field, 'a')).toEqual({ valid: true }); + }); + + it('rejects invalid enum value', () => { + const field: PluginConfigSchemaField = { + type: 'enum', + default: 'a', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }; + const result = validateConfigValue(field, 'c'); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('rejects enum with no options defined', () => { + const field: PluginConfigSchemaField = { type: 'enum', default: 'a' }; + const result = validateConfigValue(field, 'a'); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + // Range + it('accepts valid range value', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + expect(validateConfigValue(field, 5)).toEqual({ valid: true }); + }); + + it('rejects range below min', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, -1); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('rejects range above max', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, 11); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('rejects non-number for range field', () => { + const field: PluginConfigSchemaField = { type: 'range', default: 5, min: 0, max: 10 }; + const result = validateConfigValue(field, 'five'); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); +}); From 801a738030f4742952c514924f44cfe0339a32f2 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 19:31:13 -0300 Subject: [PATCH 073/148] feat(plugins): validate config values before persisting Co-Authored-By: Claude Opus 4.6 --- .../pages/settings/sections/PluginsSection.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx index 03ba22c6..bc0cfa3c 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { RefreshCw, FolderOpen, ChevronDown, Download, Trash2, Search, Check } from 'lucide-react'; import type { PluginConfigSchemaField } from '../../../../preload/index'; +import { validateConfigValue } from '@readied/plugin-api'; import { Toggle, TextInput, NumberInput, RangeInput, Select } from '../components/controls'; import { builtInPlugins } from '../../../plugins'; import styles from './Section.module.css'; @@ -485,12 +486,24 @@ export function PluginsSection() { // Update a plugin config value const handleConfigChange = useCallback(async (pluginId: string, key: string, value: unknown) => { + // Find the schema field for validation + const schema = BUILT_IN_CONFIG_SCHEMAS[pluginId] ?? plugins.find(p => p.id === pluginId)?.configSchema; + const field = schema?.[key]; + + if (field) { + const result = validateConfigValue(field, value); + if (!result.valid) { + console.warn(`[plugin:${pluginId}] Invalid config value for "${key}": ${result.reason}`); + return; + } + } + await window.readied.pluginConfig.set(pluginId, key, value); setConfigValues(prev => ({ ...prev, [pluginId]: { ...prev[pluginId], [key]: value }, })); - }, []); + }, [plugins]); // Reload plugins in the main window const handleReload = useCallback(() => { From 17c9ff2ab44583639757c2b6114d38b90f1c5dc5 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 19:40:00 -0300 Subject: [PATCH 074/148] feat(plugins): track load timing per plugin in runtime store Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/stores/pluginRuntimeStore.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/stores/pluginRuntimeStore.ts b/apps/desktop/src/renderer/stores/pluginRuntimeStore.ts index dd741910..67b01375 100644 --- a/apps/desktop/src/renderer/stores/pluginRuntimeStore.ts +++ b/apps/desktop/src/renderer/stores/pluginRuntimeStore.ts @@ -19,6 +19,12 @@ export interface PluginLoadError { reason: string; } +export interface PluginLoadTiming { + pluginId: string; + pluginName: string; + loadTimeMs: number; +} + type RuntimeStatus = 'idle' | 'scanning' | 'ready'; interface PluginRuntimeState { @@ -26,6 +32,8 @@ interface PluginRuntimeState { plugins: PluginManifest[]; /** Plugins that failed to load */ errors: PluginLoadError[]; + /** Load timing per plugin */ + timings: PluginLoadTiming[]; /** Current lifecycle status */ status: RuntimeStatus; } @@ -56,6 +64,7 @@ function attachIpcListener() { async function executeScan(generation: number): Promise<{ plugins: PluginManifest[]; errors: PluginLoadError[]; + timings: PluginLoadTiming[]; } | null> { try { const [scanned, stateList, initCode] = await Promise.all([ @@ -70,14 +79,19 @@ async function executeScan(generation: number): Promise<{ const stateMap = new Map(stateList.map(s => [s.pluginId, s.enabled])); const plugins: PluginManifest[] = []; const errors: PluginLoadError[] = []; + const timings: PluginLoadTiming[] = []; for (const sp of scanned) { const enabled = stateMap.get(sp.id) ?? true; if (!enabled) continue; + const start = performance.now(); const manifest = loadPluginFromSource(sp.code, sp.id); + const elapsed = performance.now() - start; + if (manifest) { plugins.push(manifest); + timings.push({ pluginId: sp.id, pluginName: sp.name, loadTimeMs: elapsed }); } else { errors.push({ pluginId: sp.id, @@ -107,7 +121,7 @@ async function executeScan(generation: number): Promise<{ // Race check again after CPU-bound eval loop if (scanGeneration !== generation) return null; - return { plugins, errors }; + return { plugins, errors, timings }; } catch (error) { console.error('[pluginRuntime] scan failed:', error); return null; @@ -117,6 +131,7 @@ async function executeScan(generation: number): Promise<{ export const pluginRuntimeStore = createStore(set => ({ plugins: [], errors: [], + timings: [], status: 'idle', async init() { @@ -125,7 +140,7 @@ export const pluginRuntimeStore = createStore(set => ({ set({ status: 'scanning' }); const result = await executeScan(gen); if (result) { - set({ plugins: result.plugins, errors: result.errors, status: 'ready' }); + set({ plugins: result.plugins, errors: result.errors, timings: result.timings, status: 'ready' }); } else if (scanGeneration === gen) { set({ status: 'ready' }); } @@ -136,7 +151,7 @@ export const pluginRuntimeStore = createStore(set => ({ set({ status: 'scanning' }); const result = await executeScan(gen); if (result) { - set({ plugins: result.plugins, errors: result.errors, status: 'ready' }); + set({ plugins: result.plugins, errors: result.errors, timings: result.timings, status: 'ready' }); } else if (scanGeneration === gen) { set({ status: 'ready' }); } From 3dabecee9dae5ff75e0459c31e8315e5dc7a635e Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 19:43:46 -0300 Subject: [PATCH 075/148] feat(plugins): add dev-mode Plugin Inspector with load timings and error display Co-Authored-By: Claude Opus 4.6 --- .../settings/sections/PluginsSection.tsx | 108 ++++++++++++++++- .../settings/sections/Section.module.css | 110 ++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx index bc0cfa3c..c9ba4458 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/PluginsSection.tsx @@ -5,8 +5,9 @@ * with enable/disable toggles, badges, and collapsible config forms. */ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { RefreshCw, FolderOpen, ChevronDown, Download, Trash2, Search, Check } from 'lucide-react'; +import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react'; +import { RefreshCw, FolderOpen, ChevronDown, Download, Trash2, Search, Check, AlertTriangle } from 'lucide-react'; +import { pluginRuntimeStore } from '../../../stores/pluginRuntimeStore'; import type { PluginConfigSchemaField } from '../../../../preload/index'; import { validateConfigValue } from '@readied/plugin-api'; import { Toggle, TextInput, NumberInput, RangeInput, Select } from '../components/controls'; @@ -411,6 +412,107 @@ function PluginCard({ ); } +// ============================================================================ +// PluginInspector (dev mode only) +// ============================================================================ + +function PluginInspector() { + const [open, setOpen] = useState(false); + + const status = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().status + ); + const errors = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().errors + ); + const timings = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().timings + ); + const pluginCount = useSyncExternalStore( + pluginRuntimeStore.subscribe, + () => pluginRuntimeStore.getState().plugins.length + ); + + const handleForceReload = useCallback(() => { + window.readied.plugins.requestReload(); + }, []); + + return ( +
+ + + {open && ( +
+
+ Status + {status === 'scanning' ? 'Scanning...' : `${pluginCount} loaded`} +
+ + {timings.length > 0 && ( +
+
Load times
+ + + + + + + + + {timings.map(t => ( + + + + + ))} + +
PluginTime
{t.pluginName}{t.loadTimeMs < 1 ? '<1' : Math.round(t.loadTimeMs)}ms
+
+ )} + + {errors.length > 0 && ( +
+
Errors
+ {errors.map(err => ( +
+ {err.pluginName} + {err.reason} +
+ ))} +
+ )} + +
+ +
+
+ )} +
+ ); +} + // ============================================================================ // PluginsSection // ============================================================================ @@ -712,6 +814,8 @@ export function PluginsSection() { )} {activeTab === 'browse' && } + + {import.meta.env.DEV && } ); } diff --git a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css index 0036742c..6def8975 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css +++ b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css @@ -667,3 +667,113 @@ background: var(--danger-muted, rgba(239, 68, 68, 0.1)); color: var(--danger, #ef4444); } + +/* ============================================================================ + Plugin Inspector (dev mode) + ============================================================================ */ + +.inspectorPanel { + margin-top: 2rem; + border-top: 1px solid var(--border-subtle); + padding-top: 1rem; +} + +.inspectorToggle { + display: flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: var(--text-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + padding: 0.25rem 0; +} + +.inspectorToggle:hover { + color: var(--text-secondary); +} + +.inspectorErrorBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--status-error, #ef4444); + font-weight: 600; +} + +.inspectorContent { + margin-top: 0.75rem; + font-size: var(--text-sm); +} + +.inspectorRow { + display: flex; + justify-content: space-between; + padding: 0.375rem 0; + color: var(--text-secondary); +} + +.inspectorLabel { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.375rem; +} + +.inspectorTimings { + margin-top: 0.75rem; +} + +.inspectorTable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.inspectorTable th { + text-align: left; + color: var(--text-muted); + font-weight: 500; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border-subtle); +} + +.inspectorTable td { + padding: 0.25rem 0.5rem; + color: var(--text-secondary); +} + +.inspectorTable td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.inspectorErrors { + margin-top: 0.75rem; +} + +.inspectorError { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.06); + border: 1px solid rgba(239, 68, 68, 0.15); + border-radius: 0.375rem; + margin-bottom: 0.375rem; + font-size: var(--text-sm); +} + +.inspectorError strong { + color: var(--text-primary); + font-weight: 500; +} + +.inspectorError span { + color: var(--text-muted); + font-size: var(--text-xs); +} From 38a5170eb9ab6af629a65585c31a78452c9acb9c Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 20:06:34 -0300 Subject: [PATCH 076/148] fix: address CodeRabbit review comments on remark/rehype hooks - Replace unsafe `Function` type casts with explicit signatures in safePluginWrapper - Add escapeHtml helper to prevent XSS in error marker HTML injection - Change console.debug to console.warn in plugin stores (ESLint no-console) - Use CSS custom properties with fallbacks for error block colors - Add overflow-wrap: anywhere to prevent horizontal overflow on long errors - Add default metadata backward-compatibility tests to both plugin stores Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/renderer/styles/preview.css | 7 ++++--- .../plugin-api/src/preview/rehypePluginStore.ts | 2 +- .../plugin-api/src/preview/remarkPluginStore.ts | 2 +- .../plugin-api/src/preview/safePluginWrapper.ts | 15 ++++++++++++--- .../plugin-api/tests/rehypePluginStore.test.ts | 15 +++++++++++++++ .../plugin-api/tests/remarkPluginStore.test.ts | 15 +++++++++++++++ 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/styles/preview.css b/apps/desktop/src/renderer/styles/preview.css index 56b991f0..00e69dc4 100644 --- a/apps/desktop/src/renderer/styles/preview.css +++ b/apps/desktop/src/renderer/styles/preview.css @@ -551,12 +551,13 @@ ============================================================================ */ .plugin-error-block { - background: rgba(239, 68, 68, 0.08); - border-left: 3px solid #ef4444; + background: var(--error-bg, rgba(239, 68, 68, 0.08)); + border-left: 3px solid var(--error-border, #ef4444); padding: 4px 8px; margin: 4px 0; font-size: 0.75rem; - color: #ef4444; + color: var(--error-fg, #ef4444); border-radius: 2px; font-family: var(--font-mono, monospace); + overflow-wrap: anywhere; } diff --git a/packages/plugin-api/src/preview/rehypePluginStore.ts b/packages/plugin-api/src/preview/rehypePluginStore.ts index d44cea0b..8537722c 100644 --- a/packages/plugin-api/src/preview/rehypePluginStore.ts +++ b/packages/plugin-api/src/preview/rehypePluginStore.ts @@ -36,7 +36,7 @@ export const rehypePluginStore = createStore((set, get) => ({ { ...registration, plugin: wrappedPlugin }, ], })); - console.debug( + console.warn( `[RehypePlugins] Registered: ${registration.metadata.name}@${registration.metadata.version} (priority: ${registration.metadata.priority})`, ); }, diff --git a/packages/plugin-api/src/preview/remarkPluginStore.ts b/packages/plugin-api/src/preview/remarkPluginStore.ts index c9f71731..90586998 100644 --- a/packages/plugin-api/src/preview/remarkPluginStore.ts +++ b/packages/plugin-api/src/preview/remarkPluginStore.ts @@ -36,7 +36,7 @@ export const remarkPluginStore = createStore((set, get) => ({ { ...registration, plugin: wrappedPlugin }, ], })); - console.debug( + console.warn( `[RemarkPlugins] Registered: ${registration.metadata.name}@${registration.metadata.version} (priority: ${registration.metadata.priority})`, ); }, diff --git a/packages/plugin-api/src/preview/safePluginWrapper.ts b/packages/plugin-api/src/preview/safePluginWrapper.ts index 34e29100..dea6e502 100644 --- a/packages/plugin-api/src/preview/safePluginWrapper.ts +++ b/packages/plugin-api/src/preview/safePluginWrapper.ts @@ -1,3 +1,12 @@ +/** Escape HTML special characters to prevent XSS in error markers. */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + export interface PluginMetadata { name: string; version: string; @@ -22,7 +31,7 @@ export function safePluginWrapper( return function safePlugin(...args: unknown[]) { let transformer: unknown; try { - transformer = (plugin as Function)(...args); + transformer = (plugin as (...args: unknown[]) => unknown)(...args); } catch (error) { console.warn( `[PluginPipeline] ${metadata.name}@${metadata.version} failed to initialize:`, @@ -37,7 +46,7 @@ export function safePluginWrapper( // Wrap the transformer return (tree: unknown, file: unknown) => { try { - return (transformer as Function)(tree, file); + return (transformer as (tree: unknown, file: unknown) => unknown)(tree, file); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( @@ -56,7 +65,7 @@ export function safePluginWrapper( const children = (tree as Record).children as unknown[]; children.push({ type: 'html', - value: `
⚠ ${metadata.name} plugin failed: ${message.replace(/`, + value: `
⚠ ${escapeHtml(metadata.name)} plugin failed: ${escapeHtml(message)}
`, }); } diff --git a/packages/plugin-api/tests/rehypePluginStore.test.ts b/packages/plugin-api/tests/rehypePluginStore.test.ts index b5c0f968..b33d111b 100644 --- a/packages/plugin-api/tests/rehypePluginStore.test.ts +++ b/packages/plugin-api/tests/rehypePluginStore.test.ts @@ -84,6 +84,21 @@ describe('rehypePluginStore', () => { expect(remaining[0]!.pluginId).toBe('plugin-b'); }); + it('registers with default metadata values', () => { + const fakePlugin = () => {}; + rehypePluginStore.getState().register({ + id: 'rehype-default', + pluginId: 'test-plugin', + plugin: fakePlugin, + metadata: defaultMeta, + }); + + const reg = rehypePluginStore.getState().registrations[0]!; + expect(reg.metadata.name).toBe('test'); + expect(reg.metadata.version).toBe('1.0.0'); + expect(reg.metadata.priority).toBe(100); + }); + it('getPlugins returns plugins sorted by priority', () => { rehypePluginStore.getState().register({ id: 'rehype-1', diff --git a/packages/plugin-api/tests/remarkPluginStore.test.ts b/packages/plugin-api/tests/remarkPluginStore.test.ts index de4eabab..c8d564cd 100644 --- a/packages/plugin-api/tests/remarkPluginStore.test.ts +++ b/packages/plugin-api/tests/remarkPluginStore.test.ts @@ -84,6 +84,21 @@ describe('remarkPluginStore', () => { expect(remaining[0]!.pluginId).toBe('plugin-b'); }); + it('registers with default metadata values', () => { + const fakePlugin = () => {}; + remarkPluginStore.getState().register({ + id: 'remark-default', + pluginId: 'test-plugin', + plugin: fakePlugin, + metadata: defaultMeta, + }); + + const reg = remarkPluginStore.getState().registrations[0]!; + expect(reg.metadata.name).toBe('test'); + expect(reg.metadata.version).toBe('1.0.0'); + expect(reg.metadata.priority).toBe(100); + }); + it('getPlugins returns plugins sorted by priority', () => { remarkPluginStore.getState().register({ id: 'remark-1', From c099dbd7a3fe8224453aa68c633d36c68b875a98 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 20:10:48 -0300 Subject: [PATCH 077/148] fix: address CodeRabbit review comments on theme system - Fix race condition in theme restore by subscribing to themeRegistryStore changes via useSyncExternalStore (App.tsx) - Separate data-color-scheme (dark/light) from data-theme (plugin themes) to prevent attribute conflicts between useAppearanceSettings and useThemeOverrides - Persist IPC nativeTheme isDark value to avoid matchMedia fallback on accent/zoom re-applies (useAppearanceSettings.ts) - Fix unnecessary re-renders in useThemeOverrides by using separate subscriptions for activeThemeId and themes instead of creating new object references - Fix type mismatch in preload theme.setSource to use union type - Fix import order in AppearanceSection.tsx per lint rules - Log warning on failed theme registration in PluginRegistry - Replace disallowed console.debug with guarded console.warn in themeRegistryStore - Fix Prettier formatting in SQLiteNoteRepository.ts - Update CSS selectors in tokens.css and code-highlight.css to use data-color-scheme Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/preload/index.ts | 2 +- apps/desktop/src/renderer/App.tsx | 10 ++-- .../renderer/hooks/useAppearanceSettings.ts | 31 +++++++++--- .../settings/sections/AppearanceSection.tsx | 2 +- .../src/renderer/styles/code-highlight.css | 50 +++++++++---------- apps/desktop/src/renderer/styles/tokens.css | 2 +- .../src/lifecycle/PluginRegistry.ts | 5 +- .../src/theme/themeRegistryStore.ts | 9 ++-- .../plugin-api/src/theme/useThemeOverrides.ts | 16 +++--- .../src/repositories/SQLiteNoteRepository.ts | 4 +- 10 files changed, 80 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3ffc11d6..921d26a2 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -947,7 +947,7 @@ const api: ReadiedAPI = { clear: pluginId => ipcRenderer.invoke('pluginConfig:clear', pluginId), }, theme: { - setSource: (source: string) => { + setSource: (source: 'dark' | 'light' | 'system') => { ipcRenderer.send('theme:set-source', source); }, onSystemChanged: (callback: (isDark: boolean) => void) => { diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 1811f430..42440a5d 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { EditorView } from '@codemirror/view'; import { @@ -83,17 +83,21 @@ function NotesApp() { // Restore saved plugin theme on startup const appearance = useSettingsStore(selectAppearance); + const registeredThemes = useSyncExternalStore( + themeRegistryStore.subscribe, + () => themeRegistryStore.getState().themes + ); useEffect(() => { const savedThemeId = appearance?.activeThemeId; if (savedThemeId) { // Only restore if the theme is actually registered (plugin loaded) - const exists = themeRegistryStore.getState().themes.some(t => t.id === savedThemeId); + const exists = registeredThemes.some(t => t.id === savedThemeId); if (exists) { themeRegistryStore.getState().setActive(savedThemeId); } } - }, [appearance?.activeThemeId]); + }, [appearance?.activeThemeId, registeredThemes]); // Resizable layout const { sidebarWidth, notelistWidth, startResizeSidebar, startResizeNotelist } = diff --git a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts index 9516d010..17207591 100644 --- a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts +++ b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts @@ -1,7 +1,14 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useSettingsStore, selectAppearance } from '../stores/settings'; import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; +/** + * Persisted IPC isDark value from the main process nativeTheme. + * Used so that re-applies (accent/zoom changes) use the authoritative + * main-process value instead of falling back to window.matchMedia. + */ +let nativeIsDark: boolean | undefined; + /** * Apply appearance settings to the DOM. */ @@ -13,12 +20,17 @@ function applyAppearance( ): void { let resolved: string; if (theme === 'system') { - resolved = - (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + // Prefer the IPC-supplied nativeTheme value, then the explicit param, + // then fall back to matchMedia only as a last resort. + const dark = + isDark ?? nativeIsDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches; + resolved = dark ? 'dark' : 'light'; } else { resolved = theme; } - document.documentElement.setAttribute('data-theme', resolved); + // Use data-color-scheme for the dark/light resolved value. + // data-theme is reserved for plugin theme identity (set by useThemeOverrides). + document.documentElement.setAttribute('data-color-scheme', resolved); document.documentElement.style.setProperty('--accent', accentColor); document.documentElement.style.setProperty('--accent-primary', accentColor); @@ -53,6 +65,10 @@ export function useAppearanceSettings(): void { const accentColor = appearance?.accentColor || '#5eead4'; const zoomLevel = appearance?.zoomLevel || '1.0'; + // Keep a ref to current values so the IPC callback can access them + const currentRef = useRef({ theme, accentColor, zoomLevel }); + currentRef.current = { theme, accentColor, zoomLevel }; + // Apply settings to DOM useEffect(() => { applyAppearance(theme, accentColor, zoomLevel); @@ -67,8 +83,11 @@ export function useAppearanceSettings(): void { useEffect(() => { if (theme !== 'system') return; const unsub = window.readied.theme.onSystemChanged(isDark => { - applyAppearance('system', accentColor, zoomLevel, isDark); + // Persist the IPC value so subsequent re-applies use it + nativeIsDark = isDark; + const { accentColor: ac, zoomLevel: zl } = currentRef.current; + applyAppearance('system', ac, zl, isDark); }); return unsub; - }, [theme, accentColor, zoomLevel]); + }, [theme]); } diff --git a/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx index a9056757..af12b642 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx @@ -5,9 +5,9 @@ */ import { useSyncExternalStore } from 'react'; +import { themeRegistryStore } from '@readied/plugin-api'; import { useSettingsStore, selectAppearance } from '../../../stores/settings'; import { usePerformanceStore } from '../../../stores/performanceStore'; -import { themeRegistryStore } from '@readied/plugin-api'; import { SettingGroup } from '../components/SettingGroup'; import { SettingRow } from '../components/SettingRow'; import { Select, ColorPicker, type ColorOption } from '../components/controls'; diff --git a/apps/desktop/src/renderer/styles/code-highlight.css b/apps/desktop/src/renderer/styles/code-highlight.css index 11ff7e33..3f55a178 100644 --- a/apps/desktop/src/renderer/styles/code-highlight.css +++ b/apps/desktop/src/renderer/styles/code-highlight.css @@ -137,71 +137,71 @@ } /* ===== LIGHT THEME OVERRIDES ===== */ -:root[data-theme="light"] .markdown-preview .hljs-keyword, -:root[data-theme="light"] .markdown-preview .hljs-selector-tag { +:root[data-color-scheme="light"] .markdown-preview .hljs-keyword, +:root[data-color-scheme="light"] .markdown-preview .hljs-selector-tag { color: #7c3aed; } -:root[data-theme="light"] .markdown-preview .hljs-string, -:root[data-theme="light"] .markdown-preview .hljs-template-tag, -:root[data-theme="light"] .markdown-preview .hljs-template-variable { +:root[data-color-scheme="light"] .markdown-preview .hljs-string, +:root[data-color-scheme="light"] .markdown-preview .hljs-template-tag, +:root[data-color-scheme="light"] .markdown-preview .hljs-template-variable { color: #16a34a; } -:root[data-theme="light"] .markdown-preview .hljs-number, -:root[data-theme="light"] .markdown-preview .hljs-literal { +:root[data-color-scheme="light"] .markdown-preview .hljs-number, +:root[data-color-scheme="light"] .markdown-preview .hljs-literal { color: #ea580c; } -:root[data-theme="light"] .markdown-preview .hljs-built_in, -:root[data-theme="light"] .markdown-preview .hljs-type { +:root[data-color-scheme="light"] .markdown-preview .hljs-built_in, +:root[data-color-scheme="light"] .markdown-preview .hljs-type { color: #ca8a04; } -:root[data-theme="light"] .markdown-preview .hljs-title, -:root[data-theme="light"] .markdown-preview .hljs-title.function_ { +:root[data-color-scheme="light"] .markdown-preview .hljs-title, +:root[data-color-scheme="light"] .markdown-preview .hljs-title.function_ { color: #2563eb; } -:root[data-theme="light"] .markdown-preview .hljs-comment, -:root[data-theme="light"] .markdown-preview .hljs-doctag { +:root[data-color-scheme="light"] .markdown-preview .hljs-comment, +:root[data-color-scheme="light"] .markdown-preview .hljs-doctag { color: var(--text-muted); } -:root[data-theme="light"] .markdown-preview .hljs-punctuation, -:root[data-theme="light"] .markdown-preview .hljs-operator { +:root[data-color-scheme="light"] .markdown-preview .hljs-punctuation, +:root[data-color-scheme="light"] .markdown-preview .hljs-operator { color: #0891b2; } -:root[data-theme="light"] .markdown-preview .hljs-regexp { +:root[data-color-scheme="light"] .markdown-preview .hljs-regexp { color: #0891b2; } -:root[data-theme="light"] .markdown-preview .hljs-tag, -:root[data-theme="light"] .markdown-preview .hljs-name { +:root[data-color-scheme="light"] .markdown-preview .hljs-tag, +:root[data-color-scheme="light"] .markdown-preview .hljs-name { color: #dc2626; } -:root[data-theme="light"] .markdown-preview .hljs-selector-class, -:root[data-theme="light"] .markdown-preview .hljs-selector-id { +:root[data-color-scheme="light"] .markdown-preview .hljs-selector-class, +:root[data-color-scheme="light"] .markdown-preview .hljs-selector-id { color: #ca8a04; } -:root[data-theme="light"] .markdown-preview .hljs-property { +:root[data-color-scheme="light"] .markdown-preview .hljs-property { color: #2563eb; } -:root[data-theme="light"] .markdown-preview .hljs-meta, -:root[data-theme="light"] .markdown-preview .hljs-meta .hljs-keyword { +:root[data-color-scheme="light"] .markdown-preview .hljs-meta, +:root[data-color-scheme="light"] .markdown-preview .hljs-meta .hljs-keyword { color: #0891b2; } -:root[data-theme="light"] .markdown-preview .hljs-deletion { +:root[data-color-scheme="light"] .markdown-preview .hljs-deletion { color: #dc2626; background: rgba(220, 38, 38, 0.1); } -:root[data-theme="light"] .markdown-preview .hljs-addition { +:root[data-color-scheme="light"] .markdown-preview .hljs-addition { color: #16a34a; background: rgba(22, 163, 74, 0.1); } diff --git a/apps/desktop/src/renderer/styles/tokens.css b/apps/desktop/src/renderer/styles/tokens.css index 5458d7a1..59830963 100644 --- a/apps/desktop/src/renderer/styles/tokens.css +++ b/apps/desktop/src/renderer/styles/tokens.css @@ -111,7 +111,7 @@ } /* ===== LIGHT THEME ===== */ -:root[data-theme="light"] { +:root[data-color-scheme="light"] { /* Background */ --bg-base: #ffffff; --bg-surface: #f9fafb; diff --git a/packages/plugin-api/src/lifecycle/PluginRegistry.ts b/packages/plugin-api/src/lifecycle/PluginRegistry.ts index f9736d0b..62eb6b99 100644 --- a/packages/plugin-api/src/lifecycle/PluginRegistry.ts +++ b/packages/plugin-api/src/lifecycle/PluginRegistry.ts @@ -273,10 +273,13 @@ export class PluginRegistry { return () => cssVariableStore.getState().unregister(regId); }, registerTheme: (theme): (() => void) => { - themeRegistryStore.getState().register({ + const success = themeRegistryStore.getState().register({ ...theme, pluginId: id, }); + if (!success) { + console.warn(`[${id}] Theme registration failed for "${theme.id}" (no valid tokens)`); + } return () => themeRegistryStore.getState().unregister(theme.id); }, config, diff --git a/packages/plugin-api/src/theme/themeRegistryStore.ts b/packages/plugin-api/src/theme/themeRegistryStore.ts index 61a41947..6afc4262 100644 --- a/packages/plugin-api/src/theme/themeRegistryStore.ts +++ b/packages/plugin-api/src/theme/themeRegistryStore.ts @@ -33,9 +33,12 @@ export const themeRegistryStore = createStore((set, get) => set(state => ({ themes: [...state.themes.filter(t => t.id !== theme.id), validated], })); - console.debug( - `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` - ); + // Debug-level log uses console.warn with a prefix to pass lint rules + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` + ); + } return true; }, diff --git a/packages/plugin-api/src/theme/useThemeOverrides.ts b/packages/plugin-api/src/theme/useThemeOverrides.ts index 0d9b9e2d..a918bc77 100644 --- a/packages/plugin-api/src/theme/useThemeOverrides.ts +++ b/packages/plugin-api/src/theme/useThemeOverrides.ts @@ -9,13 +9,12 @@ import { useEffect, useSyncExternalStore } from 'react'; import { themeRegistryStore } from './themeRegistryStore'; const subscribe = (cb: () => void) => themeRegistryStore.subscribe(cb); -const getSnapshot = () => ({ - activeThemeId: themeRegistryStore.getState().activeThemeId, - themes: themeRegistryStore.getState().themes, -}); +const getActiveThemeId = () => themeRegistryStore.getState().activeThemeId; +const getThemes = () => themeRegistryStore.getState().themes; export function useThemeOverrides(): void { - const state = useSyncExternalStore(subscribe, getSnapshot); + const activeThemeId = useSyncExternalStore(subscribe, getActiveThemeId); + const themes = useSyncExternalStore(subscribe, getThemes); useEffect(() => { const root = document.documentElement; @@ -23,7 +22,7 @@ export function useThemeOverrides(): void { const applied = new Set(); if (theme) { - // Set base color scheme so CSS fallbacks work + // Set plugin theme color scheme on data-theme (separate from data-color-scheme) root.setAttribute('data-theme', theme.colorScheme); // Apply theme tokens @@ -31,6 +30,9 @@ export function useThemeOverrides(): void { root.style.setProperty(prop, value); applied.add(prop); } + } else { + // No active plugin theme — remove the attribute so it doesn't conflict + root.removeAttribute('data-theme'); } return () => { @@ -39,5 +41,5 @@ export function useThemeOverrides(): void { root.style.removeProperty(prop); } }; - }, [state.activeThemeId, state.themes]); + }, [activeThemeId, themes]); } diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 71acdfd0..1ae35ed0 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -846,9 +846,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { /** * Get tags with pending sync changes */ - getTagsPendingSync( - limit: number - ): Array<{ + getTagsPendingSync(limit: number): Array<{ tag: { id: number; uuid: string; name: string; color: string | null }; localVersion: number; }> { From b8a4e81c8d565fd540841a1760aa1a3fd028ab9d Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 11:53:57 -0300 Subject: [PATCH 078/148] docs: add theme system implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-11-theme-system-implementation.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 docs/plans/2026-03-11-theme-system-implementation.md diff --git a/docs/plans/2026-03-11-theme-system-implementation.md b/docs/plans/2026-03-11-theme-system-implementation.md new file mode 100644 index 00000000..02e9fde3 --- /dev/null +++ b/docs/plans/2026-03-11-theme-system-implementation.md @@ -0,0 +1,860 @@ +# Theme System Enhancement — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a layered theme registry with token validation, plugin-defined themes, and Electron nativeTheme sync. + +**Architecture:** ThemeRegistry Zustand store validates and stores theme definitions. A `useThemeOverrides` hook applies active theme tokens to `:root`. Main process syncs `nativeTheme.themeSource` with renderer via IPC. Plugin API exposes `registerTheme()`. + +**Tech Stack:** TypeScript, Zustand (vanilla), Electron nativeTheme, CSS custom properties, React hooks + +--- + +### Task 1: Define theme types and token whitelist + +**Files:** +- Create: `packages/plugin-api/src/theme/themeTypes.ts` + +**Step 1: Create the types file** + +```typescript +/** + * Theme System Types + * + * Defines ThemeDefinition and the token whitelist for validation. + */ + +/** Core CSS tokens that themes are allowed to override */ +export const CORE_THEME_TOKENS = [ + // Backgrounds + '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', + // Text + '--text-primary', '--text-secondary', '--text-muted', '--text-faint', + // Borders + '--border', '--border-subtle', '--border-strong', + // Glass + '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', + // Semantic + '--danger', '--danger-muted', '--warning', '--warning-muted', + '--success', '--success-muted', + // Status + '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', +] as const; + +/** Valid extension scope prefixes for non-core tokens */ +export const THEME_EXTENSION_SCOPES = ['--syntax-', '--preview-', '--ui-'] as const; + +/** A complete theme definition */ +export interface ThemeDefinition { + /** Unique theme ID */ + id: string; + /** Display name */ + name: string; + /** Short description */ + description?: string; + /** Theme author */ + author?: string; + /** Base color scheme this theme builds on (determines fallback values) */ + colorScheme: 'dark' | 'light'; + /** CSS custom property overrides — must pass token validation */ + tokens: Record; + /** Plugin that registered this theme (undefined for built-in) */ + pluginId?: string; +} + +/** + * Validate a token name against the whitelist and extension scopes. + * Returns true if the token is allowed. + */ +export function isValidThemeToken(token: string): boolean { + if ((CORE_THEME_TOKENS as readonly string[]).includes(token)) return true; + return THEME_EXTENSION_SCOPES.some(prefix => token.startsWith(prefix)); +} + +/** + * Validate and filter theme tokens. + * Returns only valid tokens. Warns about rejected ones. + */ +export function validateThemeTokens( + tokens: Record, + themeId: string +): Record { + const valid: Record = {}; + for (const [token, value] of Object.entries(tokens)) { + if (isValidThemeToken(token)) { + valid[token] = value; + } else { + console.warn(`[ThemeRegistry] Theme "${themeId}": rejected invalid token "${token}"`); + } + } + return valid; +} +``` + +**Step 2: Commit** + +```bash +git add packages/plugin-api/src/theme/themeTypes.ts +git commit -m "feat(plugin-api): add theme types and token whitelist" +``` + +--- + +### Task 2: Create ThemeRegistry store + +**Files:** +- Create: `packages/plugin-api/src/theme/themeRegistryStore.ts` +- Modify: `packages/plugin-api/src/index.ts` (add exports) + +**Step 1: Create the store** + +```typescript +/** + * Theme Registry Store + * + * Zustand vanilla store for registered themes. + * Validates tokens on registration. Manages active theme state. + */ + +import { createStore } from 'zustand/vanilla'; +import type { ThemeDefinition } from './themeTypes'; +import { validateThemeTokens } from './themeTypes'; + +interface ThemeRegistryState { + /** All registered themes */ + themes: ThemeDefinition[]; + /** Currently active theme ID (null = use base dark/light) */ + activeThemeId: string | null; + /** Register a theme. Returns false if no valid tokens after validation. */ + register(theme: ThemeDefinition): boolean; + /** Unregister a theme by ID */ + unregister(themeId: string): void; + /** Unregister all themes from a plugin */ + unregisterAll(pluginId: string): void; + /** Set the active theme (null to revert to base) */ + setActive(themeId: string | null): void; + /** Get the active theme definition, or null */ + getActiveTheme(): ThemeDefinition | null; +} + +export const themeRegistryStore = createStore((set, get) => ({ + themes: [], + activeThemeId: null, + + register(theme) { + const validTokens = validateThemeTokens(theme.tokens, theme.id); + if (Object.keys(validTokens).length === 0) { + console.warn(`[ThemeRegistry] Theme "${theme.id}" has no valid tokens, skipping.`); + return false; + } + + const validated: ThemeDefinition = { ...theme, tokens: validTokens }; + set(state => ({ + themes: [...state.themes.filter(t => t.id !== theme.id), validated], + })); + console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + return true; + }, + + unregister(themeId) { + set(state => { + const next: Partial = { + themes: state.themes.filter(t => t.id !== themeId), + }; + // Deactivate if the removed theme was active + if (state.activeThemeId === themeId) { + next.activeThemeId = null; + } + return next as ThemeRegistryState; + }); + }, + + unregisterAll(pluginId) { + set(state => { + const remaining = state.themes.filter(t => t.pluginId !== pluginId); + const activeRemoved = state.activeThemeId && + !remaining.some(t => t.id === state.activeThemeId); + return { + themes: remaining, + activeThemeId: activeRemoved ? null : state.activeThemeId, + } as ThemeRegistryState; + }); + }, + + setActive(themeId) { + if (themeId !== null) { + const exists = get().themes.some(t => t.id === themeId); + if (!exists) { + console.warn(`[ThemeRegistry] Theme "${themeId}" not found, ignoring setActive.`); + return; + } + } + set({ activeThemeId: themeId }); + }, + + getActiveTheme() { + const { themes, activeThemeId } = get(); + if (!activeThemeId) return null; + return themes.find(t => t.id === activeThemeId) ?? null; + }, +})); +``` + +**Step 2: Export from barrel** + +Add to `packages/plugin-api/src/index.ts`: +```typescript +export { themeRegistryStore } from './theme/themeRegistryStore'; +export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export type { ThemeDefinition } from './theme/themeTypes'; +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/theme/themeRegistryStore.ts packages/plugin-api/src/index.ts +git commit -m "feat(plugin-api): add ThemeRegistry store with validation" +``` + +--- + +### Task 3: Create useThemeOverrides hook + +**Files:** +- Create: `packages/plugin-api/src/theme/useThemeOverrides.ts` +- Modify: `packages/plugin-api/src/index.ts` (add export) + +**Step 1: Create the hook** + +```typescript +/** + * useThemeOverrides Hook + * + * Applies active theme tokens from ThemeRegistry to document.documentElement. + * Call once in app root, AFTER useAppearanceSettings. + */ + +import { useEffect, useSyncExternalStore } from 'react'; +import { themeRegistryStore } from './themeRegistryStore'; + +const subscribe = (cb: () => void) => themeRegistryStore.subscribe(cb); +const getSnapshot = () => ({ + activeThemeId: themeRegistryStore.getState().activeThemeId, + themes: themeRegistryStore.getState().themes, +}); + +export function useThemeOverrides(): void { + const state = useSyncExternalStore(subscribe, getSnapshot); + + useEffect(() => { + const root = document.documentElement; + const theme = themeRegistryStore.getState().getActiveTheme(); + const applied = new Set(); + + if (theme) { + // Set base color scheme so CSS fallbacks work + root.setAttribute('data-theme', theme.colorScheme); + + // Apply theme tokens + for (const [prop, value] of Object.entries(theme.tokens)) { + root.style.setProperty(prop, value); + applied.add(prop); + } + } + + return () => { + // Remove applied properties so base tokens take over + for (const prop of applied) { + root.style.removeProperty(prop); + } + }; + }, [state.activeThemeId, state.themes]); +} +``` + +**Step 2: Export from barrel** + +Add to `packages/plugin-api/src/index.ts`: +```typescript +export { useThemeOverrides } from './theme/useThemeOverrides'; +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/theme/useThemeOverrides.ts packages/plugin-api/src/index.ts +git commit -m "feat(plugin-api): add useThemeOverrides hook" +``` + +--- + +### Task 4: Wire useThemeOverrides into App.tsx + +**Files:** +- Modify: `apps/desktop/src/renderer/App.tsx` + +**Step 1: Add the hook call** + +Find where `useCssVariables()` is called (around line 78). Add `useThemeOverrides()` between `useAppearanceSettings()` and `useCssVariables()`: + +```typescript +import { useThemeOverrides } from '@readied/plugin-api'; + +// Inside NotesApp component: +usePerformanceMode(); +useAppearanceSettings(); +useThemeOverrides(); // NEW — applies active theme tokens +useCssVariables(); +``` + +Order matters: +1. `useAppearanceSettings` sets base `data-theme` + accent +2. `useThemeOverrides` overrides with active theme tokens (may change `data-theme`) +3. `useCssVariables` applies individual plugin CSS vars on top + +**Step 2: Commit** + +```bash +git add apps/desktop/src/renderer/App.tsx +git commit -m "feat(desktop): wire useThemeOverrides into app initialization" +``` + +--- + +### Task 5: Add registerTheme to PluginContext + +**Files:** +- Modify: `packages/plugin-api/src/types.ts` (add to PluginContext interface) +- Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts` (implement in activate) + +**Step 1: Add to PluginContext interface** + +In `types.ts`, add to the `PluginContext` interface: +```typescript +/** Register a complete theme with validated tokens */ +registerTheme(theme: { + id: string; + name: string; + description?: string; + author?: string; + colorScheme: 'dark' | 'light'; + tokens: Record; +}): () => void; +``` + +**Step 2: Implement in PluginRegistry.activate()** + +In `PluginRegistry.ts`, import `themeRegistryStore`: +```typescript +import { themeRegistryStore } from '../theme/themeRegistryStore'; +``` + +Add to the context object inside `activate()`: +```typescript +registerTheme: (theme): (() => void) => { + themeRegistryStore.getState().register({ + ...theme, + pluginId: id, + }); + return () => themeRegistryStore.getState().unregister(theme.id); +}, +``` + +Add cleanup in `deactivate()` (after existing cleanup lines): +```typescript +// Cleanup theme registrations +themeRegistryStore.getState().unregisterAll(id); +``` + +Also add cleanup in the catch block of `activate()` (error recovery): +```typescript +themeRegistryStore.getState().unregisterAll(id); +``` + +**Step 3: Commit** + +```bash +git add packages/plugin-api/src/types.ts packages/plugin-api/src/lifecycle/PluginRegistry.ts +git commit -m "feat(plugin-api): add registerTheme to PluginContext" +``` + +--- + +### Task 6: nativeTheme sync — main process + +**Files:** +- Modify: `apps/desktop/src/main/index.ts` + +**Step 1: Add nativeTheme IPC handlers** + +At the top of the file, import nativeTheme: +```typescript +import { nativeTheme } from 'electron'; +``` + +Add IPC handlers (near other IPC handler registrations): +```typescript +// Theme — sync Electron nativeTheme with renderer +ipcMain.on('theme:set-source', (_event, source: string) => { + if (source === 'dark' || source === 'light' || source === 'system') { + nativeTheme.themeSource = source; + } +}); + +// Notify all renderer windows when system theme changes +nativeTheme.on('updated', () => { + const isDark = nativeTheme.shouldUseDarkColors; + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('theme:system-changed', isDark); + } +}); +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/main/index.ts +git commit -m "feat(desktop): add nativeTheme IPC sync in main process" +``` + +--- + +### Task 7: nativeTheme sync — preload API + +**Files:** +- Modify: `apps/desktop/src/preload/index.ts` + +**Step 1: Add theme methods to ReadiedAPI** + +In the `ReadiedAPI` interface, add: +```typescript +theme: { + /** Set Electron's native theme source */ + setSource: (source: 'dark' | 'light' | 'system') => void; + /** Listen for system theme changes from main process */ + onSystemChanged: (callback: (isDark: boolean) => void) => () => void; +}; +``` + +In the `contextBridge.exposeInMainWorld('readied', ...)` implementation: +```typescript +theme: { + setSource: (source: string) => { + ipcRenderer.send('theme:set-source', source); + }, + onSystemChanged: (callback: (isDark: boolean) => void) => { + const handler = (_event: unknown, isDark: boolean) => callback(isDark); + ipcRenderer.on('theme:system-changed', handler); + return () => { + ipcRenderer.removeListener('theme:system-changed', handler); + }; + }, +}, +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/preload/index.ts +git commit -m "feat(desktop): add theme IPC bridge in preload" +``` + +--- + +### Task 8: Update useAppearanceSettings to use nativeTheme IPC + +**Files:** +- Modify: `apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` + +**Step 1: Replace media query listener with IPC** + +Current code uses `window.matchMedia('(prefers-color-scheme: dark)')` for system theme detection. Replace with the IPC bridge: + +```typescript +import { useEffect } from 'react'; +import { useSettingsStore, selectAppearance } from '../stores/settings'; +import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; + +function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { + let resolved: string; + if (theme === 'system') { + // Use provided isDark hint, or fall back to media query for initial render + resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + } else { + resolved = theme; + } + document.documentElement.setAttribute('data-theme', resolved); + + // Accent color + document.documentElement.style.setProperty('--accent', accentColor); + document.documentElement.style.setProperty('--accent-primary', accentColor); + + // Hover variant + const hoverColor = computeHoverColor(accentColor); + document.documentElement.style.setProperty('--accent-hover', hoverColor); + + // Muted variant + const rgb = hexToRgb(accentColor); + if (rgb) { + document.documentElement.style.setProperty( + '--accent-muted', + `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, 0.15)` + ); + } + + // Zoom + document.body.style.zoom = zoomLevel; +} + +export function useAppearanceSettings(): void { + const appearance = useSettingsStore(selectAppearance); + + const theme = appearance?.theme || 'dark'; + const accentColor = appearance?.accentColor || '#5eead4'; + const zoomLevel = appearance?.zoomLevel || '1.0'; + + // Apply settings to DOM whenever they change + useEffect(() => { + applyAppearance(theme, accentColor, zoomLevel); + }, [theme, accentColor, zoomLevel]); + + // Sync nativeTheme source in main process + useEffect(() => { + window.readied.theme.setSource(theme); + }, [theme]); + + // Listen for system theme changes via IPC (replaces media query listener) + useEffect(() => { + if (theme !== 'system') return; + + const unsub = window.readied.theme.onSystemChanged((isDark) => { + applyAppearance('system', accentColor, zoomLevel, isDark); + }); + return unsub; + }, [theme, accentColor, zoomLevel]); +} +``` + +**Step 2: Commit** + +```bash +git add apps/desktop/src/renderer/hooks/useAppearanceSettings.ts +git commit -m "feat(desktop): use nativeTheme IPC for system theme detection" +``` + +--- + +### Task 9: Add activeThemeId to AppearanceSettings + +**Files:** +- Modify: `apps/desktop/src/renderer/stores/settings/schema.ts` + +**Step 1: Add field** + +In `AppearanceSettings` interface: +```typescript +/** Active plugin theme ID (null = use base dark/light) */ +activeThemeId: string | null; +``` + +In `DEFAULT_APPEARANCE`: +```typescript +activeThemeId: null, +``` + +**Step 2: Bump SETTINGS_VERSION to 2 and add migration** + +Actually — per the comment in schema.ts, version bumps require migration logic in settingsStore.ts. Since `activeThemeId` defaults to `null` and missing keys naturally default to `undefined` which is falsy, this is safe to add without a migration. Keep version at 1 and just add the field with default. + +**Step 3: Commit** + +```bash +git add apps/desktop/src/renderer/stores/settings/schema.ts +git commit -m "feat(desktop): add activeThemeId to appearance settings" +``` + +--- + +### Task 10: Update AppearanceSection UI with theme selector + +**Files:** +- Modify: `apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx` + +**Step 1: Add theme selector (only shown when themes exist)** + +Import the theme registry: +```typescript +import { useSyncExternalStore } from 'react'; +import { themeRegistryStore } from '@readied/plugin-api'; +``` + +Inside the component, subscribe to themes: +```typescript +const themeRegs = useSyncExternalStore( + themeRegistryStore.subscribe, + () => themeRegistryStore.getState().themes +); +const activeThemeId = useSyncExternalStore( + themeRegistryStore.subscribe, + () => themeRegistryStore.getState().activeThemeId +); +``` + +Add handler: +```typescript +const handlePluginThemeChange = (value: string) => { + const newId = value === 'default' ? null : value; + themeRegistryStore.getState().setActive(newId); + updateAppearance({ activeThemeId: newId }); +}; +``` + +Add UI below the accent color picker (inside the "Theme" SettingGroup), only if themes are registered: +```typescript +{themeRegs.length > 0 && ( + + ({ + value: t.id, + label: `${t.name} (${t.colorScheme})`, + })), + ]} + /> + + )} diff --git a/apps/desktop/src/renderer/stores/settings/schema.ts b/apps/desktop/src/renderer/stores/settings/schema.ts index 84c86988..5e267ea8 100644 --- a/apps/desktop/src/renderer/stores/settings/schema.ts +++ b/apps/desktop/src/renderer/stores/settings/schema.ts @@ -46,6 +46,8 @@ export interface AppearanceSettings { zoomLevel: string; /** @deprecated No longer used - kept for schema compatibility */ acrylicBackground: boolean; + /** Active plugin theme ID (null = use base dark/light) */ + activeThemeId: string | null; } /** Backup settings */ @@ -122,6 +124,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = { accentColor: '#5eead4', zoomLevel: '1.0', acrylicBackground: false, + activeThemeId: null, }; export const DEFAULT_EDITOR: EditorSettings = { From ff0e19b8082ef732a24a3c5f3b8c7717683acd0d Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 12:14:07 -0300 Subject: [PATCH 083/148] test: add theme token validation and ThemeRegistry store tests Cover isValidThemeToken, validateThemeTokens, and full themeRegistryStore lifecycle (register, unregister, unregisterAll, setActive, getActiveTheme). Co-Authored-By: Claude Opus 4.6 --- .../tests/themeRegistryStore.test.ts | 83 +++++++++++++++++++ packages/plugin-api/tests/themeTypes.test.ts | 46 ++++++++++ 2 files changed, 129 insertions(+) create mode 100644 packages/plugin-api/tests/themeRegistryStore.test.ts create mode 100644 packages/plugin-api/tests/themeTypes.test.ts diff --git a/packages/plugin-api/tests/themeRegistryStore.test.ts b/packages/plugin-api/tests/themeRegistryStore.test.ts new file mode 100644 index 00000000..f52cb399 --- /dev/null +++ b/packages/plugin-api/tests/themeRegistryStore.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { themeRegistryStore } from '../src/theme/themeRegistryStore'; + +const makeTheme = (overrides = {}) => ({ + id: 'test-theme', + name: 'Test Theme', + colorScheme: 'dark' as const, + tokens: { '--bg-base': '#111', '--text-primary': '#eee' }, + pluginId: 'test-plugin', + ...overrides, +}); + +describe('themeRegistryStore', () => { + beforeEach(() => { + const state = themeRegistryStore.getState(); + for (const t of state.themes) { + state.unregister(t.id); + } + state.setActive(null); + }); + + it('registers a valid theme', () => { + const result = themeRegistryStore.getState().register(makeTheme()); + expect(result).toBe(true); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + }); + + it('rejects theme with no valid tokens', () => { + const result = themeRegistryStore.getState().register( + makeTheme({ tokens: { '--invalid': 'red' } }) + ); + expect(result).toBe(false); + expect(themeRegistryStore.getState().themes).toHaveLength(0); + }); + + it('strips invalid tokens but keeps valid ones', () => { + themeRegistryStore.getState().register( + makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) + ); + const theme = themeRegistryStore.getState().themes[0]!; + expect(theme.tokens).toEqual({ '--bg-base': '#000' }); + }); + + it('replaces theme with same id', () => { + themeRegistryStore.getState().register(makeTheme({ name: 'V1' })); + themeRegistryStore.getState().register(makeTheme({ name: 'V2' })); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + expect(themeRegistryStore.getState().themes[0]!.name).toBe('V2'); + }); + + it('unregister removes theme and deactivates if active', () => { + themeRegistryStore.getState().register(makeTheme()); + themeRegistryStore.getState().setActive('test-theme'); + themeRegistryStore.getState().unregister('test-theme'); + expect(themeRegistryStore.getState().themes).toHaveLength(0); + expect(themeRegistryStore.getState().activeThemeId).toBeNull(); + }); + + it('unregisterAll removes all themes for a plugin', () => { + themeRegistryStore.getState().register(makeTheme({ id: 'a', pluginId: 'p1' })); + themeRegistryStore.getState().register(makeTheme({ id: 'b', pluginId: 'p1' })); + themeRegistryStore.getState().register(makeTheme({ id: 'c', pluginId: 'p2' })); + themeRegistryStore.getState().unregisterAll('p1'); + expect(themeRegistryStore.getState().themes).toHaveLength(1); + expect(themeRegistryStore.getState().themes[0]!.id).toBe('c'); + }); + + it('setActive ignores unknown theme ID', () => { + themeRegistryStore.getState().setActive('nonexistent'); + expect(themeRegistryStore.getState().activeThemeId).toBeNull(); + }); + + it('getActiveTheme returns the active theme', () => { + themeRegistryStore.getState().register(makeTheme()); + themeRegistryStore.getState().setActive('test-theme'); + const active = themeRegistryStore.getState().getActiveTheme(); + expect(active?.id).toBe('test-theme'); + }); + + it('getActiveTheme returns null when no theme active', () => { + expect(themeRegistryStore.getState().getActiveTheme()).toBeNull(); + }); +}); diff --git a/packages/plugin-api/tests/themeTypes.test.ts b/packages/plugin-api/tests/themeTypes.test.ts new file mode 100644 index 00000000..319431f4 --- /dev/null +++ b/packages/plugin-api/tests/themeTypes.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { isValidThemeToken, validateThemeTokens } from '../src/theme/themeTypes'; + +describe('isValidThemeToken', () => { + it('accepts core tokens', () => { + expect(isValidThemeToken('--bg-base')).toBe(true); + expect(isValidThemeToken('--text-primary')).toBe(true); + expect(isValidThemeToken('--danger')).toBe(true); + expect(isValidThemeToken('--status-active')).toBe(true); + }); + + it('accepts extension scope tokens', () => { + expect(isValidThemeToken('--syntax-keyword')).toBe(true); + expect(isValidThemeToken('--preview-heading-color')).toBe(true); + expect(isValidThemeToken('--ui-sidebar-bg')).toBe(true); + }); + + it('rejects unknown tokens', () => { + expect(isValidThemeToken('--custom-thing')).toBe(false); + expect(isValidThemeToken('--accent')).toBe(false); + expect(isValidThemeToken('color')).toBe(false); + expect(isValidThemeToken('--font-sans')).toBe(false); + }); +}); + +describe('validateThemeTokens', () => { + it('returns only valid tokens', () => { + const result = validateThemeTokens({ + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, 'test-theme'); + + expect(result).toEqual({ + '--bg-base': '#000', + '--text-primary': '#fff', + '--syntax-keyword': '#f0f', + }); + }); + + it('returns empty object for all-invalid tokens', () => { + const result = validateThemeTokens({ '--nope': 'red' }, 'test-theme'); + expect(result).toEqual({}); + }); +}); From 35c346e6dbebf9d9988562abc2bfbd48257f869b Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 16:35:24 -0300 Subject: [PATCH 084/148] style: fix Prettier formatting across theme system and sync files Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/services/syncService.ts | 15 +- apps/desktop/src/renderer/App.tsx | 2 +- .../renderer/hooks/useAppearanceSettings.ts | 12 +- .../2026-03-11-theme-system-implementation.md | 145 +++++++++++++----- packages/api/src/routes/sync.ts | 109 ++++++------- packages/plugin-api/src/index.ts | 7 +- .../src/theme/themeRegistryStore.ts | 7 +- packages/plugin-api/src/theme/themeTypes.ts | 32 +++- .../tests/themeRegistryStore.test.ts | 12 +- packages/plugin-api/tests/themeTypes.test.ts | 15 +- .../src/repositories/SQLiteNoteRepository.ts | 28 +++- 11 files changed, 256 insertions(+), 128 deletions(-) diff --git a/apps/desktop/src/main/services/syncService.ts b/apps/desktop/src/main/services/syncService.ts index 04a56b78..168f21d3 100644 --- a/apps/desktop/src/main/services/syncService.ts +++ b/apps/desktop/src/main/services/syncService.ts @@ -321,7 +321,11 @@ export class SyncService { this.noteRepository.deleteTagByUuid(change.tagId); } else if (change.data) { const parsed = JSON.parse(change.data); - this.noteRepository.upsertTagFromRemote(change.tagId, parsed.name, parsed.color ?? null); + this.noteRepository.upsertTagFromRemote( + change.tagId, + parsed.name, + parsed.color ?? null + ); } applied++; } catch (error) { @@ -330,9 +334,8 @@ export class SyncService { } } - this.state.tagCursor = applied === result.changes.length - ? result.cursor - : this.state.tagCursor; + this.state.tagCursor = + applied === result.changes.length ? result.cursor : this.state.tagCursor; return { success: true, applied }; } catch (error) { @@ -367,9 +370,7 @@ export class SyncService { const result = await this.apiClient.pushTagChanges(changes); - const successIds = result.results - .filter(r => r.status === 'applied') - .map(r => r.tagId); + const successIds = result.results.filter(r => r.status === 'applied').map(r => r.tagId); this.noteRepository.markMultipleTagsAsSynced(successIds); this.state.tagCursor = result.cursor; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 891445f6..1811f430 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -78,7 +78,7 @@ const queryClient = new QueryClient({ function NotesApp() { usePerformanceMode(); useAppearanceSettings(); - useThemeOverrides(); // Applies active theme tokens + useThemeOverrides(); // Applies active theme tokens useCssVariables(); // Restore saved plugin theme on startup diff --git a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts index 31ccf56f..9516d010 100644 --- a/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts +++ b/apps/desktop/src/renderer/hooks/useAppearanceSettings.ts @@ -5,10 +5,16 @@ import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; /** * Apply appearance settings to the DOM. */ -function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { +function applyAppearance( + theme: string, + accentColor: string, + zoomLevel: string, + isDark?: boolean +): void { let resolved: string; if (theme === 'system') { - resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + resolved = + (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; } else { resolved = theme; } @@ -60,7 +66,7 @@ export function useAppearanceSettings(): void { // Listen for system theme changes via IPC useEffect(() => { if (theme !== 'system') return; - const unsub = window.readied.theme.onSystemChanged((isDark) => { + const unsub = window.readied.theme.onSystemChanged(isDark => { applyAppearance('system', accentColor, zoomLevel, isDark); }); return unsub; diff --git a/docs/plans/2026-03-11-theme-system-implementation.md b/docs/plans/2026-03-11-theme-system-implementation.md index 02e9fde3..974077ce 100644 --- a/docs/plans/2026-03-11-theme-system-implementation.md +++ b/docs/plans/2026-03-11-theme-system-implementation.md @@ -13,6 +13,7 @@ ### Task 1: Define theme types and token whitelist **Files:** + - Create: `packages/plugin-api/src/theme/themeTypes.ts` **Step 1: Create the types file** @@ -27,18 +28,36 @@ /** Core CSS tokens that themes are allowed to override */ export const CORE_THEME_TOKENS = [ // Backgrounds - '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', + '--bg-base', + '--bg-surface', + '--bg-elevated', + '--bg-inset', // Text - '--text-primary', '--text-secondary', '--text-muted', '--text-faint', + '--text-primary', + '--text-secondary', + '--text-muted', + '--text-faint', // Borders - '--border', '--border-subtle', '--border-strong', + '--border', + '--border-subtle', + '--border-strong', // Glass - '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', + '--glass-bg', + '--glass-border', + '--glass-bg-menu', + '--glass-border-menu', // Semantic - '--danger', '--danger-muted', '--warning', '--warning-muted', - '--success', '--success-muted', + '--danger', + '--danger-muted', + '--warning', + '--warning-muted', + '--success', + '--success-muted', // Status - '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', + '--status-active', + '--status-on-hold', + '--status-completed', + '--status-dropped', ] as const; /** Valid extension scope prefixes for non-core tokens */ @@ -103,6 +122,7 @@ git commit -m "feat(plugin-api): add theme types and token whitelist" ### Task 2: Create ThemeRegistry store **Files:** + - Create: `packages/plugin-api/src/theme/themeRegistryStore.ts` - Modify: `packages/plugin-api/src/index.ts` (add exports) @@ -152,7 +172,9 @@ export const themeRegistryStore = createStore((set, get) => set(state => ({ themes: [...state.themes.filter(t => t.id !== theme.id), validated], })); - console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + console.debug( + `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` + ); return true; }, @@ -172,8 +194,8 @@ export const themeRegistryStore = createStore((set, get) => unregisterAll(pluginId) { set(state => { const remaining = state.themes.filter(t => t.pluginId !== pluginId); - const activeRemoved = state.activeThemeId && - !remaining.some(t => t.id === state.activeThemeId); + const activeRemoved = + state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); return { themes: remaining, activeThemeId: activeRemoved ? null : state.activeThemeId, @@ -203,9 +225,15 @@ export const themeRegistryStore = createStore((set, get) => **Step 2: Export from barrel** Add to `packages/plugin-api/src/index.ts`: + ```typescript export { themeRegistryStore } from './theme/themeRegistryStore'; -export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export { + isValidThemeToken, + validateThemeTokens, + CORE_THEME_TOKENS, + THEME_EXTENSION_SCOPES, +} from './theme/themeTypes'; export type { ThemeDefinition } from './theme/themeTypes'; ``` @@ -221,6 +249,7 @@ git commit -m "feat(plugin-api): add ThemeRegistry store with validation" ### Task 3: Create useThemeOverrides hook **Files:** + - Create: `packages/plugin-api/src/theme/useThemeOverrides.ts` - Modify: `packages/plugin-api/src/index.ts` (add export) @@ -275,6 +304,7 @@ export function useThemeOverrides(): void { **Step 2: Export from barrel** Add to `packages/plugin-api/src/index.ts`: + ```typescript export { useThemeOverrides } from './theme/useThemeOverrides'; ``` @@ -291,6 +321,7 @@ git commit -m "feat(plugin-api): add useThemeOverrides hook" ### Task 4: Wire useThemeOverrides into App.tsx **Files:** + - Modify: `apps/desktop/src/renderer/App.tsx` **Step 1: Add the hook call** @@ -303,11 +334,12 @@ import { useThemeOverrides } from '@readied/plugin-api'; // Inside NotesApp component: usePerformanceMode(); useAppearanceSettings(); -useThemeOverrides(); // NEW — applies active theme tokens +useThemeOverrides(); // NEW — applies active theme tokens useCssVariables(); ``` Order matters: + 1. `useAppearanceSettings` sets base `data-theme` + accent 2. `useThemeOverrides` overrides with active theme tokens (may change `data-theme`) 3. `useCssVariables` applies individual plugin CSS vars on top @@ -324,12 +356,14 @@ git commit -m "feat(desktop): wire useThemeOverrides into app initialization" ### Task 5: Add registerTheme to PluginContext **Files:** + - Modify: `packages/plugin-api/src/types.ts` (add to PluginContext interface) - Modify: `packages/plugin-api/src/lifecycle/PluginRegistry.ts` (implement in activate) **Step 1: Add to PluginContext interface** In `types.ts`, add to the `PluginContext` interface: + ```typescript /** Register a complete theme with validated tokens */ registerTheme(theme: { @@ -345,11 +379,13 @@ registerTheme(theme: { **Step 2: Implement in PluginRegistry.activate()** In `PluginRegistry.ts`, import `themeRegistryStore`: + ```typescript import { themeRegistryStore } from '../theme/themeRegistryStore'; ``` Add to the context object inside `activate()`: + ```typescript registerTheme: (theme): (() => void) => { themeRegistryStore.getState().register({ @@ -361,12 +397,14 @@ registerTheme: (theme): (() => void) => { ``` Add cleanup in `deactivate()` (after existing cleanup lines): + ```typescript // Cleanup theme registrations themeRegistryStore.getState().unregisterAll(id); ``` Also add cleanup in the catch block of `activate()` (error recovery): + ```typescript themeRegistryStore.getState().unregisterAll(id); ``` @@ -383,16 +421,19 @@ git commit -m "feat(plugin-api): add registerTheme to PluginContext" ### Task 6: nativeTheme sync — main process **Files:** + - Modify: `apps/desktop/src/main/index.ts` **Step 1: Add nativeTheme IPC handlers** At the top of the file, import nativeTheme: + ```typescript import { nativeTheme } from 'electron'; ``` Add IPC handlers (near other IPC handler registrations): + ```typescript // Theme — sync Electron nativeTheme with renderer ipcMain.on('theme:set-source', (_event, source: string) => { @@ -422,11 +463,13 @@ git commit -m "feat(desktop): add nativeTheme IPC sync in main process" ### Task 7: nativeTheme sync — preload API **Files:** + - Modify: `apps/desktop/src/preload/index.ts` **Step 1: Add theme methods to ReadiedAPI** In the `ReadiedAPI` interface, add: + ```typescript theme: { /** Set Electron's native theme source */ @@ -437,6 +480,7 @@ theme: { ``` In the `contextBridge.exposeInMainWorld('readied', ...)` implementation: + ```typescript theme: { setSource: (source: string) => { @@ -464,6 +508,7 @@ git commit -m "feat(desktop): add theme IPC bridge in preload" ### Task 8: Update useAppearanceSettings to use nativeTheme IPC **Files:** + - Modify: `apps/desktop/src/renderer/hooks/useAppearanceSettings.ts` **Step 1: Replace media query listener with IPC** @@ -475,11 +520,17 @@ import { useEffect } from 'react'; import { useSettingsStore, selectAppearance } from '../stores/settings'; import { computeHoverColor, hexToRgb } from '../utils/colorUtils'; -function applyAppearance(theme: string, accentColor: string, zoomLevel: string, isDark?: boolean): void { +function applyAppearance( + theme: string, + accentColor: string, + zoomLevel: string, + isDark?: boolean +): void { let resolved: string; if (theme === 'system') { // Use provided isDark hint, or fall back to media query for initial render - resolved = (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + resolved = + (isDark ?? window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; } else { resolved = theme; } @@ -527,7 +578,7 @@ export function useAppearanceSettings(): void { useEffect(() => { if (theme !== 'system') return; - const unsub = window.readied.theme.onSystemChanged((isDark) => { + const unsub = window.readied.theme.onSystemChanged(isDark => { applyAppearance('system', accentColor, zoomLevel, isDark); }); return unsub; @@ -547,17 +598,20 @@ git commit -m "feat(desktop): use nativeTheme IPC for system theme detection" ### Task 9: Add activeThemeId to AppearanceSettings **Files:** + - Modify: `apps/desktop/src/renderer/stores/settings/schema.ts` **Step 1: Add field** In `AppearanceSettings` interface: + ```typescript /** Active plugin theme ID (null = use base dark/light) */ activeThemeId: string | null; ``` In `DEFAULT_APPEARANCE`: + ```typescript activeThemeId: null, ``` @@ -578,17 +632,20 @@ git commit -m "feat(desktop): add activeThemeId to appearance settings" ### Task 10: Update AppearanceSection UI with theme selector **Files:** + - Modify: `apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx` **Step 1: Add theme selector (only shown when themes exist)** Import the theme registry: + ```typescript import { useSyncExternalStore } from 'react'; import { themeRegistryStore } from '@readied/plugin-api'; ``` Inside the component, subscribe to themes: + ```typescript const themeRegs = useSyncExternalStore( themeRegistryStore.subscribe, @@ -601,6 +658,7 @@ const activeThemeId = useSyncExternalStore( ``` Add handler: + ```typescript const handlePluginThemeChange = (value: string) => { const newId = value === 'default' ? null : value; @@ -610,6 +668,7 @@ const handlePluginThemeChange = (value: string) => { ``` Add UI below the accent color picker (inside the "Theme" SettingGroup), only if themes are registered: + ```typescript {themeRegs.length > 0 && ( { - const savedThemeId = appearance?.activeThemeId; - if (savedThemeId) { - themeRegistryStore.getState().setActive(savedThemeId); - } -}, [/* run once after plugin init */]); +useEffect( + () => { + const savedThemeId = appearance?.activeThemeId; + if (savedThemeId) { + themeRegistryStore.getState().setActive(savedThemeId); + } + }, + [ + /* run once after plugin init */ + ] +); ``` The exact placement depends on plugin loading lifecycle — the theme must be restored AFTER plugins have registered their themes. @@ -675,6 +740,7 @@ git commit -m "feat(desktop): restore active plugin theme on startup" ### Task 12: Tests for themeTypes validation **Files:** + - Create: `packages/plugin-api/tests/themeTypes.test.ts` **Step 1: Write tests** @@ -705,12 +771,15 @@ describe('isValidThemeToken', () => { describe('validateThemeTokens', () => { it('returns only valid tokens', () => { - const result = validateThemeTokens({ - '--bg-base': '#000', - '--text-primary': '#fff', - '--invalid-token': 'red', - '--syntax-keyword': '#f0f', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, + 'test-theme' + ); expect(result).toEqual({ '--bg-base': '#000', @@ -720,9 +789,12 @@ describe('validateThemeTokens', () => { }); it('returns empty object for all-invalid tokens', () => { - const result = validateThemeTokens({ - '--nope': 'red', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--nope': 'red', + }, + 'test-theme' + ); expect(result).toEqual({}); }); }); @@ -746,6 +818,7 @@ git commit -m "test(plugin-api): add theme token validation tests" ### Task 13: Tests for ThemeRegistry store **Files:** + - Create: `packages/plugin-api/tests/themeRegistryStore.test.ts` **Step 1: Write tests** @@ -780,17 +853,17 @@ describe('themeRegistryStore', () => { }); it('rejects theme with no valid tokens', () => { - const result = themeRegistryStore.getState().register( - makeTheme({ tokens: { '--invalid': 'red' } }) - ); + const result = themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--invalid': 'red' } })); expect(result).toBe(false); expect(themeRegistryStore.getState().themes).toHaveLength(0); }); it('strips invalid tokens but keeps valid ones', () => { - themeRegistryStore.getState().register( - makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) - ); + themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } })); const theme = themeRegistryStore.getState().themes[0]!; expect(theme.tokens).toEqual({ '--bg-base': '#000' }); }); diff --git a/packages/api/src/routes/sync.ts b/packages/api/src/routes/sync.ts index 7c0e1964..d9f79e58 100644 --- a/packages/api/src/routes/sync.ts +++ b/packages/api/src/routes/sync.ts @@ -398,66 +398,71 @@ sync.post('/notebooks', zValidator('json', notebookPushSchema), async c => { } // Process changes in transaction - const { results: notebookResults, finalCursor: notebookFinalCursor } = await db.transaction(async tx => { - const [maxVersionResult] = await tx - .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) - .from(notebookSyncLog) - .where(eq(notebookSyncLog.userId, userId)); - - let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; - - const txResults: Array<{ - notebookId: string; - version: number; - status: 'applied' | 'conflict'; - serverVersion?: number; - }> = []; - - for (const change of changes) { - const [latestEntry] = await tx - .select() + const { results: notebookResults, finalCursor: notebookFinalCursor } = await db.transaction( + async tx => { + const [maxVersionResult] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${notebookSyncLog.version}), 0)` }) .from(notebookSyncLog) - .where( - and(eq(notebookSyncLog.userId, userId), eq(notebookSyncLog.notebookId, change.notebookId)) - ) - .orderBy(desc(notebookSyncLog.version)) - .limit(1); + .where(eq(notebookSyncLog.userId, userId)); + + let nextVersion = (maxVersionResult?.maxVersion ?? 0) + 1; + + const txResults: Array<{ + notebookId: string; + version: number; + status: 'applied' | 'conflict'; + serverVersion?: number; + }> = []; + + for (const change of changes) { + const [latestEntry] = await tx + .select() + .from(notebookSyncLog) + .where( + and( + eq(notebookSyncLog.userId, userId), + eq(notebookSyncLog.notebookId, change.notebookId) + ) + ) + .orderBy(desc(notebookSyncLog.version)) + .limit(1); + + if ( + latestEntry && + latestEntry.deviceId !== deviceId && + change.localVersion !== undefined && + latestEntry.version > change.localVersion + ) { + txResults.push({ + notebookId: change.notebookId, + version: latestEntry.version, + status: 'conflict', + serverVersion: latestEntry.version, + }); + continue; + } - if ( - latestEntry && - latestEntry.deviceId !== deviceId && - change.localVersion !== undefined && - latestEntry.version > change.localVersion - ) { - txResults.push({ + await tx.insert(notebookSyncLog).values({ + userId, notebookId: change.notebookId, - version: latestEntry.version, - status: 'conflict', - serverVersion: latestEntry.version, + version: nextVersion, + operation: change.operation, + data: change.data ?? null, + deviceId, }); - continue; - } - await tx.insert(notebookSyncLog).values({ - userId, - notebookId: change.notebookId, - version: nextVersion, - operation: change.operation, - data: change.data ?? null, - deviceId, - }); + txResults.push({ + notebookId: change.notebookId, + version: nextVersion, + status: 'applied', + }); - txResults.push({ - notebookId: change.notebookId, - version: nextVersion, - status: 'applied', - }); + nextVersion++; + } - nextVersion++; + return { results: txResults, finalCursor: nextVersion - 1 }; } - - return { results: txResults, finalCursor: nextVersion - 1 }; - }); + ); return c.json({ results: notebookResults, cursor: notebookFinalCursor }); }); diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 82d28552..bd3dac16 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -51,7 +51,12 @@ export type { CssVariableRegistration } from './theme/cssVariableStore'; export { useCssVariables } from './theme/useCssVariables'; export { useThemeOverrides } from './theme/useThemeOverrides'; export { themeRegistryStore } from './theme/themeRegistryStore'; -export { isValidThemeToken, validateThemeTokens, CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES } from './theme/themeTypes'; +export { + isValidThemeToken, + validateThemeTokens, + CORE_THEME_TOKENS, + THEME_EXTENSION_SCOPES, +} from './theme/themeTypes'; export type { ThemeDefinition } from './theme/themeTypes'; // Validation diff --git a/packages/plugin-api/src/theme/themeRegistryStore.ts b/packages/plugin-api/src/theme/themeRegistryStore.ts index eb8b4ff2..61a41947 100644 --- a/packages/plugin-api/src/theme/themeRegistryStore.ts +++ b/packages/plugin-api/src/theme/themeRegistryStore.ts @@ -33,7 +33,9 @@ export const themeRegistryStore = createStore((set, get) => set(state => ({ themes: [...state.themes.filter(t => t.id !== theme.id), validated], })); - console.debug(`[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)`); + console.debug( + `[ThemeRegistry] Registered: "${theme.name}" (${Object.keys(validTokens).length} tokens)` + ); return true; }, @@ -47,7 +49,8 @@ export const themeRegistryStore = createStore((set, get) => unregisterAll(pluginId) { set(state => { const remaining = state.themes.filter(t => t.pluginId !== pluginId); - const activeRemoved = state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); + const activeRemoved = + state.activeThemeId && !remaining.some(t => t.id === state.activeThemeId); return { themes: remaining, activeThemeId: activeRemoved ? null : state.activeThemeId, diff --git a/packages/plugin-api/src/theme/themeTypes.ts b/packages/plugin-api/src/theme/themeTypes.ts index 4d52ac6b..8a2158d1 100644 --- a/packages/plugin-api/src/theme/themeTypes.ts +++ b/packages/plugin-api/src/theme/themeTypes.ts @@ -6,13 +6,31 @@ /** Core CSS tokens that themes are allowed to override */ export const CORE_THEME_TOKENS = [ - '--bg-base', '--bg-surface', '--bg-elevated', '--bg-inset', - '--text-primary', '--text-secondary', '--text-muted', '--text-faint', - '--border', '--border-subtle', '--border-strong', - '--glass-bg', '--glass-border', '--glass-bg-menu', '--glass-border-menu', - '--danger', '--danger-muted', '--warning', '--warning-muted', - '--success', '--success-muted', - '--status-active', '--status-on-hold', '--status-completed', '--status-dropped', + '--bg-base', + '--bg-surface', + '--bg-elevated', + '--bg-inset', + '--text-primary', + '--text-secondary', + '--text-muted', + '--text-faint', + '--border', + '--border-subtle', + '--border-strong', + '--glass-bg', + '--glass-border', + '--glass-bg-menu', + '--glass-border-menu', + '--danger', + '--danger-muted', + '--warning', + '--warning-muted', + '--success', + '--success-muted', + '--status-active', + '--status-on-hold', + '--status-completed', + '--status-dropped', ] as const; /** Valid extension scope prefixes for non-core tokens */ diff --git a/packages/plugin-api/tests/themeRegistryStore.test.ts b/packages/plugin-api/tests/themeRegistryStore.test.ts index f52cb399..aedf3119 100644 --- a/packages/plugin-api/tests/themeRegistryStore.test.ts +++ b/packages/plugin-api/tests/themeRegistryStore.test.ts @@ -26,17 +26,17 @@ describe('themeRegistryStore', () => { }); it('rejects theme with no valid tokens', () => { - const result = themeRegistryStore.getState().register( - makeTheme({ tokens: { '--invalid': 'red' } }) - ); + const result = themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--invalid': 'red' } })); expect(result).toBe(false); expect(themeRegistryStore.getState().themes).toHaveLength(0); }); it('strips invalid tokens but keeps valid ones', () => { - themeRegistryStore.getState().register( - makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } }) - ); + themeRegistryStore + .getState() + .register(makeTheme({ tokens: { '--bg-base': '#000', '--nope': 'red' } })); const theme = themeRegistryStore.getState().themes[0]!; expect(theme.tokens).toEqual({ '--bg-base': '#000' }); }); diff --git a/packages/plugin-api/tests/themeTypes.test.ts b/packages/plugin-api/tests/themeTypes.test.ts index 319431f4..d9bad65c 100644 --- a/packages/plugin-api/tests/themeTypes.test.ts +++ b/packages/plugin-api/tests/themeTypes.test.ts @@ -25,12 +25,15 @@ describe('isValidThemeToken', () => { describe('validateThemeTokens', () => { it('returns only valid tokens', () => { - const result = validateThemeTokens({ - '--bg-base': '#000', - '--text-primary': '#fff', - '--invalid-token': 'red', - '--syntax-keyword': '#f0f', - }, 'test-theme'); + const result = validateThemeTokens( + { + '--bg-base': '#000', + '--text-primary': '#fff', + '--invalid-token': 'red', + '--syntax-keyword': '#f0f', + }, + 'test-theme' + ); expect(result).toEqual({ '--bg-base': '#000', diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 476e1f6f..71acdfd0 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -846,7 +846,12 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { /** * Get tags with pending sync changes */ - getTagsPendingSync(limit: number): Array<{ tag: { id: number; uuid: string; name: string; color: string | null }; localVersion: number }> { + getTagsPendingSync( + limit: number + ): Array<{ + tag: { id: number; uuid: string; name: string; color: string | null }; + localVersion: number; + }> { const stmt = this.db.prepare(` SELECT id, uuid, name, color, local_version FROM tags @@ -900,13 +905,16 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { const normalized = name.trim().toLowerCase(); // Check if tag exists by UUID first - const byUuid = this.db.prepare('SELECT id, name, color FROM tags WHERE uuid = ?').get(uuid) as - | { id: number; name: string; color: string | null } - | undefined; + const byUuid = this.db + .prepare('SELECT id, name, color FROM tags WHERE uuid = ?') + .get(uuid) as { id: number; name: string; color: string | null } | undefined; if (byUuid) { // Update existing tag - this.db.prepare('UPDATE tags SET name = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE uuid = ?') + this.db + .prepare( + 'UPDATE tags SET name = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE uuid = ?' + ) .run(normalized, color, new Date().toISOString(), uuid); return byUuid.id; } @@ -918,13 +926,19 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { if (byName) { // Merge: adopt the remote UUID, update color - this.db.prepare('UPDATE tags SET uuid = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE id = ?') + this.db + .prepare( + 'UPDATE tags SET uuid = ?, color = ?, needs_sync = 0, last_synced_at = ? WHERE id = ?' + ) .run(uuid, color, new Date().toISOString(), byName.id); return byName.id; } // Create new tag - const result = this.db.prepare('INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)') + const result = this.db + .prepare( + 'INSERT INTO tags (name, color, uuid, needs_sync, last_synced_at) VALUES (?, ?, ?, 0, ?)' + ) .run(normalized, color, uuid, new Date().toISOString()); return Number(result.lastInsertRowid); }); From 35f3804954730aaafcb3cdefc35990aa9f2cee60 Mon Sep 17 00:00:00 2001 From: tomymaritano Date: Wed, 11 Mar 2026 20:53:36 -0300 Subject: [PATCH 085/148] docs: complete documentation update for v0.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update home page features (plugins, themes, sync) - Update getting-started with notebooks, wikilinks, sync, themes - Update principles: "Offline First" → "Offline by Default" - Rewrite architecture docs (16 packages, sync, theming, IPC) - Update plugin API reference (getTheme, onThemeChanged, manifest fields) - Add theme plugin and remark plugin examples - Create new Cloud Sync guide - Create new Built-in Plugins showcase (all 8 plugins) - Update VitePress sidebar with new pages Co-Authored-By: Claude Opus 4.6 --- apps/docs-site/.vitepress/config.ts | 2 + apps/docs-site/architecture/core.md | 20 ++- apps/docs-site/architecture/editor.md | 53 ++++++- apps/docs-site/architecture/ipc.md | 89 ++++++++++- apps/docs-site/architecture/overview.md | 124 +++++++++++---- apps/docs-site/architecture/storage.md | 51 ++++++- apps/docs-site/architecture/theming.md | 177 ++++++++++++++++++---- apps/docs-site/guide/built-in-plugins.md | 104 +++++++++++++ apps/docs-site/guide/getting-started.md | 36 ++++- apps/docs-site/guide/principles.md | 9 +- apps/docs-site/guide/sync.md | 58 +++++++ apps/docs-site/index.md | 26 ++-- apps/docs-site/plugins/api-reference.md | 162 +++++++++++++++++++- apps/docs-site/plugins/examples.md | 129 ++++++++++++++++ apps/docs-site/plugins/getting-started.md | 4 + 15 files changed, 952 insertions(+), 92 deletions(-) create mode 100644 apps/docs-site/guide/built-in-plugins.md create mode 100644 apps/docs-site/guide/sync.md diff --git a/apps/docs-site/.vitepress/config.ts b/apps/docs-site/.vitepress/config.ts index a4523808..e381677f 100644 --- a/apps/docs-site/.vitepress/config.ts +++ b/apps/docs-site/.vitepress/config.ts @@ -46,6 +46,8 @@ export default defineConfig({ items: [ { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'Principles', link: '/guide/principles' }, + { text: 'Cloud Sync', link: '/guide/sync' }, + { text: 'Built-in Plugins', link: '/guide/built-in-plugins' }, ], }, ], diff --git a/apps/docs-site/architecture/core.md b/apps/docs-site/architecture/core.md index c02069cf..a7d85a8e 100644 --- a/apps/docs-site/architecture/core.md +++ b/apps/docs-site/architecture/core.md @@ -60,7 +60,7 @@ Operations are pure functions that take input and a repository: // createNote.ts export async function createNoteOperation( input: CreateNoteInput, - repo: NoteRepository + repo: NoteRepository, ): Promise> { // Validate input // Create Note entity @@ -83,10 +83,26 @@ interface NoteRepository { } ``` +## Companion Packages + +Several packages extend core's markdown parsing capabilities. These are separate packages but work closely with the core domain: + +### Wikilinks (`@readied/wikilinks`) + +Parses `[[wikilink]]` syntax from markdown content. Extracts link targets and display text, enabling bidirectional linking between notes. The parser identifies wikilinks in raw markdown so the editor and storage layers can resolve them to actual note references. + +### Tasks (`@readied/tasks`) + +Extracts task items (`- [ ]` and `- [x]`) from markdown content. Provides structured task data including completion status, enabling task-oriented views and aggregation across notes. + +### Embeds (`@readied/embeds`) + +Resolves URLs to rich embed metadata. Recognizes known URL patterns (YouTube, images, etc.) and provides embed type information so the renderer can display rich previews instead of plain links. + ## Testing ```bash pnpm --filter @readied/core test ``` -52 tests covering all domain logic. +68 tests covering all domain logic. diff --git a/apps/docs-site/architecture/editor.md b/apps/docs-site/architecture/editor.md index 09e9478a..f130760c 100644 --- a/apps/docs-site/architecture/editor.md +++ b/apps/docs-site/architecture/editor.md @@ -35,7 +35,7 @@ const view = new EditorView({ - `@codemirror/lang-markdown` - Markdown syntax - `@codemirror/language-data` - Code block highlighting - `@codemirror/commands` - Keyboard commands -- Custom theme (matches app design) +- Custom theme (matches app design tokens) ## Editor <-> Note Sync @@ -66,3 +66,54 @@ Editor styles are isolated from the main UI: padding: 1rem; } ``` + +## Plugin Extensions + +Plugins can register custom CodeMirror extensions that are injected into the editor. This enables plugins to extend the editor without modifying core code. + +```typescript +// In a plugin's activate() +context.registerEditorExtension(myExtension); +``` + +Plugin extensions are collected and merged into the editor's extension array. When a plugin is enabled or disabled, the editor reconfigures itself with the updated set of extensions. + +### What Plugins Can Add + +- **Keybindings** - Custom keyboard shortcuts +- **Syntax extensions** - Additional highlighting or parsing rules +- **Widgets** - Inline or block-level UI elements within the editor +- **Decorations** - Line highlights, markers, and visual annotations + +## Decoration API + +The editor supports decorations for visual enhancements: + +- **Line decorations** - Highlight entire lines (e.g., active line highlight) +- **Mark decorations** - Style ranges of text inline +- **Widget decorations** - Insert arbitrary DOM elements at positions +- **Replace decorations** - Replace text ranges with widgets + +The built-in active line highlight plugin uses line decorations to visually distinguish the current cursor line. + +## Wikilink Support + +The `@readied/wikilinks` package integrates with the editor to provide: + +- **Autocomplete** - Typing `[[` triggers a completion popup with matching note titles +- **Navigation** - Clicking or activating a `[[wikilink]]` navigates to the linked note +- **Syntax highlighting** - Wikilink syntax is highlighted distinctly in the editor + +Wikilinks are parsed in real-time as the user types, and the link targets are resolved against the current note database. + +## Embed Resolution + +The `@readied/embeds` package resolves URLs in the editor preview: + +- Recognized URLs (YouTube, images, etc.) are rendered as rich embeds +- Embed resolution happens during preview rendering, not in the editor itself +- The editor displays the raw URL; the preview shows the resolved embed + +## Active Line Highlight + +A built-in plugin highlights the line where the cursor is positioned. This uses CodeMirror's decoration system to apply a subtle background color to the active line, improving focus during editing. diff --git a/apps/docs-site/architecture/ipc.md b/apps/docs-site/architecture/ipc.md index aa3a8ff6..7daeb907 100644 --- a/apps/docs-site/architecture/ipc.md +++ b/apps/docs-site/architecture/ipc.md @@ -16,7 +16,66 @@ Core + Storage ## Preload API -The preload script exposes a typed API: +The preload script exposes a typed API via `window.readied`. The API is organized into namespaces by domain: + +### `notes` - Note Operations + +Core CRUD operations plus advanced note management: + +- **CRUD** - create, get, update, delete +- **Organization** - archive, restore, pin/unpin, move to notebook, duplicate +- **Querying** - list (with filters), search, count, tags +- **Bulk operations** - bulk delete, bulk archive, bulk move + +### `notebooks` - Notebook Management + +Hierarchical notebook organization: + +- **CRUD** - create, get, update, delete +- **Hierarchy** - list with parent/child relationships +- **Note association** - move notes between notebooks + +### `tags` - Tag Management + +- List all tags +- Tag/untag notes + +### `data` - Data Management + +Backup, export, and import functionality: + +- **Backup** - create and restore backups +- **Export** - export notes as markdown files or JSON +- **Import** - import from files +- **Paths** - get data directory paths +- **Open folder** - reveal data folder in OS file manager + +### `app` - Application Info + +- **Version** - app version string +- **Platform** - OS platform info + +### `sync` - Cloud Sync + +Supabase-based sync operations: + +- **Authentication** - login, logout, get auth status +- **Sync status** - check sync state, last synced time +- **Operations** - push local changes, pull remote changes + +### `appearance` - Appearance Settings + +- **Theme** - get/set color scheme (dark, light, system) +- **Accent** - get/set accent color +- **Zoom** - get/set zoom level + +### `plugins` - Plugin System + +- **Discovery** - scan for available plugins +- **Lifecycle** - load, enable, disable plugins +- **State** - get plugin status and metadata + +## Example ```typescript interface ReadiedAPI { @@ -27,11 +86,22 @@ interface ReadiedAPI { delete: (id: string) => Promise>; archive: (id: string) => Promise>; restore: (id: string) => Promise>; + pin: (id: string) => Promise>; + move: (id: string, notebookId: string) => Promise>; + duplicate: (id: string) => Promise>; list: (options?: ListOptions) => Promise; search: (query: string) => Promise; - tags: () => Promise; count: () => Promise; + // ... bulk operations }; + notebooks: { + create: (input: CreateNotebookInput) => Promise>; + list: () => Promise; + update: (input: UpdateNotebookInput) => Promise>; + delete: (id: string) => Promise>; + // ... + }; + tags: { /* ... */ }; data: { backup: () => Promise; export: () => Promise; @@ -41,7 +111,11 @@ interface ReadiedAPI { }; app: { version: () => string; + platform: () => string; }; + sync: { /* login, logout, status, push, pull */ }; + appearance: { /* theme, accent, zoom */ }; + plugins: { /* scan, load, enable, disable */ }; } // Exposed as window.readied @@ -60,6 +134,10 @@ ipcMain.handle('notes:list', async (_event, options) => { const notes = await noteRepository.list(options); return notes.map(toSnapshot); }); + +ipcMain.handle('sync:push', async () => { + return syncEngine.push(); +}); ``` ## Security Rules @@ -70,6 +148,7 @@ ipcMain.handle('notes:list', async (_event, options) => { | contextIsolation | Preload runs in isolated context | | No executeSQL | Raw SQL never exposed | | Typed channels | All IPC is typed | +| Plugin sandbox | Plugins cannot access IPC directly | ## Usage in Renderer @@ -82,4 +161,10 @@ const result = await window.readied.notes.create({ if (result.ok) { console.log('Created:', result.data.id); } + +// Sync +await window.readied.sync.push(); + +// Appearance +await window.readied.appearance.theme('dark'); ``` diff --git a/apps/docs-site/architecture/overview.md b/apps/docs-site/architecture/overview.md index 18ee4e78..2d257b82 100644 --- a/apps/docs-site/architecture/overview.md +++ b/apps/docs-site/architecture/overview.md @@ -8,47 +8,81 @@ Readied follows a clean architecture with strict separation of concerns. readied/ ├── apps/ │ ├── desktop/ # Electron app -│ │ ├── src/main/ # Main process (SQLite, IPC) +│ │ ├── src/main/ # Main process (SQLite, IPC, sync) │ │ ├── src/preload/ # Secure bridge │ │ └── src/renderer/ # React UI │ ├── docs-site/ # This documentation (VitePress) │ └── site/ # Marketing site (Astro) ├── packages/ +│ ├── ai-assistant/ # AI assistant integration +│ ├── api/ # Supabase API backend +│ ├── command-registry/ # Command palette system +│ ├── commands/ # Built-in commands │ ├── core/ # Domain logic + markdown parsing +│ ├── design-system/ # Design tokens (CSS custom properties) +│ ├── embeds/ # URL embed resolution +│ ├── licensing/ # License validation +│ ├── plugin-api/ # Plugin system API +│ ├── plugin-cli/ # Plugin CLI scaffolding tool +│ ├── product-config/ # Product configuration │ ├── storage-core/ # Storage interfaces + utilities -│ └── storage-sqlite/ # SQLite implementation +│ ├── storage-sqlite/ # SQLite implementation +│ ├── sync-core/ # Sync engine (Supabase) +│ ├── tasks/ # Task management / extraction +│ └── wikilinks/ # [[wikilink]] parsing + resolution └── docs/ # Static documentation assets ``` +16 packages organized by domain responsibility. + ## Package Dependencies ```mermaid graph TD A[desktop] --> B[core] A --> C[storage-sqlite] + A --> E[command-registry] + A --> F[commands] + A --> G[plugin-api] + A --> H[sync-core] + A --> I[ai-assistant] + A --> J[design-system] + A --> K[licensing] + A --> L[product-config] C --> D[storage-core] B -.-> D + F --> E + H --> M[api] + A --> N[wikilinks] + A --> O[embeds] + A --> P[tasks] + G -.-> J ``` ## Data Flow ``` -┌─────────────────────────────────────────────────────────────┐ -│ Renderer Process │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ React UI │ -> │ TanStack │ -> │ Preload │ │ -│ │ │ │ Query │ │ Bridge │ │ -│ └─────────────┘ └─────────────┘ └──────┬──────┘ │ -└────────────────────────────────────────────────┼────────────┘ - │ IPC -┌────────────────────────────────────────────────┼────────────┐ -│ Main Process │ │ -│ ┌─────────────┐ ┌─────────────┐ ┌──────┴──────┐ │ -│ │ SQLite │ <- │ Core │ <- │ IPC │ │ -│ │ (better │ │ Operations │ │ Handlers │ │ -│ │ -sqlite3) │ │ │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ React UI │->│ TanStack │->│ Zustand │->│ Preload │ │ +│ │ │ │ Query │ │ Stores │ │ Bridge │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────┬───────┘ │ +└───────────────────────────────────────────────────┼─────────────┘ + │ IPC +┌───────────────────────────────────────────────────┼─────────────┐ +│ Main Process │ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────┴───────┐ │ +│ │ SQLite │<-│ Core │<-│ Plugin │<-│ IPC │ │ +│ │ (better │ │Operations│ │ System │ │ Handlers │ │ +│ │ -sqlite3)│ │ │ │ │ │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │ +│ │ │ +│ ┌─────┴───────┐ │ +│ │ Sync Core │ │ +│ │ (Supabase) │ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ ``` ## Key Boundaries @@ -74,10 +108,40 @@ graph TD - Migration definitions - **Only package with native deps** -### 4. Desktop App (`@readied/desktop`) +### 4. Plugin API (`@readied/plugin-api`) + +- Plugin lifecycle and registration +- Extension points (editor, themes, commands) +- Sandboxed plugin context +- Theme registry for CSS variable overrides + +### 5. Sync Core (`@readied/sync-core`) + +- Supabase-based sync engine +- Push/pull operations +- Conflict resolution +- Sync state management + +### 6. Command System (`@readied/command-registry` + `@readied/commands`) + +- Command palette infrastructure +- Built-in command definitions +- Keybinding management + +### 7. Companion Packages + +- **`@readied/wikilinks`** - `[[wikilink]]` parsing and resolution +- **`@readied/tasks`** - Task extraction from markdown +- **`@readied/embeds`** - URL embed resolution +- **`@readied/ai-assistant`** - AI assistant integration +- **`@readied/design-system`** - Design tokens as CSS custom properties +- **`@readied/licensing`** - License validation +- **`@readied/product-config`** - Pricing, plans, and product metadata + +### 8. Desktop App (`@readied/desktop`) - Electron + electron-vite -- Main process: SQLite, IPC handlers +- Main process: SQLite, IPC handlers, sync, plugins - Renderer: React + CodeMirror 6 - Preload: Typed API bridge @@ -89,15 +153,17 @@ graph TD | IPC whitelist | Typed channels only | | No executeSQL | No raw SQL in renderer | | Preload minimal | Only necessary APIs exposed | +| Plugin sandbox | Plugins run in isolated context | ## Technology Stack -| Layer | Technology | -| -------- | ------------------------ | -| Runtime | Electron | -| Build | electron-vite | -| Database | SQLite (better-sqlite3) | -| Editor | CodeMirror 6 | -| UI State | TanStack Query + Zustand | -| Styling | Tailwind CSS | -| Monorepo | pnpm + turborepo | +| Layer | Technology | +| -------- | ------------------------------------- | +| Runtime | Electron | +| Build | electron-vite | +| Database | SQLite (better-sqlite3) | +| Editor | CodeMirror 6 | +| UI State | TanStack Query + Zustand | +| Styling | CSS Modules + CSS custom properties | +| Sync | Supabase (via sync-core) | +| Monorepo | pnpm + turborepo | diff --git a/apps/docs-site/architecture/storage.md b/apps/docs-site/architecture/storage.md index 88021a9d..4191774f 100644 --- a/apps/docs-site/architecture/storage.md +++ b/apps/docs-site/architecture/storage.md @@ -1,6 +1,6 @@ # Storage -Storage is split into two packages for isolation of native dependencies. +Storage is split into multiple packages for isolation of native dependencies and separation of concerns. ## Packages @@ -20,7 +20,19 @@ SQLite implementation using `better-sqlite3`: - `DatabaseConnection` adapter - `SQLiteNoteRepository` +- `SQLiteNotebookRepository` +- `SQLiteTagRepository` - Migration definitions +- **Only package with native deps** + +### @readied/sync-core + +Supabase-based sync engine: + +- Push/pull operations against remote storage +- Sync state tracking (last synced timestamps, dirty flags) +- Conflict resolution strategy +- Authentication integration (login/logout) ## Why Split? @@ -40,7 +52,7 @@ By isolating native deps to `storage-sqlite`, we can: ```typescript interface DatabaseAdapter { - exec(sql: string): void; + run(sql: string): void; prepare(sql: string): PreparedStatement; transaction(fn: () => T): T; close(): void; @@ -48,6 +60,18 @@ interface DatabaseAdapter { } ``` +## Repositories + +The storage layer exposes multiple repository interfaces: + +| Repository | Purpose | +| ---------------------- | --------------------------- | +| `NoteRepository` | CRUD for notes | +| `NotebookRepository` | Notebook hierarchy | +| `TagRepository` | Tag management | + +Each repository interface is defined in `storage-core` and implemented in `storage-sqlite`. + ## Migrations Forward-only migrations run on startup: @@ -59,12 +83,35 @@ const migrations: Migration[] = [ name: 'initial_schema', up: `CREATE TABLE notes (...)`, }, + // Later migrations add: + // - notebooks table and note-notebook relationships + // - tags table + // - sync state tables (sync_log, sync_metadata) + // - indexes for performance ]; // Run pending migrations runMigrations(db, migrations); ``` +The migration system has grown to include tables for notebooks, tags, and sync state alongside the original notes schema. + +## Sync Storage + +The `@readied/sync-core` package adds a sync layer on top of local storage: + +1. **Local-first** - All writes go to SQLite first +2. **Sync state tracking** - Each record tracks its sync status (synced, dirty, conflict) +3. **Push** - Dirty records are pushed to Supabase +4. **Pull** - Remote changes are pulled and merged locally +5. **Conflict resolution** - Last-write-wins with optional manual resolution + +Sync state tables track: + +- Last successful sync timestamp per entity type +- Per-record dirty flags +- Conflict markers for manual resolution + ## Backup System ```typescript diff --git a/apps/docs-site/architecture/theming.md b/apps/docs-site/architecture/theming.md index 7e767b1f..4be0e0db 100644 --- a/apps/docs-site/architecture/theming.md +++ b/apps/docs-site/architecture/theming.md @@ -1,18 +1,54 @@ # Theming -Readied uses CSS variables for theming. +Readied uses CSS custom properties (design tokens) for theming, with full dark/light mode support and a plugin theme system. + +## Color Scheme + +The app supports three modes: + +- **Dark** - Dark background, light text +- **Light** - Light background, dark text +- **System** - Automatically follows the OS preference + +The active scheme is applied via a `data-color-scheme` attribute on the root element: + +```html + +``` ## Design Tokens +All visual properties are defined as CSS custom properties in `tokens.css` (~50 variables). These tokens cover backgrounds, text, accents, borders, and semantic colors. + +### Core Variables + ```css :root { - /* Colors */ - --color-bg: #0a0b0d; - --color-bg-secondary: #131417; - --color-text: #e4e4e7; - --color-text-muted: #71717a; - --color-accent: #3b82f6; - --color-border: #27272a; + /* Backgrounds */ + --bg-base: ...; + --bg-surface: ...; + --bg-elevated: ...; + --bg-hover: ...; + + /* Text */ + --text-primary: ...; + --text-secondary: ...; + --text-muted: ...; + --text-inverse: ...; + + /* Accent */ + --accent-primary: ...; + --accent-hover: ...; + --accent-muted: ...; + + /* Borders */ + --border-default: ...; + --border-subtle: ...; + + /* Semantic */ + --success: ...; + --warning: ...; + --danger: ...; /* Typography */ --font-sans: 'Inter', system-ui, sans-serif; @@ -31,27 +67,51 @@ Readied uses CSS variables for theming. } ``` +Each color variable has different values depending on `data-color-scheme`: + +```css +[data-color-scheme='dark'] { + --bg-base: #0a0b0d; + --bg-surface: #131417; + --text-primary: #e4e4e7; + --text-secondary: #a1a1aa; + --accent-primary: #3b82f6; + /* ... */ +} + +[data-color-scheme='light'] { + --bg-base: #ffffff; + --bg-surface: #f4f4f5; + --text-primary: #18181b; + --text-secondary: #52525b; + --accent-primary: #2563eb; + /* ... */ +} +``` + ## Component Styling -Using Tailwind CSS with design tokens: +Components use CSS Modules with CSS custom properties: -```tsx - +```css +/* Button.module.css */ +.button { + background: var(--accent-primary); + color: var(--text-inverse); + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-md); +} + +.button:hover { + background: var(--accent-hover); +} ``` -## Dark Mode +```tsx +import styles from './Button.module.css'; -Currently dark-only. Light mode planned for v0.2+. +; +``` ## Editor Theme @@ -60,11 +120,76 @@ CodeMirror has its own theme system, synchronized with app tokens: ```typescript const editorTheme = EditorView.theme({ '&': { - backgroundColor: 'var(--color-bg)', - color: 'var(--color-text)', + backgroundColor: 'var(--bg-base)', + color: 'var(--text-primary)', }, '.cm-cursor': { - borderLeftColor: 'var(--color-accent)', + borderLeftColor: 'var(--accent-primary)', + }, + '.cm-selectionBackground': { + backgroundColor: 'var(--accent-muted)', }, }); ``` + +## Plugin Theme System + +Plugins can define and register custom themes using the plugin API. + +### Registering a Theme + +The `data-theme` attribute identifies the active plugin theme: + +```html + +``` + +Plugins register CSS variable overrides via the plugin context: + +```typescript +// In a theme plugin's activate() +context.registerCssVariables({ + '--bg-base': '#002b36', + '--bg-surface': '#073642', + '--text-primary': '#839496', + '--accent-primary': '#268bd2', + // ... +}); +``` + +### Theme API + +Plugins have access to theme-related APIs: + +```typescript +// Get current theme info +const theme = context.getTheme(); +// { colorScheme: 'dark', activeTheme: 'solarized' } + +// React to theme changes +context.onThemeChanged((newTheme) => { + console.log('Theme changed to:', newTheme.colorScheme); +}); +``` + +### Theme Registry + +The app uses a theme registry store to manage multiple theme plugins: + +- Plugins register themes during activation +- The active theme is selected in **Settings > Appearance** +- `cssVariableStore` holds the current overrides +- `useCssVariables` hook applies overrides to the DOM + +### Built-in Reference: Solarized Theme + +A built-in Solarized theme plugin serves as a reference implementation for theme plugin authors. It demonstrates the full lifecycle: registering variables for both dark and light schemes, responding to color scheme changes, and cleaning up on deactivation. + +## Theme Settings + +Users configure theming in **Settings > Appearance**: + +- **Color scheme:** Dark, Light, or System (auto-detect) +- **Active theme:** Choose from installed theme plugins or default +- **Accent color:** Override the accent color +- **Zoom level:** Adjust the UI scale diff --git a/apps/docs-site/guide/built-in-plugins.md b/apps/docs-site/guide/built-in-plugins.md new file mode 100644 index 00000000..db6680ba --- /dev/null +++ b/apps/docs-site/guide/built-in-plugins.md @@ -0,0 +1,104 @@ +# Built-in Plugins + +Readied ships with 8 built-in plugins that cover core editing and productivity features. All plugins can be enabled or disabled from **Settings > Plugins**. + +## Word Count + +Shows word, character, and line counts in the editor status bar. The counts update in real-time as you type. + +- **Plugin ID:** `readied-word-count` +- **Toggle:** Use the command palette and search for "Toggle Word Count" +- **Configuration:** None +- **Keyboard shortcuts:** None + +## Reading Time + +Displays estimated reading time in the editor status bar, calculated at approximately 200 words per minute. The minimum displayed value is 1 minute. + +- **Plugin ID:** `readied-reading-time` +- **Toggle:** Use the command palette and search for "Toggle Reading Time" +- **Configuration:** None +- **Keyboard shortcuts:** None + +## Active Line Highlight + +Highlights the line where the cursor is positioned, making it easier to track your place in the document. The highlight updates as you move the cursor and clears when switching notes. + +- **Plugin ID:** `readied-active-line` +- **Toggle:** Use the command palette and search for "Toggle Active Line Highlight" +- **Configuration:** + - `enabled` (boolean, default: `true`) — Whether the highlight is active +- **Keyboard shortcuts:** None + +## Focus Mode + +Dims all lines except the current one for distraction-free writing. All non-active lines fade to 30% opacity with a smooth transition. + +- **Plugin ID:** `readied-focus-mode` +- **Toggle:** Cmd+Shift+F (macOS) / Ctrl+Shift+F (Windows/Linux), or use the command palette +- **Configuration:** + - `enabled` (boolean, default: `false`) — Enable focus mode on startup +- **Keyboard shortcuts:** + - Cmd+Shift+F / Ctrl+Shift+F — Toggle focus mode + +## Typewriter Mode + +Keeps the current line centered in the editor viewport as you type, simulating a typewriter. The editor smoothly scrolls to keep the cursor near the vertical center. Scrolling is only triggered when the cursor moves more than 40 pixels from center. + +- **Plugin ID:** `readied-typewriter-mode` +- **Toggle:** Cmd+Shift+T (macOS) / Ctrl+Shift+T (Windows/Linux), or use the command palette +- **Configuration:** + - `enabled` (boolean, default: `false`) — Enable typewriter mode on startup (persisted via config) +- **Keyboard shortcuts:** + - Cmd+Shift+T / Ctrl+Shift+T — Toggle typewriter mode + +## Export Markdown + +Copies the current note to your clipboard. Supports two formats: raw Markdown and rendered HTML. + +- **Plugin ID:** `readied-export-markdown` +- **Commands:** + - **Copy as Markdown** — Copies the raw Markdown source + - **Copy as HTML** — Converts to HTML and copies (supports headers, bold, italic, code blocks, inline code, links, and lists) +- **Configuration:** None +- **Keyboard shortcuts:** + - Cmd+Shift+C / Ctrl+Shift+C — Copy as Markdown + +## AI Assistant + +An AI-powered writing assistant panel powered by Claude. Opens a side panel where you can ask questions about your notes, get grammar suggestions, request summaries, and more. The assistant uses RAG (Retrieval-Augmented Generation) over your local notes for context-aware responses. + +- **Plugin ID:** `readied-ai-assistant` +- **Toggle:** Cmd+Shift+A (macOS) / Ctrl+Shift+A (Windows/Linux), or click the sparkles icon in the editor header +- **Commands:** + - **Toggle AI Assistant** — Open or close the panel + - **Ask AI About Current Note** — Open the panel focused on the active note +- **Configuration:** + - `apiKey` (string, default: `""`) — Your Anthropic API key from [console.anthropic.com](https://console.anthropic.com) + - `model` (enum, default: `claude-sonnet-4-5-20250929`) — Claude model to use. Options: Claude Sonnet 4.5, Claude Haiku 4.5 + - `maxContextNotes` (range, default: `5`, min: 1, max: 20) — Maximum number of notes to include as context +- **Keyboard shortcuts:** + - Cmd+Shift+A / Ctrl+Shift+A — Toggle AI Assistant + +::: tip +You need to provide your own Anthropic API key in Settings > Plugins > AI Assistant to use this feature. +::: + +## Tables + +Full GFM (GitHub Flavored Markdown) table support with visual editing, WYSIWYG rendering, sortable columns in preview, and CSV export. + +- **Plugin ID:** `readied-tables` +- **Features:** + - **Insert Table** — Opens a visual grid picker to create a table with your chosen dimensions (up to 10 rows by 6 columns) + - **WYSIWYG Rendering** — Tables are rendered as formatted HTML in the editor. Click into a table to see and edit the raw Markdown + - **Sortable Preview** — Tables in the preview pane have clickable column headers for sorting (ascending/descending, supports numeric and string sorting) + - **Export to CSV** — Copies the table at the cursor position to the clipboard as CSV +- **Commands:** + - **Insert Table** — Open the table size picker + - **Toggle Table WYSIWYG** — Enable or disable visual table rendering in the editor + - **Export Table to CSV** — Copy the table at cursor as CSV +- **Configuration:** + - `wysiwygEnabled` (boolean, default: `true`) — Whether tables render visually in the editor +- **Keyboard shortcuts:** + - Cmd+Alt+T / Ctrl+Alt+T — Insert table diff --git a/apps/docs-site/guide/getting-started.md b/apps/docs-site/guide/getting-started.md index afb40f25..eaecf86d 100644 --- a/apps/docs-site/guide/getting-started.md +++ b/apps/docs-site/guide/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Readied is a Markdown-first, offline-forever desktop note app for developers. +Readied is a Markdown-first desktop note app for developers who value their data. ## Installation @@ -31,11 +31,37 @@ Download the latest release from [GitHub Releases](https://github.com/tomymarita - Syntax highlighting for code blocks - Your markdown is **never auto-modified** -### Offline Forever +### Notebooks -- No account required -- No internet connection needed -- Your data stays on your machine +- Organize notes into hierarchical notebooks +- Drag and drop notes between notebooks +- Collapse and expand notebook trees in the sidebar + +### Wikilinks + +- Link between notes with `[[wikilink]]` syntax +- Backlinks panel shows all notes that reference the current note +- Quick navigation between linked notes + +### Plugin System + +- 8 built-in plugins covering core editing and productivity features +- Extensible architecture for community plugins +- Load custom plugins from `~/.config/readied/plugins` +- Plugins can extend the editor, sidebar, and command palette + +### Optional Cloud Sync + +- Supabase-based sync across devices +- Magic link authentication (no passwords) +- End-to-end encryption for synced notes +- Works 100% offline by default — sync is opt-in + +### Theme System + +- Customizable color palettes with dark/light awareness +- Ships with Solarized theme built-in +- Themes adapt automatically to system appearance ### Import & Export diff --git a/apps/docs-site/guide/principles.md b/apps/docs-site/guide/principles.md index 99e2fc99..859e2f79 100644 --- a/apps/docs-site/guide/principles.md +++ b/apps/docs-site/guide/principles.md @@ -14,11 +14,11 @@ These principles guide every technical and product decision in Readied. - No "prettify markdown" feature - Export = exact copy of stored markdown -### 2. Offline First +### 2. Offline by Default -- 100% functional without internet connection -- Sync is a feature, not a requirement -- No features require online connectivity +- Works 100% offline by default +- Cloud sync is an optional feature, not a requirement +- Sync enhances but never gates core functionality - Data lives on user's machine ### 3. Data Ownership @@ -67,7 +67,6 @@ Core ≠ UI ≠ Infra ≠ Packaging | Collaboration tool | Single-user product | | Mobile app | Desktop first | | AI-powered | Not core value prop | -| Cloud-required | Offline-first identity | ## One-Liner diff --git a/apps/docs-site/guide/sync.md b/apps/docs-site/guide/sync.md new file mode 100644 index 00000000..833a5cd7 --- /dev/null +++ b/apps/docs-site/guide/sync.md @@ -0,0 +1,58 @@ +# Cloud Sync + +Readied works 100% offline by default. Cloud sync is an **optional** feature for backing up your notes and syncing them across multiple devices. You never need to create an account or connect to the internet to use Readied. + +## Authentication + +Readied uses **magic link** email authentication powered by Supabase. There are no passwords to remember. + +1. Open **Settings > Account** +2. Enter your email address +3. Check your inbox for the magic link +4. Click the link to sign in + +Once authenticated, sync begins automatically. + +## How Sync Works + +- Notes are synced to Supabase cloud storage (PostgreSQL) +- Sync runs **automatically in the background** while you work +- When you go offline, changes are **queued locally** and pushed when your connection returns +- **Conflict resolution** uses a last-write-wins strategy with local priority — your local edits always take precedence if the same note was modified on two devices simultaneously + +Because Readied is offline-first, you will never lose work due to a network issue. The local SQLite database is always the primary source of truth. + +## Sync Status + +The **sync status indicator** in the sidebar footer shows the current state at a glance: + +| Status | Meaning | +| ----------- | ------------------------------------------------- | +| **Synced** | All notes are up to date with the cloud | +| **Syncing** | A sync operation is currently in progress | +| **Offline** | No internet connection; changes are queued locally | +| **Error** | Something went wrong; check troubleshooting below | + +## Privacy + +- Your data is stored in Supabase (PostgreSQL) when sync is enabled +- All data transfer uses **HTTPS** +- You can **delete your cloud data** at any time from **Settings > Account** +- Sync is entirely optional — you can use Readied without ever signing in +- If you sign out, your local notes remain untouched on your machine + +## Troubleshooting + +### Sync appears stuck + +Open **Settings > Account** and use **Force Sync** to trigger a full re-sync. + +### Sync shows an error state + +1. Check your internet connection +2. Try signing out and signing back in to refresh your authentication token +3. If the issue persists, check the Readied logs for details + +### Notes not appearing on another device + +Make sure you are signed in with the same email on both devices. After signing in, allow a moment for the initial sync to complete. diff --git a/apps/docs-site/index.md b/apps/docs-site/index.md index f7d135a2..46c8b489 100644 --- a/apps/docs-site/index.md +++ b/apps/docs-site/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Readied text: Developer Note-Taking - tagline: Markdown-first, offline-forever note app built for developers who value their data + tagline: Markdown-first desktop note app for developers who value their data image: src: /readide/logo.svg alt: Readied Logo @@ -23,15 +23,15 @@ features: link: /guide/principles linkText: Learn about our principles - icon: 🔌 - title: Offline Forever - details: 100% functional without internet. Your notes live on your machine in a local SQLite database you control. - link: /architecture/storage - linkText: See storage architecture - - icon: 🏗️ - title: Clean Architecture - details: Core domain logic runs without Electron, React, or UI dependencies. Testable, portable, future-proof. + title: Plugin System + details: 8 built-in plugins with an extensible architecture. Load community plugins from ~/.config/readied/plugins. link: /architecture/overview linkText: Explore the architecture + - icon: 🌐 + title: Offline First + details: Works 100% offline by default. Optional cloud sync via Supabase keeps your notes in sync across devices when you want it. + link: /architecture/storage + linkText: See storage architecture - icon: 📦 title: Portable Data details: Export anytime as markdown files. Import from Obsidian. Your notes survive the app - always. @@ -42,11 +42,11 @@ features: details: Built with Electron for cross-platform support. CodeMirror 6 editor for blazing fast editing. link: /architecture/editor linkText: Editor details - - icon: 🔒 - title: Privacy First - details: No telemetry. No accounts required. No cloud sync. Your notes stay on your device. - link: /decisions/ - linkText: See our decisions + - icon: 🎨 + title: Theme System + details: Customizable color palettes with dark/light awareness. Ships with Solarized built-in. Make Readied yours. + link: /guide/getting-started + linkText: Get started --- diff --git a/apps/docs-site/.vitepress/theme/custom.css b/apps/docs-site/.vitepress/theme/custom.css deleted file mode 100644 index 8e4734b0..00000000 --- a/apps/docs-site/.vitepress/theme/custom.css +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Readied Documentation Theme - * Custom colors based on Readied app branding - */ - -:root { - /* Brand colors - Light mode */ - --vp-c-brand-1: #0d9488; - --vp-c-brand-2: #14b8a6; - --vp-c-brand-3: #55E4CF; - --vp-c-brand-soft: rgba(85, 228, 207, 0.14); - - /* Button colors */ - --vp-button-brand-border: transparent; - --vp-button-brand-text: #fff; - --vp-button-brand-bg: #0d9488; - --vp-button-brand-hover-border: transparent; - --vp-button-brand-hover-text: #fff; - --vp-button-brand-hover-bg: #0f766e; - --vp-button-brand-active-border: transparent; - --vp-button-brand-active-text: #fff; - --vp-button-brand-active-bg: #115e59; - - /* Home hero */ - --vp-home-hero-name-color: transparent; - --vp-home-hero-name-background: linear-gradient(135deg, #55E4CF 0%, #0d9488 100%); - --vp-home-hero-image-background-image: linear-gradient(135deg, rgba(85, 228, 207, 0.3) 0%, rgba(13, 148, 136, 0.3) 100%); - --vp-home-hero-image-filter: blur(44px); -} - -.dark { - /* Brand colors - Dark mode (match Readied app) */ - --vp-c-brand-1: #55E4CF; - --vp-c-brand-2: #6ee7d7; - --vp-c-brand-3: #99f0e4; - --vp-c-brand-soft: rgba(85, 228, 207, 0.16); - - /* Background colors */ - --vp-c-bg: #0a0b0d; - --vp-c-bg-soft: #131417; - --vp-c-bg-mute: #1a1b1f; - --vp-c-bg-alt: #0f1012; - - /* Button colors - Dark */ - --vp-button-brand-bg: #0d9488; - --vp-button-brand-hover-bg: #14b8a6; - --vp-button-brand-active-bg: #0f766e; - - /* Sidebar */ - --vp-sidebar-bg-color: var(--vp-c-bg); - - /* Code blocks */ - --vp-code-block-bg: #131417; - - /* Home hero - Dark */ - --vp-home-hero-image-background-image: linear-gradient(135deg, rgba(85, 228, 207, 0.2) 0%, rgba(13, 148, 136, 0.2) 100%); -} - -/* Hero section enhancements */ -.VPHero .name { - font-weight: 800; -} - -.VPHero .tagline { - font-size: 1.25rem; - opacity: 0.9; -} - -/* Feature cards */ -.VPFeature { - border-radius: 12px; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.VPFeature:hover { - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(85, 228, 207, 0.1); -} - -.dark .VPFeature { - background-color: var(--vp-c-bg-soft); - border-color: rgba(85, 228, 207, 0.1); -} - -.dark .VPFeature:hover { - border-color: rgba(85, 228, 207, 0.3); -} - -/* Navigation logo */ -.VPNavBarTitle .title { - font-weight: 700; -} - -/* Badges styling */ -.VPBadge { - font-weight: 500; -} - -/* Custom callout for tips */ -.custom-block.tip { - border-color: var(--vp-c-brand-1); -} - -.dark .custom-block.tip { - background-color: rgba(85, 228, 207, 0.08); -} - -/* Footer enhancements */ -.VPFooter { - border-top-color: rgba(85, 228, 207, 0.1); -} - -/* Smooth scrolling */ -html { - scroll-behavior: smooth; -} - -/* Code highlighting accent */ -.vp-code-group .tabs label.active { - color: var(--vp-c-brand-1); -} - -/* Links */ -.vp-doc a { - color: var(--vp-c-brand-1); - text-decoration: none; -} - -.vp-doc a:hover { - color: var(--vp-c-brand-2); - text-decoration: underline; -} - -/* Status badges for roadmap */ -.status-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; -} - -.status-badge.done { - background-color: rgba(34, 197, 94, 0.2); - color: #22c55e; -} - -.status-badge.in-progress { - background-color: rgba(85, 228, 207, 0.2); - color: #55E4CF; -} - -.status-badge.planned { - background-color: rgba(148, 163, 184, 0.2); - color: #94a3b8; -} diff --git a/apps/docs-site/.vitepress/theme/index.ts b/apps/docs-site/.vitepress/theme/index.ts deleted file mode 100644 index b88d5f8e..00000000 --- a/apps/docs-site/.vitepress/theme/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import DefaultTheme from 'vitepress/theme'; -import type { Theme } from 'vitepress'; -import './custom.css'; -import ProjectBoard from './components/ProjectBoard.vue'; - -export default { - extends: DefaultTheme, - enhanceApp({ app }) { - app.component('ProjectBoard', ProjectBoard); - }, -} satisfies Theme; diff --git a/apps/docs-site/index.md b/apps/docs-site/index.md deleted file mode 100644 index 46c8b489..00000000 --- a/apps/docs-site/index.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -layout: home - -hero: - name: Readied - text: Developer Note-Taking - tagline: Markdown-first desktop note app for developers who value their data - image: - src: /readide/logo.svg - alt: Readied Logo - actions: - - theme: brand - text: Get Started - link: /guide/getting-started - - theme: alt - text: View on GitHub - link: https://github.com/tomymaritano/readide - -features: - - icon: 📝 - title: Markdown Sacred - details: Your markdown is never auto-modified. What you type is exactly what gets saved. No hidden transformations. - link: /guide/principles - linkText: Learn about our principles - - icon: 🔌 - title: Plugin System - details: 8 built-in plugins with an extensible architecture. Load community plugins from ~/.config/readied/plugins. - link: /architecture/overview - linkText: Explore the architecture - - icon: 🌐 - title: Offline First - details: Works 100% offline by default. Optional cloud sync via Supabase keeps your notes in sync across devices when you want it. - link: /architecture/storage - linkText: See storage architecture - - icon: 📦 - title: Portable Data - details: Export anytime as markdown files. Import from Obsidian. Your notes survive the app - always. - link: /guide/getting-started#data-portability - linkText: Data portability guide - - icon: ⚡ - title: Fast & Native - details: Built with Electron for cross-platform support. CodeMirror 6 editor for blazing fast editing. - link: /architecture/editor - linkText: Editor details - - icon: 🎨 - title: Theme System - details: Customizable color palettes with dark/light awareness. Ships with Solarized built-in. Make Readied yours. - link: /guide/getting-started - linkText: Get started ---- - - - - diff --git a/apps/docs-site/package.json b/apps/docs-site/package.json deleted file mode 100644 index 9466ed64..00000000 --- a/apps/docs-site/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@readied/docs", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "Technical documentation for Readied", - "scripts": { - "dev": "vitepress dev", - "build": "vitepress build", - "preview": "vitepress preview" - }, - "devDependencies": { - "vitepress": "^1.5.0", - "vue": "^3.5.0" - } -} diff --git a/apps/docs-site/roadmap/index.md b/apps/docs-site/roadmap/index.md deleted file mode 100644 index 5301b88e..00000000 --- a/apps/docs-site/roadmap/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Roadmap ---- - - - -# Roadmap - -Track the development progress of Readied. This board is automatically synced from our [GitHub Project](https://github.com/users/tomymaritano/projects/8). - - diff --git a/apps/docs-site/roadmap/project.data.ts b/apps/docs-site/roadmap/project.data.ts deleted file mode 100644 index a294c2ec..00000000 --- a/apps/docs-site/roadmap/project.data.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * GitHub Projects Data Loader - * Fetches project items at build time for the roadmap page - */ - -interface ProjectItem { - id: string; - title: string; - status: 'Todo' | 'In Progress' | 'Done'; - url?: string; - type: 'Issue' | 'DraftIssue' | 'PullRequest'; -} - -interface ProjectData { - items: ProjectItem[]; - projectUrl: string; - lastUpdated: string; - error?: string; -} - -const PROJECT_URL = 'https://github.com/users/tomymaritano/projects/8'; -const PROJECT_NUMBER = 8; -const USERNAME = 'tomymaritano'; - -async function fetchProjectItems(): Promise { - const token = process.env.VITE_GITHUB_TOKEN || process.env.GITHUB_TOKEN; - - if (!token) { - return { - items: [], - projectUrl: PROJECT_URL, - lastUpdated: new Date().toISOString(), - error: 'No GitHub token available. View the project directly on GitHub.', - }; - } - - const query = ` - query($username: String!, $number: Int!) { - user(login: $username) { - projectV2(number: $number) { - title - url - items(first: 100) { - nodes { - id - fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { - name - } - } - content { - ... on Issue { - title - url - } - ... on PullRequest { - title - url - } - ... on DraftIssue { - title - } - } - } - } - } - } - } - `; - - try { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - variables: { username: USERNAME, number: PROJECT_NUMBER }, - }), - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - - const data = await response.json(); - - if (data.errors) { - throw new Error(data.errors[0]?.message || 'GraphQL error'); - } - - const project = data.data?.user?.projectV2; - if (!project) { - throw new Error('Project not found'); - } - - const items: ProjectItem[] = project.items.nodes - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((node: any) => node.content) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((node: any) => { - const statusField = node.fieldValueByName; - const status = statusField?.name || 'Todo'; - - return { - id: node.id, - title: node.content.title, - status: normalizeStatus(status), - url: node.content.url, - type: node.content.url - ? node.content.url.includes('/pull/') - ? 'PullRequest' - : 'Issue' - : 'DraftIssue', - }; - }); - - return { - items, - projectUrl: project.url, - lastUpdated: new Date().toISOString(), - }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Failed to fetch GitHub Project:', message); - - return { - items: [], - projectUrl: PROJECT_URL, - lastUpdated: new Date().toISOString(), - error: `Failed to load: ${message}`, - }; - } -} - -function normalizeStatus(status: string): 'Todo' | 'In Progress' | 'Done' { - const lower = status.toLowerCase(); - if (lower.includes('done') || lower.includes('complete')) return 'Done'; - if (lower.includes('progress') || lower.includes('doing')) return 'In Progress'; - return 'Todo'; -} - -export default { - async load(): Promise { - return await fetchProjectItems(); - }, -}; - -export declare const data: ProjectData; diff --git a/apps/marketing-site/CHANGELOG.md b/apps/marketing-site/CHANGELOG.md deleted file mode 100644 index 30e7c37f..00000000 --- a/apps/marketing-site/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -All notable changes to Readied will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Planned - -- Markdown editor with syntax highlighting -- File browser with folder support -- Full-text search -- Backlinks -- Export to PDF/HTML -- macOS and Windows support diff --git a/apps/marketing-site/PLUGIN_CONTRACT.md b/apps/marketing-site/PLUGIN_CONTRACT.md deleted file mode 100644 index 3cfe7792..00000000 --- a/apps/marketing-site/PLUGIN_CONTRACT.md +++ /dev/null @@ -1,123 +0,0 @@ -# Readied Plugin Contract - -This document defines what plugins can and cannot do in Readied. - -## Core Principle - -**Readied can have plugins as long as the file doesn't need them.** - -A plugin is valid only if uninstalling it doesn't break any `.md` file. - ---- - -## The Five Rules - -A plugin is allowed if it passes ALL of these checks: - -1. **Can be removed without affecting any `.md` file** -2. **Does not introduce new syntax** -3. **Does not mutate content automatically** -4. **Is not required to interpret the text** -5. **Does not create dependencies between notes** - -If it fails ONE rule, it doesn't ship. - ---- - -## Valid Plugins (Examples) - -These extend the editor without touching the format: - -| Plugin | Why it's allowed | -| ------------------- | ------------------------------------- | -| Word count | Read-only, derived from content | -| Outline view | Visualization, not storage | -| Backlinks panel | Computed index, not embedded | -| Export to PDF | Output transformation, file unchanged | -| Lint warnings | Visual feedback, no mutations | -| Custom themes | Presentation only | -| Keyboard shortcuts | Commands, not auto-transforms | -| Explicit formatters | User-triggered, not implicit | - ---- - -## Invalid Plugins (Examples) - -These create dependencies or modify the format: - -| Plugin | Why it's rejected | -| ----------------------- | -------------------------- | -| Custom block syntax | Requires plugin to render | -| Auto-formatting on type | Implicit transformation | -| Wikilinks `[[page]]` | Non-standard Markdown | -| Embedded queries | Proprietary syntax | -| Sync adapters | Creates cloud dependency | -| AI writing assistants | Requires external servers | -| Template expansion | Mutates text automatically | - ---- - -## The Database Rule - -Same principle applies to internal features: - -> If deleting Readied's database doesn't lose your data, the feature is allowed. - -- Search index? Rebuilt from files. **Allowed.** -- Backlinks? Computed from files. **Allowed.** -- Graph view? Visualization of files. **Allowed.** -- Proprietary metadata? Not in files. **Rejected.** - ---- - -## Explicit vs Implicit - -The line between valid and invalid often comes down to this: - -| Type | Example | Verdict | -| -------- | ---------------------------- | ------- | -| Explicit | `Cmd+Shift+F` formats block | Valid | -| Implicit | Typing `# ` auto-expands | Invalid | -| Explicit | Click button to insert link | Valid | -| Implicit | Auto-correct Markdown syntax | Invalid | - -**User-triggered = allowed.** -**Auto-triggered = rejected.** - ---- - -## Why This Matters - -Plugin ecosystems become platforms. -Platforms optimize for extensibility. -Extensibility creates lock-in. - -Readied optimizes for **survivability**. - -Your notes should work: - -- Without the plugin -- Without the app -- In 10 years -- In any Markdown editor - -This contract ensures they will. - ---- - -## Summary - -``` -┌─────────────────────────────────────────────────────┐ -│ │ -│ Plugin extends editor? → VALID │ -│ Plugin changes file format? → REJECTED │ -│ Plugin is user-triggered? → VALID │ -│ Plugin runs automatically? → REJECTED │ -│ File works without plugin? → VALID │ -│ File needs plugin to render? → REJECTED │ -│ │ -└─────────────────────────────────────────────────────┘ -``` - -This is the line. We don't cross it. diff --git a/apps/marketing-site/astro.config.mjs b/apps/marketing-site/astro.config.mjs deleted file mode 100644 index 63038aef..00000000 --- a/apps/marketing-site/astro.config.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'astro/config'; -import icon from 'astro-icon'; -import tailwind from '@astrojs/tailwind'; -import react from '@astrojs/react'; - -export default defineConfig({ - site: 'https://readied.app', - output: 'static', - build: { - assets: 'assets' - }, - integrations: [icon(), tailwind({ applyBaseStyles: false }), react()] -}); diff --git a/apps/marketing-site/package.json b/apps/marketing-site/package.json deleted file mode 100644 index 34f86db2..00000000 --- a/apps/marketing-site/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@readied/marketing-site", - "type": "module", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview" - }, - "dependencies": { - "@astrojs/react": "^4.4.2", - "@astrojs/tailwind": "^6.0.2", - "@headlessui/react": "^2.2.9", - "@iconify-json/lucide": "^1.2.82", - "@readied/product-config": "workspace:*", - "astro": "^5.0.0", - "astro-icon": "^1.1.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tailwindcss": "3" - }, - "devDependencies": { - "@iconify-json/simple-icons": "^1.2.64", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25" - } -} diff --git a/apps/marketing-site/pnpm-lock.yaml b/apps/marketing-site/pnpm-lock.yaml deleted file mode 100644 index 35fb8b83..00000000 --- a/apps/marketing-site/pnpm-lock.yaml +++ /dev/null @@ -1,3644 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@iconify-json/lucide': - specifier: ^1.2.82 - version: 1.2.82 - astro: - specifier: ^5.0.0 - version: 5.16.6(@types/node@25.0.3)(rollup@4.54.0)(typescript@5.9.3) - astro-icon: - specifier: ^1.1.5 - version: 1.1.5 - three: - specifier: ^0.182.0 - version: 0.182.0 - vanta: - specifier: ^0.5.24 - version: 0.5.24 - devDependencies: - '@iconify-json/simple-icons': - specifier: ^1.2.64 - version: 1.2.64 - -packages: - - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - - '@antfu/utils@8.1.1': - resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} - - '@astrojs/compiler@2.13.0': - resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==} - - '@astrojs/internal-helpers@0.7.5': - resolution: {integrity: sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==} - - '@astrojs/markdown-remark@6.3.10': - resolution: {integrity: sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==} - - '@astrojs/prism@3.3.0': - resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - - '@astrojs/telemetry@3.3.0': - resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - - '@capsizecss/unpack@3.0.1': - resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==} - engines: {node: '>=18'} - - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@iconify-json/lucide@1.2.82': - resolution: {integrity: sha512-fHZWegspOZonl5GNTvOkHsjnTMdSslFh3EzpzUtRyLxO8bOonqk2OTU3hCl0k4VXzViMjqpRK3X1sotnuBXkFA==} - - '@iconify-json/simple-icons@1.2.64': - resolution: {integrity: sha512-SMmm//tjZBvHnT0EAzZLnBTL6bukSkncM0pwkOXjr0FsAeCqjQtqoxBR0Mp+PazIJjXJKHm1Ju0YgnCIPOodJg==} - - '@iconify/tools@4.2.0': - resolution: {integrity: sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA==} - - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - - '@iconify/utils@2.3.0': - resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} - - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@oslojs/encoding@1.1.0': - resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.54.0': - resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.54.0': - resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.54.0': - resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.54.0': - resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.54.0': - resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.54.0': - resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.54.0': - resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.54.0': - resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.54.0': - resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.54.0': - resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.54.0': - resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.54.0': - resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.54.0': - resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.54.0': - resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.54.0': - resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.54.0': - resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.54.0': - resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.54.0': - resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} - cpu: [x64] - os: [win32] - - '@shikijs/core@3.20.0': - resolution: {integrity: sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==} - - '@shikijs/engine-javascript@3.20.0': - resolution: {integrity: sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==} - - '@shikijs/engine-oniguruma@3.20.0': - resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} - - '@shikijs/langs@3.20.0': - resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} - - '@shikijs/themes@3.20.0': - resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} - - '@shikijs/types@3.20.0': - resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - - '@swc/helpers@0.5.18': - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} - - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/fontkit@2.0.8': - resolution: {integrity: sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==} - - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/nlcst@2.0.3': - resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} - - '@types/node@25.0.3': - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-iterate@2.0.1: - resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} - - astro-icon@1.1.5: - resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==} - - astro@5.16.6: - resolution: {integrity: sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} - hasBin: true - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - base-64@1.0.0: - resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} - - brotli@1.3.3: - resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - - cheerio@1.1.2: - resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} - engines: {node: '>=20.18.1'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - ci-info@4.3.1: - resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} - engines: {node: '>=8'} - - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - - clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - - common-ancestor-path@1.0.1: - resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - - cookie-es@1.2.2: - resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - crossws@0.3.5: - resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - - css-tree@2.2.1: - resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csso@5.0.5: - resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decode-named-character-reference@1.2.0: - resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - deterministic-object-hash@2.0.2: - resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} - engines: {node: '>=18'} - - devalue@5.6.1: - resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - dfa@1.2.0: - resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} - - diff@5.2.0: - resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} - engines: {node: '>=0.3.1'} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - dset@3.1.4: - resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} - engines: {node: '>=4'} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - flattie@1.1.1: - resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} - engines: {node: '>=8'} - - fontace@0.3.1: - resolution: {integrity: sha512-9f5g4feWT1jWT8+SbL85aLIRLIXUaDygaM2xPXRmzPYxrOMNok79Lr3FGJoKVNKibE0WCunNiEVG2mwuE+2qEg==} - - fontkit@2.0.4: - resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - - h3@1.15.4: - resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} - - hast-util-from-html@2.0.3: - resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} - - hast-util-from-parse5@8.0.3: - resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - - hast-util-parse-selector@4.0.0: - resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - - hast-util-raw@9.1.0: - resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} - - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - - hast-util-to-parse5@8.0.1: - resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} - - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - hastscript@9.0.1: - resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - - html-escaper@3.0.3: - resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - - htmlparser2@10.0.0: - resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - - iron-webcrypto@1.2.1: - resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} - engines: {node: '>=16'} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.1: - resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} - - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - - mdast-util-definitions@6.0.0: - resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} - - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} - - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - - mdn-data@2.0.28: - resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} - engines: {node: '>= 10'} - - nlcst-to-string@4.0.0: - resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - node-mock-http@1.0.4: - resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - - ofetch@1.5.1: - resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - - oniguruma-to-es@4.3.4: - resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} - - p-limit@6.2.0: - resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} - engines: {node: '>=18'} - - p-queue@8.1.1: - resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} - engines: {node: '>=18'} - - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - - pako@0.2.9: - resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} - - parse-latin@7.0.0: - resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} - - parse5-htmlparser2-tree-adapter@7.1.0: - resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - - piccolore@0.1.3: - resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - - radix3@1.1.2: - resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - - rehype-parse@9.0.1: - resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - - rehype-raw@7.0.0: - resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} - - rehype-stringify@10.0.1: - resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} - - rehype@13.0.2: - resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} - - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - remark-smartypants@3.0.2: - resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} - engines: {node: '>=16.0.0'} - - remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - - restructure@3.0.2: - resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} - - retext-latin@4.0.0: - resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} - - retext-smartypants@6.2.0: - resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} - - retext-stringify@4.0.0: - resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} - - retext@9.0.0: - resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} - - rollup@4.54.0: - resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sax@1.4.3: - resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shiki@3.20.0: - resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - smol-toml@1.6.0: - resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} - engines: {node: '>= 18'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - svgo@3.3.2: - resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} - engines: {node: '>=14.0.0'} - hasBin: true - - svgo@4.0.0: - resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} - engines: {node: '>=16'} - hasBin: true - - tar@7.5.2: - resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} - engines: {node: '>=18'} - - three@0.182.0: - resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} - - tiny-inflate@1.0.3: - resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - - ultrahtml@1.6.0: - resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} - - uncrypto@0.1.3: - resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} - engines: {node: '>=20.18.1'} - - unicode-properties@1.4.1: - resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} - - unicode-trie@2.0.0: - resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} - - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unifont@0.6.0: - resolution: {integrity: sha512-5Fx50fFQMQL5aeHyWnZX9122sSLckcDvcfFiBf3QYeHa7a1MKJooUy52b67moi2MJYkrfo/TWY+CoLdr/w0tTA==} - - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-modify-children@4.0.0: - resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-children@3.0.0: - resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.0.0: - resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - - unstorage@1.17.3: - resolution: {integrity: sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==} - peerDependencies: - '@azure/app-configuration': ^1.8.0 - '@azure/cosmos': ^4.2.0 - '@azure/data-tables': ^13.3.0 - '@azure/identity': ^4.6.0 - '@azure/keyvault-secrets': ^4.9.0 - '@azure/storage-blob': ^12.26.0 - '@capacitor/preferences': ^6.0.3 || ^7.0.0 - '@deno/kv': '>=0.9.0' - '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 - '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.34.3 - '@vercel/blob': '>=0.27.1' - '@vercel/functions': ^2.2.12 || ^3.0.0 - '@vercel/kv': ^1.0.1 - aws4fetch: ^1.0.20 - db0: '>=0.2.1' - idb-keyval: ^6.2.1 - ioredis: ^5.4.2 - uploadthing: ^7.4.4 - peerDependenciesMeta: - '@azure/app-configuration': - optional: true - '@azure/cosmos': - optional: true - '@azure/data-tables': - optional: true - '@azure/identity': - optional: true - '@azure/keyvault-secrets': - optional: true - '@azure/storage-blob': - optional: true - '@capacitor/preferences': - optional: true - '@deno/kv': - optional: true - '@netlify/blobs': - optional: true - '@planetscale/database': - optional: true - '@upstash/redis': - optional: true - '@vercel/blob': - optional: true - '@vercel/functions': - optional: true - '@vercel/kv': - optional: true - aws4fetch: - optional: true - db0: - optional: true - idb-keyval: - optional: true - ioredis: - optional: true - uploadthing: - optional: true - - vanta@0.5.24: - resolution: {integrity: sha512-fvieEbHy1ZS23zrcX+topzqAgA4Uct1enngOEWLFBgs9TtOf6RDFOYatH7KSVdrABzQDMCQ5myQy+nTSZZwLzg==} - - vfile-location@5.0.3: - resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.1: - resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - peerDependenciesMeta: - vite: - optional: true - - web-namespaces@2.0.1: - resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - which-pm-runs@1.1.0: - resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} - engines: {node: '>=4'} - - widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} - - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xxhash-wasm@1.1.0: - resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - - yocto-spinner@0.2.3: - resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} - engines: {node: '>=18.19'} - - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - - zod-to-ts@1.2.0: - resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} - peerDependencies: - typescript: ^4.9.4 || ^5.0.2 - zod: ^3 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@antfu/install-pkg@1.1.0': - dependencies: - package-manager-detector: 1.6.0 - tinyexec: 1.0.2 - - '@antfu/utils@8.1.1': {} - - '@astrojs/compiler@2.13.0': {} - - '@astrojs/internal-helpers@0.7.5': {} - - '@astrojs/markdown-remark@6.3.10': - dependencies: - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/prism': 3.3.0 - github-slugger: 2.0.0 - hast-util-from-html: 2.0.3 - hast-util-to-text: 4.0.2 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - mdast-util-definitions: 6.0.0 - rehype-raw: 7.0.0 - rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - remark-smartypants: 3.0.2 - shiki: 3.20.0 - smol-toml: 1.6.0 - unified: 11.0.5 - unist-util-remove-position: 5.0.0 - unist-util-visit: 5.0.0 - unist-util-visit-parents: 6.0.2 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@astrojs/prism@3.3.0': - dependencies: - prismjs: 1.30.0 - - '@astrojs/telemetry@3.3.0': - dependencies: - ci-info: 4.3.1 - debug: 4.4.3 - dlv: 1.1.3 - dset: 3.1.4 - is-docker: 3.0.0 - is-wsl: 3.1.0 - which-pm-runs: 1.1.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@capsizecss/unpack@3.0.1': - dependencies: - fontkit: 2.0.4 - - '@emnapi/runtime@1.7.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@iconify-json/lucide@1.2.82': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify-json/simple-icons@1.2.64': - dependencies: - '@iconify/types': 2.0.0 - - '@iconify/tools@4.2.0': - dependencies: - '@iconify/types': 2.0.0 - '@iconify/utils': 2.3.0 - cheerio: 1.1.2 - domhandler: 5.0.3 - extract-zip: 2.0.1 - local-pkg: 1.1.2 - pathe: 2.0.3 - svgo: 3.3.2 - tar: 7.5.2 - transitivePeerDependencies: - - supports-color - - '@iconify/types@2.0.0': {} - - '@iconify/utils@2.3.0': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@antfu/utils': 8.1.1 - '@iconify/types': 2.0.0 - debug: 4.4.3 - globals: 15.15.0 - kolorist: 1.8.0 - local-pkg: 1.1.2 - mlly: 1.8.0 - transitivePeerDependencies: - - supports-color - - '@img/colour@1.0.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.7.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.2 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@oslojs/encoding@1.1.0': {} - - '@rollup/pluginutils@5.3.0(rollup@4.54.0)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.54.0 - - '@rollup/rollup-android-arm-eabi@4.54.0': - optional: true - - '@rollup/rollup-android-arm64@4.54.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.54.0': - optional: true - - '@rollup/rollup-darwin-x64@4.54.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.54.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.54.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.54.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.54.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.54.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.54.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.54.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.54.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.54.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.54.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.54.0': - optional: true - - '@shikijs/core@3.20.0': - dependencies: - '@shikijs/types': 3.20.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@3.20.0': - dependencies: - '@shikijs/types': 3.20.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.4 - - '@shikijs/engine-oniguruma@3.20.0': - dependencies: - '@shikijs/types': 3.20.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@3.20.0': - dependencies: - '@shikijs/types': 3.20.0 - - '@shikijs/themes@3.20.0': - dependencies: - '@shikijs/types': 3.20.0 - - '@shikijs/types@3.20.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@swc/helpers@0.5.18': - dependencies: - tslib: 2.8.1 - - '@trysound/sax@0.2.0': {} - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree@1.0.8': {} - - '@types/fontkit@2.0.8': - dependencies: - '@types/node': 25.0.3 - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/ms@2.1.0': {} - - '@types/nlcst@2.0.3': - dependencies: - '@types/unist': 3.0.3 - - '@types/node@25.0.3': - dependencies: - undici-types: 7.16.0 - - '@types/unist@3.0.3': {} - - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.0.3 - optional: true - - '@ungap/structured-clone@1.3.0': {} - - acorn@8.15.0: {} - - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@6.2.3: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-iterate@2.0.1: {} - - astro-icon@1.1.5: - dependencies: - '@iconify/tools': 4.2.0 - '@iconify/types': 2.0.0 - '@iconify/utils': 2.3.0 - transitivePeerDependencies: - - supports-color - - astro@5.16.6(@types/node@25.0.3)(rollup@4.54.0)(typescript@5.9.3): - dependencies: - '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/markdown-remark': 6.3.10 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.54.0) - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.3.1 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.6.1 - diff: 5.2.0 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.3.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.1 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.3 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.3 - shiki: 3.20.0 - smol-toml: 1.6.0 - svgo: 4.0.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.6.0 - unist-util-visit: 5.0.0 - unstorage: 1.17.3 - vfile: 6.0.3 - vite: 6.4.1(@types/node@25.0.3) - vitefu: 1.1.1(vite@6.4.1(@types/node@25.0.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - - axobject-query@4.1.0: {} - - bail@2.0.2: {} - - base-64@1.0.0: {} - - base64-js@1.5.1: {} - - boolbase@1.0.0: {} - - boxen@8.0.1: - dependencies: - ansi-align: 3.0.1 - camelcase: 8.0.0 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 7.2.0 - type-fest: 4.41.0 - widest-line: 5.0.0 - wrap-ansi: 9.0.2 - - brotli@1.3.3: - dependencies: - base64-js: 1.5.1 - - buffer-crc32@0.2.13: {} - - camelcase@8.0.0: {} - - ccount@2.0.1: {} - - chalk@5.6.2: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - cheerio-select@2.1.0: - dependencies: - boolbase: 1.0.0 - css-select: 5.2.2 - css-what: 6.2.2 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - - cheerio@1.1.2: - dependencies: - cheerio-select: 2.1.0 - dom-serializer: 2.0.0 - domhandler: 5.0.3 - domutils: 3.2.2 - encoding-sniffer: 0.2.1 - htmlparser2: 10.0.0 - parse5: 7.3.0 - parse5-htmlparser2-tree-adapter: 7.1.0 - parse5-parser-stream: 7.1.2 - undici: 7.16.0 - whatwg-mimetype: 4.0.0 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@3.0.0: {} - - ci-info@4.3.1: {} - - cli-boxes@3.0.0: {} - - clone@2.1.2: {} - - clsx@2.1.1: {} - - comma-separated-tokens@2.0.3: {} - - commander@11.1.0: {} - - commander@7.2.0: {} - - common-ancestor-path@1.0.1: {} - - confbox@0.1.8: {} - - confbox@0.2.2: {} - - cookie-es@1.2.2: {} - - cookie@1.1.1: {} - - crossws@0.3.5: - dependencies: - uncrypto: 0.1.3 - - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - - css-tree@2.2.1: - dependencies: - mdn-data: 2.0.28 - source-map-js: 1.2.1 - - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - - css-what@6.2.2: {} - - cssesc@3.0.0: {} - - csso@5.0.5: - dependencies: - css-tree: 2.2.1 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decode-named-character-reference@1.2.0: - dependencies: - character-entities: 2.0.2 - - defu@6.1.4: {} - - dequal@2.0.3: {} - - destr@2.0.5: {} - - detect-libc@2.1.2: - optional: true - - deterministic-object-hash@2.0.2: - dependencies: - base-64: 1.0.0 - - devalue@5.6.1: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - dfa@1.2.0: {} - - diff@5.2.0: {} - - dlv@1.1.3: {} - - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - dset@3.1.4: {} - - emoji-regex@10.6.0: {} - - emoji-regex@8.0.0: {} - - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - entities@4.5.0: {} - - entities@6.0.1: {} - - es-module-lexer@1.7.0: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escape-string-regexp@5.0.0: {} - - estree-walker@2.0.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - eventemitter3@5.0.1: {} - - exsolve@1.0.8: {} - - extend@3.0.2: {} - - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - flattie@1.1.1: {} - - fontace@0.3.1: - dependencies: - '@types/fontkit': 2.0.8 - fontkit: 2.0.4 - - fontkit@2.0.4: - dependencies: - '@swc/helpers': 0.5.18 - brotli: 1.3.3 - clone: 2.1.2 - dfa: 1.2.0 - fast-deep-equal: 3.1.3 - restructure: 3.0.2 - tiny-inflate: 1.0.3 - unicode-properties: 1.4.1 - unicode-trie: 2.0.0 - - fsevents@2.3.3: - optional: true - - get-east-asian-width@1.4.0: {} - - get-stream@5.2.0: - dependencies: - pump: 3.0.3 - - github-slugger@2.0.0: {} - - globals@15.15.0: {} - - h3@1.15.4: - dependencies: - cookie-es: 1.2.2 - crossws: 0.3.5 - defu: 6.1.4 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - node-mock-http: 1.0.4 - radix3: 1.1.2 - ufo: 1.6.1 - uncrypto: 0.1.3 - - hast-util-from-html@2.0.3: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - hast-util-from-parse5: 8.0.3 - parse5: 7.3.0 - vfile: 6.0.3 - vfile-message: 4.0.3 - - hast-util-from-parse5@8.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - devlop: 1.1.0 - hastscript: 9.0.1 - property-information: 7.1.0 - vfile: 6.0.3 - vfile-location: 5.0.3 - web-namespaces: 2.0.1 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-parse-selector@4.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-raw@9.1.0: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 - hast-util-from-parse5: 8.0.3 - hast-util-to-parse5: 8.0.1 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - parse5: 7.3.0 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - - hast-util-to-parse5@8.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-text@4.0.2: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hastscript@9.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - - html-escaper@3.0.3: {} - - html-void-elements@3.0.0: {} - - htmlparser2@10.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 6.0.1 - - http-cache-semantics@4.2.0: {} - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - import-meta-resolve@4.2.0: {} - - iron-webcrypto@1.2.1: {} - - is-docker@3.0.0: {} - - is-fullwidth-code-point@3.0.0: {} - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - - is-plain-obj@4.1.0: {} - - is-wsl@3.1.0: - dependencies: - is-inside-container: 1.0.0 - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - kleur@3.0.3: {} - - kolorist@1.8.0: {} - - local-pkg@1.1.2: - dependencies: - mlly: 1.8.0 - pkg-types: 2.3.0 - quansync: 0.2.11 - - longest-streak@3.1.0: {} - - lru-cache@10.4.3: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.1: - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - source-map-js: 1.2.1 - - markdown-table@3.0.4: {} - - mdast-util-definitions@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - unist-util-visit: 5.0.0 - - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - mdast-util-from-markdown@2.0.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.2.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.2 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - mdn-data@2.0.28: {} - - mdn-data@2.0.30: {} - - mdn-data@2.12.2: {} - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.2.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.1: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.2.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3 - decode-named-character-reference: 1.2.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - - minipass@7.1.2: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.2 - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - - mrmime@2.0.1: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - neotraverse@0.6.18: {} - - nlcst-to-string@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - - node-fetch-native@1.6.7: {} - - node-mock-http@1.0.4: {} - - normalize-path@3.0.0: {} - - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - - ofetch@1.5.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.1 - - ohash@2.0.11: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - oniguruma-parser@0.12.1: {} - - oniguruma-to-es@4.3.4: - dependencies: - oniguruma-parser: 0.12.1 - regex: 6.1.0 - regex-recursion: 6.0.2 - - p-limit@6.2.0: - dependencies: - yocto-queue: 1.2.2 - - p-queue@8.1.1: - dependencies: - eventemitter3: 5.0.1 - p-timeout: 6.1.4 - - p-timeout@6.1.4: {} - - package-manager-detector@1.6.0: {} - - pako@0.2.9: {} - - parse-latin@7.0.0: - dependencies: - '@types/nlcst': 2.0.3 - '@types/unist': 3.0.3 - nlcst-to-string: 4.0.0 - unist-util-modify-children: 4.0.0 - unist-util-visit-children: 3.0.0 - vfile: 6.0.3 - - parse5-htmlparser2-tree-adapter@7.1.0: - dependencies: - domhandler: 5.0.3 - parse5: 7.3.0 - - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - pathe@2.0.3: {} - - pend@1.2.0: {} - - piccolore@0.1.3: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.3: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - pkg-types@2.3.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.8 - pathe: 2.0.3 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prismjs@1.30.0: {} - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - property-information@7.1.0: {} - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - quansync@0.2.11: {} - - radix3@1.1.2: {} - - readdirp@4.1.2: {} - - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - - rehype-parse@9.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - unified: 11.0.5 - - rehype-raw@7.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-raw: 9.1.0 - vfile: 6.0.3 - - rehype-stringify@10.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - unified: 11.0.5 - - rehype@13.0.2: - dependencies: - '@types/hast': 3.0.4 - rehype-parse: 9.0.1 - rehype-stringify: 10.0.1 - unified: 11.0.5 - - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - remark-smartypants@3.0.2: - dependencies: - retext: 9.0.0 - retext-smartypants: 6.2.0 - unified: 11.0.5 - unist-util-visit: 5.0.0 - - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - - restructure@3.0.2: {} - - retext-latin@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - parse-latin: 7.0.0 - unified: 11.0.5 - - retext-smartypants@6.2.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unist-util-visit: 5.0.0 - - retext-stringify@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unified: 11.0.5 - - retext@9.0.0: - dependencies: - '@types/nlcst': 2.0.3 - retext-latin: 4.0.0 - retext-stringify: 4.0.0 - unified: 11.0.5 - - rollup@4.54.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.54.0 - '@rollup/rollup-android-arm64': 4.54.0 - '@rollup/rollup-darwin-arm64': 4.54.0 - '@rollup/rollup-darwin-x64': 4.54.0 - '@rollup/rollup-freebsd-arm64': 4.54.0 - '@rollup/rollup-freebsd-x64': 4.54.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 - '@rollup/rollup-linux-arm-musleabihf': 4.54.0 - '@rollup/rollup-linux-arm64-gnu': 4.54.0 - '@rollup/rollup-linux-arm64-musl': 4.54.0 - '@rollup/rollup-linux-loong64-gnu': 4.54.0 - '@rollup/rollup-linux-ppc64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-musl': 4.54.0 - '@rollup/rollup-linux-s390x-gnu': 4.54.0 - '@rollup/rollup-linux-x64-gnu': 4.54.0 - '@rollup/rollup-linux-x64-musl': 4.54.0 - '@rollup/rollup-openharmony-arm64': 4.54.0 - '@rollup/rollup-win32-arm64-msvc': 4.54.0 - '@rollup/rollup-win32-ia32-msvc': 4.54.0 - '@rollup/rollup-win32-x64-gnu': 4.54.0 - '@rollup/rollup-win32-x64-msvc': 4.54.0 - fsevents: 2.3.3 - - safer-buffer@2.1.2: {} - - sax@1.4.3: {} - - semver@7.7.3: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.3 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - shiki@3.20.0: - dependencies: - '@shikijs/core': 3.20.0 - '@shikijs/engine-javascript': 3.20.0 - '@shikijs/engine-oniguruma': 3.20.0 - '@shikijs/langs': 3.20.0 - '@shikijs/themes': 3.20.0 - '@shikijs/types': 3.20.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - sisteransi@1.0.5: {} - - smol-toml@1.6.0: {} - - source-map-js@1.2.1: {} - - space-separated-tokens@2.0.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - svgo@3.3.2: - dependencies: - '@trysound/sax': 0.2.0 - commander: 7.2.0 - css-select: 5.2.2 - css-tree: 2.3.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - - svgo@4.0.0: - dependencies: - commander: 11.1.0 - css-select: 5.2.2 - css-tree: 3.1.0 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.4.3 - - tar@7.5.2: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.2 - minizlib: 3.1.0 - yallist: 5.0.0 - - three@0.182.0: {} - - tiny-inflate@1.0.3: {} - - tinyexec@1.0.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - tsconfck@3.1.6(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - - tslib@2.8.1: {} - - type-fest@4.41.0: {} - - typescript@5.9.3: {} - - ufo@1.6.1: {} - - ultrahtml@1.6.0: {} - - uncrypto@0.1.3: {} - - undici-types@7.16.0: {} - - undici@7.16.0: {} - - unicode-properties@1.4.1: - dependencies: - base64-js: 1.5.1 - unicode-trie: 2.0.0 - - unicode-trie@2.0.0: - dependencies: - pako: 0.2.9 - tiny-inflate: 1.0.3 - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unifont@0.6.0: - dependencies: - css-tree: 3.1.0 - ofetch: 1.5.1 - ohash: 2.0.11 - - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-modify-children@4.0.0: - dependencies: - '@types/unist': 3.0.3 - array-iterate: 2.0.1 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-remove-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit: 5.0.0 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-children@3.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - unstorage@1.17.3: - dependencies: - anymatch: 3.1.3 - chokidar: 4.0.3 - destr: 2.0.5 - h3: 1.15.4 - lru-cache: 10.4.3 - node-fetch-native: 1.6.7 - ofetch: 1.5.1 - ufo: 1.6.1 - - vanta@0.5.24: {} - - vfile-location@5.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile: 6.0.3 - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - - vite@6.4.1(@types/node@25.0.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.54.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.0.3 - fsevents: 2.3.3 - - vitefu@1.1.1(vite@6.4.1(@types/node@25.0.3)): - optionalDependencies: - vite: 6.4.1(@types/node@25.0.3) - - web-namespaces@2.0.1: {} - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - - which-pm-runs@1.1.0: {} - - widest-line@5.0.0: - dependencies: - string-width: 7.2.0 - - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - xxhash-wasm@1.1.0: {} - - yallist@5.0.0: {} - - yargs-parser@21.1.1: {} - - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - - yocto-queue@1.2.2: {} - - yocto-spinner@0.2.3: - dependencies: - yoctocolors: 2.1.2 - - yoctocolors@2.1.2: {} - - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): - dependencies: - typescript: 5.9.3 - zod: 3.25.76 - - zod@3.25.76: {} - - zwitch@2.0.4: {} diff --git a/apps/marketing-site/public/favicon.ico b/apps/marketing-site/public/favicon.ico deleted file mode 100644 index edd1fca8f8e08cd8575c1cbb5af90d7d1fddda1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 121831 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 diff --git a/apps/marketing-site/public/favicon.png b/apps/marketing-site/public/favicon.png deleted file mode 100644 index c67891dd57cef912f9aa4de528874d78ff2d357e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 515 zcmV+e0{s1nP)C*2JfySBf*dZV7In*8MuD;ENw6=D zVH6|b-NSzbZbe*mN?Z!e7YiabtLI>X_Vmemp-s_AWKDH%ty&Ik z3I?kW$PZdX-DqTh>)f8v2qfS^#ESy&31M4u7J<&^1xMICITKDYph1@k#QOwQSt9{E zK>;ghC5bL7>Z}rfWd diff --git a/apps/marketing-site/public/logoblack.svg b/apps/marketing-site/public/logoblack.svg deleted file mode 100644 index 34fa88c0..00000000 --- a/apps/marketing-site/public/logoblack.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/marketing-site/public/logowhite.svg b/apps/marketing-site/public/logowhite.svg deleted file mode 100644 index 5f4d8ac3..00000000 --- a/apps/marketing-site/public/logowhite.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/marketing-site/public/media/demo-navigation.svg b/apps/marketing-site/public/media/demo-navigation.svg deleted file mode 100644 index 86c46e91..00000000 --- a/apps/marketing-site/public/media/demo-navigation.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Quick open... - Cmd+P - - - - project-notes.md - Work - - standup.md - Work - - journal-feb-2026.md - Personal - - architecture-decisions.md - Work - - book-notes.md - Personal - - - ↑↓ navigate ↵ open esc close - - - - - Replace with screen recording - \ No newline at end of file diff --git a/apps/marketing-site/public/media/demo-writing.svg b/apps/marketing-site/public/media/demo-writing.svg deleted file mode 100644 index 056ea0ce..00000000 --- a/apps/marketing-site/public/media/demo-writing.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - meeting-notes.md — Readied - - - - - - - # Sprint Retro — Feb 18 - Team agreed on three things: - ## What went well - - Shipped search in 3 days - - Zero downtime deploy - - Good pairing sessions - ## Action items - - [ ] Set up monitoring - - [ ] Write ADR for caching - - [x] Update docs - - - - - - - Sprint Retro — Feb 18 - Team agreed on three things: - What went well - - Shipped search in 3 days - - Zero downtime deploy - - Good pairing sessions - Action items - - - - Set up monitoring - - Write ADR for caching - - Update docs - - - - - - Replace with screen recording - \ No newline at end of file diff --git a/apps/marketing-site/public/media/feature-organize.svg b/apps/marketing-site/public/media/feature-organize.svg deleted file mode 100644 index 63da744d..00000000 --- a/apps/marketing-site/public/media/feature-organize.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - NOTEBOOKS - - - Work - - project-notes.md - 2 min ago - standup.md - 1 hour ago - architecture-decisions.md - yesterday - api-design.md - 3 days ago - - - - Personal - - journal-feb-2026.md - today - book-notes.md - last week - ideas.md - 2 weeks ago - - - - Research - 4 - - - - PINNED - meeting-template.md - - - - Replace with real screenshot - \ No newline at end of file diff --git a/apps/marketing-site/public/media/feature-search.svg b/apps/marketing-site/public/media/feature-search.svg deleted file mode 100644 index eac30e5a..00000000 --- a/apps/marketing-site/public/media/feature-search.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - architecture decisions - - - - - - - 3 results in 2 notebooks - - - - project-notes.md - Work - ...reviewing the architecture decisions we made... - ...local-first architecture for the core module... - - - - architecture-decisions.md - Work - ...document tracks our architecture decisions... - ...ADR-001: Choose local-first architecture... - - - - standup.md - Work - ...discussed architecture patterns with team... - ...need to revisit decisions from last sprint... - - - - Replace with real screenshot - \ No newline at end of file diff --git a/apps/marketing-site/public/media/hero-editor.svg b/apps/marketing-site/public/media/hero-editor.svg deleted file mode 100644 index 5ca61be8..00000000 --- a/apps/marketing-site/public/media/hero-editor.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - project-notes.md — Readied - - - - - - - - Search notes... - - - NOTEBOOKS - - - - Work Notes - - - - project-notes.md - - - standup.md - architecture.md - Personal - journal-2026.md - book-notes.md - ideas.md - - - - - - - - Edit - Preview - - - # Q1 Project Roadmap - We decided to go local-first for v1. - Here's the breakdown. - ## Architecture - - SQLite for local storage - - Markdown as source of truth - - No network calls in core path - > Ship fast, stay simple. - ## Timeline - - Week 1-2: Core editor - - Week 3: File management - - Week 4: Search + backlinks - - - - - - - - - Q1 Project Roadmap - We decided to go local-first for v1. Here's the breakdown. - Architecture - - SQLite for local storage - - Markdown as source of truth - - No network calls in core path - - - - Ship fast, stay simple. - - Timeline - - Week 1-2: Core editor - - Week 3: File management - - Week 4: Search + backlinks - - - - - Markdown - UTF-8 - Ln 18, Col 42 - 156 words - - - - Replace with real screenshot - \ No newline at end of file diff --git a/apps/marketing-site/public/og-image.png b/apps/marketing-site/public/og-image.png deleted file mode 100644 index 1e1c9da62bc99561e7fe8204b0a6649ff1d20803..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48113 zcmeFZXFyYFw+0%=L63bhQWOLOFEJ?mM|T6_Cn)l%QLn|n6~gW0EX zk#G%z`7;ND`NQq69pESYZK?d=kE3@kUb}+9c%8st{Qix>Y=R&8{ls8gPh&7sH!&E6 zmlzD!gIA?Gir^PJZfmL&nDE$TxHbTOvg^Uc8_wX*Ncew8wXI*^?HCQh`Rg8iGlQN9 z-OgM1Up*xg_OAT`?0usC&#wLZi*nfM!rUOi&q0!3ZyoCI3UVbB=2{-66$@q2P<+aA`x5}vSwb)&=P;{W{ugZaNLfz9Fni~-t)|1*aFGlu_vz_NqM zSWLwW^8N{LForu~HTT~AL)h&jee1S_SFeoDMTC%PdkTfDd7sCapMH1q5xJ{)S!PlR zw>yTb^0l&9;`iWBje8t|g^H6suC;4oC!=df!V1R{zrP-qtRV$oY!&v+9W5m<+a>RO z;Swa|lj#!Q!HJ#3%l4PvwIpmZWo&i(Bocq&n@`Bl6cQgiSaQ&m@E01$@9jn2ZT#b;TTuy`2=NE z+3ER7?vB&sWrG^QnmxGP_F`TYzY53?-2=V~4V=cwBT;V5~h%$EM>`DB?d4 zVwsnebaT82Hn(gZU2i0NoG7r*xIlQv*H2)Q(}HwI4qzqE4F%l8=AN!3ON0{_KtMI0 zqL`nFPI`oFoJz&BkPab#ve-!2AwH~C%4Ujw;6){qU;nE5N$!ebE93kSeDQrGG3k33 zx$C3L(`8L8+f#PF2s1(4%F|_h#|2!X=eyTa&z8gQ`EgcrMMq_v6Bd?)>UFtOE&k*TL-sKZ z{9#POsjDB6=$qhZEO<814C`o=VU?jrurWH{^Y8{Xy}6T(EmV#DmHGO$AO6_%`o{vh z4ZulO=4AY;O}e0K+)c^2Y`}+oK56RdHWr2LPBmCfFPFwi4QG1Dn`tV@VA}pVb#oiMDQ&0icu9eKaMup*=Hx8@>v^9*h3RHL`5{*T(coo> z&`$&D#57@CfnKTW=0?)zYgCKZnVW?+Duk7P2jq)%V8O~vbZ&4w_e_|o&-jXsCU!Sx z6p_hRBQNE|CO=?Qd=vfH;OnR`^7Lo8AK`}6A5Ny{V%h3aB!mYP6BFK9uY799vdKo7 zT%}tOreeA#*=T~iktrvZrkggp-Bqvi>l6Ei^#n2|419FtMH4q0GIT{ws#OKi7`CPA}59=J8E4W9SIp%HVYrV#qAyvDKALVCRF_l`%x25U{^HoaJl8$ zaqNv-Mo~t2VcyY1FT)M_z@DJAIWy0THJaGyUcH_A=3H3qI-LbtlI|avjRe*bh${gj zBVWi}8I!qeC)L@dMQlu6^az{NVet_rT+jPtD-0AOTrOVJAD_#hWt_q4%{KCjlA~x=SeMjH=nEk@_bl^64L(h+&GH;7!)~8>^i(q&0X@=k)uZwV$rw zI;1Y`hztr45)!k~bFlz3zLjZqT{3m}8#$x9AzmDREYWK)KtR|$m^>>rZAIk6>g5VG zglp}>5QNaP&5@q2IhN}{u(?{Qxw%4qC>7|N2Q}i(GUO_P)!!c_ce23MW|sW$Rt=St7po&(yn&CU za$%n)TP6=(QaI9NqtbZdn9OQH}zs~nuKHFwK zqfFrTc^^KhFs+>!5tdP@bPzcr7VZ(M_%D@mnE$Qg;@zv)+!{h2&-Kc<(D4JK|MB5E zPD(0qm}`ktc9TF@c|0~Rp?nIL75eGt3l}*|8qaP+)H;nVs_NN{3G7>mF3d1ZT!-pBB z>~8leb(cV7dM%<@nxioJ08^hnl(E>?@Q~avxrfjAmkMD#DuSxdP{eW8&AbeEQNS2V zvL>?8A-Eb4ugbnGs!(q2MQC5=j6ciI4*@ynyGXD(UNaKW8r@Pj;lfuv_o^bX-MS+7 z89)EVc8*kf$bn1!BI&aBIsN^JgX4KZm0-?$KVxy|J(6Z403 zd^?7jMINzakFz2EoncyR7^WtA;x|3_`8zsl6TEKMKO6>Y(LObO^($`jD&~s%nE6EO zE4)Oa&C=p{AU~O$D=9>L4fZ2OfwdpWhmVBT@?s|^1$kb);?VIR^l~-hFDbBNZbZno znEO`sVf9QKsG>|~0$s!{WIMR9itbw!fu42a#RKwj9TJgE!b5_I5AOE1Ho9E|?`_On z9Nk!JVrmogn2T)@xiCcNkWTrP_F1edweVX-je^G0 zKaQFU8?|~r7%IbdTS*X8hPki{Hx#k0C*pSd)vBROwpQrS;TFWQH3#&uZ;2)TJRp|s zkgH9Q4(-^}I~DUXv)Kt#AahFAjyewx7vQ zlC`zm{e?DDYFZmg)shM?6=~t)!4`#ud1g)ZXKcfA*i9%YG9Qtfer7z^>kJ1Qa6yli zl=_u>i^>SP8WVmPh!sjqv+E4xuWWm-Y*7BkcS;`RKpurZk0Oyr!DDW!zGO{(eX~y0 zQJ>(Ya_?S!3-+5?&{5s5q8^fI$fNvSJpEDQ8XcQSY;EMu3o8m#wH{pjzYJ&4rCOI+J z;!a~$un;+MF%b)0Ej=wHAS@tXK=?@${~6{_vSIe-=O$r|)IW~iLk(Po(G44TlB{T) zS%*^M1KN0x3`H3j)-Ur2ZBaR!s%%`wkYQIcy@LFbZ>{Dtc=JsQmQ7E^b=6PubevOV z$M@M>ZH&e`8rtYjy$3e%@!9|kK)D_h@+0GJ;db-I|RqNudyGz6xg#d)`u!-z7Y&Y1j9Fy!PucPVz~ps@uQOIB#!-;CqFAtKMXbWIDd7Uo051_si|;x%1HRpxs zhz6q;l{5ULy14jc;;-dA`{r|mq`Igk(S@qhm@fYevBq;R@O(O+Tl6)qrl8;0k%v+n zzp=pGX&WZC0&mmLZ1 zMPhCaxmq3fFn(lmz}F?dS8Mi!mFIy^4c0fF7x;kGRv zQ*USHCuZiSXXYI+`r=Y3uaMVHR=%usNQbU7ZEQUE*YlVgIhv(cI^JOX#-2}JWz1*v zVgq}&R-ctH{mFJxaZ-^&f2KLJY&@x!ef2V)Ux$7x)7WSqWHZkW1fFY^cWEgmHz`Nx zX6`s1wXSFjfst10rOx+K*DziC(5a~C<{9lJS7IOYNHbOQ zKMQnRxKt#hbP9OP$A+siZtZ&l#r@NKoU3N{I0TRnFZf)(q`;x_ITeM|D^=NvXOx45 zxW7*Ii1qjjd1U?i`DqHc1x6zc4N3v+&RNkkdx8o7{j5|Cwe5+gAMbpcxL@gf^PfS$0K1{+uI(n0FC>f zhr4Q=19qyjD|HUf=GB2i$4Kws=x;J6ZG~XF8JtB%^lWgYE^~Cx+x(KXMHt@oFUX(t zVjbFimY`__>x@P188SI7nK<=Ol_E-&14g;p(!P@-`IhXEpR^WDLOPnj%$RRJ&1P3*@o&kqWIF&J_zh$=Z7#l%}A+mXLmCJHm~-)P09% zR6-E?q_T4&(-i5s?x^GY`SgCSz)CD}-E3KPT3?C%4uKi=^vH1z! z-I$vj7SL-iyZLNy`*!2uU$8-jy@*>7HZCtk6R>TVF0++v;m!zk!u6T66OT*c1crO% zPH1TYL)$__ugc$>)xC}#Ymwa2BOI8vi+b}{Jsrj}upFPq*_#I?e$RZd?r$I-gOPLQ z{lT5JLs-?Fg#)Ro-T=B17L4Fa-!J0ixvlz;r+Fg+eotD^54{={@b=1w0HN~ynV4|_T*rKri^RqS;I)`AgYNhA zip4zw!fWx&X*MhM>fx8X&VYI33^$>ha{(Nep-R?To|6$;555P-$7dTXWt#Mw5>}P^^qJLLYM2=?A)yF z($9lEw5(sBE?YRH;(H6dq9I>qjo(im9T!sOYcVR2Cw}9Fv{JXRo|*BDzjP#BTDnSj z)T)C#lurP2w{Bpqxbe+{N8=Q0w_*TfDjj(vxijl~e@FgbBq5KYyR*aLzY$CtgSl%_ zXVtsFt#VED{C~kK#vhYO$+6npAP*lf9_+BW3J@J*7+DiHaYVt&gkTxpkgf;#;&y34 zbWOi}QJ_APKHGE_udT2bgAvInpO@veq#nffTPj$&X)Ek&V>d)i8;A3%S#Z2dSWtiO zU{`XX;}!;!mM%C_GGFVCac$mn3#FD&hJME-c8a@{0h=kv`kIVCq)B^Qr+F(yYU-`R*3p!xtp9Sy(eC z_EiIf1B|QSxuM1oCT6i2wOnbj3%5-M4(jBYmT}r0xC}4Dcq2T2+}GKd5EOiV0Rpt( zf6!yLzP4LiV*0C3L6R;8^X$c^&c;%i`DZS>6C`zzh@YS#v~IglsT!&d2jAXf0QoS- z1(hoATGoe@EF4p@L;bd`xLHCjF+rW6b3Rblc&`?8;gy=Og&*{X0=v&%1Y7rv^-8(- z9A&i$$1;ndgRF~c*&KMi%zBayx|sna|pVaLe|n&1f2vhzl2 zX4bJcRg<@9g5eqp$Z3yWn39-Wn7ktp5L+OA-_;j7ZEI^z33{PZOE&I|mA>5#SBs8& zeosE4CnLQdO3}}QuLKjvu0JLtc50d*|yJtLYoJyAgO+ab1+ z{ptLqJ1&4~vZ4=fRNj6~-#9NR zT*a-)r-Fmw`?q)Pj64bps0l=GgrSG!+SPuTeBp8kgIPBpyI?slMeF>{%D08xL__ja z0Yc@kC;%mkEb}s{8a%IiuxBw(f z=`3FfjE``=0@;68u;9qxd@V&~1Wig+fXd8KaEfkBu*r0pUS5LC$FKA`@4RK*f4}Hs zL$_pM=zD>Ht+vHYPTFy3_WB`$Bh>lY&nhEmB>s3w?{uO^Y+}CvqO$@vk1^D!^OzNBoI$4KdJrS z`;fGw?yaLbp?-C?uTV)EGOVkV7kJ6(SO|^y8zU}n4(BY~uKHM#*53V1!_CO&n?ck)p;GTfBi;($9 z@2zcZdw|aJnYnQtb@(CqlWv2;i-L>L?n>{~tZk(Rr~dQg0(yyZ;o_wA@55GMT7Y)$ z3ty0yRzzKTR6XjO2$A!*b^*QpvSw{3H8}g9P1X#{g?`>}@n~O$uux^>rq8~8EPbdz z7uIDE5hWgS0_!7@LD>`@hwUp-=w26uZF=9DURJ_7T4p#@*}zAuHe9x_61H&mssqdD zL>`;Q%y;>OBGrv>l^@3#xmw zg`)h;4e3ys+Xdvr{>CgDq8k*<67CtD;bYqS>_5)hp0TjaJDWqxmDqMgeQj5O=wJ_Z zsKIqnL9PE(vu0tkTV2LG`@?Yv(NzAuAm4Q|ok^^}8@3K z-wS*AIglc}WdU!BkKvihBKBil^2nO}PKYDo^l@mlX?tpESJ%+piYZ=x6~+j3(1c9v zDp%0jn>qAwrS%YroTXy}TuR%cSxch^GykEZx`lP(wMQMM9oaA_8y*f~zL`T+acLbk zE6z3X(qqs*d~CQ$Kj4ZkBq3b`(wEtXy6?ZY>^}+No#ElgASR`ZAG7%THJ-Q?x2e}j z7r^%VKfq%=(M@ctX1^>_n)@Ie-gpi0n~mSUNwRveSM+cM6vmJ4n%jAl$y#E7nWN8< z!#Y=ux-xz?K)!;}Fm%HI8f0v{>Wv!K6@pFo^0$ZK;SiQ|?VLMsl*D>B`Jtui`@pPA z@CNZ0iy`Tlzv^6uv}wlpR5-D+9iD?S+qZX%}@Iv}Ak%Q_P$wzry3xJY5p zA`_IID3}7)@(mH1^>AW$sQ5dWk6q;#D=}Z6+uCO6h^{iYTbHd<_fGhJut@3un&VHX zMHk7P{_svG7l%E}ff9~AU}9ugpL>zx1;=|cIR)6BFMQwHW}}4|WWsad;*!ya!xHeG zN&g8xZu}aP5mtX3smA&#_9|>~auHt4blWc#PQZc9`eMU;tjt2}5A4GIBL`D~^K?oJpmxN&O- zbB0+fihf8`6$r?HgM&0-?&QrH)*flGuFnNz!=}TLk6$pTb4W;6+lx5RPNda7V{asK_jpxOvI9==9 zUCP-MKwb&c0`4a5(n|}xeEl)Ut{+TDAB_2+&gVJ$T%E7p_og|OqHh2V_3Q8;h#KbH zta`|XH77Avddx|@lD?bElbxyR<%+2xCus^5kxZ~lB6sy5)#c|~^6MYsB})hox^ z!&Zd7yb3Nq+umSp-{sbF+rE3goQdgr+^KbL_Cu#uBXwn>hqASjO=3*b$GoS~Q(t>c zrITCnO!`tQKAX{#2alvsga7-%O!!9LXOTP{Z z*wC1>&9`ihk+J1$&oV7@o9$C|nHwl^o@i;L4EC6L&ldEU1s0D*8rzqQMUtnp%H~R^ zvjSh9bBA6OPW%dXQ#AYG(jIeRMM+$U|6q6vv{(1RUQHPu;m~;ck5>y|ek#jT-I)g2 zW>_wBpM@GO^CJuG>ioXT?dn){Ue_)ib>0Y21_9M<0~@CwK*B*xamSp*%?;?Z@f|5w zV$a(35lrCec-vJgtqnN0Dy}3!uz{OMg2f?*BvMDflb5;2KZPW#DTco%$`JOx=1d zqR4y1i@!hIBM!$;jOMH8saPYi$-h9fd^<|q9;pFb$?(?6IF}BMlX2{l)@=#rAMF5M z0cO?pnN5N{NDD%pwnHUU#58SY5(C9ce{gUfEo{17hqvxu_jsLzPy)-3e9FU!#F(eJ3UBYvQjxuptoc9}YbM&D#31 z(;5#4_6}grE8zg=M%%0X51!O|bto5|QvKLZ*hb<6LKLRb-i9a$7u~&i4<1`ODJ13=Ljdy3JGDXdd55(@cdGB&AYyk{462SjR!=s7Y1Qw| z^*eF1;l(N4l*|5dzdmk1p!n+}-GKrdc0u6JukVeE><7b$h2Zj6w5JYCl9Han@Qmy` zFcX!7i`56U6&I=xVzu#%etVwW`V+DD^XgBO94_noaIX`XJh=lNye*|56!76M-SG z>GwrLULwOA6Y-?2jR^rh@0r{>K5x-s6kWhNH}V^VGvDPk2xH4;@*2gUUBcKTg@i^BA~gY6l5nfmXIR6kg^C#nW=mW-IfgEtrX_gKh8lsR!@EXrKX zXR-cdyw9SBeR`8d_=zhq(x==_fWaxaPIV=P{%iI<3=w7a)z6Myc(V8K-?`0a`|syB zpT#P9E&ptPt4^p3;XUUz^YWa1??{A_R`1AZ`+;J|ky_OO*gL%)s%i5S<`!-KBxA-V z+qMTnrC^~`&`4*j4iRayMO5NB7Es4cYum|_FgMwjgMT8RmG1rLW^z97KSy}w`t^oYoU4A;gZ%Qm`OcXhann+l$qvEW zMT2hrc143Y7*NDZg@)fe<@5Ua<|%G#y$-*%5K3wilk95?g7*AdE|$l>_{S5T@gL@w z3-0&kSu{psVbpLKY9J|R;R{CS+x(0U#dwL!oW*h0R%)mNiosnn!>FT3=^WVN73~rM zX}{lzv_xp&nEi1|-mAcn42G7hzrMQx0trsM{MV;F66L35FEr)c`ut=ckN(=@ocsUW z%Y0whXOsCJ2gMIyAtJXIN9)5+YKMHg9Vk`^zD2kzM)p|7+7;EGx({GcNbRol&@=8$7F7>9BJVW%h3r1$IT7bRF-fKgp*iP@qwSMr`DJIzyX*0wsu+XxtA|7QXUyT6cF1Fd zjG9Jc^M|kxZ!SFD&#NFk9BuX5b3ED#x4AmoU*ted(iQ8EvCS$OkFg!Myxh?8Ed0gH z-%h>B2N8t3ax?3nhwvbym>uW=q9qhEH;dS-oSQFq zt_Bejazr(0hoN#1QZ)GEIHKaJEUU>_0kt5q92_}TzeN-=XPRz})CTj&+M87O*=AR) z_Sv5Fz3v2{48k+`2;Ms}E>%r(=C+v@l?GuZ5Di{Z>E3%%kQxqyj zOCRe!P`(DmrPXf&q<#jVN01|tfzLvY`-L>ea#{bAuRVtSPdaHD$Vda2g|L>-aV)~X z%yTTll|pHWgV>4uFzk)1{ox{JT@fg2G8ce+CRsD8v|rn8M;78Jd>Krs(PiYru@JHW zczfj%^Q3hp)$J7?b9L=n6#J3aec~{0)&0J;Ju(aH`{z!K=#|DuG2_Dfhb55!g|d-R z2U_p#e^o}nLlwjyC zYuFI6z*txBK!L1Lf7e7TC=nY@rJ3kKZy99Hu#58CK$&P3jeh(Wochh(`vvZX} zTbl>Wr5fSP#$N}QcxB-bhdY7b8r{x4{L0zhyWv-GL|Q*}Yqf~dOm(w@Nch)xWT{rg zr6#G?APA=+uqGopN;1=AwU{~QPN5CX6S*8)fr~sjIr*L#jVAy>Dlywq_8jLl~6X7m@VJ?0W2vuDCA`nVI7)qXI z$35;4KtX_M2_>3Sh>XpZKo2Mn0qmb{$BbvvCm?JgBhdO`wZD;*oR__Xa3=kw*%ic< zOV+d@=ymydb!k>l9~$G9mK#4V%Qa@gOsAls`C^2aeb@EEwr65Oqqa|8&0ID_B5`GO zqp2|xitIQgJ>f{z<8!VT*U&6W^;v3`?NK(+_k>3^!bfOhk%gJ*_s=hFPG?iLHm3#l zLrnGnUXkQU5-M{Ul<6^XZkFlkB6`EW&Zh9?qQ_f6H7mS|Vr{q2xh%X)+16co649u# zl^?TS`6;SlT$Ut&W8AIp3Tp&v^tKn^6Wc=FYQPrFXO zdd3{qLr(OF5LcwbJ(MzNv+r$(`je*4Nuxu%}WDtH%^^%qEl+_j*( zqsj4MNyLH2@$R}mHg(IUwIi2yV)QG!HrTw`?<4xhsRzt$nvh5!S~SyPfaaXB%vt7Q z6ooz}K?0Hw7|)*STypbIhnRiz89}CJJV<>(2o@fOq89LUTS~?KH;E~EH9-*NJ|cHI z5oX+l49DD2kW4@sg;Pnsl#a!oT~Kso zHqU~H9f&8PhhUlD&SKHvyD4~(?}W&Yzq}KY&e(hPN(A5%S$GhbFSbRe6Vtm9g=W_r z{$@JRGyIYpTE2(H9lMK!(zG(hY$a{3Y`)TatI)k$&%o5RThEpBDhm+_@>ctAxDUGa zn7R*=Q7sIP&E0say0O6x}F0|+0yAA>% z{7Vv+5GKH{Q4lv>DHEOI22ILfSe zBYINr%Na5*S$Jk34NVhltzZo&N8+&3cjyr8kl zHze_xXHktb+K~$HHOL>fEVVolQWsp>8W&+&Dq&8WMY2)@v#ruX;OUS*VZ&Ci z!e=6wiinfD=O%qtVL;t(o-U9q1ZXoBPVKzsqP-Q%q>VJ- zL4dTe0$Z+jNE?egrw-|PfJK!Yn307d`Pts%q>{YH(KA5?sc~+7_fq3H9Ve5c%)BO( zHTVl6WG`I1(2fMG(MUq3U9R=LOPbISa6^1t<~EA0IQY$OeW#+{zDb*Pw1I?xvv!M7 zfQmuczs8)053DINT%BkRLjU6=+Kh5-_aai~@zFUvP`Y<_$+Bte#Q3EgEvu$NT)sJ$ z>$mWRunfh6k~Uos^&E?II`=c-(SC)W3FM8XiPm^Lo+KX-2n_-8lF=|t@si`i{7fXJ zG{K%cWdV=+SxRL+C;y_UwKXDf{5K+@FoJ^+4jhQYTBWUZP3F58&YZjn)z#t&%?`G( znUyEZSa3u}?b8s*O#K7(Zf^^rf&%M~FKAxSB>>Tndcpvl=DHbI!RIz#3h*qGUa^0P4fj~gQ4}(+yx_xvi7nMdr zz5#IxQmDu%ePPmi;RM#PjtnVShow&7;Vbbteb4$6sukiFf1m5j*tpl|%>YbXMd_E#cw(_IEU|_|DVK1S zDlcfHaz$)^1&gR6Gk@hmN)eEYHmI#owhu0)t_aI^3>W(y$)#cQe^j4JedW|}DwPn# zEd$xQYgAbl!Ux5fYek5@RI;W~X%Y3QspZ9L+l2#4%WW4**6I<3vy9%a39046qzxOR zkDEW1j3FWi*j(SlB&vZ9L6nf{BSmU;_IwY^52aHLREG;A;7IhB;UgKn`sqLsiiQP6t1Ok3S~g=8LN3xee|t03X?HU;&()*FN5WP zM*)>s8CBZUQmv~CC08KBilO|Wf#gP!l!>B5I8g!cR>F{F&%$r~00#EwU~I2wzB7Tq z$8-fi`cbDBIo9cM?%icy^Qq~p+dsuy^bXCsc0fFHRsVK3shi83VucbdJdw4o*71jK<_DW zcena#pLDxz)2M#86m_!fy0s|>SCl0v=>Q=pSO^vZ%{*y#X(pt_&mbz**9SE;eOWg) z!(eU-eu4fz>S(dY`tm@`b`^mKXvT|0f`HNB>;$SlEg3A=qyCrqo3=1k;o8xKr(}cz z=gftue?=Lh`T!yak*Zqw_``x9$*wUt)7bhXDMv=QN#Js0JHAUDt3%X9Yrx13ud4y1iRokez^ zRDb!4&vS%`t!rBTYkVo`f(j!E1l{0|583u`iW>gqh@jq&g7{t5_lyc*b&`zAc@4}} zR83+wQCEpf8kGsmxs}wykCY##(#S|jWAzKiustjZRLBg&&M_7x0F_06m_>$b2kul; zuUofO12r~YYh+|aexScW^i#J+>S_m|DyD%XPbnnjQ~&K>PQiyHZv)z1lH5FXsW2@} zY&7RK$aCEo^-ch+oX(^g;IGEjMGHZCRxOBI(9IyyI8dR2xGEPKV!H>4dHl=sNM5ZC zOdJ(CHu{H9soe47ww*^FeB8Ey-`|_e1cDkIuzIQHPy%%InTVX|`_t`kO@t;v3Tc1` zT`3#Nkf(bjm~|qykPN!>K*%=)k(TIoMTMGAECK^jr8Jsi5^G;NnG$Pn2}3WqCVF@LV{=UfLBv9|w;gQ}FT9M!7PjHqXjC=RPi6vaeH z8!;}*Vyq&SCvUOa9Z67ypuxNa$%?Z>zoF@JpMyl%iykzao(R}iumY-rkSc_Q!=z=? zB69%NhF(6Wj1-`vI}k7{S!D}2{VW;?qs&?&N?SMa9MH}o0sR@%hb8w-w8X+nbz+Qz zgz?A2s0{O4RI(R_4{_rdq@MXiQ!TQ-&_(`R=7TaE?l)*CA7+6W5(a^ICiB(sPO!{Z zvK*2-(&=_SoBa_ytri*P>M<)WGFJ4 zm3wTE#Hp%f_umK@R~sTV&)FDtO zHyJL$?c{GFwNM#|YJ@ZM`0<#_%;VTFQWx7ERZ(pe1(y;2UVtSP(w4J*eGyUymSM`r z3irfwlx@+U0c!U5ul^~`%z5qq*2gdX7I6Nx`er! zz@|YLOXEYU%Q6xwm!UfGhkLB5Ryf6{cf=8?5_n=!|L0vmnL^3DF)TOQz9%D;-7R3f z*#Q=3Pb4Bm(`NJ6$@MMeOR(BV+0>gYii$fnfk@2;Hk9fgd1%F-RTP^mMkLirF9HgU z&*21SkiV6`q4G5%1DWX3W-E}~(W@F!`FKDc42j90CR2@2bpzUL$yQg$(gbOY24e2K zh+MWuR3b@@J>tWi% z7!=cZ8FzjR;V?{EpMwM3QK8)`CK(~NSDF*yc(1}J!=JxA7XSsK!wBKF?DE~Zf9%*K zU=K)~ixc_xSR*LhK`EXZ9pPKtZGEdyCA0B9<9`7F&{q%Sv{26lh>CJ)?EUGP4V8Zkm$kK9FwqGm79ahqCik zbNN)sx+Bg_l6A+yl%0`MKvzZ6<7inaXQYvW4G2(^K!8fv#jOmd63&`C#*NXg4@Vd< zT=}9t{_gTXLn(P36*D8;2W*6mzsVu&sF;k@$F<*;F#u;)gt_KqQc^r4^vWwaD9OOG zRDVaP*8`wB7C|+~*&;k#sM|S#U!%iWCVwv4Qi8pMl!Y7cL`_6vs~wG0X>RdRCl*6q z0FuKt*Jzg%U4+6_v5H3LdlNO?G(MrRaNY{42LguUtm!eoK)|&btt#!DYrP+F zI1Hggzm4h}ToiqMgoRnIMe&*v70Vr(63t@RC<(aEA5Y@Tg;hN&5sUN{X;-r%Y2;|8 zZSQam3bv|8T-wg>i(nr_$JNJGUVp+8omVhV1&1zqQ#7Ceyapl78 z4^=y`aLr-j)j?Qu(peaZZ{)mTlZhN%5KBfYJ(ty++>JEM6d%0}*R((H6F}M=%Iqdv z^HVrMKf{9`pU5m)fF!y~P7_=rQLK9Gi5mHZ9=LPx%0t=#!;CHxr7M+`xep`Kiu#v2 z(z=kZHCwa>ia-$UV^xfwFXtN*d!rv}$&bCz)sh!XM5-E0+6Ias=WatSHFcrcw25v6 zZ`GkD1<@H$#uB%_UE1}3pkx}Xt`bGvp9%@#SYRdm;?uy_?I zq@|Xu-B@L;plkq(0K(VBi7%XvHpYHFsPIXKtA3$)vyHm^H;!nju{aE0#M3|CPqyaE zOjQ8)GlG2fI`YVnF{@_oGi9zFw-SixP5;*41B^e|cP}zklb)n9b>&16*bkFWwRzgm zjt!jfJ!dsnrIGs2W<7d8%s!Kq_YpmMzHk?4f}|5=TVo2qTrk=`S|HCLfoa+usQc;) zplM(v{L|Q!)>p8Z2zO!zKd0?8Jd1W>?hwznSrO=_{_j>F=uOfOqT}@(bd*H@^W)yo zG%c@Ls=;`pR?pZWs{215wQ}{sq)>1eIV`YGsq^flDAWmdjX3qruF{{J7NDy0+J!qU z9=$=Rt<87s{6*vzI;4GaZ{&cvkXh9ks4Wee#NW|F5mpAoXt*6z@&RdEBicxu@H|bq zB2RlP8R?ANzQ&b9XLUyUH&tg$O~w7cdgYLhbIru3*+>27$K@6um6DMx6nMXu4h!cuW^0MV@>l+_d(&7tcyr}nLMEVF@V)Sd^e*g ztvE4H7~aJT|Esk#9C>ic|5t10Wy+Lgw=r@k@W*MgC?zzf#Y_)ke}yrX|9R;NSFb7} z)SHi)%5Tb+nlPYp1(Rj9rQfy zk}3Ou?GrzR(H_sw@%wR1MDjqBPk5fmr%!AdmVxKer9Q`%u*#ZC$<$oL)9d9g{hx={>As!b$-3~4guN&!o2tyXTs_QHspQZy z4A|3W^!|0f6NwqV8v5ccAe2^&_#+O%F$RY0mAeEW=}LSeEOb zZuFi$xp)w6aXendz>K1;^(qH-E9`5q5!rCp%EgDYZC~){#->=(nl_{3PeRJ&#eaie zoMZhWMVM37lQ2fygEw0zz~&iotA3qXM%(t^9o}hE6w%iv;HG-0=RTrpoqs9>weEuv zhYe&ZY~U_LjL4$}d{-QZCU?(h?1cUA=;6l_5zP);YOII7KydaYHDk8HJCbrlr`L+u z`*0uN+ri=J4R|rd8Tw=H;>xFCdpA8j^t6=KPL+^2lEUJgLc=cA3P#zNTF#SzGgieG@uP~%fijnB3Epjpf6}WhA|Bq zftMQUVBh<)bHv-wb9XqX_= zKI%a6jxSp)@s8)oZ4@qhm)9r^UNpg=m6PJvK+S8O@oVV7{FP0iF>1!zdoS=!m2NR^ zZnH<9LFh~>qxnhtLxO_~}Cyj?8; zl@wcVWe1%Ps_mSr*or4B++S@Ct5+O-P2!i&Tl;-IQaauy^Hgo`q+HU-vk>f{m3#c4 zuNxuXICija<}0~my2aFet`vJv3i`sXU8Y!VDs_fy4X&>!`!{b?pA)0r+t6wx%bngI zv^`v_=fMYN4&&)8K1j}WMb`lDxGR#2<^NiIw+Pd1GDhXmBJ`Lbwd;eG9<-V`Tt^YRxglK-x;fd8L&x>!FkTcqg-a{s)&A*1ha%syS)O*{#Ok>s@JJBue==_Aea?8I%2KN4 zY&V?8S|jj9%kj1}V*gryWxUJglWd3hO5e@Z4aWr=g!P0LTMo+{%#J?f*sRlY#I;!m z+q&je*|Ss;rnL_veOFIHyg`&3i619|CT@*Mf&0X+bimy=sqtzqP$4i?3;SN{p6jJd zUEEjs2XWhbuQ%=XFU9#~HmUdwsuOH!1SFPUXcwyj~ixDP4^2;m3 z63gzxSe?+Q;GF<+`@$9!b2`8y1N=@*hF8V!-Magr8f`BhTs`cNeJ_L9as_3Ml> zaFs>)Ds~+O;aPDmcGYteGsKp;iP1=0`P+G3-}#?B&!b;}uMhNoV~0^MfgYa2?VInv zIusB^hNsmj#N}yqfg#E~gEmBwxQ(FBWq>fZJ=1@eRjl?o&EAu@R^Xl)~J4p)bo#e=(g7Wucc3AT`T6c+g z9M8i164Q5!(i-X^F%-CFv#@#B>Tg8E{p`e6v^Gy<&aKUd9CGbVYg9Ak)Tz9+@nno= zYkfjD9QqPCi}zZ3D~ngSeY5vt-ai)DOnlDf_DuX=IzIOoa%<|;d>EQq;tip8kKn__?SIyq`!P4biSowB1Q@ z&H1D>{>{d&W&yM+>Q>?2GN;R=MQDtQDc{VYqA6c2DS9I+niL)FO$QjA6-2|(oPpPU;PUp^ZzB!#8&Rr`TFCwz1 zzwdcUd9c3cY4-Ypvg3FE?b=5UwT~q5?Oc%)!VLwxj|>hl9q=iPm#cS(K1ZnEHiO?& zzbOna?vtSYTHF^^$w+ZJ5|vU(C#FqL*R`b1`)szPs?*CGTg&L>nsW>Ixy?FLX0dH` zPaI{A?m98n+CTHvvgmA?%o;5@hjfeiX$!lSO=M23W%DhxwLN{JEG=C!5$hl_t$3qv zzD{>YX>3q;sJS^oVLkp4Q+_=jI~`)|K2SP>yR_rQUZKO!cpNIt7QO1H1+k%|rFGd* z(m_cYKA$N?!^@DiM5b3VGN-Fk4xWw6nfy0-oALf{P9Gz&$Xx-p_5Ou8Nb(um4(T zY$SU2N=O8fB_&VP5(tj$>W+lEAG}Z9kMKR;y0R%dNhMUzI(iRO4%2#zokkjhMFc{A zdK0Jun^_(U!nA)zvmLfpnkkj?i4!@mKYQ(`4ap907S-5cC6Df?_9JJt%ap3kJt`4= z{PfYtcR4xST+q3Zma*1&u_oTUU(X&t6%G2fm?GxTx0o{T`}v-u(JM~u+dD2nAwmNw z4`M&r;YwADesW?5S7)J^C9q>3zqC)=FLLIVQ@456re<}W713wB4xhW+Z);pxIA&|C z=EBo%M<~LU#7iiDSZ^D?5XIQ`Gi-vkeLB>HG`cAe#q@NzUa7O8HdcbCcy(sVR(Ru8@M54a&0Z|`hAmJ z$C_F!d;`aWTZH;%LD{H*SFu&;rS7itQ$yIX)+oIBme1ToMs`+Z>E;x_gM&r9c4OYB zo+@r(QWm{raRSFVSMgx}v9~%qud&Ph_FU}aw|msNLPEYAiI5j|zHSd}Z0ps?dgoN* zMt)uvzl8nfaEgRIcdkKCMd@6(OyW(as?mq_*x1yw+IFu4JdX4EtLqB8;7^ak+asMvcZh*mV~z9jDRexMqp%;2UnY)Z^$+mkBHW-6!?qU(%lb zVLMYi}k|aayl)ny^YM ziYf0NeSf0vT3vJVTTkgwa!aTSF==T<6o zspn+y^ODwSXXf=Ct}@EV#qvvPmXDBUAXtLu;9&fij?O8jDRpbL^B*q(2Cfnw8JBys z?D&rurqPnQ?UqR5yr!@D6zv+!^dj$^1B&?Bb58jJvggj=v;xA1lgPW&wd9;WJ;(CS z&wG&SXi<2B!UrxcM|joMvpgci&ML~$t9~5VAL>!?tNW9|9)a<9vQ`!H(_@x@{v_(G z1(uq#OOC!eHnCU!Wa8{SJ8fHUqiWPu)fL@qoDCG+RjX-sL;nv~Ume%<8a0l3)vI3R zDgsJNh_r%$KAnyl@d|v4;RqQgJl$i^ZT=@F2?PXY2jPhPyR!k?mLa7{|rNT~We$oTWfDQ5G zFY~N{^e^*50emA{KQlt-_~1()bI$}UeGC)TR9D*Ucwduz`d^KEWoI42O4xC0=9qVN zr*q6}Mu8rIc9TOgm3EW);cltv$#YW`{1D1ig^;?+e3_@Zis)x@akr~;uw%0=*2QPl z7OU|W-+s2mS?4V0@~AMjz_efZX1=rYYe8#0^J{6Ti-L}w90rviO7AuQyuft_O$z?e zbA3CYo)5|3_g8%MrSauy4nPTAIbLw@R0IcyRAgRt>2znf1e^*xSSON@vf9w%SG^dR z3^vNZCCvk08iRev7L(#P&^-3nBi3%eXRUYS1R*MG6?lk>^ykgjot)y(utx`}mb9=- zaIVeVOYly1v12&UF1?YYc|Yj!^bt1xu-&IIGDP5m9X_6jJ;-FNn}g)8{P1*DfR~X| zyFHNvQ4-vPAxfG*sWtr4j2N;?>{TDpY<`Q&*j;^#)1dm-fm})RtAcatN~De&eQ6j& z8;wqy1=2v7xu71<-x1depG~&IA!j#!$3C4E(!b#Uy`3$wp&C=sVYDl-D^JKemm{&uTUf{U==IzH1yXcpRh1yLQRUi#B_`(b>kICIl4<~am&sI z3PO!DMrr7A&~z~#r%W%#d&=<<>ptO5(_9{H95&vMn5InJPZ@rS!QxSTq#gYq)gQKZ zJnjMedgiycPe>e+=l5y)b=YV$dUba+I>ygEj#JU!Jua-kv7jWs_VUv#sLOU&RPaUj zpYe!fZiWqsNwr-`By#@mWo-9FH&>Sl@kC9=I zR5)5Pf#~Q5%yh-O&DoBZE#($d!sM}Lp2fh&2I5b}3l)udr#0@yvnK(d8AP9)XAP!5 zZqBs=@lqClg+(9bJ*CVc>6`Y5vAB~7VJwD8SNYUbbdhWeYVq+ZdNug?Fd+1q8UIY% zz;wruuYm+nyR)2V>U7ds{xdYUh0r@fJ>g~3MFbd>7*b2Z)BNB^qYZ}N+K48mU%2Km z(e5ua4|Ba&l!~9LRubF^SlSs_uE87mK!;&AB&oo<;qO2lJ*&R9Y)@S-DKW@?;;AJO zp9~3pmM%Kv|1@1xP$9K)xV#&MP3sDNyo=#rIBj4!K=hg_`o<7wVo$TVr416a0%+V+ z6pb*-K*8=NA?t$1XP0L|>cjKbCMymhPy0!c{CrNd|IRs!4m)ktvmsFqy=}}-z1?iCrY4_h) z<66c7J&U5*T=~`6iGs(H7X6enqQmmXb9{hIr0JD;OfY?AUdW8~ncByl=VQmQ48Ki& zeLB8_p8x{ncxU1Oxm;2rfcJh1eiy2p3DSmwN|C-8&Op?!t(LIguJxw}qJQ?or@Wo? zVuBOQt~nqEN!ksFVffWulWuJxoqVJX!m%4cF>x!AHYVz@*?tps-fPai<@fqI?lnhj zJgvWQwjs-RJK9eQ5`h(bI#VEI!dv6eY>LU;p_*dm!#e|`ah}u+d{P=@XN^{KT_Pa; z*UD0N$;8T1*cG5kPnaLFJJ5N;kFf zG1||ACW?i7(Ebe{Bu^Yq?-DcrVd~cY@(u@2Y+!whvy_XY`{<^t=<-3X2R0EE-yI(O z59&9oIE$8`TW%1-J>|p>LYLMXH#w1|!8hdEUfOY}mQ5yy9cOG$X7W+{OX2D&P5tA! z7g`Q|-#p$<4GHw8-mq(19oka)7-$20& zKLk#K23h3%`m6~sHuoSe@^Cw}5`r(Sl$2`uREa_n3{+)C9s}XW`E)g03QFB{XI8Z% zS~T~-B`8Uu9K`HUWfk!T#y)RK5WL8}y@28c@6T=#-=$7gwbN?I3`aU6g@F#+USNU^ zfq=+@D(@k(gi5%42EP=(xyL*c1%jf^@~7|gn)0VttYn5>e&RPb{3G=2;Ji$5;Ko<& zP{6?|h*DJN&+u2+dI;qG*aLp|=k2h^J5we6YkN~AHEooGNNOOzX)6A^*lPnph4KBO z2-iuwOjC-j&@n@_?k;w9bR0FlI(pCIz%KW`JNSj^`%0v34O=YpeOWuS%=b5FZ&H$$ zy6xJBHr=r3q8yqm^`aKx?RY^EH$DxY=+=A2@jJ-vp5*@d<7){>eP<^}cF{d>ido zGdcS*2~DbE8Akk>G#h%f(j?k{_Jc{Z=^o|E;(XobrDgwEsXvPIu(}W#{{~^wyi;QX zWo50b4g(=>A!U%*aER!?{#MewybsihB<~Zmi&ul!&&QDN{#9%BlQ(27?9mR&h+w`W zYWu94<5cc>J+U(;oVkNKcPmX7z{tZD^UKx5) zb*64MQOk4{Kd25_#fJ@MSgsBaW<+VR>Hx6!_OvA@w#ECbyW<#Fl{ACCpM7V{mw{b_ zkI?k%7-E8h*O)-^s|-s>&!4J+s*}3rgKMax&If)~{=aHH*vXM^O*Ga>| z9$pjInB{XlI0Fu*(p>5IZg@R8CagtYD%fGe;B3qOsCjAV)AG6K^qGmt$RK+Wj<`BfMcDvr zzHLC8R0epcOFcl9gn0M3Q~7^OM92;5>n7+8)u-RIkLYo^ft$tciac7y?e2Otd|Wcikv+!=t5g+Fhhl^KOwXpfi}&{OJJd`wsvuGw{3^yK)?ELqw>4WzHmPa*%p zw3Y1A?bhTyk;#EHz-j=8xcl+&;Zh$~pimG%`uPRW`?oSP_T% z-BFpWyTs?Nth)*%8nl=;I07x^y|(izU-Z7(rKg>V29;^0HYLxi>KW^YQa=CiHwrWv z=RKR-<_)yG_+2OqsQq{S+NtS?A3wr=kc~8rij%x92z&=>nXH2@?<5b{fiuH?JWS4# z)szYp`yLErdj;d50qgBddr}teUwW@lj-axIk|RXiaI7lxQE{va3sJn`7ZRcv_KO^n z4gx;HCfEY>7t}6&#?SMD2)q7w$xmiO6ZkncJeFn$y7E&nlk0at7PWamg6FNEu_^o+ ze&4WN>vho((8V+gW4|;AWrgjxyHF+&$g;92eVtXb63H*0kpAA$w3H(C!QyJoV~@3s zm2(>M2{2yx6_uuDRvWu#evYG6l`zNAF+8{Aug8tYPINU?`~^c`TPcOiUT;M!>n?7RAzsPo#pICJtN*L z6RH#+FYqets|88_6p#wTSZUmCk(p!oG=?42U@Ny)1&WdJXk`Egev^T=t8$K+rj)xu zU+~9Og1=bZWgfzeI^)7nGCQz*g6>&Dv&x3vjQY)G`6}!f#M4vG>cNP6Uv!*L5iDBX zu0+YRJNc|A+IKTh6wUeYI^vm5Z+zHqHQ@euz|xRGRZq9!#-dVY#@3=zP4YS0940*N z=nH{c&Lfwp-sxd|SEBaTmHH0cW_B7Dn?FP_7Mu5*9Y%{PyB$XFQlGSeR(3;qg;F%P zp7EVU`)zXS)2BPa90Q>Jl#~fwAj|Zd0fad+%pG!P5@XGET=M;UR@`GCy%GUZG)gJI zmo4siK8gtv^jpiU2`I>3z~b^*rb9Ayjt{!oxCW@;X*3|{NBUm%h6p#bbVCFr{g~#q z*tilKl8|Hw0sCvOZ7llgp*{D~YeXVSbN6Y2L>)F9tPc+ zuCOFny6$IT9l>d`LPXXZofITFg#cI2Ldb>8Zq*uR=&NN0s3#-_)z0*bWa{Xa-_;dn zr+%G`ISP+2Fw6%dbl4ev*+#8K7fo!^&dc9$a2t3mrpz2>OV?0syL@;Nb6zIc-&s9f zp*Yavq%2^!5&4zh?XN^bB?p56O$}y{1dB%!x)mR5MBqw4_Pm){c+;Y{7Bd8^T$5v9 zDgp+Vj0%$&trn+E07yE&dB0um5@y#fUJK|=BcJa$heP9~cwHC$A{fVzt{qkTIY??| zdw$(*j_MF0fVFai6d@yhuDWO`TJ|gd6IH0CUEpbyy+fN>KOgWQTXhw?bTi-49YcehM=ePRgsZ%X@ z8VTVMUL`XD9F%^;;S{rm2Z>vR$|0N?+!4mWkj>{@lhFu6FXLX*dv;{*{GcFRR{A*3 zuCHL*1>q~vu$+0Ta5rpPC_HAJ5!{t66<0Ykd8`NxN1;Tb4$qmN+&07E@(85v#O|Kl zk1g)hwjf0c^%}sD#-X}@LgGm^^_W?hp15M=*e?$&1b9ogTSguSGI**ndbEpfdnGkf z^fg?ZYA}^q!l23R1!t0Xejx3F8*Z08CfCb$_)<-PG!+{g_Qi-?l;2WBZuTV{D>=BG z)Ylxvrl>czka=++ckom(HCDOZLX#5-Ec-Q5`2a>4^4FCC$ z=^;L6HZeTj#nThffhudx9cCCNFzRbcnD9_j(TZ8vLCG`cpnTR09A*C6g}v73onY!+ zg||+z8^qD3%1~$4Erx}?KVmi^5h}~pEWbHk9jV0Qd1G$9!j*)c1FQq|96b{zCuu>T zP^<_1DnSf7m9k}5Z#g~P-10VprmMQQYx}+6OZNBJle$!l-N5sT+~Ew{KxzAL;#)q+ zRbBNk{}Mcs7rwgd*q7XAr;{lLqfstO2I)%f#q$_>7(|gr^ujeX6~n(@2*;|g_gB_eO)DgQZ`c`*yj+(dT4jVT-g7?m}oKw6;TU0bRuV}^Ir$N@@;Oaf%gc%e1_D(E|7G(-J z^M8STBOapTIDbJO!(Y%8)fL~xRinxtl(8x>>wvMmQ@Kh%Rw}cP-ov>2#`{tHu9*z- zn^7(o$La?KrKO#n;u0cNqpLo3h^QPUaFggL?px|dL)m$Z-F{WL5>k^3yQZZh>LgKA zQlg9$)C^TmZrgEi!aD)8R+iFTvJewmGm5GV+6Dr}?sU z3}k23X2co*VQ(=k!Xn87o5`pu$Mpr*S?7ar=QLOwwtlLxHe@+woc)en7}!q}lD2=o zDMj?05oy%l8_Bp1wa!T9l`7f--(=?{DqhQ(g%ok{0WCNClJ-29v%2-R-` zPoHN~zU*~>!4L0wL@9i5tHds9#(pMcavGWnZ13li8(2Te^e*>JViQB;7|SF-6(;um z*l8{xPV*ZstkkMAT1+KJ%U+_-IJ>yooluvx5#6f7MS&B$?@l`Ab$Z%msBDcbG8zc2 z;O+`}?2v^-!=v?+vaP`H%41Xvg)7X3*LS(v zyv-7o?)oOW#fme=Qgw(CbWp@ca>od|lK{6zw2!~96Y{0l@o-neCW$Xb5d~@m#JrIv zcoyAk6&}pkqGTo+!>X5>jQIC5U9aXhW*>{orhlT{p@izN^A)WB^Exy;ukm3>)-l~{ zO;&H({z_U4*eKy71Uw9=>?nJX;XKXAAanKS+)tPF?i_H$lodBt9vfvc1*3f#D2-WO zTLi$LOe_Iez^m!+i2EDq5Mv9@pgVtc0l>D~_mYazL6#N*Kp38*m7FO9MFo*JXAw^* zp{x9r(1H!^D1C>ZX^`Ggxa6)?BR_4&s=ewCig&a4e1(dG?|=*Av?~<<$#t#)Ew|4@ zen_YzG!nRs9O4J&<`qCyWYdC%PsBGP+}X_gb|qmLzW;~9Oy?~DNU%_|1OOqZ8Nnmv zF@WCw=n4WbWA5qPJ&D6bT4T4GvHyU!M?c}t(2O0fBYNXC45&M@K>gL@oBZz%3h11& z|EKw5*sZg%WU(!Sx!i0|8%%(v-Bey zcaG@}nwUT>VHN0GT~*sTuJ*RLc`7NnHc_>F=937kjirl#s5`HY`3Hrr1;CBRa3pLKdt?)0F=ii)H>b??MQGIu;__-)QA9YY`$evpwasoxG6_wZ#NbJzF zD3?jN`_-)V$nSC|b=fgi6T2iEnF|0@CK&g3`i&UL zxbPVv5%W*A@ze{o6B(`ZTRRm-WX^wRnSGzY1_dm(K?m1seHl52_G)X4J*5;?yYAa{SDGM#StCh5g@cul+yRN7jRJB}ZiCe9$pSs#aQrxA$ z8hrM{&Gqu%rsTa9HsEDKepAO8QI)iKFR-XLIOV!aU$r(qTVB7&|7o{~+^Tibme?+C%R7a4bS;qRuoT~tIWDmL z69@wT23nW@u)R0f-3kfOBevsS{o1e>s_}$=Xpk}PDCR};1zc({mKC_5y-7VU;7m5Yd)1Tx| z%mt(_J0}+8rxa+5D-`}JdRj)+9ZB047f&_#SG&?->nkC4{PDKDY2I!_w-y_ZvP%28 zy)axTx$VndtGjdqiLZ~|%C=9*(KVKgpEtndX6Pe;yXD6!1b^L#h=OWKp&U0XBM4Nv z^}G9HHz^TNKEC?VrgtK+x*^BTaNT(JF#q@7PIo4?_i0--7{bG=6Ch+9(2fm8q3LG*%fth-&@r?5JJpmRS4$(5-cI)mv5& zWVGU42y&$seX+x3z60RK4z77pQfBqfHa@q2>kh3RE9aifOzy1j1>j-0%tfdyD!Y6R zFVBx8KiX?(Bd7%vE)AP7VRj57A%p{wXr4HbsB>{?Mdy$>U8C!FYRC^06z#!E$}jn^ zh2?hb8J7azXraCLiu+yDvzz43U~Z@iZTUa9X!&$kM7PISOr(!X>r!Xdvn)BL(tfLF zc=G$MnWs!-)gfzW&M8(1viBD7Jup1DabW+0{GnG2^6#3W2@3ng^=z{Y4uA8$k`-NR za&j5EjyS9v$t+C5@`oZN)DEYNz1?@Idq6d-pJi9nHjDYg{_qHkP<)g204$EElx3FG zXZbsg&ZXb)cl3|bOqKFrD8}1;!GoeNYcOlDv%F-(+Y9VIiUJ~r4@UbFs;goVyIc#Gw570P#(~cKh^)gMPXr4GBlU^NdiBL zczN`ssx32woJAn;tBg|M+88!=Zb4ej8YuYw;^RST2k@ht!K3pEl3oaFm|}r}p)Rl{ zz-aY2t(~4x`^ck9eWdr-!gUtekl(@9z>kW+@npkCyh#>NdKQ@05xkYa{J$M9fSLsl zZ0Lg974L+=T41lbh0O!Q*+tq86%PdywKE{mXM19q(--=;M*?sNma|#E^k{13ve*OB zh7-T9h>gJG(5+1@9)JC>#mBl!_3Cse%>@zS`J?txI>rN!g3CmQ9-85~evtJ6ywqP) z+P;@-_p~Q)jy#(ti;k*D1JNw z4`oq`S^OS?bp-0D9gLf6wrafNf(5Uv$;yBhYJad$$@t(O8!v@4JX*3Pwbg8ar8Rx~ zJC+@|Z1|<4!BTa(ZzQv7I?LFFNY@u*A-{Wr04f9V=SW~G=}Cw8b%5gtW;HP%^n&cD z8t5K$N?<3Q>4R8-S$?|_lznX8zT##F%JXR9(CWaOIgxT4E zWyWib%D*ks037_|Ve}yI67AtWF$+{Zrk)TG`knRyT>Pg) zj?&OvVgq<#t=?+OSdrP~fyIMMb9QDvu1b5NvIMaf4p7%S-fO@6eE^#+kL>cQ#%^@z z*M!``-%sLpV_PLyOz!;mA5FK{+3dQka{Ei^ak4durG))7;7-~f&(ofK;xm-#c%Lz& zv*x_Nga0T*x5ja{C9$~4|KB;km+S}nLRP&m3Kc}XedM%1XKUXQ*~OUbs`#<=KGMh> z;H|oO^?%JddWJ6^GL$IoZ>4r>?Ct*Uxal(+wL@Lf+m7KsG#T)}ke(d<43(%8eLR+w zJ8-}hOC{QlbQ}k}V4-+TmTJg3B^_X#kWca~In^~lYj*#6vqZG=<15KRcX7xP~KJJ%yMz6SX7hCJNvIc)=H& ztpUN_qI+$=Ebj*YyZLinDMeldn&9hVnRoBdar?{X*0AE;({foaY2b(XNLOqKi_77E zTTcQz8qDcBprj)TN~|9M_6*?BGKNRtfLC^l`wxHL*!}puR=pJC(|Exu2z9Fi@@Q6s zY(?Q%CT;)YG0)+XVy=w-jgpxRXLGAS48ZeQSk^b9FhO{Ema8ZKhd5a*j$lj534Nm4 z85$)gr0L#3UG8sJQ5zpzgO0Ii$XWWW2SlCum%ZNnXF)0{TKh@P%OhLJI7982)LJf} zIUb7u=dY}e3Hw+;`wx0*sm*3%bb=`(|9i+EbI)r^#qOOr0%;Irw)Coy)b?@2EiP)BmvH!s7Y%UCs~! z+&-yFn5${qpJOA2JV9=ppV!T>>R~UL1Cakb@_()0X=?~~3eSk~Nz=0pq7LtS?SUu0 zj49LBK@1qj&Y#hQwDACv;M?HW-%TfhpB3K`G(IgGj+B&p?~~G>e5eh!C=-^K|F)h4 z{`2m2QPo|A$Y&y^8K?RAQQ!ApWjKZc8@*}H#uNxf%5q_c|GfPlE4s|3e%b&*o{qbA(-&vzZmM6uy)YO)2I;eLS7Ju?-gaSCJCacEr zT#FWq-Llrn9e5b4=a^M_G5sXV;kN(TIxJ&JBu|LFAUns6 zyb*XgG>ssb$o0p&w*AxuTMt85D2WU@conPb02xXJC2p6!ZQ9v#Ed-h7sCe$3elh-Lq?9iP_-zB?tz zX7z?U{aCwcpMDETot87B8rLVZqn6bV%#BmP?;m8I$;~hAtH6_HC7ZtIzH&||dM)G{ zWqkrrWZxaN$nEn%6vvHaIjgNy0pfwBl%Gi*HF5W*PR4z&Wp_$t+!1qvy?7Or9(m^I zj1#eT{%Kr}F3}a!qrD>20Vt9hxt)iB2Q4r$7hz$5WU-Lj8u>Z%vl84MzM%Gyse1t0 z!(qF)c%A%WVGM6{eY6sj->h^wc>$yr`8V;ZBG-#lDW~z*Tiw1{pxn9<3kHU{D66_nv!K@q}6 zefXS`eV#M?o$morFK(sj9H(eS>~G$Eh2>9mCc|oH7yKMif3Y76xX?;~SjP6$Yp_VY z_Z?JV6-_=u4*ki|2tl$~zlcx-uTGTV*_L(oh0YpaZM`cbw;n-l90GRnJ0&VqlI3Q! z?F1jk#l2V7tA9@juN(!IsQMg^yr=>VVXgvfe-*wANw6q3-ZeG4?ct*};g2f#SK0wQ zYtnYpk^#dX!tDZgS7HXswD$@|a7eJ#-7cMrk-Ye@1z0fpTZwC{T6=n8t21x!oOlqa z=jjc+E9vw8yBZ@|F0|Fl6d5l3EjBZsxA>5sNk%NzN9{rf^zt!U8Jk29FZ1k>#eSWi4MdP#GcHFCX^8rq0eO_9Urbw8%Iv5)SXm7)noZ zj$cDt?QAQFX15=1+2lx;di_ch%{$)B2-0*_7j2o~E2}y4q7s>ryES z(i%IT|G0mNR$zE_@5;CzJRQk=SF@~sY6y=R^zdvUNQ8~>)~UC1;kv0zkkv}^2f+7) zcJyx85h){t7Ts#7RdD8F)Y2(-R2&Wsno#tf4L6O^k|HqHLNJwD?yTP6x)BZj@}hp1 z;XbNdIGKd=wM3_!lQYY7Quv0pEPg3t#I@VhjmaFu*KZZ&e{;Iyr=PZCwh57CEYWEeq2nqJDsP*b8^_=bAR7?<`M6jiPI-qL*Y>^3JD&m#WU?Yw#nAtcJNE zl1?9c#Y$pjjt_Efpaj!?sINm_V!jAw zQQ-b~VLLbYz0USCqnyQ{ECV}UeYkH?V4k7V{kRVSmL+sjOW}1|PNRpmoDtMHQZMFl z!uT@#^OYvbN5|Z{Pu56JX$AM%fAv&A#8|Mp7R{UZPK#i-BCx(JH-}2O09EgK(c^T^ zUP&s@E?F$AZ0yfL%MhN1P_3mYSO?{6xz~;r^sfstHEY_IANKOHWy&pL^;$|K>b3j6 z8tGo9L$n>#pLG_o5_J-Ecj%k%-}XiOsuxjbMdWlJ&1=d;?P(|{);}9LS>-iX(|xCk zHBR_m!Q)mmEAB$z`X6lMou;ppjhdXRo!C34E^d5{5~UXv`46(B zn;aZ!$CE6a?oj4J&N-Gu72N!OhuSe%H{=?7GRV|fA7KFANa$9JqQ^(uzYms^_2W?5 z+RM2#S`6;J_MmUsH<73c0_e!(!*EYfv$8~X&3TG*NNa^vcA1Dz+IXD6;=L>qOXHO~ ztOU#OKXR`2t|}c5)!ZL@$aDml45+=o+yPc~3jTmya05tKXRK|N^y@*aqsLN~Jx#>f zz#jGl5U}n0&Z2ihrjWP0VpM=YWHDyKo0aXyz;D-zyRN6ptk%Fce-M(iN&CKtX~_!4S7Zf^g=kQVx)B0wt93`_pH zlk~cTPKmTGzaHg!Oa`}wsI+YF4`_=_hc<{;NHpwL@Y`vfZf(H1%qWP}2mHu-eGE$9 zL(LN&-Pr3%I!;bn+mvc~v;)P=1a4Mam+;D}3e)ytNP|R`EZQlG?(4+h4#I~d$&mGNRC1_?XS6PZ|2H?iz#93vN` zXGkrkrkodp8_s&pI%-W(PQf0z_0GaM%Rcw~;2O=2zZaSb8Zj-6()#5a;MJvlp0p+Emn0(U>jcF;;}8)`Qw0;+BeKHnA)bv}155zr&Xe5+G`J z5(iz#IvgPzOuLEX>`uB*qvMCbHLpr?l`WoV7YD>dk8gIWFZLvfqIVkEZ6hTh)3@(D zUKRq?Q}2P&HYSpz4_Qp}p095J>5dZUd7CB;*)4MXOAO>FK$}0r%a-WB0#R|s zzZga{NOLGg9Wy8HJ0*|^DbJ;Uu5H=XKudBnReYKWN z43#|&F`pjZsS0p*fxcQCp@R*Zxva73yFr&SmZ#r0i`#tZJixd35!RYgF@C zS8gcf@ofges+H;(?H*)kWEPKHMxi9KW78?(qc=6ZolFmi2~vq%&VhL87L{_kh#~{C6MM$oH{k zgo!eSv%U7F5COz1SPT^P{rCi_I~8>*Wy?2SJd>cb`~GP%4p&rA_+qH#=JHuOXlYP> zE{{!FwRsN22#P_q#`oPJ)DhN%#fV8N-yPGFrqkgAW50G<=i3+dR^kTne`RhrZ8+t_z5h0qW=lrmPk6i|``LoK_1~8E!$YZkBZ;?t68DzKZutC@F^c zNl%cvyrx;;N=vk>Qnp;GqT&SV84p{c7cMOY=XUG~@6b%Z8Y7UBsl{?ez{T+4Y6?v42 zcCWJi2%HlNvDg{q$q7+^o!Su{Fi?%CE~cSzIK<6gm)M>6KYDl*%38h zDEH)axx2QwVUt8i(ao)LTB>(WoPl+#4LB#Lidd?p>xy)54;ff>q%MTJ@xQC*9v>ZV zbET4co+YKBB_ew~5`x-aecs`&DpWK*NkKf9ho7~#NAoouTdA{tgU&I_zhK{0MNr+a zj~w`jsl%ZabVsgj_amT;`KKXEeqsTRi(jj#CVMm-rhdIIJr=fIWAs){IBMufY&l{@ z#S)Y+CN`luL5;MA$xM6gp0$UPU2!SB`g^z!uFCpJGYuH_U`TW7XWLcIQP7eWh37w%I3^Bo(c;R~TK=#I4)tY?KhBeqzJsoCD!O8Z3~ z6)H^CzSpMkHlI_FH(*+{JC5$~m$*WXc3XgDc0w*Cii=ol_;I?}PQqf!y9lEhbP=`D zI#>}IZ4mZ3RRapnJy~5aX8twA(2DePN#7q4wWC?HCoyR4ym+`>s}P;s(hoQJ%$0FsK5du__nP6 zsbuE{>zwDRZJqH~fVa?iU2!s9y29Tnc#xo{W0pg$wLW&-K(n7WCD6U~%V+QmTZ=EJ zsaVTuz{lWG&1{d{x{O-FNky$B(yE$kyTamF>}rCKNe7jV2#ALHd>)y_h?ZMAj>c>1 z)OunvXjuqjo7{xdpC&pMXcFqkEF#X)*oTUDUtIre+QjLVsLGutl)e>3FLzPLkqgI@ zgYzZ3IadIQ;2#aWfi%caSH-srcNksT!5Wfg;l7oVm3(!r+Bw!+rk-Q3ld0iJ@tgDu zzS#rw;JwyYw{yF+iQngqMTn>%iq$-)zWLPq?dRc3N3>%#W6#u6ts0YkcPDWv#@MPfkiK z!EwvHSS}H!E`Q6ZGNmwXCMCV#x^{+dT6%g!_TQe{TfNRvXO08$K$F|qkq?rE{JE5a z-4qmnqaXnxroYNVm~^8}Y5q^ZJ7)0&!<~NFO$V;JS zbpO+%oK8NY{YK(i(W)I##E0zocS29vDB5Nc`6cd@*^86m-mD4R<=Gu3yCM5>P6Q^k z7~C<|cg}8D1Q7*Nl6`{Ori9WM7w9|em5!fe&8}z%+==aDZK+Zlgeq4D)sm7F`Z{i* z;a`XuU5YAb;^Phw9OX`#r)L%y(6WW?w-}>@2s!(rdPee|Owaq1QNa0DMFse)+Jl&Z zOo0TaU_vEF7owDSJfa-PolERneJt0bG)2$xP5b$2XK@soh6*^vA8!UaL!1 zYdPd-JGLdf>toZ`xEvp^7fpf9)cW^zuu1IE+##L(M*jZfOW_1B%!)f@ec6?HL%nP{mBGo7PE!-m|a6D)p{H*%gc7fP) z+HjrP>`w~)*of0To_a}Mdt+~e03y4#>QXgMj52(t8`WcEt1YUCbf|Ypx7LvbIYr00 zD0z|6R7hHjylmg~;R5Ny_5KS!g);!AQdVbLjuoXO&D8a{7eCzB*FF6*JgJjTBEtr3 zTf#<(x9w)6I2*KOMLrNpXB3&tx3Q6XnLL<&MTt*K{hdc$_7w~(pY4Y~o;zXoy^*Ca zDN)dgdrI#uD?pgxu}Ua;_}Md%PzWd`V`!3qOu`Qa`Tf5 z;z-IgM}yA+G7slD=-C}V#mjg%tpwk<_ z(=_&Il{fyi5+i+3&7bg^(+JhJuJbaccT0O>fY_$j93h9HMx*BbpJuw3$vvL`=%fdG z^}n4*#B0X9;x(7P7cVVawH^x26T*F}m$5{_$PTRY{*zYuYio)VyvBFLgLOUivp^#K zSk6RY>M9zhqRN2U6K0U154go!7`5Vr+OyzY`fa8R%g48H?+xF#U4Cl2d{+7+#72rq z9P{TGm?8gO6QFf0U=~H-F78sSi9wx`xF3)2Ei2lay8_iuT>ELKKWF2;lZwqNR+mP( zjj+-D<_`3R_;RZsxz3jr<3CG^WQf;VhNluI`JM4Jcv8^v3phXhPPm>}Mg3i~R{XC6 z(~Q8bo_QZ46EqFq%_6)(B0e-$#Ndp<;$C@g%9i{Vf6R49`AV*XIGmsrHxYm5FLY?Q zj=AfE$5>Q&(s+|W5RI<2os&1uoS!9kmYI9h$5#v1M9qlD(X|3}nMZo>8ptI{(Mjh_ zE?4!wUS5<24rmUJx&bTE+jY-l)H=&x?1qY7Fm*ZmL2Tizz4=O$Y}?c56&l?RoIg5h z>}Y8LFzdNafLSBpgueqZzTmPqP+|Z>juCsMPA3R54=RkDi1?PST;{Tp_yoLJe0)NQ z8fsWY*TQZ|AnF~(ovw@;bz+fEV>~3X&D26g&jf&F`A;PC_I(H84J$W3_B`-lk6Tlg<~8Y^NUUIs)gYOW#MWo@h9{`1(qu0$rxpTGd zp`6$6u3qR+G1zHvrk?f@i;%uPT{CS^m4i$S&v2r)@}2-wn;b7Y-+N`cVg~g+<6P(VI7h4%4#Oco8MkvEFzg}gkp$WJ0K*_ive=YpfqT!F-&%dCO4S=VFhC?n!d(+o&3TXTyhg;a z8OKxb&i2b`q86(p`$t#6Fl^#Z}Q;1 z>Nwxb0ug6D9=SbyQ{V97sQDV#JdQruAlKGiEMw$*dUKn6t28a<0uRK{EH^0}&HgOU zsok>I=R5s{u-HI5LY8q2dZs`|G2FGa0pQ&abKxJp+6;cQg9E*G;@8(xjw}~yIsUlg z8~saqe8{VPJ+}{`qM|P?AN+hKTtDy%EXpf;x_(m^oSpta^nS)UG5f|A&by8v<>6hCvU-W#1%0_iV3afqI+#f7UfqV?v9|~5}Ej!s5 z!-W%9@M|;A>!dPOK79*z8qG!>t0;P$mfBPr8q1D8q}hM__T_DMwL%4JmuhSAr60iP zHQxeSJ0<;XEVE@`+zv?m9GD;;Xp~faT(~F|Xr?fdXT#_5#XVAjU@3oxW3>Zm zKoruB@}S&Q0!rj(j7uyQ)e~xgRlrDEc%Q?kJo@Oi+1tIe!Ru~zI=p_YZd-NtZ$hxmK`nF=^Xt)%qA;o-Z@{bt z);7}4L)5Q>wXH@kfe2UnS7_wd*ZuB?BLsq^XGNpkcNQ-S!4@9tPY3eQiLbJhKm)90 zknO(-GcX)~cG{)MZKd=K@7fz-ZG&7KjVd&w_~kh!3I|>oY!7AqYRp*>%(3;*Fa~;| z=y$yk4D&yKX`wSF-S!W5W$#UMa0yaH)06t%12zH1iE5!IF~cSsBf+pK~Bx< zqPKq1PSwdInqEM4#;3taqUVGquwZr71UJD$5vYFILq;`axwo>OkBW11=$)fHy;mr_ z+P#32ZF!cy1wo|)vJ)O!(Qhc0uH{;ZF+5~gakm?bGXO&EC7w{50mRgCpN_LKC;EhQ z6YAzw+F2RgF4Rk5<*`DuqEPb#nT4eSTxbQXRBjP2dy3=Yk2v||_=T&r8z6@IJB^gY zVzS)H=a0oRh1v$^f)9*}a~N|Of2~1HK@@*{(O#hG3mS9#B+(fNbS`oIp8~_C(2U## zp;{1ASy>D52_1wZ7T2xR!59?oP#>RPV&ja^qh*h!o;)?bZ>eLk$s6b$CLu4lOv=r? z-=m9@->0@d0804PFkr7M<~ZR00iCvfB!w<%t|Lh605$^dz+>EzR(p$|m#pO8oR+=#av`Nysx|#uvpZf1{^Uty zZAtmv#2(uZH?r4U=KDgLXDqbzQ@((XS~}9)pfM|&unNpL{!(qRdTT{;qUme%vAv=S zq&<%Fj&@v(#(ftPEQ*F6p*X@l-DVF{*m-Q%Wf^X7UE#rfob{}w^0qe$xu5MeoM#y; zI3J3CvYQ&q~$IJ+W`S8f&Rl%6m0(DyF&vC-96Nzmi1 zzFed3qy0vclmXld(_2}YpIaeY7SXpYj-LH*ZUvsMRJz+og@n4U^UI2j5c(EQ%ypF8 zJ9}SzN9-WlX|j-)E}C3prW|JjKmhCyJQVi1u%IH$ypppsL}vj<`HDML@QBD|30#!> z6W-1g-bRKRh94%EL2GYVVb8&H7@ZOSgYk;KQ~hpT#6idHF(Z4HZh1s#%z99>xwZd( z(qf^SW@izUTEVP*Duaz@^c>-ChM?+jgUr7Vm&Z#^MKh46tAo>Y#1DGggfS7O0Old) zLH#u-{R`-{lBrn_u9xCa|1g~}llyz<&T7V9^FnP#gerpJa z+-=rlvDmA|^@`8mey~ljfSlwLs1aq-<7);xgN_L~N=2vy)#RpH2X~m?0r6{aU_Ubq z(?s=4I4N852X$~JgQ_Ao!zE+8Ct$F@z4-3IBjxy{y&FaAMOv4JYb7z(Pl}N3u{JW) zKdTNrL7d#%rv0ZU(d9uSu>=9+T+Y2=<&I?cotVEo(?;W1^lWBY zFo6TDhdp!PvyGtg?N(8F(yR1Pl5w(1;l$l*tM-)!dyR}P$AAtI)LdrQ{!uFr9eTAC z*L<5gLaTq*dj@jW8RX_>lO^p!h%b(qp(<=bmA<1tkRVO+sLWXp~d9PEQWuN}U zQu#(`CH77414CdbxH3Ur@0_G9qCd>}UPKMmpXoGNB#-5i#=I*H^Iz%H2gBP6W|vFqaHe2<-a#hOf7eDg)SV#}9xL_C#as zNfPnGvbR{Mh*)0TwdZ*lu2x?86MOc+zMP$VKEmMSqhK%GMpQX7QpAIIjc7l5hp zF`&R1cgHAO_59kquNP0@JWVJo)<#%fcIYh+g!Bv$7u!%rZwOvVCr??07|GS&9*C+E zh73eAFb%cGd?re$pAg@XEI}CTSN?W&7x~@~L+7^=2d8AXq4Z1}8Je^jXg-^5ng5y? zzdNa0M&LXlDxeD=jtrGy8-z_cfA zfkF~Fz^cz3dy6^}XY&ilw}L;YL@RK1uEIi0H2 zaq2~~y~b*nq?eI0Y1XM@Y6G<5Pv1zdg0M-!xi#e;S>D0UPc`(lG}?RLdj=XR5`633 zf-@n5gAG27qp3~w;eB1MP~8>U{et_s-J_YK9_WIa6YuYU7p%js?Bwg}XL%|@_V;o( zhGFCo&3;Aam1Id%r2czPldTFmmhWvjmM=?OnW7W8SH|Ndd>K>kt4>bOerAo+eFbd5kHa)Eu8LmNIY+~>0a-cX5XpUX7nIEMWGG`RA zV56O`TaVASK3{0Kl7d@!4TzcH=pWe$23Ov7()x7DDU{qoM(+0fG3zSiqI$hG8PU5X z-`SdgXvX@SFH$0nY8szyu+@lg-{bD1Jae8^HMJd@Ibs`S>x^cmWoeBll16pLyh>$e zJbR_`A41rK!kZMi!U9EeHqy|2$lD1{a`G;kkwa@+bt%Q+bd=B^hOrnGGpsUjMr`{MIq51-V22wo zyiQ!GrbTphL7~yh>zR_2Lj#(WMys9xrVDdB<R)_)%57EYMp6+z=9P?tu3=i}%z&ALI#6Fby>gZB=y*RZaC% z>YDm$I{KR0%4%x*YHCgnRbc$jSHb_H2lfKte^*f6uq72#*#FNz;IaOmm+)u(1O9iN z%R7{gfjT$CPC4K&g?QHL@#Mq345}2S0{lUsMR%h|LF=@aYyZkEe&f2`+k$K0k1QJ O!O-S5W~HX?SN;Vf2)3O7 diff --git a/apps/marketing-site/public/og-image.svg b/apps/marketing-site/public/og-image.svg deleted file mode 100644 index 2900874b..00000000 --- a/apps/marketing-site/public/og-image.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Readied - - - - - Markdown editor for developers - - - - - - Offline-first - - - Local files - - - Pure Markdown - - - - - diff --git a/apps/marketing-site/src/components/Audience.astro b/apps/marketing-site/src/components/Audience.astro deleted file mode 100644 index 21ce40b3..00000000 --- a/apps/marketing-site/src/components/Audience.astro +++ /dev/null @@ -1,81 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; -import { getProductConfig } from '@readied/product-config'; - -const config = getProductConfig(); ---- - -
-
- -
-

Give it a try. It's free.

-

No account needed. No credit card. Download the app, point it at a folder of .md files, and start writing. That's it.

- - - - -
-
- - Free forever -
-
- - {config.trialDays}-day Pro trial -
-
- - 100% offline -
-
-
- - -
-
- -
-
- Tomy Maritano -
-
- - -
- Built by an indie developer -

- Readied is made by Tomy Maritano, a developer who cares about software longevity. - No investors. No growth targets. Just a tool that works. -

- - Read the philosophy - - -
-
-
-
-
diff --git a/apps/marketing-site/src/components/Features.astro b/apps/marketing-site/src/components/Features.astro deleted file mode 100644 index 1b43ee07..00000000 --- a/apps/marketing-site/src/components/Features.astro +++ /dev/null @@ -1,81 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; ---- - -
-
- -
- -

- Tools that get out of your way. -

-

- A focused set of tools for people who think in plain text. -

-
- - -
-
-

Write in Markdown. See it rendered.

-

Split-pane editor with syntax highlighting, live preview, and keyboard shortcuts. No WYSIWYG weirdness—just you and your text.

-

CodeMirror 6 under the hood. Fast enough for 10,000-line files.

-
- Writing markdown with live preview -
- - -
- Organizing notes in notebooks -
-

Organize your way.

-

Notebooks, folders, pinned notes—structure your thoughts however makes sense to you. It's your file system, not ours.

-

Real .md files on your disk. Open them in VS Code, sync with git, back up however you want.

-
-
- - -
-
-

Find anything, instantly.

-

Full-text search across all your notes. Backlinks computed on the fly from your files—no hidden database required.

-

Cmd+P quick-open. Search as you type. Jump between notes in milliseconds.

-
- Searching across notes with highlighted results -
- - -
- - -
-
- -
-

Works on a plane.

-

No WiFi? No problem. Readied works entirely offline. Always.

-
- - -
-
- -
-

Opens in under 2 seconds.

-

No Electron bloat. No loading spinners. Just instant access to your notes.

-
- - -
-
- - Pro -
-

AI that stays local.

-

Get writing suggestions without sending your notes to the cloud.

-
- -
-
-
diff --git a/apps/marketing-site/src/components/Footer.astro b/apps/marketing-site/src/components/Footer.astro deleted file mode 100644 index 6faf195c..00000000 --- a/apps/marketing-site/src/components/Footer.astro +++ /dev/null @@ -1,72 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; -import NewsletterForm from './NewsletterForm.tsx'; - -const year = new Date().getFullYear(); ---- - - diff --git a/apps/marketing-site/src/components/Hero.astro b/apps/marketing-site/src/components/Hero.astro deleted file mode 100644 index a12b1b5d..00000000 --- a/apps/marketing-site/src/components/Hero.astro +++ /dev/null @@ -1,70 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; -import { getProductConfig } from '@readied/product-config'; - -const config = getProductConfig(); ---- - -
-
-
- -
- v0.6 - Early access -
- - -

- Your Markdown.
- Your Machine.
- Your Rules. -

- - -

- A note app that doesn't phone home, doesn't lock you in, and doesn't need an internet connection. Just you, your files, and Markdown. -

- - - - - -
- - - Offline-first - - - macOS, Windows & Linux - - Free forever · Pro {config.trialDays}-day trial -
-
- - -
-
- Readied editor showing a project roadmap in split Markdown and preview mode - -
-
-
-
-
diff --git a/apps/marketing-site/src/components/NavDropdown.tsx b/apps/marketing-site/src/components/NavDropdown.tsx deleted file mode 100644 index da136aad..00000000 --- a/apps/marketing-site/src/components/NavDropdown.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Fragment } from 'react'; -import { Popover, Transition } from '@headlessui/react'; - -interface NavDropdownItem { - label: string; - href: string; - icon?: string; - external?: boolean; -} - -interface NavDropdownProps { - label: string; - items: NavDropdownItem[]; -} - -export default function NavDropdown({ label, items }: NavDropdownProps) { - return ( - - {({ open }) => ( - <> - - {label} - - - - - - - - - - )} - - ); -} diff --git a/apps/marketing-site/src/components/Navbar.astro b/apps/marketing-site/src/components/Navbar.astro deleted file mode 100644 index c4317b99..00000000 --- a/apps/marketing-site/src/components/Navbar.astro +++ /dev/null @@ -1,103 +0,0 @@ ---- -import MobileNav from './MobileNav.tsx'; -import NavDropdown from './NavDropdown.tsx'; ---- - -
- -
diff --git a/apps/marketing-site/src/components/SocialProof.astro b/apps/marketing-site/src/components/SocialProof.astro deleted file mode 100644 index d24e6ede..00000000 --- a/apps/marketing-site/src/components/SocialProof.astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; ---- - -
-
- - Works 100% offline -
-
- - Standard Markdown -
-
- - macOS, Windows & Linux -
- - - Star on GitHub - -
diff --git a/apps/marketing-site/src/components/TypewriterDemo.tsx b/apps/marketing-site/src/components/TypewriterDemo.tsx deleted file mode 100644 index 039a7c2c..00000000 --- a/apps/marketing-site/src/components/TypewriterDemo.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { useState, useEffect, useMemo } from 'react'; - -const MARKDOWN = `# Project Notes - -Welcome to the project. This document tracks our key decisions. - -## Key Decisions - -- Use local-first architecture -- Standard Markdown format only -- No cloud dependency - -> Your files, your disk, your rules.`; - -const TYPING_SPEED = 40; -const RESTART_DELAY = 2000; - -function highlightMarkdown(line: string) { - // Heading 1 - if (line.startsWith('# ')) { - return ( - <> - # - {line.slice(2)} - - ); - } - // Heading 2 - if (line.startsWith('## ')) { - return ( - <> - ## - {line.slice(3)} - - ); - } - // List item - if (line.startsWith('- ')) { - return ( - <> - - - {line.slice(2)} - - ); - } - // Blockquote - if (line.startsWith('> ')) { - return ( - <> - > - {line.slice(2)} - - ); - } - // Empty line - if (line === '') { - return  ; - } - // Plain text - return {line}; -} - -interface PreviewSection { - type: 'h1' | 'h2' | 'p' | 'li' | 'blockquote' | 'blank'; - text: string; -} - -function parsePreview(lines: string[]): PreviewSection[] { - const sections: PreviewSection[] = []; - for (const line of lines) { - if (line.startsWith('# ')) { - sections.push({ type: 'h1', text: line.slice(2) }); - } else if (line.startsWith('## ')) { - sections.push({ type: 'h2', text: line.slice(3) }); - } else if (line.startsWith('- ')) { - sections.push({ type: 'li', text: line.slice(2) }); - } else if (line.startsWith('> ')) { - sections.push({ type: 'blockquote', text: line.slice(2) }); - } else if (line === '') { - sections.push({ type: 'blank', text: '' }); - } else { - sections.push({ type: 'p', text: line }); - } - } - return sections; -} - -function renderPreview(sections: PreviewSection[]) { - const elements: JSX.Element[] = []; - let listItems: string[] = []; - - const flushList = () => { - if (listItems.length > 0) { - elements.push( -
    - {listItems.map((item, i) => ( -
  • {item}
  • - ))} -
- ); - listItems = []; - } - }; - - for (const section of sections) { - if (section.type === 'li') { - listItems.push(section.text); - continue; - } - flushList(); - - switch (section.type) { - case 'h1': - elements.push( -

- {section.text} -

- ); - break; - case 'h2': - elements.push( -

- {section.text} -

- ); - break; - case 'p': - elements.push( -

- {section.text} -

- ); - break; - case 'blockquote': - elements.push( -
- {section.text} -
- ); - break; - case 'blank': - // skip blank lines in preview - break; - } - } - flushList(); - - return elements; -} - -export default function TypewriterDemo() { - const [charIndex, setCharIndex] = useState(0); - - useEffect(() => { - const totalChars = MARKDOWN.length; - - if (charIndex <= totalChars) { - const timer = setTimeout(() => setCharIndex(prev => prev + 1), TYPING_SPEED); - return () => clearTimeout(timer); - } - - // Finished typing, pause then restart - const restartTimer = setTimeout(() => setCharIndex(0), RESTART_DELAY); - return () => clearTimeout(restartTimer); - }, [charIndex]); - - const visibleText = MARKDOWN.slice(0, charIndex); - const visibleLines = visibleText.split('\n'); - - const previewSections = useMemo(() => parsePreview(visibleLines), [visibleText]); - - return ( -
- {/* Window chrome */} -
-
- - - -
- notes.md — Readied -
- - {/* Editor split */} -
- {/* Left: markdown source */} -
-
- - Edit - - Preview -
-
- {visibleLines.map((line, i) => ( -
{highlightMarkdown(line)}
- ))} -
- {/* Blinking cursor */} -
-
- - {/* Right: rendered preview */} -
-
{renderPreview(previewSections)}
-
-
-
- ); -} diff --git a/apps/marketing-site/src/components/WhyLocal.astro b/apps/marketing-site/src/components/WhyLocal.astro deleted file mode 100644 index 1402df54..00000000 --- a/apps/marketing-site/src/components/WhyLocal.astro +++ /dev/null @@ -1,73 +0,0 @@ ---- -import { Icon } from 'astro-icon/components'; -import ComparisonTable from './ComparisonTable.tsx'; ---- - -
-
-
- -

- Most note apps treat your data like
- their business asset. -

-

We don't. Your files stay on your disk, in standard Markdown, forever.

-
- - - - -
- Quick-open navigation between notes -

Quick-open: jump between notes with Cmd+P

-
- - -
-
- -
-
- What you write -
-
-
# Meeting Notes
-
-
- Decided on local-first architecture
-
- Launch timeline: Q2 2026
-
- Next step: prototype by Friday
-
-
- - -
-
- What's on your disk -
-
-
- - ~/notes/ -
-
- - meeting-notes.md - 1.2 KB -
-
- - project-plan.md - 3.4 KB -
-
- - ideas.md - 0.8 KB -
-
-

Plain .md files. Open with any editor.

-
-
-
-
-
diff --git a/apps/marketing-site/src/components/WorkflowTabs.tsx b/apps/marketing-site/src/components/WorkflowTabs.tsx deleted file mode 100644 index dfdef52f..00000000 --- a/apps/marketing-site/src/components/WorkflowTabs.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useState } from 'react'; - -/* ---------- Write Tab ---------- */ -function WriteTab() { - return ( -
- {/* Markdown source */} -
-
-
- # - Meeting Notes -
-
-
Discussed the roadmap for Q2.
-
-
- ## - Action Items -
-
-
- - - Finalize design specs -
-
- - - Review pull requests -
-
- - - Ship v1.0 by Friday -
-
-
-
- - {/* Preview */} -
-
-

Meeting Notes

-

- Discussed the roadmap for Q2. -

-

Action Items

-
    -
  • Finalize design specs
  • -
  • Review pull requests
  • -
  • Ship v1.0 by Friday
  • -
-
-
-
- ); -} - -/* ---------- Organize Tab ---------- */ -function OrganizeTab() { - const items = [ - { icon: 'folder', label: 'work/', indent: 0, active: false, isFolder: true }, - { icon: 'file', label: 'standup.md', indent: 1, active: false, isFolder: false }, - { icon: 'file', label: 'roadmap.md', indent: 1, active: true, isFolder: false }, - { icon: 'file', label: 'retro-q1.md', indent: 1, active: false, isFolder: false }, - { icon: 'folder', label: 'personal/', indent: 0, active: false, isFolder: true }, - { icon: 'file', label: 'journal.md', indent: 1, active: false, isFolder: false }, - { icon: 'file', label: 'goals-2026.md', indent: 1, active: false, isFolder: false }, - { icon: 'folder', label: 'archive/', indent: 0, active: false, isFolder: true }, - ]; - - return ( -
-
- {/* Search bar */} -
- - - - Filter notes... -
- - {/* File tree */} -
- {items.map((item, i) => ( -
- {/* Drag handle */} - - - - {/* Icon */} - {item.isFolder ? ( - - - - ) : ( - - - - )} - {item.label} -
- ))} -
-
-

- Drag to reorder. Folders and files live on your disk. -

-
- ); -} - -/* ---------- Search Tab ---------- */ -function SearchTab() { - const results = [ - { - file: 'roadmap.md', - snippet: '...reviewed the architecture decisions we made...', - highlight: 'architecture decisions', - primary: true, - }, - { - file: 'standup.md', - snippet: '...discussed architecture patterns with the team...', - highlight: 'architecture', - primary: false, - }, - { - file: 'retro-q1.md', - snippet: '...rethink our architecture approach for scalability...', - highlight: 'architecture', - primary: false, - }, - ]; - - return ( -
- {/* Search input */} -
-
- - - - architecture decisions - 3 results -
-
- - {/* Results */} -
- {results.map((r, i) => ( -
-
- {r.file} -
-
- {r.snippet.split(r.highlight).map((part, j, arr) => ( - - {part} - {j < arr.length - 1 && {r.highlight}} - - ))} -
-
- ))} -
-
- ); -} - -/* ---------- Main Component ---------- */ -export default function WorkflowTabs() { - const [active, setActive] = useState(0); - const tabs = ['Write', 'Organize', 'Search']; - - return ( -
- {/* Tab bar */} -
- {tabs.map((tab, i) => ( - - ))} -
- {/* Content */} -
- {active === 0 && } - {active === 1 && } - {active === 2 && } -
-
- ); -} diff --git a/apps/marketing-site/src/layouts/Base.astro b/apps/marketing-site/src/layouts/Base.astro deleted file mode 100644 index a2383941..00000000 --- a/apps/marketing-site/src/layouts/Base.astro +++ /dev/null @@ -1,73 +0,0 @@ ---- -import '../styles/global.css'; -import Navbar from '../components/Navbar.astro'; - -interface Props { - title: string; - description?: string; - image?: string; -} - -const { - title, - description = "Offline-first Markdown editor. Local files, standard Markdown, no cloud dependency.", - image = "/og-image.png" -} = Astro.props; - -const canonicalURL = new URL(Astro.url.pathname, Astro.site || 'https://readied.app'); ---- - - - - - - - - - - - - - {title} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {import.meta.env.PROD && ( - - )} - - - -
- -
- - diff --git a/apps/marketing-site/src/pages/404.astro b/apps/marketing-site/src/pages/404.astro deleted file mode 100644 index 075b7289..00000000 --- a/apps/marketing-site/src/pages/404.astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -import Base from '../layouts/Base.astro'; -import { Icon } from 'astro-icon/components'; ---- - - -
-
-
-
- -
- - 404 - -

Oops, this page wandered off

-

The page you're looking for doesn't exist or has been moved.

- - - -
- Or try these: - -
-
-
-
- diff --git a/apps/marketing-site/src/pages/auth/verify.astro b/apps/marketing-site/src/pages/auth/verify.astro deleted file mode 100644 index fa45b6a7..00000000 --- a/apps/marketing-site/src/pages/auth/verify.astro +++ /dev/null @@ -1,148 +0,0 @@ ---- -// Extract token from URL query params -const token = Astro.url.searchParams.get('token'); - -// If no token, redirect to 404 -if (!token) { - return Astro.redirect('/404'); -} ---- - - - - - - - Verifying... - - - - -
-
-

Opening Readied...

-

- If the app doesn't open automatically, - click here -

-

- Don't have Readied? Download now -

- - -
- - - - diff --git a/apps/marketing-site/src/pages/changelog.astro b/apps/marketing-site/src/pages/changelog.astro deleted file mode 100644 index 52059015..00000000 --- a/apps/marketing-site/src/pages/changelog.astro +++ /dev/null @@ -1,126 +0,0 @@ ---- -import Base from '../layouts/Base.astro'; -import Footer from '../components/Footer.astro'; -import NewsletterForm from '../components/NewsletterForm.tsx'; -import { Icon } from 'astro-icon/components'; -import { fetchAllReleases } from '../lib/github'; -import type { ChangelogRelease } from '../lib/github'; - -const releases = await fetchAllReleases(); - -function typeColor(type: string): string { - switch (type) { - case 'added': case 'feat': case 'feature': return 'text-accent'; - case 'fixed': case 'fix': return 'text-green-400'; - case 'changed': case 'updated': case 'improved': return 'text-amber-400'; - case 'removed': case 'deprecated': return 'text-red-400'; - case 'security': return 'text-orange-400'; - default: return 'text-[#71717a]'; - } -} - -function typeLabel(type: string): string { - switch (type) { - case 'feat': case 'feature': return 'added'; - case 'fix': return 'fixed'; - case 'updated': case 'improved': return 'changed'; - default: return type; - } -} ---- - - -
-
-
- -

- Release history -

-

Every improvement, fix, and new feature — pulled directly from our releases.

-
- - {releases.length > 0 && ( -
- {releases.length} release{releases.length !== 1 ? 's' : ''} - · - Latest: {releases[0].version} -
- )} - - -
-
-

Stay in the loop

-

Get notified when we ship new features.

-
- -
- - -
- - - -
- {releases.map((release, ri) => ( -
- - - -
-
- - v{release.version} - - {ri === 0 && latest} -
- - - {release.date} - -
- - {release.changes.length > 0 ? ( -
    - {release.changes.map((change, ci) => ( -
  • - {typeLabel(change.type)} - {change.text} -
  • - ))} -
- ) : ( - - )} -
- ))} -
-
- - {releases.length === 0 && ( -
-
- -
-

No releases yet

-

Check back soon — we ship fast!

-
- )} - - - -
-
- -