Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor/slides-decompose' (#295) from refactor/slides-decompose into main

scott 51584f61 9f2ab36e

+1676 -1324
+193
server/db.ts
··· 1 + /** 2 + * Database setup, migrations, and prepared statements. 3 + */ 4 + 5 + import Database from 'better-sqlite3'; 6 + import { existsSync, renameSync, mkdirSync } from 'fs'; 7 + import path from 'path'; 8 + import { fileURLToPath } from 'url'; 9 + import type { PreparedStatements } from './types.js'; 10 + 11 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 + export const PROJECT_ROOT = path.resolve(__dirname, '..'); 13 + export const DATA_DIR = process.env['DATA_DIR'] || PROJECT_ROOT; 14 + 15 + // Ensure data directory exists (Electron sets DATA_DIR to app userData) 16 + if (!existsSync(DATA_DIR)) { 17 + mkdirSync(DATA_DIR, { recursive: true }); 18 + } 19 + 20 + // Migrate legacy database filename 21 + const toolsDbPath = path.join(DATA_DIR, 'tools.db'); 22 + const legacyDbPath = path.join(DATA_DIR, 'crypt.db'); 23 + if (!existsSync(toolsDbPath) && existsSync(legacyDbPath)) { 24 + renameSync(legacyDbPath, toolsDbPath); 25 + if (existsSync(legacyDbPath + '-wal')) renameSync(legacyDbPath + '-wal', toolsDbPath + '-wal'); 26 + if (existsSync(legacyDbPath + '-shm')) renameSync(legacyDbPath + '-shm', toolsDbPath + '-shm'); 27 + console.log('Migrated crypt.db → tools.db'); 28 + } 29 + 30 + export const db = new Database(toolsDbPath); 31 + db.pragma('journal_mode = WAL'); 32 + 33 + // --- Schema creation --- 34 + db.exec(` 35 + CREATE TABLE IF NOT EXISTS documents ( 36 + id TEXT PRIMARY KEY, 37 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 38 + name_encrypted TEXT, 39 + snapshot BLOB, 40 + share_mode TEXT DEFAULT 'edit', 41 + expires_at TEXT, 42 + created_at TEXT DEFAULT (datetime('now')), 43 + updated_at TEXT DEFAULT (datetime('now')) 44 + ) 45 + `); 46 + 47 + // --- Migrations --- 48 + // Add share_mode and expires_at columns if missing (existing databases) 49 + try { 50 + db.prepare("SELECT share_mode FROM documents LIMIT 1").get(); 51 + } catch { 52 + db.exec("ALTER TABLE documents ADD COLUMN share_mode TEXT DEFAULT 'edit'"); 53 + console.log('Migrated: added share_mode column'); 54 + } 55 + try { 56 + db.prepare("SELECT expires_at FROM documents LIMIT 1").get(); 57 + } catch { 58 + db.exec("ALTER TABLE documents ADD COLUMN expires_at TEXT"); 59 + console.log('Migrated: added expires_at column'); 60 + } 61 + try { 62 + db.prepare("SELECT deleted_at FROM documents LIMIT 1").get(); 63 + } catch { 64 + db.exec("ALTER TABLE documents ADD COLUMN deleted_at TEXT"); 65 + console.log('Migrated: added deleted_at column'); 66 + } 67 + try { 68 + db.prepare("SELECT tags FROM documents LIMIT 1").get(); 69 + } catch { 70 + db.exec("ALTER TABLE documents ADD COLUMN tags TEXT"); 71 + console.log('Migrated: added tags column'); 72 + } 73 + 74 + // Add owner column (must run before type CHECK migration) 75 + try { 76 + db.prepare("SELECT owner FROM documents LIMIT 1").get(); 77 + } catch { 78 + db.exec("ALTER TABLE documents ADD COLUMN owner TEXT"); 79 + console.log('Migrated: added owner column'); 80 + } 81 + 82 + // Expand type CHECK constraint to include form, slide, diagram 83 + try { 84 + const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='documents'").get() as { sql: string } | undefined; 85 + if (tableInfo && !tableInfo.sql.includes("'slide'")) { 86 + db.exec("DROP TABLE IF EXISTS documents_new"); 87 + db.exec(` 88 + CREATE TABLE documents_new ( 89 + id TEXT PRIMARY KEY, 90 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 91 + name_encrypted TEXT, 92 + snapshot BLOB, 93 + share_mode TEXT DEFAULT 'edit', 94 + expires_at TEXT, 95 + deleted_at TEXT, 96 + tags TEXT, 97 + owner TEXT, 98 + created_at TEXT DEFAULT (datetime('now')), 99 + updated_at TEXT DEFAULT (datetime('now')) 100 + ); 101 + INSERT INTO documents_new SELECT id, type, name_encrypted, snapshot, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents; 102 + DROP TABLE documents; 103 + ALTER TABLE documents_new RENAME TO documents; 104 + `); 105 + console.log('Migrated: expanded type CHECK for slide/diagram/form'); 106 + } 107 + } catch (e) { 108 + console.log('Type migration skipped:', (e as Error).message); 109 + } 110 + 111 + db.exec(` 112 + CREATE TABLE IF NOT EXISTS versions ( 113 + id TEXT PRIMARY KEY, 114 + document_id TEXT NOT NULL, 115 + snapshot BLOB NOT NULL, 116 + created_at TEXT DEFAULT (datetime('now')), 117 + metadata TEXT 118 + ) 119 + `); 120 + 121 + db.exec(` 122 + CREATE TABLE IF NOT EXISTS users ( 123 + login TEXT PRIMARY KEY, 124 + name TEXT NOT NULL, 125 + profile_pic TEXT, 126 + first_seen TEXT DEFAULT (datetime('now')), 127 + last_seen TEXT DEFAULT (datetime('now')) 128 + ) 129 + `); 130 + 131 + db.exec(` 132 + CREATE TABLE IF NOT EXISTS user_keys ( 133 + login TEXT PRIMARY KEY REFERENCES users(login), 134 + keys_json TEXT NOT NULL DEFAULT '{}', 135 + updated_at TEXT DEFAULT (datetime('now')) 136 + ) 137 + `); 138 + 139 + db.exec(` 140 + CREATE TABLE IF NOT EXISTS blobs ( 141 + id TEXT PRIMARY KEY, 142 + document_id TEXT NOT NULL, 143 + file_name TEXT NOT NULL, 144 + mime_type TEXT NOT NULL, 145 + size INTEGER NOT NULL, 146 + data BLOB NOT NULL, 147 + created_at TEXT DEFAULT (datetime('now')) 148 + ) 149 + `); 150 + 151 + export const MAX_VERSIONS_PER_DOC = 50; 152 + 153 + // Auto-purge trash older than 30 days on startup and every 24 hours 154 + function purgeExpiredTrash(): void { 155 + const result = db.prepare("DELETE FROM documents WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days')").run(); 156 + if (result.changes > 0) { 157 + console.log(`Purged ${result.changes} expired trashed document(s)`); 158 + } 159 + } 160 + purgeExpiredTrash(); 161 + setInterval(purgeExpiredTrash, 24 * 60 * 60 * 1000); 162 + 163 + export const stmts: PreparedStatements = { 164 + insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 165 + insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 166 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 167 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 168 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 169 + getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 170 + putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 171 + putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 172 + trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 173 + restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 174 + deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 175 + insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 176 + getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 177 + getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 178 + countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 179 + updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 180 + putTags: db.prepare("UPDATE documents SET tags = ?, updated_at = datetime('now') WHERE id = ?"), 181 + upsertUser: db.prepare(`INSERT INTO users (login, name, profile_pic) VALUES (?, ?, ?) 182 + ON CONFLICT(login) DO UPDATE SET name=excluded.name, profile_pic=excluded.profile_pic, last_seen=datetime('now')`), 183 + getUser: db.prepare('SELECT * FROM users WHERE login = ?'), 184 + getAllUsers: db.prepare('SELECT login, name, profile_pic FROM users ORDER BY last_seen DESC'), 185 + getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 186 + putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 187 + ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 188 + insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 189 + getBlob: db.prepare('SELECT id, document_id, file_name, mime_type, size, data, created_at FROM blobs WHERE id = ?'), 190 + listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 191 + deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 192 + deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 193 + };
+17 -773
server/index.ts
··· 1 1 import express, { type Request, type Response } from 'express'; 2 2 import { createServer } from 'http'; 3 3 import { createServer as createHttpsServer, type Server as HttpsServer } from 'https'; 4 - import { readFileSync, existsSync, renameSync, mkdirSync } from 'fs'; 4 + import { readFileSync, existsSync } from 'fs'; 5 5 import { WebSocketServer, type WebSocket } from 'ws'; 6 - import Database, { type Statement } from 'better-sqlite3'; 7 - import { randomUUID } from 'crypto'; 8 6 import path from 'path'; 9 - import { fileURLToPath } from 'url'; 10 7 import compression from 'compression'; 11 8 import type { IncomingMessage } from 'http'; 12 9 import type { Duplex } from 'stream'; 13 - import { isValidDocId, RateLimiter, isValidDocType, isValidMimeType, filterMetadata, sanitizeAiRequest } from './validation.js'; 14 - 15 - // --- Interfaces --- 16 - 17 - type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'; 18 - 19 - interface DocumentRow { 20 - id: string; 21 - type: DocType; 22 - name_encrypted: string | null; 23 - snapshot: Buffer | null; 24 - share_mode: 'edit' | 'view' | null; 25 - expires_at: string | null; 26 - deleted_at: string | null; 27 - created_at: string; 28 - updated_at: string; 29 - } 30 - 31 - interface DocumentListRow { 32 - id: string; 33 - type: DocType; 34 - name_encrypted: string | null; 35 - share_mode: 'edit' | 'view' | null; 36 - expires_at: string | null; 37 - deleted_at: string | null; 38 - tags: string | null; 39 - owner: string | null; 40 - created_at: string; 41 - updated_at: string; 42 - } 43 - 44 - interface SnapshotRow { 45 - snapshot: Buffer | null; 46 - expires_at: string | null; 47 - } 48 - 49 - interface VersionRow { 50 - id: string; 51 - document_id: string; 52 - created_at: string; 53 - metadata: string | null; 54 - } 55 - 56 - interface VersionSnapshotRow { 57 - snapshot: Buffer; 58 - } 59 - 60 - interface VersionCountRow { 61 - count: number; 62 - } 63 - 64 - interface WsControlMessage { 65 - type: 'peer-count' | 'peer-joined' | 'peer-left'; 66 - count?: number; 67 - user?: TailscaleUser | null; 68 - } 69 - 70 - interface TailscaleUser { 71 - login: string; 72 - name: string; 73 - profilePic: string | null; 74 - } 75 - 76 - interface UserRow { 77 - login: string; 78 - name: string; 79 - profile_pic: string | null; 80 - first_seen: string; 81 - last_seen: string; 82 - } 83 - 84 - interface CreateDocumentBody { 85 - type?: string; 86 - name_encrypted?: string; 87 - } 88 - 89 - interface UpdateNameBody { 90 - name_encrypted?: string; 91 - } 92 - 93 - interface UpdateShareBody { 94 - share_mode?: string; 95 - expires_at?: string | null; 96 - } 97 - 98 - interface PreparedStatements { 99 - insert: Statement; 100 - insertWithOwner: Statement; 101 - getOne: Statement; 102 - getAll: Statement; 103 - getTrash: Statement; 104 - getSnapshot: Statement; 105 - putSnapshot: Statement; 106 - putName: Statement; 107 - trashDoc: Statement; 108 - restoreDoc: Statement; 109 - deleteDoc: Statement; 110 - insertVersion: Statement; 111 - getVersions: Statement; 112 - getVersionSnapshot: Statement; 113 - countVersions: Statement; 114 - updateShare: Statement; 115 - putTags: Statement; 116 - upsertUser: Statement; 117 - getUser: Statement; 118 - getAllUsers: Statement; 119 - getKeys: Statement; 120 - putKeys: Statement; 121 - insertBlob: Statement; 122 - getBlob: Statement; 123 - listBlobs: Statement; 124 - deleteBlob: Statement; 125 - deleteBlobsForDoc: Statement; 126 - } 10 + import { isValidDocId } from './validation.js'; 11 + import { db, stmts, DATA_DIR, PROJECT_ROOT } from './db.js'; 12 + import type { TailscaleUser, WsControlMessage } from './types.js'; 13 + import documentRoutes from './routes/documents.js'; 14 + import versionRoutes from './routes/versions.js'; 15 + import blobRoutes from './routes/blobs.js'; 16 + import aiRoutes from './routes/ai.js'; 17 + import apiV1Routes from './routes/api-v1.js'; 127 18 128 19 // --- Setup --- 129 20 130 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 131 - const PROJECT_ROOT = path.resolve(__dirname, '..'); 132 - const DATA_DIR = process.env['DATA_DIR'] || PROJECT_ROOT; 133 21 const PORT = process.env['PORT'] || 3000; 134 22 const TS_CERT_DIR = '/var/lib/tailscale/certs'; 135 23 const TLS_CERT = process.env['TLS_CERT'] || ( ··· 141 29 path.join(TS_CERT_DIR, 'key.pem') 142 30 ); 143 31 144 - // --- Database --- 145 - // Ensure data directory exists (Electron sets DATA_DIR to app userData) 146 - if (!existsSync(DATA_DIR)) { 147 - mkdirSync(DATA_DIR, { recursive: true }); 148 - } 149 - // Migrate legacy database filename 150 - const toolsDbPath = path.join(DATA_DIR, 'tools.db'); 151 - const legacyDbPath = path.join(DATA_DIR, 'crypt.db'); 152 - if (!existsSync(toolsDbPath) && existsSync(legacyDbPath)) { 153 - renameSync(legacyDbPath, toolsDbPath); 154 - // Also migrate WAL/SHM files if present 155 - if (existsSync(legacyDbPath + '-wal')) renameSync(legacyDbPath + '-wal', toolsDbPath + '-wal'); 156 - if (existsSync(legacyDbPath + '-shm')) renameSync(legacyDbPath + '-shm', toolsDbPath + '-shm'); 157 - console.log('Migrated crypt.db → tools.db'); 158 - } 159 - const db = new Database(toolsDbPath); 160 - db.pragma('journal_mode = WAL'); 161 - db.exec(` 162 - CREATE TABLE IF NOT EXISTS documents ( 163 - id TEXT PRIMARY KEY, 164 - type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 165 - name_encrypted TEXT, 166 - snapshot BLOB, 167 - share_mode TEXT DEFAULT 'edit', 168 - expires_at TEXT, 169 - created_at TEXT DEFAULT (datetime('now')), 170 - updated_at TEXT DEFAULT (datetime('now')) 171 - ) 172 - `); 173 - 174 - // Migration: add share_mode and expires_at columns if missing (existing databases) 175 - try { 176 - db.prepare("SELECT share_mode FROM documents LIMIT 1").get(); 177 - } catch { 178 - db.exec("ALTER TABLE documents ADD COLUMN share_mode TEXT DEFAULT 'edit'"); 179 - console.log('Migrated: added share_mode column'); 180 - } 181 - try { 182 - db.prepare("SELECT expires_at FROM documents LIMIT 1").get(); 183 - } catch { 184 - db.exec("ALTER TABLE documents ADD COLUMN expires_at TEXT"); 185 - console.log('Migrated: added expires_at column'); 186 - } 187 - try { 188 - db.prepare("SELECT deleted_at FROM documents LIMIT 1").get(); 189 - } catch { 190 - db.exec("ALTER TABLE documents ADD COLUMN deleted_at TEXT"); 191 - console.log('Migrated: added deleted_at column'); 192 - } 193 - try { 194 - db.prepare("SELECT tags FROM documents LIMIT 1").get(); 195 - } catch { 196 - db.exec("ALTER TABLE documents ADD COLUMN tags TEXT"); 197 - console.log('Migrated: added tags column'); 198 - } 199 - 200 - // Migration: add owner column to documents (must run before type CHECK migration) 201 - try { 202 - db.prepare("SELECT owner FROM documents LIMIT 1").get(); 203 - } catch { 204 - db.exec("ALTER TABLE documents ADD COLUMN owner TEXT"); 205 - console.log('Migrated: added owner column'); 206 - } 207 - 208 - // Migration: expand type CHECK constraint to include form, slide, diagram 209 - try { 210 - const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='documents'").get() as { sql: string } | undefined; 211 - if (tableInfo && !tableInfo.sql.includes("'slide'")) { 212 - // Drop leftover temp table from any previous failed migration attempt 213 - db.exec("DROP TABLE IF EXISTS documents_new"); 214 - db.exec(` 215 - CREATE TABLE documents_new ( 216 - id TEXT PRIMARY KEY, 217 - type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 218 - name_encrypted TEXT, 219 - snapshot BLOB, 220 - share_mode TEXT DEFAULT 'edit', 221 - expires_at TEXT, 222 - deleted_at TEXT, 223 - tags TEXT, 224 - owner TEXT, 225 - created_at TEXT DEFAULT (datetime('now')), 226 - updated_at TEXT DEFAULT (datetime('now')) 227 - ); 228 - INSERT INTO documents_new SELECT id, type, name_encrypted, snapshot, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents; 229 - DROP TABLE documents; 230 - ALTER TABLE documents_new RENAME TO documents; 231 - `); 232 - console.log('Migrated: expanded type CHECK for slide/diagram/form'); 233 - } 234 - } catch (e) { 235 - console.log('Type migration skipped:', (e as Error).message); 236 - } 237 - 238 - db.exec(` 239 - CREATE TABLE IF NOT EXISTS versions ( 240 - id TEXT PRIMARY KEY, 241 - document_id TEXT NOT NULL, 242 - snapshot BLOB NOT NULL, 243 - created_at TEXT DEFAULT (datetime('now')), 244 - metadata TEXT 245 - ) 246 - `); 247 - 248 - // --- Users table (Tailscale identity) --- 249 - db.exec(` 250 - CREATE TABLE IF NOT EXISTS users ( 251 - login TEXT PRIMARY KEY, 252 - name TEXT NOT NULL, 253 - profile_pic TEXT, 254 - first_seen TEXT DEFAULT (datetime('now')), 255 - last_seen TEXT DEFAULT (datetime('now')) 256 - ) 257 - `); 258 - 259 - // --- User key bundles (cross-device encryption key sync) --- 260 - db.exec(` 261 - CREATE TABLE IF NOT EXISTS user_keys ( 262 - login TEXT PRIMARY KEY REFERENCES users(login), 263 - keys_json TEXT NOT NULL DEFAULT '{}', 264 - updated_at TEXT DEFAULT (datetime('now')) 265 - ) 266 - `); 267 - 268 - // --- Blob storage (E2EE file uploads) --- 269 - db.exec(` 270 - CREATE TABLE IF NOT EXISTS blobs ( 271 - id TEXT PRIMARY KEY, 272 - document_id TEXT NOT NULL, 273 - file_name TEXT NOT NULL, 274 - mime_type TEXT NOT NULL, 275 - size INTEGER NOT NULL, 276 - data BLOB NOT NULL, 277 - created_at TEXT DEFAULT (datetime('now')) 278 - ) 279 - `); 280 - 281 - const MAX_VERSIONS_PER_DOC = 50; 282 - 283 - // Auto-purge trash older than 30 days on startup and every 24 hours 284 - function purgeExpiredTrash(): void { 285 - const result = db.prepare("DELETE FROM documents WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days')").run(); 286 - if (result.changes > 0) { 287 - console.log(`Purged ${result.changes} expired trashed document(s)`); 288 - } 289 - } 290 - purgeExpiredTrash(); 291 - setInterval(purgeExpiredTrash, 24 * 60 * 60 * 1000); 292 - 293 - const stmts: PreparedStatements = { 294 - insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 295 - insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 296 - getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 297 - getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 298 - getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 299 - getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 300 - putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 301 - putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 302 - trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 303 - restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 304 - deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 305 - // Version history 306 - insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 307 - getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 308 - getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 309 - countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 310 - // Sharing 311 - updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 312 - putTags: db.prepare("UPDATE documents SET tags = ?, updated_at = datetime('now') WHERE id = ?"), 313 - // Users 314 - upsertUser: db.prepare(`INSERT INTO users (login, name, profile_pic) VALUES (?, ?, ?) 315 - ON CONFLICT(login) DO UPDATE SET name=excluded.name, profile_pic=excluded.profile_pic, last_seen=datetime('now')`), 316 - getUser: db.prepare('SELECT * FROM users WHERE login = ?'), 317 - getAllUsers: db.prepare('SELECT login, name, profile_pic FROM users ORDER BY last_seen DESC'), 318 - // Key sync 319 - getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 320 - putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 321 - ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 322 - // Blob storage 323 - insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 324 - getBlob: db.prepare('SELECT id, document_id, file_name, mime_type, size, data, created_at FROM blobs WHERE id = ?'), 325 - listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 326 - deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 327 - deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 328 - }; 329 - 330 - const rateLimiter = new RateLimiter(); 331 - // Periodically clean up expired entries (every 60s) 332 - setInterval(() => rateLimiter.cleanup(), 60000).unref(); 333 - 334 32 // --- Express --- 335 33 const app = express(); 336 34 app.use(compression()); ··· 376 74 next(); 377 75 }); 378 76 379 - // API routes 380 - 381 - // Current user identity (from Tailscale headers or anonymous) 382 - app.get('/api/me', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 383 - if (req.tsUser) { 384 - res.json(req.tsUser); 385 - } else { 386 - res.json({ anonymous: true }); 387 - } 388 - }); 389 - 390 - // List all known users 391 - app.get('/api/users', (_req: Request, res: Response) => { 392 - res.json(stmts.getAllUsers.all() as Pick<UserRow, 'login' | 'name' | 'profile_pic'>[]); 393 - }); 394 - 395 - // --- Key sync (cross-device encryption key access) --- 396 - app.get('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 397 - if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 398 - const row = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 399 - res.json({ keys: row ? JSON.parse(row.keys_json) : {} }); 400 - }); 401 - 402 - app.put('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 403 - if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 404 - const incoming = req.body?.keys; 405 - if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { 406 - res.status(400).json({ error: 'keys must be an object' }); return; 407 - } 408 - for (const [docId, keyStr] of Object.entries(incoming)) { 409 - if (typeof keyStr !== 'string' || keyStr.length === 0) { 410 - res.status(400).json({ error: `Invalid key for doc ${docId}` }); return; 411 - } 412 - } 413 - // Server-side merge: read existing, overlay incoming, write back 414 - const existing = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 415 - const merged = { ...(existing ? JSON.parse(existing.keys_json) : {}), ...incoming }; 416 - stmts.putKeys.run(req.tsUser.login, JSON.stringify(merged)); 417 - res.json({ ok: true }); 418 - }); 419 - 420 - app.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 421 - const id = randomUUID(); 422 - const { type, name_encrypted } = req.body; 423 - if (!isValidDocType(type)) { 424 - res.status(400).json({ error: 'type must be doc, sheet, form, slide, or diagram' }); 425 - return; 426 - } 427 - const owner = req.tsUser?.login || null; 428 - if (owner) { 429 - stmts.insertWithOwner.run(id, type, name_encrypted || null, owner); 430 - } else { 431 - stmts.insert.run(id, type, name_encrypted || null); 432 - } 433 - res.json({ id }); 434 - }); 435 - 436 - app.get('/api/documents', (_req: Request, res: Response) => { 437 - const docs = stmts.getAll.all() as (DocumentListRow & { owner: string | null })[]; 438 - // Attach owner display name if available 439 - const enriched = docs.map(doc => { 440 - if (doc.owner) { 441 - const user = stmts.getUser.get(doc.owner) as UserRow | undefined; 442 - return { ...doc, owner_name: user?.name || doc.owner }; 443 - } 444 - return { ...doc, owner_name: null }; 445 - }); 446 - res.json(enriched); 447 - }); 448 - 449 - // Trash list MUST be registered before :id to avoid "trash" matching as a document ID 450 - app.get('/api/documents/trash', (_req: Request, res: Response) => { 451 - res.json(stmts.getTrash.all() as DocumentListRow[]); 452 - }); 453 - 454 - app.get('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 455 - const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 456 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 457 - res.json(doc); 458 - }); 459 - 460 - app.delete('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 461 - const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 462 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 463 - // Only the document owner (or unauthenticated legacy docs with no owner) can permanently delete 464 - if (doc.owner && req.tsUser?.login !== doc.owner) { 465 - res.status(403).json({ error: 'Only the document owner can delete' }); 466 - return; 467 - } 468 - stmts.deleteDoc.run(req.params.id); 469 - res.json({ ok: true }); 470 - }); 471 - 472 - app.put('/api/documents/:id/trash', (req: Request<{ id: string }>, res: Response) => { 473 - const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 474 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 475 - stmts.trashDoc.run(req.params.id); 476 - res.json({ ok: true }); 477 - }); 478 - 479 - app.put('/api/documents/:id/restore', (req: Request<{ id: string }>, res: Response) => { 480 - const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 481 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 482 - stmts.restoreDoc.run(req.params.id); 483 - res.json({ ok: true }); 484 - }); 485 - 486 - app.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 487 - const { name_encrypted } = req.body; 488 - if (name_encrypted != null && typeof name_encrypted !== 'string') { 489 - return res.status(400).json({ error: 'name_encrypted must be a string or null' }); 490 - } 491 - if (typeof name_encrypted === 'string' && name_encrypted.length > 10000) { 492 - return res.status(400).json({ error: 'name_encrypted too long' }); 493 - } 494 - stmts.putName.run(name_encrypted, req.params.id); 495 - res.json({ ok: true }); 496 - }); 497 - 498 - app.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }>, res: Response) => { 499 - const { tags } = req.body; 500 - if (tags != null && typeof tags !== 'string') { 501 - return res.status(400).json({ error: 'tags must be a string or null' }); 502 - } 503 - if (typeof tags === 'string' && tags.length > 10000) { 504 - return res.status(400).json({ error: 'tags too long' }); 505 - } 506 - stmts.putTags.run(tags ?? null, req.params.id); 507 - res.json({ ok: true }); 508 - }); 509 - 510 - // Accept both PUT (normal save) and POST (sendBeacon — which can only POST) 511 - const snapshotHandler = (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response): void => { 512 - // Note: :id is already validated by app.param('id') middleware 513 - // Rate limit: 60 snapshot writes per minute per user 514 - const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 515 - if (!rateLimiter.check(rlKey, 60, 60000)) { 516 - res.status(429).json({ error: 'Too many snapshot writes, please slow down' }); 517 - return; 518 - } 519 - if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { 520 - res.status(400).json({ error: 'Empty or missing snapshot body' }); 521 - return; 522 - } 523 - try { 524 - const result = stmts.putSnapshot.run(req.body, req.params.id); 525 - if (result.changes === 0) { 526 - // Document doesn't exist — auto-create in a transaction to prevent races 527 - db.transaction(() => { 528 - db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, 'doc', NULL)").run(req.params.id); 529 - stmts.putSnapshot.run(req.body, req.params.id); 530 - })(); 531 - } 532 - res.json({ ok: true }); 533 - } catch (err: unknown) { 534 - console.error('Snapshot save failed:', err); 535 - res.status(500).json({ error: 'Failed to save snapshot' }); 536 - } 537 - }; 538 - const snapshotMiddleware = express.raw({ limit: '50mb', type: '*/*' }); 539 - app.put('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 540 - app.post('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 541 - 542 - app.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => { 543 - const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined; 544 - if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 545 - 546 - // Check link expiry 547 - if (row.expires_at) { 548 - const expiresAt = new Date(row.expires_at); 549 - if (expiresAt <= new Date()) { 550 - res.status(410).json({ error: 'Document link has expired' }); 551 - return; 552 - } 553 - } 554 - 555 - res.type('application/octet-stream').send(row.snapshot); 556 - }); 557 - 558 - // --- Sharing --- 559 - app.put('/api/documents/:id/share', (req: Request<{ id: string }, unknown, UpdateShareBody>, res: Response) => { 560 - const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 561 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 562 - 563 - // Only the document owner can change sharing settings 564 - if (doc.owner && req.tsUser?.login !== doc.owner) { 565 - res.status(403).json({ error: 'Only the document owner can change sharing settings' }); 566 - return; 567 - } 568 - 569 - const { share_mode, expires_at } = req.body; 570 - 571 - // Validate share_mode (also reject empty string) 572 - if (share_mode !== undefined && share_mode !== null && !['edit', 'view'].includes(share_mode)) { 573 - res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 574 - return; 575 - } 576 - 577 - // Validate expires_at if provided 578 - if (expires_at !== null && expires_at !== undefined && expires_at !== '') { 579 - const d = new Date(expires_at); 580 - if (isNaN(d.getTime())) { 581 - res.status(400).json({ error: 'Invalid expires_at date' }); 582 - return; 583 - } 584 - } 77 + // Mount route modules 78 + app.use(documentRoutes); 79 + app.use(versionRoutes); 80 + app.use(blobRoutes); 81 + app.use(aiRoutes); 82 + app.use(apiV1Routes); 585 83 586 - stmts.updateShare.run( 587 - share_mode || doc.share_mode, 588 - expires_at === null ? null : (expires_at || doc.expires_at), 589 - req.params.id 590 - ); 591 - 592 - const updated = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 593 - res.json({ 594 - share_mode: updated?.share_mode, 595 - expires_at: updated?.expires_at, 596 - }); 597 - }); 598 - 599 - // --- Version History --- 600 - app.get('/api/documents/:id/versions', (req: Request<{ id: string }>, res: Response) => { 601 - const versions = stmts.getVersions.all(req.params.id) as VersionRow[]; 602 - res.json(versions.map(v => ({ 603 - ...v, 604 - metadata: v.metadata ? JSON.parse(v.metadata) as unknown : null, 605 - }))); 606 - }); 607 - 608 - app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 609 - const docId = req.params.id; 610 - const id = randomUUID(); 611 - const metadata = req.headers['x-version-metadata'] as string | undefined ?? null; 612 - stmts.insertVersion.run(id, docId, req.body, metadata); 613 - // FIFO: prune if over limit 614 - const countRow = stmts.countVersions.get(docId) as VersionCountRow | undefined; 615 - const count = countRow?.count ?? 0; 616 - if (count > MAX_VERSIONS_PER_DOC) { 617 - const excess = count - MAX_VERSIONS_PER_DOC; 618 - db.prepare(` 619 - DELETE FROM versions WHERE id IN ( 620 - SELECT id FROM versions WHERE document_id = ? 621 - ORDER BY created_at ASC, rowid ASC 622 - LIMIT ? 623 - ) 624 - `).run(docId, excess); 625 - } 626 - res.json({ id }); 627 - }); 628 - 629 - app.get('/api/documents/:id/versions/:versionId', (req: Request<{ id: string; versionId: string }>, res: Response) => { 630 - const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id) as VersionSnapshotRow | undefined; 631 - if (!row || !row.snapshot) { res.status(404).json({ error: 'Version not found' }); return; } 632 - res.type('application/octet-stream').send(row.snapshot); 633 - }); 634 - 635 - app.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }>, res: Response) => { 636 - const { id: docId, versionId } = req.params; 637 - 638 - // Fetch existing version metadata 639 - const version = (stmts.getVersions.all(docId) as VersionRow[]).find(v => v.id === versionId); 640 - if (!version) { 641 - res.status(404).json({ error: 'Version not found' }); 642 - return; 643 - } 644 - 645 - // Merge incoming body into existing metadata (whitelisted keys only) 646 - let existing: Record<string, unknown> = {}; 647 - if (version.metadata) { 648 - try { 649 - existing = JSON.parse(version.metadata) as Record<string, unknown>; 650 - } catch { /* ignore parse errors */ } 651 - } 652 - const incoming = filterMetadata(req.body); 653 - const merged = { ...existing, ...incoming }; 654 - 655 - db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') 656 - .run(JSON.stringify(merged), versionId, docId); 657 - 658 - res.json({ ok: true, metadata: merged }); 659 - }); 660 - 661 - // AI gateway proxy — avoids CORS issues by keeping requests same-origin 662 - const AI_GATEWAY_URL = process.env.AI_GATEWAY_URL || 'https://ai.lobster-hake.ts.net'; 663 - 664 - app.post('/api/ai/chat/completions', async (req: Request, res: Response) => { 665 - const gatewayUrl = `${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/chat/completions`; 666 - try { 667 - // Whitelist allowed fields to prevent arbitrary payload forwarding 668 - const sanitized = sanitizeAiRequest(req.body); 669 - if (!sanitized) { 670 - res.status(400).json({ error: 'messages array is required' }); 671 - return; 672 - } 673 - 674 - const upstream = await fetch(gatewayUrl, { 675 - method: 'POST', 676 - headers: { 'Content-Type': 'application/json' }, 677 - body: JSON.stringify(sanitized), 678 - }); 679 - 680 - if (!upstream.ok) { 681 - const errText = await upstream.text(); 682 - res.status(upstream.status).send(errText); 683 - return; 684 - } 685 - 686 - // Stream the response through 687 - res.status(upstream.status); 688 - for (const [key, value] of upstream.headers.entries()) { 689 - if (['content-type', 'transfer-encoding'].includes(key.toLowerCase())) { 690 - res.set(key, value); 691 - } 692 - } 693 - 694 - if (upstream.body) { 695 - const reader = upstream.body.getReader(); 696 - for (;;) { 697 - const { done, value } = await reader.read(); 698 - if (done) break; 699 - res.write(value); 700 - } 701 - res.end(); 702 - } else { 703 - res.end(); 704 - } 705 - } catch (err: unknown) { 706 - const message = err instanceof Error ? err.message : 'Unknown error'; 707 - res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 708 - } 709 - }); 710 - 711 - app.get('/api/ai/models', async (_req: Request, res: Response) => { 712 - try { 713 - const upstream = await fetch(`${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/models`); 714 - if (!upstream.ok) { 715 - res.status(upstream.status).send(await upstream.text()); 716 - return; 717 - } 718 - res.json(await upstream.json()); 719 - } catch (err: unknown) { 720 - const message = err instanceof Error ? err.message : 'Unknown error'; 721 - res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 722 - } 723 - }); 724 - 725 - // Desktop app release info (cached 5 min) 726 - let releaseCache: { data: unknown; expires: number } | null = null; 727 - const GITEA_RELEASES_URL = 'https://gitea.lobster-hake.ts.net/api/v1/repos/lanos-Familia/tools/releases'; 728 - 729 - app.get('/api/releases/latest', async (_req: Request, res: Response) => { 730 - try { 731 - const now = Date.now(); 732 - if (releaseCache && now < releaseCache.expires) { 733 - return res.json(releaseCache.data); 734 - } 735 - const upstream = await fetch(`${GITEA_RELEASES_URL}?limit=1`, { signal: AbortSignal.timeout(5000) }); 736 - if (!upstream.ok) return res.json({ available: false }); 737 - const releases = await upstream.json() as Array<{ 738 - tag_name: string; name: string; html_url: string; 739 - assets: Array<{ name: string; browser_download_url: string; size: number }>; 740 - }>; 741 - const release = releases[0]; 742 - if (!release) return res.json({ available: false }); 743 - const dmg = release.assets?.find(a => a.name.endsWith('.dmg')); 744 - const result = { 745 - available: true, 746 - version: release.tag_name, 747 - name: release.name || release.tag_name, 748 - url: release.html_url, 749 - dmg: dmg ? { url: dmg.browser_download_url, size: dmg.size } : null, 750 - }; 751 - releaseCache = { data: result, expires: now + 5 * 60 * 1000 }; 752 - res.json(result); 753 - } catch { 754 - res.json({ available: false }); 755 - } 756 - }); 757 - 758 - // --- Blob storage API (E2EE file uploads) --- 759 - const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 760 - 761 - app.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer>, res: Response) => { 762 - const docId = req.headers['x-document-id'] as string; 763 - const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 764 - const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 765 - const mimeType = isValidMimeType(rawMime) ? rawMime : 'application/octet-stream'; 766 - if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 767 - const data = req.body; 768 - if (!data || !data.length) return res.status(400).json({ error: 'No data' }); 769 - if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); 770 - const id = randomUUID(); 771 - stmts.insertBlob.run(id, docId, fileName, mimeType, data.length, data); 772 - res.status(201).json({ id, size: data.length }); 773 - }); 774 - 775 - app.get('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 776 - const row = stmts.getBlob.get(req.params.id) as { id: string; document_id: string; file_name: string; mime_type: string; size: number; data: Buffer; created_at: string } | undefined; 777 - if (!row) return res.status(404).json({ error: 'Not found' }); 778 - res.set('Content-Type', row.mime_type); 779 - res.set('Content-Length', String(row.size)); 780 - const safeName = row.file_name.replace(/["\\\r\n]/g, '_'); 781 - res.set('Content-Disposition', `inline; filename="${safeName}"`); 782 - res.send(row.data); 783 - }); 784 - 785 - app.get('/api/documents/:id/blobs', (req: Request<{ id: string }>, res: Response) => { 786 - const rows = stmts.listBlobs.all(req.params.id) as { id: string; file_name: string; mime_type: string; size: number; created_at: string }[]; 787 - res.json(rows); 788 - }); 789 - 790 - app.delete('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 791 - stmts.deleteBlob.run(req.params.id); 792 - res.json({ ok: true }); 793 - }); 794 - 795 - // ── REST API v1 (Wave 9) ────────────────────────────────────────────── 796 - 797 - // Search documents by name (encrypted names are matched client-side, but 798 - // the server can filter by type and return metadata for cross-doc linking) 799 - app.get('/api/v1/documents', (req: Request, res: Response) => { 800 - const { type, limit: lim, offset: off } = req.query; 801 - const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram']; 802 - let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL'; 803 - const params: unknown[] = []; 804 - 805 - if (type && validTypes.includes(type as string)) { 806 - query += ' AND type = ?'; 807 - params.push(type); 808 - } 809 - 810 - query += ' ORDER BY updated_at DESC'; 811 - 812 - const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 813 - const offset = Math.max(parseInt(off as string) || 0, 0); 814 - query += ` LIMIT ? OFFSET ?`; 815 - params.push(limit, offset); 816 - 817 - const rows = db.prepare(query).all(...params); 818 - const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL').get() as { count: number }).count; 819 - res.json({ data: rows, total, limit, offset }); 820 - }); 821 - 822 - // Get single document metadata 823 - app.get('/api/v1/documents/:id', (req: Request<{ id: string }>, res: Response) => { 824 - const row = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?').get(req.params.id); 825 - if (!row) return res.status(404).json({ error: 'Not found' }); 826 - res.json(row); 827 - }); 828 - 829 - // Batch resolve document metadata (for cross-doc embeds / wiki links) 830 - app.post('/api/v1/documents/resolve', (req: Request<Record<string, string>, unknown, { ids: string[] }>, res: Response) => { 831 - const { ids } = req.body; 832 - if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 833 - const capped = ids.slice(0, 50).filter(id => typeof id === 'string' && id.length > 0); 834 - if (capped.length === 0) return res.status(400).json({ error: 'ids must be non-empty strings' }); 835 - const placeholders = capped.map(() => '?').join(','); 836 - const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`).all(...capped); 837 - res.json({ data: rows }); 838 - }); 84 + // Room management for E2EE relay (referenced by health check) 85 + const rooms = new Map<string, Set<WebSocket>>(); 839 86 840 87 // Health check 841 88 app.get('/health', (_req: Request, res: Response) => { ··· 900 147 } 901 148 902 149 const wss = new WebSocketServer({ noServer: true }); 903 - 904 - // Room management for E2EE relay 905 - const rooms = new Map<string, Set<WebSocket>>(); 906 150 907 151 function handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void { 908 152 const url = new URL(request.url || '', 'http://localhost');
+105
server/routes/ai.ts
··· 1 + /** 2 + * AI gateway proxy and desktop release info routes. 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import { sanitizeAiRequest } from '../validation.js'; 7 + 8 + const router = Router(); 9 + 10 + const AI_GATEWAY_URL = process.env.AI_GATEWAY_URL || 'https://ai.lobster-hake.ts.net'; 11 + 12 + router.post('/api/ai/chat/completions', async (req: Request, res: Response) => { 13 + const gatewayUrl = `${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/chat/completions`; 14 + try { 15 + const sanitized = sanitizeAiRequest(req.body); 16 + if (!sanitized) { 17 + res.status(400).json({ error: 'messages array is required' }); 18 + return; 19 + } 20 + 21 + const upstream = await fetch(gatewayUrl, { 22 + method: 'POST', 23 + headers: { 'Content-Type': 'application/json' }, 24 + body: JSON.stringify(sanitized), 25 + }); 26 + 27 + if (!upstream.ok) { 28 + const errText = await upstream.text(); 29 + res.status(upstream.status).send(errText); 30 + return; 31 + } 32 + 33 + // Stream the response through 34 + res.status(upstream.status); 35 + for (const [key, value] of upstream.headers.entries()) { 36 + if (['content-type', 'transfer-encoding'].includes(key.toLowerCase())) { 37 + res.set(key, value); 38 + } 39 + } 40 + 41 + if (upstream.body) { 42 + const reader = upstream.body.getReader(); 43 + for (;;) { 44 + const { done, value } = await reader.read(); 45 + if (done) break; 46 + res.write(value); 47 + } 48 + res.end(); 49 + } else { 50 + res.end(); 51 + } 52 + } catch (err: unknown) { 53 + const message = err instanceof Error ? err.message : 'Unknown error'; 54 + res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 55 + } 56 + }); 57 + 58 + router.get('/api/ai/models', async (_req: Request, res: Response) => { 59 + try { 60 + const upstream = await fetch(`${AI_GATEWAY_URL.replace(/\/$/, '')}/v1/models`); 61 + if (!upstream.ok) { 62 + res.status(upstream.status).send(await upstream.text()); 63 + return; 64 + } 65 + res.json(await upstream.json()); 66 + } catch (err: unknown) { 67 + const message = err instanceof Error ? err.message : 'Unknown error'; 68 + res.status(502).json({ error: { message: `AI gateway unreachable: ${message}` } }); 69 + } 70 + }); 71 + 72 + // Desktop app release info (cached 5 min) 73 + let releaseCache: { data: unknown; expires: number } | null = null; 74 + const GITEA_RELEASES_URL = 'https://gitea.lobster-hake.ts.net/api/v1/repos/lanos-Familia/tools/releases'; 75 + 76 + router.get('/api/releases/latest', async (_req: Request, res: Response) => { 77 + try { 78 + const now = Date.now(); 79 + if (releaseCache && now < releaseCache.expires) { 80 + return res.json(releaseCache.data); 81 + } 82 + const upstream = await fetch(`${GITEA_RELEASES_URL}?limit=1`, { signal: AbortSignal.timeout(5000) }); 83 + if (!upstream.ok) return res.json({ available: false }); 84 + const releases = await upstream.json() as Array<{ 85 + tag_name: string; name: string; html_url: string; 86 + assets: Array<{ name: string; browser_download_url: string; size: number }>; 87 + }>; 88 + const release = releases[0]; 89 + if (!release) return res.json({ available: false }); 90 + const dmg = release.assets?.find(a => a.name.endsWith('.dmg')); 91 + const result = { 92 + available: true, 93 + version: release.tag_name, 94 + name: release.name || release.tag_name, 95 + url: release.html_url, 96 + dmg: dmg ? { url: dmg.browser_download_url, size: dmg.size } : null, 97 + }; 98 + releaseCache = { data: result, expires: now + 5 * 60 * 1000 }; 99 + res.json(result); 100 + } catch { 101 + res.json({ available: false }); 102 + } 103 + }); 104 + 105 + export default router;
+53
server/routes/api-v1.ts
··· 1 + /** 2 + * REST API v1 routes (Wave 9) -- paginated document listing and batch resolve. 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import { db } from '../db.js'; 7 + 8 + const router = Router(); 9 + 10 + // Search documents by name (encrypted names are matched client-side, but 11 + // the server can filter by type and return metadata for cross-doc linking) 12 + router.get('/api/v1/documents', (req: Request, res: Response) => { 13 + const { type, limit: lim, offset: off } = req.query; 14 + const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram']; 15 + let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL'; 16 + const params: unknown[] = []; 17 + 18 + if (type && validTypes.includes(type as string)) { 19 + query += ' AND type = ?'; 20 + params.push(type); 21 + } 22 + 23 + query += ' ORDER BY updated_at DESC'; 24 + 25 + const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 26 + const offset = Math.max(parseInt(off as string) || 0, 0); 27 + query += ` LIMIT ? OFFSET ?`; 28 + params.push(limit, offset); 29 + 30 + const rows = db.prepare(query).all(...params); 31 + const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL').get() as { count: number }).count; 32 + res.json({ data: rows, total, limit, offset }); 33 + }); 34 + 35 + // Get single document metadata 36 + router.get('/api/v1/documents/:id', (req: Request<{ id: string }>, res: Response) => { 37 + const row = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?').get(req.params.id); 38 + if (!row) return res.status(404).json({ error: 'Not found' }); 39 + res.json(row); 40 + }); 41 + 42 + // Batch resolve document metadata (for cross-doc embeds / wiki links) 43 + router.post('/api/v1/documents/resolve', (req: Request<Record<string, string>, unknown, { ids: string[] }>, res: Response) => { 44 + const { ids } = req.body; 45 + if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 46 + const capped = ids.slice(0, 50).filter(id => typeof id === 'string' && id.length > 0); 47 + if (capped.length === 0) return res.status(400).json({ error: 'ids must be non-empty strings' }); 48 + const placeholders = capped.map(() => '?').join(','); 49 + const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`).all(...capped); 50 + res.json({ data: rows }); 51 + }); 52 + 53 + export default router;
+49
server/routes/blobs.ts
··· 1 + /** 2 + * Blob storage routes (E2EE file uploads). 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import express from 'express'; 7 + import { randomUUID } from 'crypto'; 8 + import { stmts } from '../db.js'; 9 + import { isValidMimeType } from '../validation.js'; 10 + 11 + const router = Router(); 12 + 13 + const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 14 + 15 + router.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer>, res: Response) => { 16 + const docId = req.headers['x-document-id'] as string; 17 + const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 18 + const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 19 + const mimeType = isValidMimeType(rawMime) ? rawMime : 'application/octet-stream'; 20 + if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 21 + const data = req.body; 22 + if (!data || !data.length) return res.status(400).json({ error: 'No data' }); 23 + if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); 24 + const id = randomUUID(); 25 + stmts.insertBlob.run(id, docId, fileName, mimeType, data.length, data); 26 + res.status(201).json({ id, size: data.length }); 27 + }); 28 + 29 + router.get('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 30 + const row = stmts.getBlob.get(req.params.id) as { id: string; document_id: string; file_name: string; mime_type: string; size: number; data: Buffer; created_at: string } | undefined; 31 + if (!row) return res.status(404).json({ error: 'Not found' }); 32 + res.set('Content-Type', row.mime_type); 33 + res.set('Content-Length', String(row.size)); 34 + const safeName = row.file_name.replace(/["\\\r\n]/g, '_'); 35 + res.set('Content-Disposition', `inline; filename="${safeName}"`); 36 + res.send(row.data); 37 + }); 38 + 39 + router.get('/api/documents/:id/blobs', (req: Request<{ id: string }>, res: Response) => { 40 + const rows = stmts.listBlobs.all(req.params.id) as { id: string; file_name: string; mime_type: string; size: number; created_at: string }[]; 41 + res.json(rows); 42 + }); 43 + 44 + router.delete('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 45 + stmts.deleteBlob.run(req.params.id); 46 + res.json({ ok: true }); 47 + }); 48 + 49 + export default router;
+236
server/routes/documents.ts
··· 1 + /** 2 + * Document CRUD, snapshot, sharing, tags, and key sync routes. 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import express from 'express'; 7 + import { randomUUID } from 'crypto'; 8 + import { db, stmts } from '../db.js'; 9 + import { isValidDocType, RateLimiter } from '../validation.js'; 10 + import type { 11 + TailscaleUser, 12 + UserRow, 13 + DocumentRow, 14 + DocumentListRow, 15 + SnapshotRow, 16 + CreateDocumentBody, 17 + UpdateNameBody, 18 + UpdateShareBody, 19 + } from '../types.js'; 20 + 21 + const router = Router(); 22 + 23 + const rateLimiter = new RateLimiter(); 24 + setInterval(() => rateLimiter.cleanup(), 60000).unref(); 25 + 26 + // Current user identity (from Tailscale headers or anonymous) 27 + router.get('/api/me', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 28 + if (req.tsUser) { 29 + res.json(req.tsUser); 30 + } else { 31 + res.json({ anonymous: true }); 32 + } 33 + }); 34 + 35 + // List all known users 36 + router.get('/api/users', (_req: Request, res: Response) => { 37 + res.json(stmts.getAllUsers.all() as Pick<UserRow, 'login' | 'name' | 'profile_pic'>[]); 38 + }); 39 + 40 + // --- Key sync (cross-device encryption key access) --- 41 + router.get('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 42 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 43 + const row = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 44 + res.json({ keys: row ? JSON.parse(row.keys_json) : {} }); 45 + }); 46 + 47 + router.put('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 48 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 49 + const incoming = req.body?.keys; 50 + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { 51 + res.status(400).json({ error: 'keys must be an object' }); return; 52 + } 53 + for (const [docId, keyStr] of Object.entries(incoming)) { 54 + if (typeof keyStr !== 'string' || keyStr.length === 0) { 55 + res.status(400).json({ error: `Invalid key for doc ${docId}` }); return; 56 + } 57 + } 58 + // Server-side merge: read existing, overlay incoming, write back 59 + const existing = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 60 + const merged = { ...(existing ? JSON.parse(existing.keys_json) : {}), ...incoming }; 61 + stmts.putKeys.run(req.tsUser.login, JSON.stringify(merged)); 62 + res.json({ ok: true }); 63 + }); 64 + 65 + // --- Document CRUD --- 66 + router.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 67 + const id = randomUUID(); 68 + const { type, name_encrypted } = req.body; 69 + if (!isValidDocType(type)) { 70 + res.status(400).json({ error: 'type must be doc, sheet, form, slide, or diagram' }); 71 + return; 72 + } 73 + const owner = req.tsUser?.login || null; 74 + if (owner) { 75 + stmts.insertWithOwner.run(id, type, name_encrypted || null, owner); 76 + } else { 77 + stmts.insert.run(id, type, name_encrypted || null); 78 + } 79 + res.json({ id }); 80 + }); 81 + 82 + router.get('/api/documents', (_req: Request, res: Response) => { 83 + const docs = stmts.getAll.all() as (DocumentListRow & { owner: string | null })[]; 84 + const enriched = docs.map(doc => { 85 + if (doc.owner) { 86 + const user = stmts.getUser.get(doc.owner) as UserRow | undefined; 87 + return { ...doc, owner_name: user?.name || doc.owner }; 88 + } 89 + return { ...doc, owner_name: null }; 90 + }); 91 + res.json(enriched); 92 + }); 93 + 94 + // Trash list MUST be registered before :id to avoid "trash" matching as a document ID 95 + router.get('/api/documents/trash', (_req: Request, res: Response) => { 96 + res.json(stmts.getTrash.all() as DocumentListRow[]); 97 + }); 98 + 99 + router.get('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 100 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 101 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 102 + res.json(doc); 103 + }); 104 + 105 + router.delete('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 106 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 107 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 108 + if (doc.owner && req.tsUser?.login !== doc.owner) { 109 + res.status(403).json({ error: 'Only the document owner can delete' }); 110 + return; 111 + } 112 + stmts.deleteDoc.run(req.params.id); 113 + res.json({ ok: true }); 114 + }); 115 + 116 + router.put('/api/documents/:id/trash', (req: Request<{ id: string }>, res: Response) => { 117 + const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 118 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 119 + stmts.trashDoc.run(req.params.id); 120 + res.json({ ok: true }); 121 + }); 122 + 123 + router.put('/api/documents/:id/restore', (req: Request<{ id: string }>, res: Response) => { 124 + const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 125 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 126 + stmts.restoreDoc.run(req.params.id); 127 + res.json({ ok: true }); 128 + }); 129 + 130 + router.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 131 + const { name_encrypted } = req.body; 132 + if (name_encrypted != null && typeof name_encrypted !== 'string') { 133 + return res.status(400).json({ error: 'name_encrypted must be a string or null' }); 134 + } 135 + if (typeof name_encrypted === 'string' && name_encrypted.length > 10000) { 136 + return res.status(400).json({ error: 'name_encrypted too long' }); 137 + } 138 + stmts.putName.run(name_encrypted, req.params.id); 139 + res.json({ ok: true }); 140 + }); 141 + 142 + router.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }>, res: Response) => { 143 + const { tags } = req.body; 144 + if (tags != null && typeof tags !== 'string') { 145 + return res.status(400).json({ error: 'tags must be a string or null' }); 146 + } 147 + if (typeof tags === 'string' && tags.length > 10000) { 148 + return res.status(400).json({ error: 'tags too long' }); 149 + } 150 + stmts.putTags.run(tags ?? null, req.params.id); 151 + res.json({ ok: true }); 152 + }); 153 + 154 + // Accept both PUT (normal save) and POST (sendBeacon -- which can only POST) 155 + const snapshotHandler = (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response): void => { 156 + const rlKey = `snap:${req.tsUser?.login || 'anon'}`; 157 + if (!rateLimiter.check(rlKey, 60, 60000)) { 158 + res.status(429).json({ error: 'Too many snapshot writes, please slow down' }); 159 + return; 160 + } 161 + if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { 162 + res.status(400).json({ error: 'Empty or missing snapshot body' }); 163 + return; 164 + } 165 + try { 166 + const result = stmts.putSnapshot.run(req.body, req.params.id); 167 + if (result.changes === 0) { 168 + db.transaction(() => { 169 + db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, 'doc', NULL)").run(req.params.id); 170 + stmts.putSnapshot.run(req.body, req.params.id); 171 + })(); 172 + } 173 + res.json({ ok: true }); 174 + } catch (err: unknown) { 175 + console.error('Snapshot save failed:', err); 176 + res.status(500).json({ error: 'Failed to save snapshot' }); 177 + } 178 + }; 179 + const snapshotMiddleware = express.raw({ limit: '50mb', type: '*/*' }); 180 + router.put('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 181 + router.post('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 182 + 183 + router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => { 184 + const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined; 185 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 186 + 187 + if (row.expires_at) { 188 + const expiresAt = new Date(row.expires_at); 189 + if (expiresAt <= new Date()) { 190 + res.status(410).json({ error: 'Document link has expired' }); 191 + return; 192 + } 193 + } 194 + 195 + res.type('application/octet-stream').send(row.snapshot); 196 + }); 197 + 198 + // --- Sharing --- 199 + router.put('/api/documents/:id/share', (req: Request<{ id: string }, unknown, UpdateShareBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 200 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 201 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 202 + 203 + if (doc.owner && req.tsUser?.login !== doc.owner) { 204 + res.status(403).json({ error: 'Only the document owner can change sharing settings' }); 205 + return; 206 + } 207 + 208 + const { share_mode, expires_at } = req.body; 209 + 210 + if (share_mode !== undefined && share_mode !== null && !['edit', 'view'].includes(share_mode)) { 211 + res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 212 + return; 213 + } 214 + 215 + if (expires_at !== null && expires_at !== undefined && expires_at !== '') { 216 + const d = new Date(expires_at); 217 + if (isNaN(d.getTime())) { 218 + res.status(400).json({ error: 'Invalid expires_at date' }); 219 + return; 220 + } 221 + } 222 + 223 + stmts.updateShare.run( 224 + share_mode || doc.share_mode, 225 + expires_at === null ? null : (expires_at || doc.expires_at), 226 + req.params.id 227 + ); 228 + 229 + const updated = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 230 + res.json({ 231 + share_mode: updated?.share_mode, 232 + expires_at: updated?.expires_at, 233 + }); 234 + }); 235 + 236 + export default router;
+73
server/routes/versions.ts
··· 1 + /** 2 + * Version history routes. 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import express from 'express'; 7 + import { randomUUID } from 'crypto'; 8 + import { db, stmts, MAX_VERSIONS_PER_DOC } from '../db.js'; 9 + import { filterMetadata } from '../validation.js'; 10 + import type { VersionRow, VersionSnapshotRow, VersionCountRow } from '../types.js'; 11 + 12 + const router = Router(); 13 + 14 + router.get('/api/documents/:id/versions', (req: Request<{ id: string }>, res: Response) => { 15 + const versions = stmts.getVersions.all(req.params.id) as VersionRow[]; 16 + res.json(versions.map(v => ({ 17 + ...v, 18 + metadata: v.metadata ? JSON.parse(v.metadata) as unknown : null, 19 + }))); 20 + }); 21 + 22 + router.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 23 + const docId = req.params.id; 24 + const id = randomUUID(); 25 + const metadata = req.headers['x-version-metadata'] as string | undefined ?? null; 26 + stmts.insertVersion.run(id, docId, req.body, metadata); 27 + // FIFO: prune if over limit 28 + const countRow = stmts.countVersions.get(docId) as VersionCountRow | undefined; 29 + const count = countRow?.count ?? 0; 30 + if (count > MAX_VERSIONS_PER_DOC) { 31 + const excess = count - MAX_VERSIONS_PER_DOC; 32 + db.prepare(` 33 + DELETE FROM versions WHERE id IN ( 34 + SELECT id FROM versions WHERE document_id = ? 35 + ORDER BY created_at ASC, rowid ASC 36 + LIMIT ? 37 + ) 38 + `).run(docId, excess); 39 + } 40 + res.json({ id }); 41 + }); 42 + 43 + router.get('/api/documents/:id/versions/:versionId', (req: Request<{ id: string; versionId: string }>, res: Response) => { 44 + const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id) as VersionSnapshotRow | undefined; 45 + if (!row || !row.snapshot) { res.status(404).json({ error: 'Version not found' }); return; } 46 + res.type('application/octet-stream').send(row.snapshot); 47 + }); 48 + 49 + router.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }>, res: Response) => { 50 + const { id: docId, versionId } = req.params; 51 + 52 + const version = (stmts.getVersions.all(docId) as VersionRow[]).find(v => v.id === versionId); 53 + if (!version) { 54 + res.status(404).json({ error: 'Version not found' }); 55 + return; 56 + } 57 + 58 + let existing: Record<string, unknown> = {}; 59 + if (version.metadata) { 60 + try { 61 + existing = JSON.parse(version.metadata) as Record<string, unknown>; 62 + } catch { /* ignore parse errors */ } 63 + } 64 + const incoming = filterMetadata(req.body); 65 + const merged = { ...existing, ...incoming }; 66 + 67 + db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') 68 + .run(JSON.stringify(merged), versionId, docId); 69 + 70 + res.json({ ok: true, metadata: merged }); 71 + }); 72 + 73 + export default router;
+116
server/types.ts
··· 1 + /** 2 + * Shared types for server modules. 3 + */ 4 + 5 + import type { Statement } from 'better-sqlite3'; 6 + 7 + export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'; 8 + 9 + export interface DocumentRow { 10 + id: string; 11 + type: DocType; 12 + name_encrypted: string | null; 13 + snapshot: Buffer | null; 14 + share_mode: 'edit' | 'view' | null; 15 + expires_at: string | null; 16 + deleted_at: string | null; 17 + created_at: string; 18 + updated_at: string; 19 + } 20 + 21 + export interface DocumentListRow { 22 + id: string; 23 + type: DocType; 24 + name_encrypted: string | null; 25 + share_mode: 'edit' | 'view' | null; 26 + expires_at: string | null; 27 + deleted_at: string | null; 28 + tags: string | null; 29 + owner: string | null; 30 + created_at: string; 31 + updated_at: string; 32 + } 33 + 34 + export interface SnapshotRow { 35 + snapshot: Buffer | null; 36 + expires_at: string | null; 37 + } 38 + 39 + export interface VersionRow { 40 + id: string; 41 + document_id: string; 42 + created_at: string; 43 + metadata: string | null; 44 + } 45 + 46 + export interface VersionSnapshotRow { 47 + snapshot: Buffer; 48 + } 49 + 50 + export interface VersionCountRow { 51 + count: number; 52 + } 53 + 54 + export interface WsControlMessage { 55 + type: 'peer-count' | 'peer-joined' | 'peer-left'; 56 + count?: number; 57 + user?: TailscaleUser | null; 58 + } 59 + 60 + export interface TailscaleUser { 61 + login: string; 62 + name: string; 63 + profilePic: string | null; 64 + } 65 + 66 + export interface UserRow { 67 + login: string; 68 + name: string; 69 + profile_pic: string | null; 70 + first_seen: string; 71 + last_seen: string; 72 + } 73 + 74 + export interface CreateDocumentBody { 75 + type?: string; 76 + name_encrypted?: string; 77 + } 78 + 79 + export interface UpdateNameBody { 80 + name_encrypted?: string; 81 + } 82 + 83 + export interface UpdateShareBody { 84 + share_mode?: string; 85 + expires_at?: string | null; 86 + } 87 + 88 + export interface PreparedStatements { 89 + insert: Statement; 90 + insertWithOwner: Statement; 91 + getOne: Statement; 92 + getAll: Statement; 93 + getTrash: Statement; 94 + getSnapshot: Statement; 95 + putSnapshot: Statement; 96 + putName: Statement; 97 + trashDoc: Statement; 98 + restoreDoc: Statement; 99 + deleteDoc: Statement; 100 + insertVersion: Statement; 101 + getVersions: Statement; 102 + getVersionSnapshot: Statement; 103 + countVersions: Statement; 104 + updateShare: Statement; 105 + putTags: Statement; 106 + upsertUser: Statement; 107 + getUser: Statement; 108 + getAllUsers: Statement; 109 + getKeys: Statement; 110 + putKeys: Statement; 111 + insertBlob: Statement; 112 + getBlob: Statement; 113 + listBlobs: Statement; 114 + deleteBlob: Statement; 115 + deleteBlobsForDoc: Statement; 116 + }
+147
src/slides/ai-chat-panel.ts
··· 1 + /** 2 + * Slides AI Chat Panel — chat sidebar setup and message handling. 3 + * 4 + * Extracts the AI chat integration from main.ts into a self-contained 5 + * module. Receives deck state through AppActions for context building. 6 + */ 7 + 8 + import type { DeckState } from './canvas-engine.js'; 9 + import { 10 + createChatSidebar, createChatState, loadConfig, isConfigured, 11 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 12 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 13 + type ChatMessage, 14 + } from '../lib/ai-chat.js'; 15 + import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 16 + import { executeSlideAction } from './ai-slide-actions.js'; 17 + import type { DOMRefs, AppActions } from './types.js'; 18 + 19 + /** 20 + * Initialize the AI chat panel and return the teardown-free wiring. 21 + */ 22 + export function setupAIChatPanel(refs: DOMRefs, actions: AppActions): void { 23 + const $ = (id: string) => document.getElementById(id)!; 24 + 25 + const chatUI = createChatSidebar(); 26 + $('main-content').appendChild(chatUI.container); 27 + 28 + const chatState = createChatState(); 29 + 30 + const chatWiring = initChatWiring({ 31 + chatUI, 32 + chatState, 33 + chatConfig: loadConfig(), 34 + toggleBtn: $('btn-ai-chat'), 35 + editorType: 'slide', 36 + onSend: sendChatMessage, 37 + }); 38 + 39 + function getSlideContextText(): string { 40 + const { deck } = actions.getState(); 41 + const lines: string[] = []; 42 + deck.slides.forEach((slide, i) => { 43 + lines.push(`Slide ${i + 1}${i === deck.currentSlide ? ' (current)' : ''}:`); 44 + if (slide.notes) lines.push(` Notes: ${slide.notes}`); 45 + slide.elements.forEach(el => { 46 + if (el.content) lines.push(` ${el.type}: "${el.content}"`); 47 + else lines.push(` ${el.type} (${Math.round(el.width)}x${Math.round(el.height)})`); 48 + }); 49 + }); 50 + return lines.join('\n'); 51 + } 52 + 53 + async function sendChatMessage(): Promise<void> { 54 + const text = chatUI.input.value.trim(); 55 + if (!text || chatState.loading) return; 56 + 57 + const cfg = chatWiring.getConfig(); 58 + if (!isConfigured(cfg)) { 59 + chatUI.settingsPanel.style.display = ''; 60 + chatUI.endpointInput.focus(); 61 + return; 62 + } 63 + 64 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 65 + chatState.messages.push(userMsg); 66 + appendMessage(chatUI.messageList, userMsg); 67 + 68 + chatUI.input.value = ''; 69 + chatUI.input.style.height = ''; 70 + chatUI.sendBtn.style.display = 'none'; 71 + chatUI.stopBtn.style.display = ''; 72 + chatState.loading = true; 73 + chatState.error = null; 74 + 75 + const title = refs.deckTitle.value.trim() || 'Untitled Presentation'; 76 + const includeContext = chatUI.contextToggle.checked; 77 + const actionsEnabled = chatUI.actionsToggle.checked; 78 + const contextText = includeContext ? getSlideContextText() : ''; 79 + 80 + const systemPrompt = buildSystemMessage(title, contextText, { 81 + editorType: 'slide', 82 + actionsEnabled, 83 + }); 84 + 85 + const { deck } = actions.getState(); 86 + const slideDeps = { 87 + getState: () => actions.getState().deck, 88 + setState: (s: DeckState) => { 89 + actions.setState({ deck: s }); 90 + actions.syncDeckToYjs(); 91 + }, 92 + render: () => actions.render(), 93 + }; 94 + 95 + const abortController = new AbortController(); 96 + chatState.abortController = abortController; 97 + const bubble = appendStreamingBubble(chatUI.messageList); 98 + let fullText = ''; 99 + 100 + await streamChat( 101 + cfg, 102 + chatState.messages, 103 + systemPrompt, 104 + { 105 + onChunk(chunk) { 106 + fullText += chunk; 107 + bubble.update(renderMarkdown(fullText)); 108 + }, 109 + onDone(doneText) { 110 + if (doneText) { 111 + chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 112 + 113 + if (actionsEnabled) { 114 + const { displayText, actions: parsedActions } = splitResponse(doneText); 115 + if (parsedActions.length > 0) { 116 + bubble.update(renderMarkdown(displayText)); 117 + for (const action of parsedActions) { 118 + if (!isSlideAction(action)) continue; 119 + appendActionCard(chatUI.messageList, action, { 120 + onApply: (a) => { 121 + const result = executeSlideAction(a as Parameters<typeof executeSlideAction>[0], slideDeps); 122 + if (!result.success && result.error) { 123 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 124 + } 125 + }, 126 + onDismiss: () => {}, 127 + }); 128 + } 129 + } 130 + } 131 + } 132 + }, 133 + onError(err) { 134 + chatState.error = err; 135 + bubble.el.classList.add('ai-chat-bubble--error'); 136 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 137 + }, 138 + }, 139 + abortController.signal, 140 + ); 141 + 142 + chatState.loading = false; 143 + chatState.abortController = null; 144 + chatUI.sendBtn.style.display = ''; 145 + chatUI.stopBtn.style.display = 'none'; 146 + } 147 + }
+277
src/slides/event-handlers.ts
··· 1 + /** 2 + * Slides Event Handlers — button clicks, drag/touch, keyboard shortcuts. 3 + * 4 + * All DOM event wiring extracted from main.ts. Each handler reads/writes 5 + * state through the injected AppActions interface. 6 + */ 7 + 8 + import { 9 + addSlide, goToSlide, addElement, removeElement, moveElement, currentSlide, 10 + } from './canvas-engine.js'; 11 + import { setSlideLayout, setDeckTheme, getTheme } from './layouts-themes.js'; 12 + import type { LayoutType } from './layouts-themes.js'; 13 + import { createTransition, setDefaultTransition } from './transitions.js'; 14 + import { setNotes, nextSlide as presenterNext, prevSlide as presenterPrev } from './presenter-mode.js'; 15 + import { enterPresenter, exitPresenter, renderPresenter } from './presenter-ui.js'; 16 + import type { DOMRefs, AppActions } from './types.js'; 17 + 18 + const $ = (id: string) => document.getElementById(id)!; 19 + 20 + /** 21 + * Wire up all event listeners. Call once during init. 22 + */ 23 + export function setupEventHandlers(refs: DOMRefs, actions: AppActions): void { 24 + setupToolbarButtons(refs, actions); 25 + setupPresenterButtons(refs, actions); 26 + setupDropdownHandlers(refs, actions); 27 + setupNotesInput(refs, actions); 28 + setupCanvasInteraction(refs, actions); 29 + setupDragHandlers(refs, actions); 30 + setupTouchHandlers(refs, actions); 31 + setupKeyboardShortcuts(refs, actions); 32 + setupTitleEditing(refs, actions); 33 + } 34 + 35 + // --- Toolbar buttons --- 36 + 37 + function setupToolbarButtons(refs: DOMRefs, actions: AppActions): void { 38 + $('btn-add-slide').addEventListener('click', () => { 39 + const s = actions.getState(); 40 + let deck = addSlide(s.deck, s.deck.currentSlide + 1); 41 + deck = goToSlide(deck, deck.currentSlide + 1); 42 + actions.setState({ 43 + deck, 44 + themedDeck: { ...s.themedDeck, layouts: [...s.themedDeck.layouts, 'titleContent'] }, 45 + }); 46 + actions.syncDeckToYjs(); 47 + actions.render(); 48 + }); 49 + 50 + $('btn-add-text').addEventListener('click', () => { 51 + const s = actions.getState(); 52 + actions.setState({ deck: addElement(s.deck, 'text', 100, 100, 300, 60, 'Click to edit') }); 53 + actions.syncDeckToYjs(); 54 + actions.renderCanvas(); 55 + }); 56 + 57 + $('btn-add-shape').addEventListener('click', () => { 58 + const s = actions.getState(); 59 + const fill = getTheme(s.themedDeck.themeId)?.palette.primary ?? ''; 60 + actions.setState({ deck: addElement(s.deck, 'shape', 200, 150, 150, 150, '', { fill }) }); 61 + actions.syncDeckToYjs(); 62 + actions.renderCanvas(); 63 + }); 64 + 65 + $('btn-add-image').addEventListener('click', () => { 66 + const url = prompt('Image URL:'); 67 + if (url) { 68 + const s = actions.getState(); 69 + actions.setState({ deck: addElement(s.deck, 'image', 150, 100, 300, 200, url) }); 70 + actions.syncDeckToYjs(); 71 + actions.renderCanvas(); 72 + } 73 + }); 74 + 75 + $('btn-delete-element').addEventListener('click', () => { 76 + const s = actions.getState(); 77 + if (s.selectedElementId) { 78 + actions.setState({ 79 + deck: removeElement(s.deck, s.selectedElementId), 80 + selectedElementId: null, 81 + }); 82 + actions.syncDeckToYjs(); 83 + actions.renderCanvas(); 84 + } 85 + }); 86 + } 87 + 88 + // --- Presenter buttons --- 89 + 90 + function setupPresenterButtons(refs: DOMRefs, actions: AppActions): void { 91 + $('btn-present').addEventListener('click', () => enterPresenter(refs, actions)); 92 + $('btn-presenter-exit').addEventListener('click', () => exitPresenter(refs, actions)); 93 + 94 + $('btn-presenter-next').addEventListener('click', () => { 95 + const s = actions.getState(); 96 + actions.setState({ presenter: presenterNext(s.presenter) }); 97 + renderPresenter(refs, actions); 98 + }); 99 + 100 + $('btn-presenter-prev').addEventListener('click', () => { 101 + const s = actions.getState(); 102 + actions.setState({ presenter: presenterPrev(s.presenter) }); 103 + renderPresenter(refs, actions); 104 + }); 105 + } 106 + 107 + // --- Dropdowns --- 108 + 109 + function setupDropdownHandlers(refs: DOMRefs, actions: AppActions): void { 110 + refs.layoutSelect.addEventListener('change', () => { 111 + const s = actions.getState(); 112 + actions.setState({ 113 + themedDeck: setSlideLayout(s.themedDeck, s.deck.currentSlide, refs.layoutSelect.value as LayoutType), 114 + }); 115 + actions.syncDeckToYjs(); 116 + actions.render(); 117 + }); 118 + 119 + refs.themeSelect.addEventListener('change', () => { 120 + const s = actions.getState(); 121 + actions.setState({ themedDeck: setDeckTheme(s.themedDeck, refs.themeSelect.value) }); 122 + actions.syncDeckToYjs(); 123 + actions.render(); 124 + }); 125 + 126 + refs.transitionSelect.addEventListener('change', () => { 127 + const s = actions.getState(); 128 + actions.setState({ 129 + transitions: setDefaultTransition(s.transitions, createTransition(refs.transitionSelect.value as 'none')), 130 + }); 131 + actions.syncDeckToYjs(); 132 + }); 133 + } 134 + 135 + // --- Notes --- 136 + 137 + function setupNotesInput(refs: DOMRefs, actions: AppActions): void { 138 + refs.notesInput.addEventListener('input', () => { 139 + const s = actions.getState(); 140 + const presenter = setNotes(s.presenter, s.deck.currentSlide, refs.notesInput.value); 141 + const slide = currentSlide(s.deck); 142 + slide.notes = refs.notesInput.value; 143 + actions.setState({ presenter }); 144 + actions.syncDeckToYjs(); 145 + }); 146 + } 147 + 148 + // --- Canvas click-to-deselect --- 149 + 150 + function setupCanvasInteraction(refs: DOMRefs, actions: AppActions): void { 151 + refs.slideCanvas.addEventListener('mousedown', (e) => { 152 + if (e.target === refs.slideCanvas) { 153 + actions.setState({ selectedElementId: null }); 154 + actions.renderCanvas(); 155 + } 156 + }); 157 + } 158 + 159 + // --- Mouse drag --- 160 + 161 + function setupDragHandlers(refs: DOMRefs, actions: AppActions): void { 162 + document.addEventListener('mousemove', (e) => { 163 + const s = actions.getState(); 164 + if (!s.isDragging || !s.selectedElementId) return; 165 + const dx = e.clientX - s.dragStartX; 166 + const dy = e.clientY - s.dragStartY; 167 + actions.setState({ deck: moveElement(s.deck, s.selectedElementId, s.dragElStartX + dx, s.dragElStartY + dy) }); 168 + actions.renderCanvas(); 169 + }); 170 + 171 + document.addEventListener('mouseup', () => { 172 + const s = actions.getState(); 173 + if (s.isDragging) { 174 + actions.setState({ isDragging: false }); 175 + actions.syncDeckToYjs(); 176 + } 177 + }); 178 + } 179 + 180 + // --- Touch drag --- 181 + 182 + function setupTouchHandlers(refs: DOMRefs, actions: AppActions): void { 183 + refs.slideCanvas.addEventListener('touchstart', (e) => { 184 + if (e.touches.length !== 1) return; 185 + const touch = e.touches[0]!; 186 + const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('[data-element-id]') as HTMLElement | null; 187 + if (!target) return; 188 + const s = actions.getState(); 189 + const elId = target.dataset.elementId!; 190 + const el = currentSlide(s.deck).elements.find(el => el.id === elId); 191 + if (!el) return; 192 + e.preventDefault(); 193 + actions.setState({ 194 + selectedElementId: elId, 195 + isDragging: true, 196 + dragStartX: touch.clientX, 197 + dragStartY: touch.clientY, 198 + dragElStartX: el.x, 199 + dragElStartY: el.y, 200 + }); 201 + actions.renderCanvas(); 202 + }, { passive: false }); 203 + 204 + document.addEventListener('touchmove', (e) => { 205 + const s = actions.getState(); 206 + if (!s.isDragging || !s.selectedElementId) return; 207 + const touch = e.touches[0]!; 208 + const dx = touch.clientX - s.dragStartX; 209 + const dy = touch.clientY - s.dragStartY; 210 + actions.setState({ deck: moveElement(s.deck, s.selectedElementId, s.dragElStartX + dx, s.dragElStartY + dy) }); 211 + actions.renderCanvas(); 212 + e.preventDefault(); 213 + }, { passive: false }); 214 + 215 + document.addEventListener('touchend', () => { 216 + const s = actions.getState(); 217 + if (s.isDragging) { 218 + actions.setState({ isDragging: false }); 219 + actions.syncDeckToYjs(); 220 + } 221 + }); 222 + } 223 + 224 + // --- Keyboard shortcuts --- 225 + 226 + function setupKeyboardShortcuts(refs: DOMRefs, actions: AppActions): void { 227 + document.addEventListener('keydown', (e) => { 228 + // Presenter mode shortcuts 229 + if (refs.presenterOverlay.style.display !== 'none') { 230 + const s = actions.getState(); 231 + if (e.key === 'ArrowRight' || e.key === ' ') { 232 + actions.setState({ presenter: presenterNext(s.presenter) }); 233 + renderPresenter(refs, actions); 234 + } else if (e.key === 'ArrowLeft') { 235 + actions.setState({ presenter: presenterPrev(s.presenter) }); 236 + renderPresenter(refs, actions); 237 + } else if (e.key === 'Escape') { 238 + exitPresenter(refs, actions); 239 + } 240 + return; 241 + } 242 + 243 + if (e.key === 'F5') { 244 + e.preventDefault(); 245 + enterPresenter(refs, actions); 246 + } 247 + 248 + if (e.key === 'Delete' && actions.getState().selectedElementId 249 + && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { 250 + const s = actions.getState(); 251 + actions.setState({ 252 + deck: removeElement(s.deck, s.selectedElementId!), 253 + selectedElementId: null, 254 + }); 255 + actions.syncDeckToYjs(); 256 + actions.renderCanvas(); 257 + } 258 + }); 259 + } 260 + 261 + // --- Title editing --- 262 + 263 + function setupTitleEditing(refs: DOMRefs, actions: AppActions): void { 264 + refs.deckTitle.addEventListener('change', async () => { 265 + const s = actions.getState(); 266 + if (!s.cryptoKey) return; 267 + const { encrypt } = await import('../lib/crypto.js'); 268 + const nameBytes = new TextEncoder().encode(refs.deckTitle.value); 269 + const encrypted = await encrypt(nameBytes, s.cryptoKey); 270 + const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 271 + fetch(`/api/documents/${s.docId}/name`, { 272 + method: 'PUT', 273 + headers: { 'Content-Type': 'application/json' }, 274 + body: JSON.stringify({ name_encrypted: b64 }), 275 + }); 276 + }); 277 + }
+79 -551
src/slides/main.ts
··· 1 1 /** 2 2 * Tools Slides — E2EE collaborative presentations. 3 3 * Backed by Yjs for real-time collaboration. 4 + * 5 + * Orchestrator: state, Yjs sync, init, and module wiring. 6 + * Rendering, events, presenter UI, and AI chat live in dedicated modules. 4 7 */ 5 8 6 9 import * as Y from 'yjs'; 7 - import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 10 + import { importKey } from '../lib/crypto.js'; 8 11 import { EncryptedProvider } from '../lib/provider.js'; 9 12 import { setupTooltips } from '../lib/tooltips.js'; 10 - import { 11 - createDeck, addSlide, removeSlide, moveSlide, duplicateSlide, goToSlide, 12 - addElement, removeElement, moveElement, resizeElement, bringToFront, sendToBack, 13 - currentSlide, slideCount, elementCount, 14 - SLIDE_WIDTH, SLIDE_HEIGHT, 15 - } from './canvas-engine.js'; 16 - import type { DeckState, SlideElement, ElementType } from './canvas-engine.js'; 17 - import { 18 - getLayouts, getLayout, getThemes, getTheme, createThemedDeck, 19 - setSlideLayout, setDeckTheme, themeToCSS, 20 - } from './layouts-themes.js'; 21 - import type { LayoutType, Theme } from './layouts-themes.js'; 13 + import { createDeck, slideCount } from './canvas-engine.js'; 14 + import type { DeckState } from './canvas-engine.js'; 15 + import { getLayouts, getThemes, createThemedDeck } from './layouts-themes.js'; 22 16 import { 23 - createTransition, createSlideTransitions, setDefaultTransition, 24 - getTransitionForSlide, getTransitionTypes, transitionCSS, generateDeckCSS, 17 + createSlideTransitions, getTransitionTypes, 25 18 } from './transitions.js'; 26 19 import type { SlideTransitions } from './transitions.js'; 27 - import { 28 - createPresenterState, startPresentation, stopPresentation, 29 - nextSlide as presenterNext, prevSlide as presenterPrev, 30 - tickTimer, toggleTimer, setNotes, currentNotes, formatTime, 31 - progressPercent, isOverTime, 32 - } from './presenter-mode.js'; 20 + import { createPresenterState } from './presenter-mode.js'; 33 21 import type { PresenterState } from './presenter-mode.js'; 34 - import { 35 - createChatSidebar, createChatState, loadConfig, isConfigured, 36 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 37 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 38 - type ChatMessage, 39 - } from '../lib/ai-chat.js'; 40 - import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 41 - import { executeSlideAction } from './ai-slide-actions.js'; 22 + import { createCommandPalette } from '../command-palette.js'; 23 + 24 + import type { AppState, DOMRefs, AppActions } from './types.js'; 25 + import { render as doRender, renderCanvas as doRenderCanvas } from './rendering.js'; 26 + import { setupEventHandlers } from './event-handlers.js'; 27 + import { setupAIChatPanel } from './ai-chat-panel.js'; 42 28 43 29 // --- DOM refs --- 44 30 const $ = (id: string) => document.getElementById(id)!; 45 - const deckTitle = $('deck-title') as HTMLInputElement; 46 - const thumbnailList = $('thumbnail-list'); 47 - const slideCanvas = $('slide-canvas'); 48 - const layoutSelect = $('layout-select') as HTMLSelectElement; 49 - const themeSelect = $('theme-select') as HTMLSelectElement; 50 - const transitionSelect = $('transition-select') as HTMLSelectElement; 51 - const notesInput = $('notes-input') as HTMLTextAreaElement; 52 - const presenterOverlay = $('presenter-overlay'); 53 - const presenterCurrent = $('presenter-current'); 54 - const presenterNextPreview = $('presenter-next-preview'); 55 - const presenterNotesEl = $('presenter-notes'); 56 - const presenterTimerEl = $('presenter-timer'); 57 - const presenterProgressEl = $('presenter-progress'); 31 + const refs: DOMRefs = { 32 + deckTitle: $('deck-title') as HTMLInputElement, 33 + thumbnailList: $('thumbnail-list'), 34 + slideCanvas: $('slide-canvas'), 35 + layoutSelect: $('layout-select') as HTMLSelectElement, 36 + themeSelect: $('theme-select') as HTMLSelectElement, 37 + transitionSelect: $('transition-select') as HTMLSelectElement, 38 + notesInput: $('notes-input') as HTMLTextAreaElement, 39 + presenterOverlay: $('presenter-overlay'), 40 + presenterCurrent: $('presenter-current'), 41 + presenterNextPreview: $('presenter-next-preview'), 42 + presenterNotesEl: $('presenter-notes'), 43 + presenterTimerEl: $('presenter-timer'), 44 + presenterProgressEl: $('presenter-progress'), 45 + }; 58 46 59 - // --- State --- 60 - let deck: DeckState = createDeck(); 61 - let themedDeck = createThemedDeck(1); 62 - let transitions: SlideTransitions = createSlideTransitions(); 63 - let presenter: PresenterState = createPresenterState(1); 64 - let selectedElementId: string | null = null; 65 - let isDragging = false; 66 - let dragStartX = 0; 67 - let dragStartY = 0; 68 - let dragElStartX = 0; 69 - let dragElStartY = 0; 47 + // --- Mutable state --- 48 + const state: AppState = { 49 + deck: createDeck(), 50 + themedDeck: createThemedDeck(1), 51 + transitions: createSlideTransitions(), 52 + presenter: createPresenterState(1), 53 + selectedElementId: null, 54 + isDragging: false, 55 + dragStartX: 0, 56 + dragStartY: 0, 57 + dragElStartX: 0, 58 + dragElStartY: 0, 59 + cryptoKey: null, 60 + docId: window.location.pathname.split('/').pop() || '', 61 + }; 70 62 71 63 // --- Yjs setup --- 72 - const docId = window.location.pathname.split('/').pop() || ''; 73 - const keyFragment = window.location.hash.slice(1); 74 - let cryptoKey: CryptoKey | null = null; 75 64 const ydoc = new Y.Doc(); 76 65 const yDeck = ydoc.getMap('deck'); 77 66 78 - async function initCrypto() { 79 - if (keyFragment) { 80 - try { cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 81 - } 82 - } 83 - 84 67 function syncDeckToYjs() { 85 - yDeck.set('slides', JSON.stringify(deck.slides)); 86 - yDeck.set('currentSlide', deck.currentSlide); 87 - yDeck.set('themed', JSON.stringify(themedDeck)); 88 - yDeck.set('transitions', JSON.stringify(transitions)); 68 + yDeck.set('slides', JSON.stringify(state.deck.slides)); 69 + yDeck.set('currentSlide', state.deck.currentSlide); 70 + yDeck.set('themed', JSON.stringify(state.themedDeck)); 71 + yDeck.set('transitions', JSON.stringify(state.transitions)); 89 72 } 90 73 91 74 function loadDeckFromYjs() { ··· 93 76 const slidesJson = yDeck.get('slides') as string; 94 77 if (slidesJson) { 95 78 const slides = JSON.parse(slidesJson); 96 - deck = { ...deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 79 + state.deck = { ...state.deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 97 80 } 98 81 const themedJson = yDeck.get('themed') as string; 99 - if (themedJson) themedDeck = JSON.parse(themedJson); 82 + if (themedJson) state.themedDeck = JSON.parse(themedJson); 100 83 const transJson = yDeck.get('transitions') as string; 101 84 if (transJson) { 102 85 const parsed = JSON.parse(transJson); 103 - transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 86 + state.transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 104 87 } 105 88 } catch { /* use defaults */ } 106 89 } 107 90 91 + // --- Actions (dependency injection for modules) --- 92 + const actions: AppActions = { 93 + getState: () => state, 94 + setState: (patch) => { Object.assign(state, patch); }, 95 + syncDeckToYjs, 96 + render: () => doRender(refs, actions), 97 + renderCanvas: () => doRenderCanvas(refs, actions), 98 + }; 99 + 108 100 // --- Populate dropdowns --- 109 101 function initDropdowns() { 110 102 getLayouts().forEach(l => { 111 103 const opt = document.createElement('option'); 112 104 opt.value = l.type; 113 105 opt.textContent = l.label; 114 - layoutSelect.appendChild(opt); 106 + refs.layoutSelect.appendChild(opt); 115 107 }); 116 108 getThemes().forEach(t => { 117 109 const opt = document.createElement('option'); 118 110 opt.value = t.id; 119 111 opt.textContent = t.name; 120 - themeSelect.appendChild(opt); 112 + refs.themeSelect.appendChild(opt); 121 113 }); 122 114 getTransitionTypes().forEach(t => { 123 115 const opt = document.createElement('option'); 124 116 opt.value = t.type; 125 117 opt.textContent = t.label; 126 - transitionSelect.appendChild(opt); 127 - }); 128 - } 129 - 130 - // --- Rendering --- 131 - function renderThumbnails() { 132 - thumbnailList.innerHTML = ''; 133 - deck.slides.forEach((slide, i) => { 134 - const thumb = document.createElement('div'); 135 - thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 136 - thumb.dataset.index = String(i); 137 - const num = document.createElement('span'); 138 - num.className = 'slides-thumb-num'; 139 - num.textContent = String(i + 1); 140 - const preview = document.createElement('div'); 141 - preview.className = 'slides-thumb-preview'; 142 - preview.style.background = slide.background; 143 - preview.style.aspectRatio = '16/9'; 144 - // Mini element indicators 145 - if (slide.elements.length > 0) { 146 - preview.innerHTML = `<span class="slides-thumb-count">${slide.elements.length}</span>`; 147 - } 148 - thumb.appendChild(num); 149 - thumb.appendChild(preview); 150 - thumb.addEventListener('click', () => { 151 - deck = goToSlide(deck, i); 152 - syncDeckToYjs(); 153 - render(); 154 - }); 155 - // Context menu for delete/duplicate 156 - thumb.addEventListener('contextmenu', (e) => { 157 - e.preventDefault(); 158 - if (deck.slides.length > 1) { 159 - const action = confirm('Delete this slide? (Cancel to duplicate)'); 160 - if (action) { 161 - deck = removeSlide(deck, i); 162 - themedDeck = { ...themedDeck, layouts: themedDeck.layouts.filter((_, idx) => idx !== i) }; 163 - } else { 164 - deck = duplicateSlide(deck, i); 165 - } 166 - syncDeckToYjs(); 167 - render(); 168 - } 169 - }); 170 - thumbnailList.appendChild(thumb); 171 - }); 172 - } 173 - 174 - function renderCanvas() { 175 - const slide = currentSlide(deck); 176 - const theme = getTheme(themedDeck.themeId); 177 - const cssVars = theme ? themeToCSS(theme) : {}; 178 - 179 - let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slide.background};`; 180 - for (const [k, v] of Object.entries(cssVars)) { 181 - style += `${k}:${v};`; 182 - } 183 - slideCanvas.setAttribute('style', style); 184 - 185 - slideCanvas.innerHTML = ''; 186 - const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 187 - 188 - for (const el of sorted) { 189 - const div = document.createElement('div'); 190 - div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 191 - div.dataset.elementId = el.id; 192 - div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 193 - + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 194 - 195 - if (el.type === 'text') { 196 - const textDiv = document.createElement('div'); 197 - textDiv.className = 'slide-el-text'; 198 - textDiv.contentEditable = 'true'; 199 - textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 200 - textDiv.textContent = el.content || 'Text'; 201 - div.appendChild(textDiv); 202 - } else if (el.type === 'shape') { 203 - const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 204 - div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 205 - } else if (el.type === 'image') { 206 - const img = document.createElement('img'); 207 - const imgUrl = el.content || ''; 208 - // Only allow safe URL schemes to prevent javascript: XSS 209 - const isSafe = /^(https?:|data:|blob:)/i.test(imgUrl) || imgUrl === ''; 210 - img.src = isSafe ? imgUrl : ''; 211 - img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 212 - img.alt = ''; 213 - div.appendChild(img); 214 - } 215 - 216 - // Click to select 217 - div.addEventListener('mousedown', (e) => { 218 - e.stopPropagation(); 219 - selectedElementId = el.id; 220 - isDragging = true; 221 - dragStartX = e.clientX; 222 - dragStartY = e.clientY; 223 - dragElStartX = el.x; 224 - dragElStartY = el.y; 225 - renderCanvas(); 226 - }); 227 - 228 - // Inline text editing 229 - const textEl = div.querySelector('[contenteditable]'); 230 - if (textEl) { 231 - textEl.addEventListener('blur', () => { 232 - const slide = currentSlide(deck); 233 - const elIdx = slide.elements.findIndex(e => e.id === el.id); 234 - if (elIdx >= 0) { 235 - slide.elements[elIdx] = { ...slide.elements[elIdx]!, content: (textEl as HTMLElement).textContent || '' }; 236 - syncDeckToYjs(); 237 - } 238 - }); 239 - } 240 - 241 - slideCanvas.appendChild(div); 242 - } 243 - } 244 - 245 - function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 246 - switch (shapeType) { 247 - case 'ellipse': 248 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-2}" ry="${h/2-2}" fill="${fill}" stroke="${fill}" stroke-width="2" opacity="0.8"/></svg>`; 249 - case 'triangle': 250 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polygon points="${w/2},2 ${w-2},${h-2} 2,${h-2}" fill="${fill}" opacity="0.8"/></svg>`; 251 - case 'line': 252 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w}" y2="${h/2}" stroke="${fill}" stroke-width="3"/></svg>`; 253 - case 'arrow': 254 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w-10}" y2="${h/2}" stroke="${fill}" stroke-width="3"/><polygon points="${w},${h/2} ${w-12},${h/2-6} ${w-12},${h/2+6}" fill="${fill}"/></svg>`; 255 - default: // rectangle 256 - return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="2" y="2" width="${w-4}" height="${h-4}" rx="4" fill="${fill}" opacity="0.8"/></svg>`; 257 - } 258 - } 259 - 260 - function render() { 261 - renderThumbnails(); 262 - renderCanvas(); 263 - // Sync notes 264 - const slide = currentSlide(deck); 265 - notesInput.value = slide.notes || ''; 266 - // Update dropdown selections 267 - if (themedDeck.layouts[deck.currentSlide]) { 268 - layoutSelect.value = themedDeck.layouts[deck.currentSlide]!; 269 - } 270 - themeSelect.value = themedDeck.themeId; 271 - presenter = { ...presenter, totalSlides: slideCount(deck) }; 272 - } 273 - 274 - // --- Presenter mode --- 275 - let timerInterval: ReturnType<typeof setInterval> | null = null; 276 - 277 - function enterPresenter() { 278 - presenter = startPresentation({ ...presenter, totalSlides: slideCount(deck) }); 279 - presenterOverlay.style.display = ''; 280 - document.body.classList.add('presenting'); 281 - renderPresenter(); 282 - timerInterval = setInterval(() => { 283 - presenter = tickTimer(presenter); 284 - renderPresenter(); 285 - }, 1000); 286 - } 287 - 288 - function exitPresenter() { 289 - presenter = stopPresentation(presenter); 290 - presenterOverlay.style.display = 'none'; 291 - document.body.classList.remove('presenting'); 292 - if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 293 - } 294 - 295 - function renderPresenter() { 296 - // Current slide 297 - const slide = deck.slides[presenter.currentSlide]; 298 - if (slide) { 299 - presenterCurrent.style.background = slide.background; 300 - presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${getTheme(themedDeck.themeId)?.palette.text || '#1a1815'}">${slide.elements.filter(e => e.type === 'text').map(e => escapeHtml(e.content)).join('<br>') || ''}</div>`; 301 - } 302 - // Next slide preview 303 - const next = deck.slides[presenter.currentSlide + 1]; 304 - if (next) { 305 - presenterNextPreview.style.background = next.background; 306 - presenterNextPreview.innerHTML = `<div style="padding:8px;font-size:10px;">${next.elements.length} elements</div>`; 307 - } else { 308 - presenterNextPreview.innerHTML = '<div style="padding:8px;opacity:0.5;">End</div>'; 309 - } 310 - // Notes 311 - presenterNotesEl.textContent = currentNotes(presenter); 312 - // Timer 313 - presenterTimerEl.textContent = formatTime(presenter.elapsedSeconds); 314 - if (isOverTime(presenter)) presenterTimerEl.classList.add('over-time'); 315 - else presenterTimerEl.classList.remove('over-time'); 316 - // Progress 317 - presenterProgressEl.textContent = `${presenter.currentSlide + 1} / ${presenter.totalSlides}`; 318 - } 319 - 320 - // --- Event handlers --- 321 - $('btn-add-slide').addEventListener('click', () => { 322 - deck = addSlide(deck, deck.currentSlide + 1); 323 - deck = goToSlide(deck, deck.currentSlide + 1); 324 - themedDeck = { ...themedDeck, layouts: [...themedDeck.layouts, 'titleContent'] }; 325 - syncDeckToYjs(); 326 - render(); 327 - }); 328 - 329 - $('btn-present').addEventListener('click', () => enterPresenter()); 330 - $('btn-presenter-exit').addEventListener('click', () => exitPresenter()); 331 - $('btn-presenter-next').addEventListener('click', () => { presenter = presenterNext(presenter); renderPresenter(); }); 332 - $('btn-presenter-prev').addEventListener('click', () => { presenter = presenterPrev(presenter); renderPresenter(); }); 333 - 334 - $('btn-add-text').addEventListener('click', () => { 335 - deck = addElement(deck, 'text', 100, 100, 300, 60, 'Click to edit'); 336 - syncDeckToYjs(); 337 - renderCanvas(); 338 - }); 339 - 340 - $('btn-add-shape').addEventListener('click', () => { 341 - deck = addElement(deck, 'shape', 200, 150, 150, 150, '', { fill: getTheme(themedDeck.themeId)?.palette.primary ?? '' }); 342 - syncDeckToYjs(); 343 - renderCanvas(); 344 - }); 345 - 346 - $('btn-add-image').addEventListener('click', () => { 347 - const url = prompt('Image URL:'); 348 - if (url) { 349 - deck = addElement(deck, 'image', 150, 100, 300, 200, url); 350 - syncDeckToYjs(); 351 - renderCanvas(); 352 - } 353 - }); 354 - 355 - $('btn-delete-element').addEventListener('click', () => { 356 - if (selectedElementId) { 357 - deck = removeElement(deck, selectedElementId); 358 - selectedElementId = null; 359 - syncDeckToYjs(); 360 - renderCanvas(); 361 - } 362 - }); 363 - 364 - layoutSelect.addEventListener('change', () => { 365 - themedDeck = setSlideLayout(themedDeck, deck.currentSlide, layoutSelect.value as LayoutType); 366 - syncDeckToYjs(); 367 - render(); 368 - }); 369 - 370 - themeSelect.addEventListener('change', () => { 371 - themedDeck = setDeckTheme(themedDeck, themeSelect.value); 372 - syncDeckToYjs(); 373 - render(); 374 - }); 375 - 376 - transitionSelect.addEventListener('change', () => { 377 - transitions = setDefaultTransition(transitions, createTransition(transitionSelect.value as any)); 378 - syncDeckToYjs(); 379 - }); 380 - 381 - notesInput.addEventListener('input', () => { 382 - presenter = setNotes(presenter, deck.currentSlide, notesInput.value); 383 - const slide = currentSlide(deck); 384 - slide.notes = notesInput.value; 385 - syncDeckToYjs(); 386 - }); 387 - 388 - // Canvas click to deselect 389 - slideCanvas.addEventListener('mousedown', (e) => { 390 - if (e.target === slideCanvas) { 391 - selectedElementId = null; 392 - renderCanvas(); 393 - } 394 - }); 395 - 396 - // Drag handling (mouse) 397 - document.addEventListener('mousemove', (e) => { 398 - if (!isDragging || !selectedElementId) return; 399 - const dx = e.clientX - dragStartX; 400 - const dy = e.clientY - dragStartY; 401 - deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 402 - renderCanvas(); 403 - }); 404 - 405 - document.addEventListener('mouseup', () => { 406 - if (isDragging) { 407 - isDragging = false; 408 - syncDeckToYjs(); 409 - } 410 - }); 411 - 412 - // Drag handling (touch) 413 - slideCanvas.addEventListener('touchstart', (e) => { 414 - if (e.touches.length !== 1) return; 415 - const touch = e.touches[0]!; 416 - const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest('[data-element-id]') as HTMLElement | null; 417 - if (!target) return; 418 - const elId = target.dataset.elementId!; 419 - const el = currentSlide(deck).elements.find(el => el.id === elId); 420 - if (!el) return; 421 - e.preventDefault(); 422 - selectedElementId = elId; 423 - isDragging = true; 424 - dragStartX = touch.clientX; 425 - dragStartY = touch.clientY; 426 - dragElStartX = el.x; 427 - dragElStartY = el.y; 428 - renderCanvas(); 429 - }, { passive: false }); 430 - 431 - document.addEventListener('touchmove', (e) => { 432 - if (!isDragging || !selectedElementId) return; 433 - const touch = e.touches[0]!; 434 - const dx = touch.clientX - dragStartX; 435 - const dy = touch.clientY - dragStartY; 436 - deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 437 - renderCanvas(); 438 - e.preventDefault(); 439 - }, { passive: false }); 440 - 441 - document.addEventListener('touchend', () => { 442 - if (isDragging) { 443 - isDragging = false; 444 - syncDeckToYjs(); 445 - } 446 - }); 447 - 448 - // Keyboard shortcuts 449 - document.addEventListener('keydown', (e) => { 450 - if (presenterOverlay.style.display !== 'none') { 451 - if (e.key === 'ArrowRight' || e.key === ' ') { presenter = presenterNext(presenter); renderPresenter(); } 452 - else if (e.key === 'ArrowLeft') { presenter = presenterPrev(presenter); renderPresenter(); } 453 - else if (e.key === 'Escape') exitPresenter(); 454 - return; 455 - } 456 - if (e.key === 'F5') { e.preventDefault(); enterPresenter(); } 457 - if (e.key === 'Delete' && selectedElementId && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { 458 - deck = removeElement(deck, selectedElementId); 459 - selectedElementId = null; 460 - syncDeckToYjs(); 461 - renderCanvas(); 462 - } 463 - }); 464 - 465 - // Title editing 466 - deckTitle.addEventListener('change', async () => { 467 - if (!cryptoKey) return; 468 - const { encrypt } = await import('../lib/crypto.js'); 469 - const nameBytes = new TextEncoder().encode(deckTitle.value); 470 - const encrypted = await encrypt(nameBytes, cryptoKey); 471 - const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 472 - fetch(`/api/documents/${docId}/name`, { 473 - method: 'PUT', 474 - headers: { 'Content-Type': 'application/json' }, 475 - body: JSON.stringify({ name_encrypted: b64 }), 476 - }); 477 - }); 478 - 479 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 480 - 481 - const chatUI = createChatSidebar(); 482 - $('main-content').appendChild(chatUI.container); 483 - 484 - const chatState = createChatState(); 485 - 486 - const chatWiring = initChatWiring({ 487 - chatUI, 488 - chatState, 489 - chatConfig: loadConfig(), 490 - toggleBtn: $('btn-ai-chat'), 491 - editorType: 'slide', 492 - onSend: sendChatMessage, 493 - }); 494 - 495 - function getSlideContextText(): string { 496 - const lines: string[] = []; 497 - deck.slides.forEach((slide, i) => { 498 - lines.push(`Slide ${i + 1}${i === deck.currentSlide ? ' (current)' : ''}:`); 499 - if (slide.notes) lines.push(` Notes: ${slide.notes}`); 500 - slide.elements.forEach(el => { 501 - if (el.content) lines.push(` ${el.type}: "${el.content}"`); 502 - else lines.push(` ${el.type} (${Math.round(el.width)}x${Math.round(el.height)})`); 503 - }); 118 + refs.transitionSelect.appendChild(opt); 504 119 }); 505 - return lines.join('\n'); 506 120 } 507 121 508 - async function sendChatMessage(): Promise<void> { 509 - const text = chatUI.input.value.trim(); 510 - if (!text || chatState.loading) return; 511 - 512 - const cfg = chatWiring.getConfig(); 513 - if (!isConfigured(cfg)) { 514 - chatUI.settingsPanel.style.display = ''; 515 - chatUI.endpointInput.focus(); 516 - return; 122 + // --- Initialize --- 123 + async function init() { 124 + const keyFragment = window.location.hash.slice(1); 125 + if (keyFragment) { 126 + try { state.cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 517 127 } 518 128 519 - const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 520 - chatState.messages.push(userMsg); 521 - appendMessage(chatUI.messageList, userMsg); 522 - 523 - chatUI.input.value = ''; 524 - chatUI.input.style.height = ''; 525 - chatUI.sendBtn.style.display = 'none'; 526 - chatUI.stopBtn.style.display = ''; 527 - chatState.loading = true; 528 - chatState.error = null; 529 - 530 - const title = deckTitle.value.trim() || 'Untitled Presentation'; 531 - const includeContext = chatUI.contextToggle.checked; 532 - const actionsEnabled = chatUI.actionsToggle.checked; 533 - const contextText = includeContext ? getSlideContextText() : ''; 534 - 535 - const systemPrompt = buildSystemMessage(title, contextText, { 536 - editorType: 'slide', 537 - actionsEnabled, 538 - }); 539 - 540 - const slideDeps = { 541 - getState: () => deck, 542 - setState: (s: DeckState) => { deck = s; syncDeckToYjs(); }, 543 - render, 544 - }; 545 - 546 - const abortController = new AbortController(); 547 - chatState.abortController = abortController; 548 - const bubble = appendStreamingBubble(chatUI.messageList); 549 - let fullText = ''; 550 - 551 - await streamChat( 552 - cfg, 553 - chatState.messages, 554 - systemPrompt, 555 - { 556 - onChunk(chunk) { 557 - fullText += chunk; 558 - bubble.update(renderMarkdown(fullText)); 559 - }, 560 - onDone(doneText) { 561 - if (doneText) { 562 - chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 563 - 564 - if (actionsEnabled) { 565 - const { displayText, actions } = splitResponse(doneText); 566 - if (actions.length > 0) { 567 - bubble.update(renderMarkdown(displayText)); 568 - for (const action of actions) { 569 - if (!isSlideAction(action)) continue; 570 - appendActionCard(chatUI.messageList, action, { 571 - onApply: (a) => { 572 - const result = executeSlideAction(a as Parameters<typeof executeSlideAction>[0], slideDeps); 573 - if (!result.success && result.error) { 574 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 575 - } 576 - }, 577 - onDismiss: () => {}, 578 - }); 579 - } 580 - } 581 - } 582 - } 583 - }, 584 - onError(err) { 585 - chatState.error = err; 586 - bubble.el.classList.add('ai-chat-bubble--error'); 587 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 588 - }, 589 - }, 590 - abortController.signal, 591 - ); 592 - 593 - chatState.loading = false; 594 - chatState.abortController = null; 595 - chatUI.sendBtn.style.display = ''; 596 - chatUI.stopBtn.style.display = 'none'; 597 - } 598 - 599 - // --- Initialize --- 600 - async function init() { 601 - await initCrypto(); 602 129 initDropdowns(); 603 130 setupTooltips(); 131 + setupEventHandlers(refs, actions); 132 + setupAIChatPanel(refs, actions); 604 133 605 - if (cryptoKey) { 606 - const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 134 + if (state.cryptoKey) { 135 + const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 607 136 provider.on('sync', () => { 608 137 loadDeckFromYjs(); 609 - render(); 138 + actions.render(); 610 139 }); 611 140 } 612 141 613 142 // Load title 614 143 try { 615 - const res = await fetch(`/api/documents/${docId}`); 144 + const res = await fetch(`/api/documents/${state.docId}`); 616 145 if (res.ok) { 617 146 const doc = await res.json(); 618 - if (doc.name_encrypted && cryptoKey) { 147 + if (doc.name_encrypted && state.cryptoKey) { 619 148 const bytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 620 149 const { decrypt } = await import('../lib/crypto.js'); 621 - const plain = await decrypt(new Uint8Array(bytes.buffer), cryptoKey); 622 - deckTitle.value = new TextDecoder().decode(plain); 150 + const plain = await decrypt(new Uint8Array(bytes.buffer), state.cryptoKey); 151 + refs.deckTitle.value = new TextDecoder().decode(plain); 623 152 } 624 153 } 625 154 } catch { /* ignore */ } 626 155 627 - render(); 156 + actions.render(); 628 157 } 629 158 630 159 // --- Command Palette --- 631 - import { createCommandPalette } from '../command-palette.js'; 632 160 createCommandPalette({ 633 161 actions: [ 634 162 { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } },
+91
src/slides/presenter-ui.ts
··· 1 + /** 2 + * Presenter UI — enter/exit/render the presenter overlay. 3 + * 4 + * Bridges the pure presenter-mode.ts logic with DOM rendering. 5 + * Timer interval management lives here since it is UI-specific. 6 + */ 7 + 8 + import { 9 + startPresentation, stopPresentation, tickTimer, 10 + currentNotes, formatTime, isOverTime, 11 + } from './presenter-mode.js'; 12 + import { slideCount } from './canvas-engine.js'; 13 + import { getTheme } from './layouts-themes.js'; 14 + import { escapeHtml } from '../lib/ai-chat.js'; 15 + import type { DOMRefs, AppActions } from './types.js'; 16 + 17 + let timerInterval: ReturnType<typeof setInterval> | null = null; 18 + 19 + /** 20 + * Render the presenter overlay contents (current slide, next preview, notes, timer). 21 + */ 22 + export function renderPresenter(refs: DOMRefs, actions: AppActions): void { 23 + const { deck, themedDeck, presenter } = actions.getState(); 24 + 25 + // Current slide 26 + const slide = deck.slides[presenter.currentSlide]; 27 + if (slide) { 28 + refs.presenterCurrent.style.background = slide.background; 29 + const textColor = getTheme(themedDeck.themeId)?.palette.text || '#1a1815'; 30 + const textContent = slide.elements 31 + .filter(e => e.type === 'text') 32 + .map(e => escapeHtml(e.content)) 33 + .join('<br>'); 34 + refs.presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${textColor}">${textContent || ''}</div>`; 35 + } 36 + 37 + // Next slide preview 38 + const next = deck.slides[presenter.currentSlide + 1]; 39 + if (next) { 40 + refs.presenterNextPreview.style.background = next.background; 41 + refs.presenterNextPreview.innerHTML = `<div style="padding:8px;font-size:10px;">${next.elements.length} elements</div>`; 42 + } else { 43 + refs.presenterNextPreview.innerHTML = '<div style="padding:8px;opacity:0.5;">End</div>'; 44 + } 45 + 46 + // Notes 47 + refs.presenterNotesEl.textContent = currentNotes(presenter); 48 + 49 + // Timer 50 + refs.presenterTimerEl.textContent = formatTime(presenter.elapsedSeconds); 51 + if (isOverTime(presenter)) refs.presenterTimerEl.classList.add('over-time'); 52 + else refs.presenterTimerEl.classList.remove('over-time'); 53 + 54 + // Progress 55 + refs.presenterProgressEl.textContent = `${presenter.currentSlide + 1} / ${presenter.totalSlides}`; 56 + } 57 + 58 + /** 59 + * Enter presenter mode: activate state, show overlay, start timer. 60 + */ 61 + export function enterPresenter(refs: DOMRefs, actions: AppActions): void { 62 + const state = actions.getState(); 63 + const presenter = startPresentation({ ...state.presenter, totalSlides: slideCount(state.deck) }); 64 + actions.setState({ presenter }); 65 + 66 + refs.presenterOverlay.style.display = ''; 67 + document.body.classList.add('presenting'); 68 + renderPresenter(refs, actions); 69 + 70 + timerInterval = setInterval(() => { 71 + const s = actions.getState(); 72 + actions.setState({ presenter: tickTimer(s.presenter) }); 73 + renderPresenter(refs, actions); 74 + }, 1000); 75 + } 76 + 77 + /** 78 + * Exit presenter mode: deactivate state, hide overlay, stop timer. 79 + */ 80 + export function exitPresenter(refs: DOMRefs, actions: AppActions): void { 81 + const state = actions.getState(); 82 + actions.setState({ presenter: stopPresentation(state.presenter) }); 83 + 84 + refs.presenterOverlay.style.display = 'none'; 85 + document.body.classList.remove('presenting'); 86 + 87 + if (timerInterval) { 88 + clearInterval(timerInterval); 89 + timerInterval = null; 90 + } 91 + }
+187
src/slides/rendering.ts
··· 1 + /** 2 + * Slides Rendering — thumbnail panel, canvas rendering, shape SVG generation. 3 + * 4 + * Pure rendering functions that read state and write to the DOM. 5 + * No event handling or state mutation — those live in event-handlers.ts and main.ts. 6 + */ 7 + 8 + import { 9 + currentSlide, goToSlide, removeSlide, duplicateSlide, slideCount, 10 + SLIDE_WIDTH, SLIDE_HEIGHT, 11 + } from './canvas-engine.js'; 12 + import { getTheme, themeToCSS } from './layouts-themes.js'; 13 + import { escapeHtml } from '../lib/ai-chat.js'; 14 + import type { DOMRefs, AppActions } from './types.js'; 15 + 16 + /** 17 + * Render the thumbnail sidebar for slide navigation. 18 + */ 19 + export function renderThumbnails(refs: DOMRefs, actions: AppActions): void { 20 + const { deck, themedDeck } = actions.getState(); 21 + refs.thumbnailList.innerHTML = ''; 22 + 23 + deck.slides.forEach((slide, i) => { 24 + const thumb = document.createElement('div'); 25 + thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 26 + thumb.dataset.index = String(i); 27 + 28 + const num = document.createElement('span'); 29 + num.className = 'slides-thumb-num'; 30 + num.textContent = String(i + 1); 31 + 32 + const preview = document.createElement('div'); 33 + preview.className = 'slides-thumb-preview'; 34 + preview.style.background = slide.background; 35 + preview.style.aspectRatio = '16/9'; 36 + 37 + if (slide.elements.length > 0) { 38 + preview.innerHTML = `<span class="slides-thumb-count">${slide.elements.length}</span>`; 39 + } 40 + 41 + thumb.appendChild(num); 42 + thumb.appendChild(preview); 43 + 44 + thumb.addEventListener('click', () => { 45 + const s = actions.getState(); 46 + actions.setState({ deck: goToSlide(s.deck, i) }); 47 + actions.syncDeckToYjs(); 48 + actions.render(); 49 + }); 50 + 51 + thumb.addEventListener('contextmenu', (e) => { 52 + e.preventDefault(); 53 + const s = actions.getState(); 54 + if (s.deck.slides.length > 1) { 55 + const shouldDelete = confirm('Delete this slide? (Cancel to duplicate)'); 56 + if (shouldDelete) { 57 + actions.setState({ 58 + deck: removeSlide(s.deck, i), 59 + themedDeck: { ...s.themedDeck, layouts: s.themedDeck.layouts.filter((_, idx) => idx !== i) }, 60 + }); 61 + } else { 62 + actions.setState({ deck: duplicateSlide(s.deck, i) }); 63 + } 64 + actions.syncDeckToYjs(); 65 + actions.render(); 66 + } 67 + }); 68 + 69 + refs.thumbnailList.appendChild(thumb); 70 + }); 71 + } 72 + 73 + /** 74 + * Render an SVG for a shape element. 75 + */ 76 + export function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 77 + switch (shapeType) { 78 + case 'ellipse': 79 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-2}" ry="${h/2-2}" fill="${fill}" stroke="${fill}" stroke-width="2" opacity="0.8"/></svg>`; 80 + case 'triangle': 81 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polygon points="${w/2},2 ${w-2},${h-2} 2,${h-2}" fill="${fill}" opacity="0.8"/></svg>`; 82 + case 'line': 83 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w}" y2="${h/2}" stroke="${fill}" stroke-width="3"/></svg>`; 84 + case 'arrow': 85 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w-10}" y2="${h/2}" stroke="${fill}" stroke-width="3"/><polygon points="${w},${h/2} ${w-12},${h/2-6} ${w-12},${h/2+6}" fill="${fill}"/></svg>`; 86 + default: // rectangle 87 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="2" y="2" width="${w-4}" height="${h-4}" rx="4" fill="${fill}" opacity="0.8"/></svg>`; 88 + } 89 + } 90 + 91 + /** 92 + * Render the main slide canvas with all elements. 93 + */ 94 + export function renderCanvas(refs: DOMRefs, actions: AppActions): void { 95 + const { deck, themedDeck, selectedElementId } = actions.getState(); 96 + const slide = currentSlide(deck); 97 + const theme = getTheme(themedDeck.themeId); 98 + const cssVars = theme ? themeToCSS(theme) : {}; 99 + 100 + let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slide.background};`; 101 + for (const [k, v] of Object.entries(cssVars)) { 102 + style += `${k}:${v};`; 103 + } 104 + refs.slideCanvas.setAttribute('style', style); 105 + refs.slideCanvas.innerHTML = ''; 106 + 107 + const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 108 + 109 + for (const el of sorted) { 110 + const div = document.createElement('div'); 111 + div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 112 + div.dataset.elementId = el.id; 113 + div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 114 + + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 115 + 116 + if (el.type === 'text') { 117 + const textDiv = document.createElement('div'); 118 + textDiv.className = 'slide-el-text'; 119 + textDiv.contentEditable = 'true'; 120 + textDiv.style.cssText = `width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;`; 121 + textDiv.textContent = el.content || 'Text'; 122 + div.appendChild(textDiv); 123 + } else if (el.type === 'shape') { 124 + const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 125 + div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 126 + } else if (el.type === 'image') { 127 + const img = document.createElement('img'); 128 + const imgUrl = el.content || ''; 129 + const isSafe = /^(https?:|data:|blob:)/i.test(imgUrl) || imgUrl === ''; 130 + img.src = isSafe ? imgUrl : ''; 131 + img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 132 + img.alt = ''; 133 + div.appendChild(img); 134 + } 135 + 136 + // Click to select + start drag 137 + div.addEventListener('mousedown', (e) => { 138 + e.stopPropagation(); 139 + actions.setState({ 140 + selectedElementId: el.id, 141 + isDragging: true, 142 + dragStartX: e.clientX, 143 + dragStartY: e.clientY, 144 + dragElStartX: el.x, 145 + dragElStartY: el.y, 146 + }); 147 + renderCanvas(refs, actions); 148 + }); 149 + 150 + // Inline text editing — commit on blur 151 + const textEl = div.querySelector('[contenteditable]'); 152 + if (textEl) { 153 + textEl.addEventListener('blur', () => { 154 + const s = actions.getState(); 155 + const current = currentSlide(s.deck); 156 + const elIdx = current.elements.findIndex(e => e.id === el.id); 157 + if (elIdx >= 0) { 158 + current.elements[elIdx] = { ...current.elements[elIdx]!, content: (textEl as HTMLElement).textContent || '' }; 159 + actions.syncDeckToYjs(); 160 + } 161 + }); 162 + } 163 + 164 + refs.slideCanvas.appendChild(div); 165 + } 166 + } 167 + 168 + /** 169 + * Full render pass: thumbnails, canvas, notes, dropdowns. 170 + */ 171 + export function render(refs: DOMRefs, actions: AppActions): void { 172 + renderThumbnails(refs, actions); 173 + renderCanvas(refs, actions); 174 + 175 + const state = actions.getState(); 176 + const slide = currentSlide(state.deck); 177 + refs.notesInput.value = slide.notes || ''; 178 + 179 + if (state.themedDeck.layouts[state.deck.currentSlide]) { 180 + refs.layoutSelect.value = state.themedDeck.layouts[state.deck.currentSlide]!; 181 + } 182 + refs.themeSelect.value = state.themedDeck.themeId; 183 + 184 + actions.setState({ 185 + presenter: { ...state.presenter, totalSlides: slideCount(state.deck) }, 186 + }); 187 + }
+53
src/slides/types.ts
··· 1 + /** 2 + * Slides App Types — shared interfaces for the slides editor modules. 3 + * 4 + * Dependency-injection context that wires modules together without 5 + * circular imports. Each module receives the parts of AppContext it needs. 6 + */ 7 + 8 + import type { DeckState } from './canvas-engine.js'; 9 + import type { ThemedDeck } from './layouts-themes.js'; 10 + import type { SlideTransitions } from './transitions.js'; 11 + import type { PresenterState } from './presenter-mode.js'; 12 + 13 + /** Mutable application state — single source of truth in main.ts. */ 14 + export interface AppState { 15 + deck: DeckState; 16 + themedDeck: ThemedDeck; 17 + transitions: SlideTransitions; 18 + presenter: PresenterState; 19 + selectedElementId: string | null; 20 + isDragging: boolean; 21 + dragStartX: number; 22 + dragStartY: number; 23 + dragElStartX: number; 24 + dragElStartY: number; 25 + cryptoKey: CryptoKey | null; 26 + docId: string; 27 + } 28 + 29 + /** DOM element references used across modules. */ 30 + export interface DOMRefs { 31 + deckTitle: HTMLInputElement; 32 + thumbnailList: HTMLElement; 33 + slideCanvas: HTMLElement; 34 + layoutSelect: HTMLSelectElement; 35 + themeSelect: HTMLSelectElement; 36 + transitionSelect: HTMLSelectElement; 37 + notesInput: HTMLTextAreaElement; 38 + presenterOverlay: HTMLElement; 39 + presenterCurrent: HTMLElement; 40 + presenterNextPreview: HTMLElement; 41 + presenterNotesEl: HTMLElement; 42 + presenterTimerEl: HTMLElement; 43 + presenterProgressEl: HTMLElement; 44 + } 45 + 46 + /** Operations that modules call back into the main orchestrator. */ 47 + export interface AppActions { 48 + getState: () => AppState; 49 + setState: (patch: Partial<AppState>) => void; 50 + syncDeckToYjs: () => void; 51 + render: () => void; 52 + renderCanvas: () => void; 53 + }