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

Configure Feed

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

feat: file upload, guest management, media elements, collab cursors (0.48.0)

Final batch completing all 20 identified feature gaps:
- Forms: file upload handling with MIME validation and size limits (#663)
- Calendar: guest management with RSVP tracking (#661)
- Slides: video/audio embedding with YouTube/Vimeo URL parsing (#658)
- Diagrams: live collaboration cursors via Yjs awareness (#669)

Closes #658, closes #661, closes #663, closes #669

+2193 -4
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.48.0] — 2026-04-15 11 + 12 + ### Added 13 + - Forms: file upload handling — upload endpoint, MIME validation, size limits, preview in responses (#663) 14 + - Calendar: guest management — add/remove attendees, RSVP tracking, email validation (#661) 15 + - Slides: video/audio embedding — YouTube/Vimeo URL parsing, direct media playback, foreignObject rendering (#658) 16 + - Diagrams: live collaboration cursors — real-time peer cursor rendering via Yjs awareness, color-coded with names (#669) 17 + 10 18 ## [0.47.0] — 2026-04-15 11 19 12 20 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.47.0", 3 + "version": "0.48.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+106
src/calendar/guest-management.ts
··· 1 + /** 2 + * Guest management — pure functions for managing event attendees. 3 + * No DOM or Yjs dependencies; extracted for testability. 4 + */ 5 + 6 + export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative'; 7 + 8 + export interface Guest { 9 + email: string; 10 + name?: string; 11 + status: RsvpStatus; 12 + } 13 + 14 + export interface GuestCounts { 15 + total: number; 16 + pending: number; 17 + accepted: number; 18 + declined: number; 19 + tentative: number; 20 + } 21 + 22 + /** 23 + * Basic email validation — checks for a@b.c pattern. 24 + * Not RFC-5322 compliant, but sufficient for a client-side calendar. 25 + */ 26 + export function validateEmail(email: string): boolean { 27 + if (!email || typeof email !== 'string') return false; 28 + const trimmed = email.trim(); 29 + if (trimmed.length === 0) return false; 30 + // Must have exactly one @, with non-empty local and domain parts, 31 + // domain must have at least one dot with non-empty segments. 32 + const atIndex = trimmed.indexOf('@'); 33 + if (atIndex < 1) return false; 34 + if (trimmed.indexOf('@', atIndex + 1) !== -1) return false; // multiple @ 35 + const local = trimmed.slice(0, atIndex); 36 + const domain = trimmed.slice(atIndex + 1); 37 + if (local.length === 0 || domain.length === 0) return false; 38 + const dotIndex = domain.indexOf('.'); 39 + if (dotIndex < 1 || dotIndex === domain.length - 1) return false; 40 + return true; 41 + } 42 + 43 + /** 44 + * Parse a comma-or-semicolon-separated string of emails into an array. 45 + * Trims whitespace, filters empty entries. 46 + */ 47 + export function parseGuestInput(input: string): string[] { 48 + if (!input || typeof input !== 'string') return []; 49 + return input 50 + .split(/[,;]+/) 51 + .map(s => s.trim().toLowerCase()) 52 + .filter(s => s.length > 0); 53 + } 54 + 55 + /** 56 + * Add a guest to an event's guest list. Returns a new array (immutable). 57 + * Prevents duplicates (by email, case-insensitive). 58 + * Returns null if the email is invalid. 59 + */ 60 + export function addGuest( 61 + guests: Guest[], 62 + email: string, 63 + name?: string, 64 + ): Guest[] | null { 65 + const normalized = email.trim().toLowerCase(); 66 + if (!validateEmail(normalized)) return null; 67 + if (guests.some(g => g.email === normalized)) return guests; // already present 68 + return [...guests, { email: normalized, name: name?.trim() || undefined, status: 'pending' }]; 69 + } 70 + 71 + /** 72 + * Remove a guest by email (case-insensitive). Returns a new array. 73 + */ 74 + export function removeGuest(guests: Guest[], email: string): Guest[] { 75 + const normalized = email.trim().toLowerCase(); 76 + return guests.filter(g => g.email !== normalized); 77 + } 78 + 79 + /** 80 + * Update RSVP status for a guest. Returns a new array. 81 + * If the guest is not found, returns the original array unchanged. 82 + */ 83 + export function updateRsvp(guests: Guest[], email: string, status: RsvpStatus): Guest[] { 84 + const normalized = email.trim().toLowerCase(); 85 + let found = false; 86 + const result = guests.map(g => { 87 + if (g.email === normalized) { 88 + found = true; 89 + return { ...g, status }; 90 + } 91 + return g; 92 + }); 93 + return found ? result : guests; 94 + } 95 + 96 + /** 97 + * Get guest counts: total and per-status breakdown. 98 + */ 99 + export function getGuestCount(guests: Guest[]): GuestCounts { 100 + const counts: GuestCounts = { total: 0, pending: 0, accepted: 0, declined: 0, tentative: 0 }; 101 + for (const g of guests) { 102 + counts.total++; 103 + counts[g.status]++; 104 + } 105 + return counts; 106 + }
+5
src/calendar/helpers.ts
··· 2 2 * Calendar pure helper functions — extracted for testability. 3 3 */ 4 4 5 + import type { Guest, RsvpStatus } from './guest-management.js'; 6 + export type { Guest, RsvpStatus } from './guest-management.js'; 7 + 5 8 export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; 6 9 export type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'; 7 10 ··· 41 44 timezone?: string; 42 45 recurrence?: Recurrence; 43 46 reminders?: Reminder[]; 47 + /** Event attendees with RSVP status. */ 48 + guests?: Guest[]; 44 49 createdAt: number; 45 50 updatedAt: number; 46 51 }
+9
src/calendar/index.html
··· 303 303 <button type="button" class="btn-add-reminder" id="btn-add-reminder">+ Add reminder</button> 304 304 </div> 305 305 306 + <div class="event-modal-field"> 307 + <label>Guests</label> 308 + <div class="event-guest-input-row"> 309 + <input type="text" id="event-guest-input" class="event-modal-input" placeholder="Add emails (comma-separated)"> 310 + <button type="button" class="btn-add-guest" id="btn-add-guest">Add</button> 311 + </div> 312 + <div class="event-guest-list" id="event-guest-list"></div> 313 + </div> 314 + 306 315 <div class="event-modal-actions"> 307 316 <button class="btn-danger" id="btn-event-delete" title="Delete event" style="display:none">Delete</button> 308 317 <button class="btn-secondary" id="btn-event-duplicate" title="Duplicate event" style="display:none">Duplicate</button>
+22
src/css/app.css
··· 3131 3131 .form-preview-error { color: oklch(0.6 0.2 25); font-size: 0.8rem; min-height: 1.2em; } 3132 3132 .form-preview-actions { margin-top: var(--space-lg); } 3133 3133 3134 + /* File upload question */ 3135 + .form-file-upload { margin-top: var(--space-xs); } 3136 + .form-file-input { 3137 + display: block; 3138 + width: 100%; 3139 + padding: var(--space-sm); 3140 + border: 2px dashed var(--color-border); 3141 + border-radius: var(--radius-sm, 4px); 3142 + background: var(--color-bg); 3143 + cursor: pointer; 3144 + font-size: 0.85rem; 3145 + } 3146 + .form-file-input:hover { border-color: var(--color-accent); } 3147 + .form-file-input:disabled { opacity: 0.5; cursor: wait; } 3148 + .form-file-status { font-size: 0.8rem; margin-top: 4px; min-height: 1.2em; } 3149 + .form-file-uploading { color: var(--color-text-secondary); } 3150 + .form-file-success { color: oklch(0.6 0.18 145); } 3151 + .form-file-error { color: oklch(0.6 0.2 25); } 3152 + .form-file-name { font-weight: 500; } 3153 + .form-file-size { color: var(--color-text-secondary); } 3154 + .form-file-link { color: var(--color-accent); text-decoration: underline; } 3155 + 3134 3156 .sheet-grid td.editing .cell-display { display: none; } 3135 3157 3136 3158 .cell-editor {
+8
src/diagrams/canvas-events.ts
··· 53 53 clearSnapGuides: () => void; 54 54 startEdgeScroll: (clientX: number, clientY: number) => void; 55 55 stopEdgeScroll: () => void; 56 + onCursorMove?: (x: number, y: number) => void; 56 57 } 57 58 58 59 // --------------------------------------------------------------------------- ··· 288 289 // --- mousemove --- 289 290 canvas.addEventListener('mousemove', (e) => { 290 291 _updateCursor(interactionDeps, e); 292 + 293 + // Broadcast cursor position to collaboration peers 294 + if (deps.onCursorMove) { 295 + const cursorPt = deps.screenToCanvas(e.clientX, e.clientY); 296 + deps.onCursorMove(cursorPt.x, cursorPt.y); 297 + } 298 + 291 299 let wb = deps.getState(); 292 300 const activeTool = deps.getActiveTool(); 293 301 const selectedShapeIds = deps.getSelectedShapeIds();
+335
src/diagrams/collaboration-cursors.ts
··· 1 + /** 2 + * Collaboration Cursors — live cursor presence for the diagrams canvas. 3 + * 4 + * Pure logic + rendering module. Uses Yjs Awareness protocol to share 5 + * transient cursor state between peers. Cursor positions are in world-space 6 + * (pre-transform) coordinates. 7 + * 8 + * Architecture: 9 + * - State management: immutable CursorState objects 10 + * - Rendering: buildCursorSvgElements returns descriptors (testable without DOM) 11 + * - DOM rendering: renderRemoteCursors applies descriptors to an SVG layer 12 + * - Integration: initCollaborationCursors wires awareness + cleanup 13 + */ 14 + 15 + import type { Awareness } from 'y-protocols/awareness'; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Constants 19 + // --------------------------------------------------------------------------- 20 + 21 + /** 8 distinct colors that work in both light and dark mode */ 22 + export const CURSOR_PALETTE = [ 23 + '#e74c3c', // red 24 + '#3498db', // blue 25 + '#2ecc71', // green 26 + '#f39c12', // orange 27 + '#9b59b6', // purple 28 + '#1abc9c', // teal 29 + '#e67e22', // dark orange 30 + '#e84393', // pink 31 + ] as const; 32 + 33 + /** Cursors fade out after this many ms of inactivity */ 34 + export const STALE_THRESHOLD_MS = 10_000; 35 + 36 + /** Max 20 updates/sec = 50ms between sends */ 37 + export const THROTTLE_INTERVAL_MS = 50; 38 + 39 + /** Opacity for stale cursors (fading out) */ 40 + const STALE_OPACITY = 0.3; 41 + 42 + /** SVG path for the cursor arrow (pointing top-left, 16x20 viewBox) */ 43 + const CURSOR_ARROW_PATH = 'M 0 0 L 0 16 L 4.5 12.5 L 8 20 L 11 18.5 L 7.5 11 L 13 11 Z'; 44 + 45 + // --------------------------------------------------------------------------- 46 + // Types 47 + // --------------------------------------------------------------------------- 48 + 49 + export interface CursorState { 50 + clientId: number; 51 + name: string; 52 + color: string; 53 + x: number; 54 + y: number; 55 + lastUpdated: number; 56 + } 57 + 58 + export interface DiagramCursorsState { 59 + localClientId: number; 60 + cursors: Map<number, CursorState>; 61 + } 62 + 63 + export interface CursorSvgElement { 64 + clientId: number; 65 + x: number; 66 + y: number; 67 + color: string; 68 + label: string; 69 + arrowPath: string; 70 + opacity: number; 71 + } 72 + 73 + // --------------------------------------------------------------------------- 74 + // Color assignment 75 + // --------------------------------------------------------------------------- 76 + 77 + /** Deterministic color from client ID */ 78 + export function getCursorColor(clientId: number): string { 79 + return CURSOR_PALETTE[clientId % CURSOR_PALETTE.length]!; 80 + } 81 + 82 + // --------------------------------------------------------------------------- 83 + // State management (immutable) 84 + // --------------------------------------------------------------------------- 85 + 86 + export function createCursorState(localClientId: number): DiagramCursorsState { 87 + return { localClientId, cursors: new Map() }; 88 + } 89 + 90 + export function updateRemoteCursor( 91 + state: DiagramCursorsState, 92 + clientId: number, 93 + name: string, 94 + x: number, 95 + y: number, 96 + ): DiagramCursorsState { 97 + if (clientId === state.localClientId) return state; 98 + 99 + const cursors = new Map(state.cursors); 100 + const existing = cursors.get(clientId); 101 + cursors.set(clientId, { 102 + clientId, 103 + name, 104 + color: existing?.color ?? getCursorColor(clientId), 105 + x, 106 + y, 107 + lastUpdated: Date.now(), 108 + }); 109 + return { ...state, cursors }; 110 + } 111 + 112 + export function removeRemoteCursor( 113 + state: DiagramCursorsState, 114 + clientId: number, 115 + ): DiagramCursorsState { 116 + if (!state.cursors.has(clientId)) return state; 117 + const cursors = new Map(state.cursors); 118 + cursors.delete(clientId); 119 + return { ...state, cursors }; 120 + } 121 + 122 + // --------------------------------------------------------------------------- 123 + // Staleness 124 + // --------------------------------------------------------------------------- 125 + 126 + export function isCursorStale(cursor: CursorState, now = Date.now()): boolean { 127 + return now - cursor.lastUpdated > STALE_THRESHOLD_MS; 128 + } 129 + 130 + export function getActiveCursors( 131 + state: DiagramCursorsState, 132 + now = Date.now(), 133 + ): CursorState[] { 134 + const result: CursorState[] = []; 135 + for (const cursor of state.cursors.values()) { 136 + if (!isCursorStale(cursor, now)) { 137 + result.push(cursor); 138 + } 139 + } 140 + return result; 141 + } 142 + 143 + // --------------------------------------------------------------------------- 144 + // SVG element descriptors (pure, testable without DOM) 145 + // --------------------------------------------------------------------------- 146 + 147 + export function buildCursorSvgElements( 148 + cursors: CursorState[], 149 + localClientId: number, 150 + ): CursorSvgElement[] { 151 + const elements: CursorSvgElement[] = []; 152 + for (const cursor of cursors) { 153 + if (cursor.clientId === localClientId) continue; 154 + const stale = isCursorStale(cursor); 155 + elements.push({ 156 + clientId: cursor.clientId, 157 + x: cursor.x, 158 + y: cursor.y, 159 + color: cursor.color, 160 + label: cursor.name, 161 + arrowPath: CURSOR_ARROW_PATH, 162 + opacity: stale ? STALE_OPACITY : 1, 163 + }); 164 + } 165 + return elements; 166 + } 167 + 168 + // --------------------------------------------------------------------------- 169 + // Throttle helper 170 + // --------------------------------------------------------------------------- 171 + 172 + export function createThrottledUpdater( 173 + handler: (x: number, y: number) => void, 174 + ): (x: number, y: number) => void { 175 + let lastCall = 0; 176 + let pendingTimer: ReturnType<typeof setTimeout> | null = null; 177 + let pendingX = 0; 178 + let pendingY = 0; 179 + 180 + return (x: number, y: number) => { 181 + const now = Date.now(); 182 + pendingX = x; 183 + pendingY = y; 184 + 185 + if (now - lastCall >= THROTTLE_INTERVAL_MS) { 186 + lastCall = now; 187 + if (pendingTimer !== null) { 188 + clearTimeout(pendingTimer); 189 + pendingTimer = null; 190 + } 191 + handler(x, y); 192 + } else if (pendingTimer === null) { 193 + pendingTimer = setTimeout(() => { 194 + lastCall = Date.now(); 195 + pendingTimer = null; 196 + handler(pendingX, pendingY); 197 + }, THROTTLE_INTERVAL_MS - (now - lastCall)); 198 + } 199 + }; 200 + } 201 + 202 + // --------------------------------------------------------------------------- 203 + // DOM rendering (applies descriptors to SVG layer) 204 + // --------------------------------------------------------------------------- 205 + 206 + export function renderRemoteCursors( 207 + layer: SVGGElement, 208 + cursors: CursorState[], 209 + localClientId: number, 210 + ): void { 211 + // Remove old cursor elements 212 + layer.querySelectorAll('.collab-cursor').forEach(el => el.remove()); 213 + 214 + const elements = buildCursorSvgElements(cursors, localClientId); 215 + 216 + for (const desc of elements) { 217 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 218 + g.classList.add('collab-cursor'); 219 + g.setAttribute('data-client-id', String(desc.clientId)); 220 + g.setAttribute('transform', `translate(${desc.x}, ${desc.y})`); 221 + if (desc.opacity < 1) { 222 + g.setAttribute('opacity', String(desc.opacity)); 223 + } 224 + 225 + // Cursor arrow 226 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 227 + path.setAttribute('d', desc.arrowPath); 228 + path.setAttribute('fill', desc.color); 229 + path.setAttribute('stroke', '#fff'); 230 + path.setAttribute('stroke-width', '1'); 231 + g.appendChild(path); 232 + 233 + // Name label background 234 + const labelG = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 235 + labelG.setAttribute('transform', 'translate(12, 16)'); 236 + 237 + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 238 + text.setAttribute('font-size', '11'); 239 + text.setAttribute('font-family', 'system-ui, sans-serif'); 240 + text.setAttribute('fill', '#fff'); 241 + text.textContent = desc.label; 242 + 243 + // Background rect behind name (approximate width) 244 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 245 + const textWidth = desc.label.length * 7 + 8; 246 + rect.setAttribute('x', '-4'); 247 + rect.setAttribute('y', '-11'); 248 + rect.setAttribute('width', String(textWidth)); 249 + rect.setAttribute('height', '16'); 250 + rect.setAttribute('rx', '3'); 251 + rect.setAttribute('fill', desc.color); 252 + 253 + labelG.appendChild(rect); 254 + labelG.appendChild(text); 255 + g.appendChild(labelG); 256 + layer.appendChild(g); 257 + } 258 + } 259 + 260 + // --------------------------------------------------------------------------- 261 + // Awareness integration 262 + // --------------------------------------------------------------------------- 263 + 264 + /** 265 + * Update own cursor position in awareness state (world-space coordinates). 266 + */ 267 + export function updateLocalCursor( 268 + awareness: Awareness, 269 + x: number, 270 + y: number, 271 + ): void { 272 + awareness.setLocalStateField('cursor', { x, y }); 273 + } 274 + 275 + /** 276 + * Wire up collaboration cursors to awareness + canvas. 277 + * 278 + * @returns Cleanup function that removes all listeners. 279 + */ 280 + export function initCollaborationCursors( 281 + awareness: Awareness, 282 + layer: SVGGElement, 283 + getTransform: () => { panX: number; panY: number; zoom: number }, 284 + ): () => void { 285 + const localClientId = awareness.clientID; 286 + 287 + let cursorState = createCursorState(localClientId); 288 + 289 + function onAwarenessChange() { 290 + const states = awareness.getStates(); 291 + const seen = new Set<number>(); 292 + 293 + for (const [clientId, state] of states) { 294 + if (clientId === localClientId) continue; 295 + seen.add(clientId); 296 + const cursor = state.cursor as { x: number; y: number } | undefined; 297 + const user = state.user as { name?: string } | undefined; 298 + if (cursor && typeof cursor.x === 'number' && typeof cursor.y === 'number') { 299 + cursorState = updateRemoteCursor( 300 + cursorState, 301 + clientId, 302 + user?.name || `User ${clientId}`, 303 + cursor.x, 304 + cursor.y, 305 + ); 306 + } 307 + } 308 + 309 + // Remove cursors for disconnected clients 310 + for (const clientId of cursorState.cursors.keys()) { 311 + if (!seen.has(clientId)) { 312 + cursorState = removeRemoteCursor(cursorState, clientId); 313 + } 314 + } 315 + 316 + const allCursors = [...cursorState.cursors.values()]; 317 + renderRemoteCursors(layer, allCursors, localClientId); 318 + } 319 + 320 + awareness.on('change', onAwarenessChange); 321 + 322 + // Periodic re-render to fade stale cursors 323 + const fadeInterval = setInterval(() => { 324 + const allCursors = [...cursorState.cursors.values()]; 325 + if (allCursors.length > 0) { 326 + renderRemoteCursors(layer, allCursors, localClientId); 327 + } 328 + }, 1000); 329 + 330 + return () => { 331 + awareness.off('change', onAwarenessChange); 332 + clearInterval(fadeInterval); 333 + layer.querySelectorAll('.collab-cursor').forEach(el => el.remove()); 334 + }; 335 + }
+32 -1
src/diagrams/main.ts
··· 45 45 clearSnapGuides as _clearSnapGuides, 46 46 } from './snap-guides.js'; 47 47 import { initAiChat } from './ai-chat-wiring.js'; 48 + import { 49 + initCollaborationCursors, updateLocalCursor, createThrottledUpdater, 50 + } from './collaboration-cursors.js'; 48 51 49 52 // --- DOM refs --- 50 53 const $ = (id: string) => document.getElementById(id)!; ··· 78 81 // Selection (multi-select) 79 82 let selectedShapeIds: Set<string> = new Set(); 80 83 84 + // Collaboration cursors 85 + let cursorUpdateFn: ((x: number, y: number) => void) | null = null; 86 + 81 87 // Inline text editing 82 88 let editingShapeId: string | null = null; 83 89 ··· 101 107 getEditingShapeId: () => editingShapeId, getActiveTool: () => activeTool, 102 108 }; 103 109 104 - function render() { _render(renderDeps); _updateToolbarFull(renderDeps); _updateProps(renderDeps); _updateStylePanel(renderDeps); } 110 + function render() { 111 + _render(renderDeps); _updateToolbarFull(renderDeps); _updateProps(renderDeps); _updateStylePanel(renderDeps); 112 + // Keep cursor layer transform in sync with diagram layer 113 + const cursorLayer = document.getElementById('cursor-layer'); 114 + if (cursorLayer) { 115 + cursorLayer.setAttribute('transform', `translate(${wb.panX}, ${wb.panY}) scale(${wb.zoom})`); 116 + } 117 + } 105 118 function updateToolbar() { _updateToolbarFull(renderDeps); } 106 119 function updateProps() { _updateProps(renderDeps); } 107 120 function updateStylePanel() { _updateStylePanel(renderDeps); } ··· 295 308 clearSnapGuides, 296 309 startEdgeScroll: (cx, cy) => startEdgeScroll(toolbarDeps, cx, cy), 297 310 stopEdgeScroll, 311 + onCursorMove: (x, y) => { if (cursorUpdateFn) cursorUpdateFn(x, y); }, 298 312 }; 299 313 300 314 // Wire extracted modules ··· 370 384 pushHistory(); 371 385 render(); 372 386 }); 387 + 388 + // Wire up collaboration cursors via awareness 389 + const cursorLayer = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 390 + cursorLayer.setAttribute('id', 'cursor-layer'); 391 + canvas.appendChild(cursorLayer); 392 + 393 + const cleanupCursors = initCollaborationCursors( 394 + provider.awareness, 395 + cursorLayer, 396 + () => ({ panX: wb.panX, panY: wb.panY, zoom: wb.zoom }), 397 + ); 398 + 399 + // Throttled local cursor broadcast 400 + const throttledUpdate = createThrottledUpdater((x, y) => { 401 + updateLocalCursor(provider.awareness, x, y); 402 + }); 403 + cursorUpdateFn = throttledUpdate; 373 404 } 374 405 375 406 // Load title
+70
src/forms/file-upload.ts
··· 1 + /** 2 + * File Upload — constants, validation, and helpers for file_upload question type. 3 + * 4 + * Pure logic module: MIME type allowlists, size validation, formatting. 5 + * Actual upload/download handled by the blob-upload layer. 6 + */ 7 + 8 + /** Maximum upload size in bytes (10MB, matches server blob limit). */ 9 + export const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; 10 + 11 + /** Allowed MIME types for form file uploads. */ 12 + export const ALLOWED_UPLOAD_TYPES: readonly string[] = [ 13 + // Images 14 + 'image/png', 15 + 'image/jpeg', 16 + 'image/gif', 17 + 'image/webp', 18 + 'image/svg+xml', 19 + // Documents 20 + 'application/pdf', 21 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx 22 + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 23 + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx 24 + 'application/msword', // .doc 25 + 'application/vnd.ms-excel', // .xls 26 + // Text 27 + 'text/plain', 28 + 'text/csv', 29 + 'text/markdown', 30 + ]; 31 + 32 + /** 33 + * Check whether a MIME type is in the allowed upload list. 34 + */ 35 + export function isAllowedUploadType(mimeType: string): boolean { 36 + return ALLOWED_UPLOAD_TYPES.includes(mimeType); 37 + } 38 + 39 + /** 40 + * Format a byte count for human display. 41 + */ 42 + export function formatUploadSize(bytes: number): string { 43 + if (bytes < 1024) return `${bytes} B`; 44 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 45 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 46 + } 47 + 48 + /** 49 + * Validate a file before upload. 50 + * Returns null if valid, or an error message string. 51 + */ 52 + export function validateFileUpload(size: number, mimeType: string): string | null { 53 + if (size === 0) { 54 + return 'File is empty'; 55 + } 56 + if (size > MAX_UPLOAD_SIZE) { 57 + return `File too large (max ${formatUploadSize(MAX_UPLOAD_SIZE)})`; 58 + } 59 + if (!isAllowedUploadType(mimeType)) { 60 + return 'File type not allowed. Accepted: images, PDFs, and common document types.'; 61 + } 62 + return null; 63 + } 64 + 65 + /** 66 + * Get the accept attribute string for a file input element. 67 + */ 68 + export function fileInputAccept(): string { 69 + return ALLOWED_UPLOAD_TYPES.join(','); 70 + }
+62
src/forms/render-preview.ts
··· 12 12 import { createResponse, type FormResponse } from './responses.js'; 13 13 import { escapeHtml } from '../lib/ai-chat.js'; 14 14 import { resolvePipes } from './answer-piping.js'; 15 + import { validateFileUpload, fileInputAccept, formatUploadSize } from './file-upload.js'; 16 + import { uploadBlob, readFileAsBuffer } from '../lib/blob-upload.js'; 15 17 16 18 export interface PreviewDeps { 17 19 getForm: () => FormSchema; ··· 61 63 } 62 64 63 65 wireRatingAndScaleButtons(pane); 66 + wireFileUploadInputs(pane); 64 67 wirePipingUpdates(pane, deps); 65 68 wireSubmitHandler(pane, deps); 66 69 } ··· 90 93 const max = q.scaleMax ?? 10; 91 94 return `<div class="form-preview-scale" data-qid="${q.id}">${Array.from({ length: max - min + 1 }, (_, i) => `<button class="form-scale-btn" data-value="${min + i}">${min + i}</button>`).join('')}</div>`; 92 95 } 96 + case 'file_upload': 97 + return `<div class="form-file-upload" data-qid="${q.id}"> 98 + <input type="file" class="form-file-input" data-qid="${q.id}" accept="${fileInputAccept()}"> 99 + <div class="form-file-status" data-file-status="${q.id}"></div> 100 + </div>`; 93 101 default: 94 102 return ''; 95 103 } ··· 107 115 }); 108 116 } 109 117 118 + function wireFileUploadInputs(pane: HTMLElement): void { 119 + pane.querySelectorAll('.form-file-input').forEach(input => { 120 + input.addEventListener('change', async (e) => { 121 + const fileInput = e.target as HTMLInputElement; 122 + const file = fileInput.files?.[0]; 123 + if (!file) return; 124 + 125 + const qid = fileInput.dataset.qid!; 126 + const container = pane.querySelector(`.form-file-upload[data-qid="${qid}"]`) as HTMLElement | null; 127 + const statusEl = pane.querySelector(`[data-file-status="${qid}"]`) as HTMLElement | null; 128 + if (!container || !statusEl) return; 129 + 130 + // Client-side validation 131 + const validationError = validateFileUpload(file.size, file.type); 132 + if (validationError) { 133 + statusEl.textContent = validationError; 134 + statusEl.className = 'form-file-status form-file-error'; 135 + fileInput.value = ''; 136 + return; 137 + } 138 + 139 + // Show uploading state 140 + statusEl.textContent = `Uploading ${file.name}...`; 141 + statusEl.className = 'form-file-status form-file-uploading'; 142 + fileInput.disabled = true; 143 + 144 + try { 145 + // Extract docId from the URL path 146 + const pathMatch = window.location.pathname.match(/\/forms\/(.+)/); 147 + const docId = pathMatch?.[1] ?? ''; 148 + 149 + const buffer = await readFileAsBuffer(file); 150 + const result = await uploadBlob(docId, new Uint8Array(buffer), file.name, file.type); 151 + 152 + // Store blob ID on the container for answer collection 153 + container.dataset.blobId = result.id; 154 + statusEl.innerHTML = `<span class="form-file-name">${escapeHtml(file.name)}</span> <span class="form-file-size">(${formatUploadSize(file.size)})</span>`; 155 + statusEl.className = 'form-file-status form-file-success'; 156 + } catch (err: unknown) { 157 + const message = err instanceof Error ? err.message : 'Upload failed'; 158 + statusEl.textContent = message; 159 + statusEl.className = 'form-file-status form-file-error'; 160 + // Clear the stored blob ID on failure 161 + delete container.dataset.blobId; 162 + } finally { 163 + fileInput.disabled = false; 164 + } 165 + }); 166 + }); 167 + } 168 + 110 169 function collectAnswers(pane: HTMLElement): Map<string, unknown> { 111 170 const formAnswers = new Map<string, unknown>(); 112 171 ··· 124 183 } 125 184 } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { 126 185 formAnswers.set(qid, el.value); 186 + } else if (el instanceof HTMLElement && el.classList.contains('form-file-upload')) { 187 + const blobId = el.dataset.blobId; 188 + if (blobId) formAnswers.set(qid, blobId); 127 189 } else if (el instanceof HTMLElement && (el.classList.contains('form-preview-rating') || el.classList.contains('form-preview-scale'))) { 128 190 const val = el.dataset.selectedValue; 129 191 if (val) formAnswers.set(qid, Number(val));
+15 -1
src/forms/render-responses.ts
··· 43 43 const headers = pipelineHeaders(config); 44 44 const rows = responses.map(r => responseToRow(r, config)); 45 45 46 + // Build a set of question indices that are file_upload type for link rendering 47 + const fileUploadIndices = new Set<number>(); 48 + form.questions.forEach((q, i) => { 49 + if (q.type === 'file_upload') fileUploadIndices.add(i); 50 + }); 51 + 46 52 let tableHtml = '<table class="pivot-table"><thead><tr>'; 47 53 for (const h of headers) tableHtml += `<th>${escapeHtml(String(h))}</th>`; 48 54 tableHtml += '</tr></thead><tbody>'; 49 55 for (const row of rows) { 50 56 tableHtml += '<tr>'; 51 - for (const cell of row) tableHtml += `<td>${escapeHtml(String(cell ?? ''))}</td>`; 57 + for (let ci = 0; ci < row.length; ci++) { 58 + const cell = row[ci]; 59 + const cellStr = String(cell ?? ''); 60 + if (fileUploadIndices.has(ci) && cellStr) { 61 + tableHtml += `<td><a href="/api/blobs/${escapeHtml(cellStr)}" target="_blank" class="form-file-link">View file</a></td>`; 62 + } else { 63 + tableHtml += `<td>${escapeHtml(cellStr)}</td>`; 64 + } 65 + } 52 66 tableHtml += '</tr>'; 53 67 } 54 68 tableHtml += '</tbody></table>';
+1 -1
src/slides/canvas-engine.ts
··· 5 5 * DOM/canvas rendering handled by the slides UI layer. 6 6 */ 7 7 8 - export type ElementType = 'text' | 'image' | 'shape' | 'code' | 'chart' | 'embed' | 'table'; 8 + export type ElementType = 'text' | 'image' | 'shape' | 'code' | 'chart' | 'embed' | 'table' | 'video' | 'audio'; 9 9 export type ShapeType = 'rectangle' | 'ellipse' | 'triangle' | 'arrow' | 'line'; 10 10 11 11 export interface SlideElement {
+2
src/slides/index.html
··· 64 64 <button class="btn-icon" id="btn-add-text" title="Add text">T</button> 65 65 <button class="btn-icon" id="btn-add-shape" title="Add shape">&#9632;</button> 66 66 <button class="btn-icon" id="btn-add-image" title="Add image">&#9635;</button> 67 + <button class="btn-icon" id="btn-add-video" title="Add video">&#9654;</button> 68 + <button class="btn-icon" id="btn-add-audio" title="Add audio">&#9835;</button> 67 69 <button class="btn-icon" id="btn-delete-element" title="Delete selected">&#10005;</button> 68 70 </div> 69 71 <div class="slides-canvas-wrapper">
+266
src/slides/media-elements.ts
··· 1 + /** 2 + * Media Elements — URL parsing, sanitization, and element creation for 3 + * video/audio embedding in the slides editor. 4 + * 5 + * Supports: 6 + * - YouTube (watch, short, embed, nocookie) → iframe embed 7 + * - Vimeo (standard, player embed) → iframe embed 8 + * - Direct video files (.mp4, .webm, .ogv) → <video> element 9 + * - Direct audio files (.mp3, .ogg, .wav, .flac, .aac) → <audio> element 10 + */ 11 + 12 + import type { SlideElement, ElementType } from './canvas-engine.js'; 13 + 14 + // --------------------------------------------------------------------------- 15 + // URL detection 16 + // --------------------------------------------------------------------------- 17 + 18 + const YOUTUBE_HOSTS = ['youtube.com', 'www.youtube.com', 'youtu.be', 'youtube-nocookie.com', 'www.youtube-nocookie.com']; 19 + const VIMEO_HOSTS = ['vimeo.com', 'www.vimeo.com', 'player.vimeo.com']; 20 + 21 + const VIDEO_EXTENSIONS = /\.(mp4|webm|ogv|mov)$/i; 22 + const AUDIO_EXTENSIONS = /\.(mp3|ogg|wav|flac|aac|m4a)$/i; 23 + 24 + /** Allowed data: URL media type prefixes. */ 25 + const SAFE_DATA_PREFIXES = ['data:video/', 'data:audio/', 'data:image/']; 26 + 27 + /** 28 + * Check whether a URL points to YouTube. 29 + */ 30 + export function isYouTubeUrl(url: string): boolean { 31 + try { 32 + const parsed = new URL(url); 33 + return YOUTUBE_HOSTS.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h)); 34 + } catch { 35 + return false; 36 + } 37 + } 38 + 39 + /** 40 + * Check whether a URL points to Vimeo. 41 + */ 42 + export function isVimeoUrl(url: string): boolean { 43 + try { 44 + const parsed = new URL(url); 45 + return VIMEO_HOSTS.some(h => parsed.hostname === h); 46 + } catch { 47 + return false; 48 + } 49 + } 50 + 51 + // --------------------------------------------------------------------------- 52 + // URL conversion 53 + // --------------------------------------------------------------------------- 54 + 55 + /** 56 + * Extract the YouTube video ID from any supported URL shape. 57 + * Returns empty string when no ID can be found. 58 + */ 59 + function extractYouTubeId(url: string): string { 60 + try { 61 + const parsed = new URL(url); 62 + 63 + // youtu.be/<id> 64 + if (parsed.hostname === 'youtu.be') { 65 + return parsed.pathname.slice(1).split('/')[0] ?? ''; 66 + } 67 + 68 + // youtube.com/watch?v=<id> 69 + const v = parsed.searchParams.get('v'); 70 + if (v) return v; 71 + 72 + // youtube.com/embed/<id> or youtube-nocookie.com/embed/<id> 73 + const embedMatch = parsed.pathname.match(/^\/embed\/([^/?]+)/); 74 + if (embedMatch) return embedMatch[1] ?? ''; 75 + 76 + return ''; 77 + } catch { 78 + return ''; 79 + } 80 + } 81 + 82 + /** 83 + * Extract the Vimeo video ID from any supported URL shape. 84 + */ 85 + function extractVimeoId(url: string): string { 86 + try { 87 + const parsed = new URL(url); 88 + // player.vimeo.com/video/<id> 89 + const playerMatch = parsed.pathname.match(/^\/video\/(\d+)/); 90 + if (playerMatch) return playerMatch[1] ?? ''; 91 + 92 + // vimeo.com/<id> 93 + const idMatch = parsed.pathname.match(/^\/(\d+)/); 94 + if (idMatch) return idMatch[1] ?? ''; 95 + 96 + return ''; 97 + } catch { 98 + return ''; 99 + } 100 + } 101 + 102 + /** 103 + * Convert a YouTube or Vimeo watch URL to an embeddable URL. 104 + * Non-embed URLs are returned as-is. 105 + * 106 + * YouTube → youtube-nocookie.com/embed/<id> (privacy-enhanced) 107 + * Vimeo → player.vimeo.com/video/<id> 108 + */ 109 + export function toEmbedUrl(url: string): string { 110 + if (isYouTubeUrl(url)) { 111 + const id = extractYouTubeId(url); 112 + if (id) return `https://www.youtube-nocookie.com/embed/${id}`; 113 + } 114 + 115 + if (isVimeoUrl(url)) { 116 + const id = extractVimeoId(url); 117 + if (id) return `https://player.vimeo.com/video/${id}`; 118 + } 119 + 120 + return url; 121 + } 122 + 123 + // --------------------------------------------------------------------------- 124 + // Sanitization 125 + // --------------------------------------------------------------------------- 126 + 127 + /** 128 + * Sanitize a media URL. Returns the URL if safe, empty string otherwise. 129 + * 130 + * Allowed protocols: https, http, data (with media MIME types only). 131 + * Rejects javascript:, ftp:, data:text/*, and anything else. 132 + */ 133 + export function sanitizeMediaUrl(raw: string): string { 134 + const url = raw.trim(); 135 + if (!url) return ''; 136 + 137 + // data: URLs — only allow media types 138 + if (url.startsWith('data:')) { 139 + const isSafe = SAFE_DATA_PREFIXES.some(p => url.startsWith(p)); 140 + return isSafe ? url : ''; 141 + } 142 + 143 + // Must be http or https 144 + try { 145 + const parsed = new URL(url); 146 + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { 147 + return url; 148 + } 149 + } catch { 150 + // invalid URL 151 + } 152 + 153 + return ''; 154 + } 155 + 156 + // --------------------------------------------------------------------------- 157 + // URL parsing 158 + // --------------------------------------------------------------------------- 159 + 160 + export interface MediaParseResult { 161 + type: 'video' | 'audio' | 'embed'; 162 + embedUrl: string; 163 + } 164 + 165 + /** 166 + * Parse a media URL and determine its type and embeddable form. 167 + * 168 + * - YouTube/Vimeo → { type: 'embed', embedUrl: <iframe src> } 169 + * - .mp4/.webm/.ogv → { type: 'video', embedUrl: <direct url> } 170 + * - .mp3/.ogg/.wav/.flac/.aac → { type: 'audio', embedUrl: <direct url> } 171 + * - Unknown → { type: 'video', embedUrl: <direct url> } 172 + */ 173 + export function parseMediaUrl(url: string): MediaParseResult { 174 + const safe = sanitizeMediaUrl(url); 175 + 176 + // YouTube / Vimeo → embed 177 + if (safe && (isYouTubeUrl(safe) || isVimeoUrl(safe))) { 178 + return { type: 'embed', embedUrl: toEmbedUrl(safe) }; 179 + } 180 + 181 + // Determine file extension from the URL path (strip query string first) 182 + let pathname = ''; 183 + try { 184 + pathname = new URL(safe || 'https://invalid').pathname; 185 + } catch { 186 + // fall through 187 + } 188 + 189 + if (AUDIO_EXTENSIONS.test(pathname)) { 190 + return { type: 'audio', embedUrl: safe }; 191 + } 192 + 193 + if (VIDEO_EXTENSIONS.test(pathname)) { 194 + return { type: 'video', embedUrl: safe }; 195 + } 196 + 197 + // Default to video for unknown media 198 + return { type: 'video', embedUrl: safe }; 199 + } 200 + 201 + // --------------------------------------------------------------------------- 202 + // Element creation 203 + // --------------------------------------------------------------------------- 204 + 205 + let _mediaCounter = 0; 206 + 207 + const DEFAULT_MEDIA_STYLE: Record<string, string> = { 208 + controls: 'true', 209 + autoplay: 'false', 210 + loop: 'false', 211 + muted: 'false', 212 + }; 213 + 214 + /** 215 + * Create a video SlideElement. YouTube/Vimeo URLs are auto-converted to 216 + * embed elements with iframe-friendly URLs. 217 + */ 218 + export function createVideoElement( 219 + url: string, 220 + x: number, 221 + y: number, 222 + width: number, 223 + height: number, 224 + ): SlideElement { 225 + const parsed = parseMediaUrl(url); 226 + const type: ElementType = parsed.type === 'embed' ? 'embed' : 'video'; 227 + 228 + return { 229 + id: `media-${Date.now()}-${++_mediaCounter}`, 230 + type, 231 + x, 232 + y, 233 + width, 234 + height, 235 + rotation: 0, 236 + zIndex: 0, 237 + content: parsed.embedUrl, 238 + style: { ...DEFAULT_MEDIA_STYLE }, 239 + }; 240 + } 241 + 242 + /** 243 + * Create an audio SlideElement. 244 + */ 245 + export function createAudioElement( 246 + url: string, 247 + x: number, 248 + y: number, 249 + width: number, 250 + height: number, 251 + ): SlideElement { 252 + const safe = sanitizeMediaUrl(url); 253 + 254 + return { 255 + id: `media-${Date.now()}-${++_mediaCounter}`, 256 + type: 'audio' as ElementType, 257 + x, 258 + y, 259 + width, 260 + height, 261 + rotation: 0, 262 + zIndex: 0, 263 + content: safe, 264 + style: { ...DEFAULT_MEDIA_STYLE }, 265 + }; 266 + }
+38
src/slides/rendering.ts
··· 253 253 div.appendChild(img); 254 254 } else if (el.type === 'table') { 255 255 div.appendChild(renderTableElement(el.content, el.width, el.height, theme)); 256 + } else if (el.type === 'video') { 257 + const videoUrl = el.content || ''; 258 + const isSafe = /^(https?:|data:video\/|blob:)/i.test(videoUrl) || videoUrl === ''; 259 + const video = document.createElement('video'); 260 + video.src = isSafe ? videoUrl : ''; 261 + video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000;'; 262 + if (el.style.controls === 'true') video.controls = true; 263 + if (el.style.loop === 'true') video.loop = true; 264 + if (el.style.muted === 'true') video.muted = true; 265 + // Don't autoplay in edit mode 266 + video.preload = 'metadata'; 267 + div.appendChild(video); 268 + } else if (el.type === 'audio') { 269 + const audioUrl = el.content || ''; 270 + const isSafe = /^(https?:|data:audio\/|blob:)/i.test(audioUrl) || audioUrl === ''; 271 + const wrapper = document.createElement('div'); 272 + wrapper.className = 'slide-el-audio'; 273 + wrapper.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--color-bg-muted, #f0f0f0);border-radius:8px;'; 274 + const audio = document.createElement('audio'); 275 + audio.src = isSafe ? audioUrl : ''; 276 + audio.style.cssText = 'width:90%;'; 277 + if (el.style.controls === 'true') audio.controls = true; 278 + if (el.style.loop === 'true') audio.loop = true; 279 + if (el.style.muted === 'true') audio.muted = true; 280 + audio.preload = 'metadata'; 281 + wrapper.appendChild(audio); 282 + div.appendChild(wrapper); 283 + } else if (el.type === 'embed') { 284 + const embedUrl = el.content || ''; 285 + const isSafe = /^https?:/i.test(embedUrl); 286 + const iframe = document.createElement('iframe'); 287 + iframe.src = isSafe ? embedUrl : ''; 288 + iframe.style.cssText = 'width:100%;height:100%;border:none;'; 289 + iframe.setAttribute('allowfullscreen', ''); 290 + iframe.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); 291 + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); 292 + iframe.loading = 'lazy'; 293 + div.appendChild(iframe); 256 294 } 257 295 258 296 // Click to select + start drag
+337
tests/collaboration-cursors.test.ts
··· 1 + /** 2 + * Tests for diagrams collaboration cursors. 3 + * VSDD: Red phase — tests define the spec. 4 + * 5 + * Covers: color assignment, cursor state, staleness, throttling, 6 + * SVG rendering, and local cursor exclusion. 7 + */ 8 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 9 + import { 10 + getCursorColor, 11 + CURSOR_PALETTE, 12 + STALE_THRESHOLD_MS, 13 + THROTTLE_INTERVAL_MS, 14 + createCursorState, 15 + updateRemoteCursor, 16 + removeRemoteCursor, 17 + getActiveCursors, 18 + isCursorStale, 19 + buildCursorSvgElements, 20 + createThrottledUpdater, 21 + type CursorState, 22 + type DiagramCursorsState, 23 + } from '../src/diagrams/collaboration-cursors.js'; 24 + 25 + // --------------------------------------------------------------------------- 26 + // Color assignment 27 + // --------------------------------------------------------------------------- 28 + 29 + describe('getCursorColor', () => { 30 + it('is deterministic for the same clientId', () => { 31 + expect(getCursorColor(42)).toBe(getCursorColor(42)); 32 + }); 33 + 34 + it('returns different colors for different clientIds', () => { 35 + const colors = new Set<string>(); 36 + for (let i = 0; i < CURSOR_PALETTE.length; i++) { 37 + colors.add(getCursorColor(i)); 38 + } 39 + expect(colors.size).toBe(CURSOR_PALETTE.length); 40 + }); 41 + 42 + it('covers all palette colors', () => { 43 + const fromPalette = new Set(CURSOR_PALETTE); 44 + const fromFunc = new Set<string>(); 45 + for (let i = 0; i < CURSOR_PALETTE.length; i++) { 46 + fromFunc.add(getCursorColor(i)); 47 + } 48 + expect(fromFunc).toEqual(fromPalette); 49 + }); 50 + 51 + it('wraps around for clientIds larger than palette size', () => { 52 + expect(getCursorColor(CURSOR_PALETTE.length)).toBe(getCursorColor(0)); 53 + expect(getCursorColor(CURSOR_PALETTE.length + 3)).toBe(getCursorColor(3)); 54 + }); 55 + }); 56 + 57 + // --------------------------------------------------------------------------- 58 + // State creation 59 + // --------------------------------------------------------------------------- 60 + 61 + describe('createCursorState', () => { 62 + it('creates state with given local client ID', () => { 63 + const state = createCursorState(99); 64 + expect(state.localClientId).toBe(99); 65 + expect(state.cursors.size).toBe(0); 66 + }); 67 + }); 68 + 69 + // --------------------------------------------------------------------------- 70 + // Cursor state updates 71 + // --------------------------------------------------------------------------- 72 + 73 + describe('updateRemoteCursor', () => { 74 + it('adds a new remote cursor', () => { 75 + let state = createCursorState(1); 76 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 77 + expect(state.cursors.size).toBe(1); 78 + const cursor = state.cursors.get(2)!; 79 + expect(cursor.name).toBe('Alice'); 80 + expect(cursor.x).toBe(100); 81 + expect(cursor.y).toBe(200); 82 + expect(cursor.color).toBe(getCursorColor(2)); 83 + }); 84 + 85 + it('updates an existing cursor position', () => { 86 + let state = createCursorState(1); 87 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 88 + state = updateRemoteCursor(state, 2, 'Alice', 300, 400); 89 + expect(state.cursors.size).toBe(1); 90 + expect(state.cursors.get(2)!.x).toBe(300); 91 + expect(state.cursors.get(2)!.y).toBe(400); 92 + }); 93 + 94 + it('preserves color when updating position', () => { 95 + let state = createCursorState(1); 96 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 97 + const originalColor = state.cursors.get(2)!.color; 98 + state = updateRemoteCursor(state, 2, 'Alice', 300, 400); 99 + expect(state.cursors.get(2)!.color).toBe(originalColor); 100 + }); 101 + 102 + it('ignores local client cursor', () => { 103 + let state = createCursorState(1); 104 + state = updateRemoteCursor(state, 1, 'Me', 100, 200); 105 + expect(state.cursors.size).toBe(0); 106 + }); 107 + 108 + it('updates lastUpdated timestamp', () => { 109 + let state = createCursorState(1); 110 + const before = Date.now(); 111 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 112 + const after = Date.now(); 113 + const cursor = state.cursors.get(2)!; 114 + expect(cursor.lastUpdated).toBeGreaterThanOrEqual(before); 115 + expect(cursor.lastUpdated).toBeLessThanOrEqual(after); 116 + }); 117 + 118 + it('handles multiple remote cursors', () => { 119 + let state = createCursorState(1); 120 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 121 + state = updateRemoteCursor(state, 3, 'Bob', 300, 400); 122 + expect(state.cursors.size).toBe(2); 123 + }); 124 + }); 125 + 126 + // --------------------------------------------------------------------------- 127 + // Cursor removal 128 + // --------------------------------------------------------------------------- 129 + 130 + describe('removeRemoteCursor', () => { 131 + it('removes an existing cursor', () => { 132 + let state = createCursorState(1); 133 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 134 + state = removeRemoteCursor(state, 2); 135 + expect(state.cursors.size).toBe(0); 136 + }); 137 + 138 + it('returns same state if cursor not found', () => { 139 + const state = createCursorState(1); 140 + expect(removeRemoteCursor(state, 999)).toBe(state); 141 + }); 142 + }); 143 + 144 + // --------------------------------------------------------------------------- 145 + // Stale cursor detection 146 + // --------------------------------------------------------------------------- 147 + 148 + describe('isCursorStale', () => { 149 + it('returns false for recent cursor', () => { 150 + const cursor: CursorState = { 151 + clientId: 2, name: 'Alice', color: '#f00', 152 + x: 10, y: 20, lastUpdated: Date.now(), 153 + }; 154 + expect(isCursorStale(cursor)).toBe(false); 155 + }); 156 + 157 + it('returns true for cursor older than threshold', () => { 158 + const cursor: CursorState = { 159 + clientId: 2, name: 'Alice', color: '#f00', 160 + x: 10, y: 20, lastUpdated: Date.now() - STALE_THRESHOLD_MS - 1, 161 + }; 162 + expect(isCursorStale(cursor)).toBe(true); 163 + }); 164 + 165 + it('accepts custom now parameter', () => { 166 + const cursor: CursorState = { 167 + clientId: 2, name: 'Alice', color: '#f00', 168 + x: 10, y: 20, lastUpdated: 1000, 169 + }; 170 + expect(isCursorStale(cursor, 1000 + STALE_THRESHOLD_MS - 1)).toBe(false); 171 + expect(isCursorStale(cursor, 1000 + STALE_THRESHOLD_MS + 1)).toBe(true); 172 + }); 173 + }); 174 + 175 + // --------------------------------------------------------------------------- 176 + // Active cursors (excludes stale) 177 + // --------------------------------------------------------------------------- 178 + 179 + describe('getActiveCursors', () => { 180 + it('returns non-stale cursors', () => { 181 + let state = createCursorState(1); 182 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 183 + state = updateRemoteCursor(state, 3, 'Bob', 300, 400); 184 + const active = getActiveCursors(state); 185 + expect(active).toHaveLength(2); 186 + }); 187 + 188 + it('excludes stale cursors', () => { 189 + let state = createCursorState(1); 190 + state = updateRemoteCursor(state, 2, 'Alice', 100, 200); 191 + // Manually age the cursor 192 + const cursors = new Map(state.cursors); 193 + const aged = { ...cursors.get(2)!, lastUpdated: Date.now() - STALE_THRESHOLD_MS - 1000 }; 194 + cursors.set(2, aged); 195 + state = { ...state, cursors }; 196 + expect(getActiveCursors(state)).toHaveLength(0); 197 + }); 198 + 199 + it('returns empty array for empty state', () => { 200 + const state = createCursorState(1); 201 + expect(getActiveCursors(state)).toHaveLength(0); 202 + }); 203 + }); 204 + 205 + // --------------------------------------------------------------------------- 206 + // SVG element building (pure function, returns element descriptors) 207 + // --------------------------------------------------------------------------- 208 + 209 + describe('buildCursorSvgElements', () => { 210 + it('returns an element for each active remote cursor', () => { 211 + const cursors: CursorState[] = [ 212 + { clientId: 2, name: 'Alice', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() }, 213 + { clientId: 3, name: 'Bob', color: '#3498db', x: 150, y: 160, lastUpdated: Date.now() }, 214 + ]; 215 + const elements = buildCursorSvgElements(cursors, 1); 216 + expect(elements).toHaveLength(2); 217 + }); 218 + 219 + it('excludes local client from rendering', () => { 220 + const cursors: CursorState[] = [ 221 + { clientId: 1, name: 'Me', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() }, 222 + { clientId: 2, name: 'Alice', color: '#3498db', x: 150, y: 160, lastUpdated: Date.now() }, 223 + ]; 224 + const elements = buildCursorSvgElements(cursors, 1); 225 + expect(elements).toHaveLength(1); 226 + expect(elements[0].clientId).toBe(2); 227 + }); 228 + 229 + it('each element has cursor arrow path data', () => { 230 + const cursors: CursorState[] = [ 231 + { clientId: 2, name: 'Alice', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() }, 232 + ]; 233 + const elements = buildCursorSvgElements(cursors, 1); 234 + expect(elements[0].arrowPath).toBeTruthy(); 235 + expect(typeof elements[0].arrowPath).toBe('string'); 236 + }); 237 + 238 + it('each element has a name label', () => { 239 + const cursors: CursorState[] = [ 240 + { clientId: 2, name: 'Alice', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() }, 241 + ]; 242 + const elements = buildCursorSvgElements(cursors, 1); 243 + expect(elements[0].label).toBe('Alice'); 244 + }); 245 + 246 + it('each element has position and color', () => { 247 + const cursors: CursorState[] = [ 248 + { clientId: 2, name: 'Alice', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() }, 249 + ]; 250 + const elements = buildCursorSvgElements(cursors, 1); 251 + expect(elements[0].x).toBe(50); 252 + expect(elements[0].y).toBe(60); 253 + expect(elements[0].color).toBe('#e74c3c'); 254 + }); 255 + 256 + it('marks stale cursors with faded opacity', () => { 257 + const cursors: CursorState[] = [ 258 + { clientId: 2, name: 'Alice', color: '#e74c3c', x: 50, y: 60, lastUpdated: Date.now() - STALE_THRESHOLD_MS - 1 }, 259 + ]; 260 + const elements = buildCursorSvgElements(cursors, 1); 261 + expect(elements).toHaveLength(1); 262 + expect(elements[0].opacity).toBeLessThan(1); 263 + }); 264 + 265 + it('returns empty array for empty input', () => { 266 + expect(buildCursorSvgElements([], 1)).toHaveLength(0); 267 + }); 268 + }); 269 + 270 + // --------------------------------------------------------------------------- 271 + // Throttle 272 + // --------------------------------------------------------------------------- 273 + 274 + describe('createThrottledUpdater', () => { 275 + beforeEach(() => { 276 + vi.useFakeTimers(); 277 + }); 278 + 279 + afterEach(() => { 280 + vi.useRealTimers(); 281 + }); 282 + 283 + it('calls handler immediately on first invocation', () => { 284 + const handler = vi.fn(); 285 + const throttled = createThrottledUpdater(handler); 286 + throttled(10, 20); 287 + expect(handler).toHaveBeenCalledTimes(1); 288 + expect(handler).toHaveBeenCalledWith(10, 20); 289 + }); 290 + 291 + it('suppresses rapid calls within the throttle window', () => { 292 + const handler = vi.fn(); 293 + const throttled = createThrottledUpdater(handler); 294 + throttled(10, 20); 295 + throttled(30, 40); 296 + throttled(50, 60); 297 + expect(handler).toHaveBeenCalledTimes(1); 298 + }); 299 + 300 + it('sends the latest position after the throttle window', () => { 301 + const handler = vi.fn(); 302 + const throttled = createThrottledUpdater(handler); 303 + throttled(10, 20); 304 + throttled(30, 40); 305 + throttled(50, 60); 306 + vi.advanceTimersByTime(THROTTLE_INTERVAL_MS); 307 + expect(handler).toHaveBeenCalledTimes(2); 308 + expect(handler).toHaveBeenLastCalledWith(50, 60); 309 + }); 310 + 311 + it('allows new calls after throttle window passes', () => { 312 + const handler = vi.fn(); 313 + const throttled = createThrottledUpdater(handler); 314 + throttled(10, 20); 315 + vi.advanceTimersByTime(THROTTLE_INTERVAL_MS + 1); 316 + throttled(30, 40); 317 + expect(handler).toHaveBeenCalledTimes(2); 318 + }); 319 + }); 320 + 321 + // --------------------------------------------------------------------------- 322 + // Constants 323 + // --------------------------------------------------------------------------- 324 + 325 + describe('constants', () => { 326 + it('stale threshold is 10 seconds', () => { 327 + expect(STALE_THRESHOLD_MS).toBe(10_000); 328 + }); 329 + 330 + it('throttle interval is 50ms (20 updates/sec)', () => { 331 + expect(THROTTLE_INTERVAL_MS).toBe(50); 332 + }); 333 + 334 + it('palette has exactly 8 colors', () => { 335 + expect(CURSOR_PALETTE).toHaveLength(8); 336 + }); 337 + });
+215
tests/file-upload.test.ts
··· 1 + /** 2 + * Tests for file upload support in the forms module. 3 + * 4 + * Covers: 5 + * - File upload constants and validation utilities 6 + * - File upload question type in form builder 7 + * - Validation of file_upload answers (blob references) 8 + * - Form submission with file upload questions 9 + */ 10 + 11 + import { describe, it, expect } from 'vitest'; 12 + import { 13 + createForm, 14 + addQuestion, 15 + validateAnswer, 16 + validateSubmission, 17 + type Question, 18 + } from '../src/forms/form-builder.js'; 19 + import { 20 + ALLOWED_UPLOAD_TYPES, 21 + MAX_UPLOAD_SIZE, 22 + isAllowedUploadType, 23 + formatUploadSize, 24 + validateFileUpload, 25 + } from '../src/forms/file-upload.js'; 26 + 27 + // --- ALLOWED_UPLOAD_TYPES --- 28 + 29 + describe('ALLOWED_UPLOAD_TYPES', () => { 30 + it('includes common image types', () => { 31 + expect(ALLOWED_UPLOAD_TYPES).toContain('image/png'); 32 + expect(ALLOWED_UPLOAD_TYPES).toContain('image/jpeg'); 33 + expect(ALLOWED_UPLOAD_TYPES).toContain('image/gif'); 34 + expect(ALLOWED_UPLOAD_TYPES).toContain('image/webp'); 35 + }); 36 + 37 + it('includes PDF', () => { 38 + expect(ALLOWED_UPLOAD_TYPES).toContain('application/pdf'); 39 + }); 40 + 41 + it('includes common document types', () => { 42 + expect(ALLOWED_UPLOAD_TYPES).toContain('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); 43 + expect(ALLOWED_UPLOAD_TYPES).toContain('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); 44 + expect(ALLOWED_UPLOAD_TYPES).toContain('text/plain'); 45 + expect(ALLOWED_UPLOAD_TYPES).toContain('text/csv'); 46 + }); 47 + }); 48 + 49 + // --- MAX_UPLOAD_SIZE --- 50 + 51 + describe('MAX_UPLOAD_SIZE', () => { 52 + it('is 10MB', () => { 53 + expect(MAX_UPLOAD_SIZE).toBe(10 * 1024 * 1024); 54 + }); 55 + }); 56 + 57 + // --- isAllowedUploadType --- 58 + 59 + describe('isAllowedUploadType', () => { 60 + it('accepts image/png', () => { 61 + expect(isAllowedUploadType('image/png')).toBe(true); 62 + }); 63 + 64 + it('accepts application/pdf', () => { 65 + expect(isAllowedUploadType('application/pdf')).toBe(true); 66 + }); 67 + 68 + it('rejects application/javascript', () => { 69 + expect(isAllowedUploadType('application/javascript')).toBe(false); 70 + }); 71 + 72 + it('rejects application/x-executable', () => { 73 + expect(isAllowedUploadType('application/x-executable')).toBe(false); 74 + }); 75 + 76 + it('rejects empty string', () => { 77 + expect(isAllowedUploadType('')).toBe(false); 78 + }); 79 + 80 + it('rejects text/html', () => { 81 + expect(isAllowedUploadType('text/html')).toBe(false); 82 + }); 83 + }); 84 + 85 + // --- formatUploadSize --- 86 + 87 + describe('formatUploadSize', () => { 88 + it('formats bytes', () => { 89 + expect(formatUploadSize(500)).toBe('500 B'); 90 + }); 91 + 92 + it('formats kilobytes', () => { 93 + expect(formatUploadSize(2048)).toBe('2.0 KB'); 94 + }); 95 + 96 + it('formats megabytes', () => { 97 + expect(formatUploadSize(5 * 1024 * 1024)).toBe('5.0 MB'); 98 + }); 99 + }); 100 + 101 + // --- validateFileUpload --- 102 + 103 + describe('validateFileUpload', () => { 104 + it('accepts valid file within size and type limits', () => { 105 + expect(validateFileUpload(1024, 'image/png')).toBeNull(); 106 + }); 107 + 108 + it('rejects file exceeding max size', () => { 109 + const result = validateFileUpload(11 * 1024 * 1024, 'image/png'); 110 + expect(result).not.toBeNull(); 111 + expect(result).toContain('10'); 112 + }); 113 + 114 + it('rejects disallowed MIME type', () => { 115 + const result = validateFileUpload(1024, 'application/javascript'); 116 + expect(result).not.toBeNull(); 117 + expect(result).toContain('type'); 118 + }); 119 + 120 + it('accepts file at exactly max size', () => { 121 + expect(validateFileUpload(MAX_UPLOAD_SIZE, 'image/png')).toBeNull(); 122 + }); 123 + 124 + it('rejects file one byte over max size', () => { 125 + const result = validateFileUpload(MAX_UPLOAD_SIZE + 1, 'image/png'); 126 + expect(result).not.toBeNull(); 127 + }); 128 + 129 + it('rejects zero-byte file', () => { 130 + const result = validateFileUpload(0, 'image/png'); 131 + expect(result).not.toBeNull(); 132 + expect(result).toContain('empty'); 133 + }); 134 + }); 135 + 136 + // --- file_upload question in form builder --- 137 + 138 + describe('file_upload question type', () => { 139 + it('can be added to a form', () => { 140 + let form = createForm('Test'); 141 + form = addQuestion(form, 'file_upload', 'Upload your resume'); 142 + expect(form.questions).toHaveLength(1); 143 + expect(form.questions[0]!.type).toBe('file_upload'); 144 + expect(form.questions[0]!.label).toBe('Upload your resume'); 145 + }); 146 + 147 + it('can be required', () => { 148 + let form = createForm('Test'); 149 + form = addQuestion(form, 'file_upload', 'ID Photo', { required: true }); 150 + expect(form.questions[0]!.required).toBe(true); 151 + }); 152 + 153 + it('validates required file_upload rejects empty answer', () => { 154 + const q: Question = { 155 + id: 'q-file', 156 + type: 'file_upload', 157 + label: 'File', 158 + description: '', 159 + required: true, 160 + options: [], 161 + }; 162 + expect(validateAnswer(q, '')).toBe('This field is required'); 163 + expect(validateAnswer(q, null)).toBe('This field is required'); 164 + expect(validateAnswer(q, undefined)).toBe('This field is required'); 165 + }); 166 + 167 + it('validates required file_upload accepts blob reference', () => { 168 + const q: Question = { 169 + id: 'q-file', 170 + type: 'file_upload', 171 + label: 'File', 172 + description: '', 173 + required: true, 174 + options: [], 175 + }; 176 + expect(validateAnswer(q, 'blob-abc-123')).toBeNull(); 177 + }); 178 + 179 + it('validates non-required file_upload accepts empty', () => { 180 + const q: Question = { 181 + id: 'q-file', 182 + type: 'file_upload', 183 + label: 'File', 184 + description: '', 185 + required: false, 186 + options: [], 187 + }; 188 + expect(validateAnswer(q, '')).toBeNull(); 189 + expect(validateAnswer(q, null)).toBeNull(); 190 + }); 191 + 192 + it('file_upload works in full form submission validation', () => { 193 + let form = createForm('Job Application'); 194 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 195 + form = addQuestion(form, 'file_upload', 'Resume', { required: true }); 196 + 197 + const answers = new Map<string, unknown>([ 198 + [form.questions[0]!.id, 'Alice'], 199 + [form.questions[1]!.id, 'blob-resume-123'], 200 + ]); 201 + 202 + const errors = validateSubmission(form, answers); 203 + expect(errors.size).toBe(0); 204 + }); 205 + 206 + it('file_upload submission fails when required and missing', () => { 207 + let form = createForm('Job Application'); 208 + form = addQuestion(form, 'file_upload', 'Resume', { required: true }); 209 + 210 + const answers = new Map<string, unknown>(); 211 + const errors = validateSubmission(form, answers); 212 + expect(errors.size).toBe(1); 213 + expect(errors.get(form.questions[0]!.id)).toBe('This field is required'); 214 + }); 215 + });
+339
tests/guest-management.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + validateEmail, 4 + parseGuestInput, 5 + addGuest, 6 + removeGuest, 7 + updateRsvp, 8 + getGuestCount, 9 + type Guest, 10 + } from '../src/calendar/guest-management.js'; 11 + 12 + // --------------------------------------------------------------------------- 13 + // validateEmail 14 + // --------------------------------------------------------------------------- 15 + 16 + describe('validateEmail', () => { 17 + it('accepts a standard email', () => { 18 + expect(validateEmail('alice@example.com')).toBe(true); 19 + }); 20 + 21 + it('accepts emails with subdomains', () => { 22 + expect(validateEmail('bob@mail.example.co.uk')).toBe(true); 23 + }); 24 + 25 + it('accepts emails with dots in local part', () => { 26 + expect(validateEmail('first.last@example.com')).toBe(true); 27 + }); 28 + 29 + it('accepts emails with plus addressing', () => { 30 + expect(validateEmail('user+tag@example.com')).toBe(true); 31 + }); 32 + 33 + it('rejects empty string', () => { 34 + expect(validateEmail('')).toBe(false); 35 + }); 36 + 37 + it('rejects whitespace-only string', () => { 38 + expect(validateEmail(' ')).toBe(false); 39 + }); 40 + 41 + it('rejects string without @', () => { 42 + expect(validateEmail('notanemail')).toBe(false); 43 + }); 44 + 45 + it('rejects string with multiple @', () => { 46 + expect(validateEmail('a@b@c.com')).toBe(false); 47 + }); 48 + 49 + it('rejects missing local part', () => { 50 + expect(validateEmail('@example.com')).toBe(false); 51 + }); 52 + 53 + it('rejects missing domain', () => { 54 + expect(validateEmail('user@')).toBe(false); 55 + }); 56 + 57 + it('rejects domain without dot', () => { 58 + expect(validateEmail('user@localhost')).toBe(false); 59 + }); 60 + 61 + it('rejects domain ending with dot', () => { 62 + expect(validateEmail('user@example.')).toBe(false); 63 + }); 64 + 65 + it('rejects domain starting with dot', () => { 66 + expect(validateEmail('user@.example.com')).toBe(false); 67 + }); 68 + 69 + it('trims whitespace before validating', () => { 70 + expect(validateEmail(' alice@example.com ')).toBe(true); 71 + }); 72 + }); 73 + 74 + // --------------------------------------------------------------------------- 75 + // parseGuestInput 76 + // --------------------------------------------------------------------------- 77 + 78 + describe('parseGuestInput', () => { 79 + it('parses comma-separated emails', () => { 80 + expect(parseGuestInput('a@b.com, c@d.com')).toEqual(['a@b.com', 'c@d.com']); 81 + }); 82 + 83 + it('parses semicolon-separated emails', () => { 84 + expect(parseGuestInput('a@b.com; c@d.com')).toEqual(['a@b.com', 'c@d.com']); 85 + }); 86 + 87 + it('handles mixed separators', () => { 88 + expect(parseGuestInput('a@b.com, c@d.com; e@f.com')).toEqual(['a@b.com', 'c@d.com', 'e@f.com']); 89 + }); 90 + 91 + it('trims whitespace around emails', () => { 92 + expect(parseGuestInput(' a@b.com , c@d.com ')).toEqual(['a@b.com', 'c@d.com']); 93 + }); 94 + 95 + it('filters empty entries from consecutive separators', () => { 96 + expect(parseGuestInput('a@b.com,,c@d.com')).toEqual(['a@b.com', 'c@d.com']); 97 + }); 98 + 99 + it('lowercases emails', () => { 100 + expect(parseGuestInput('Alice@Example.COM')).toEqual(['alice@example.com']); 101 + }); 102 + 103 + it('returns empty array for empty string', () => { 104 + expect(parseGuestInput('')).toEqual([]); 105 + }); 106 + 107 + it('returns empty array for whitespace-only', () => { 108 + expect(parseGuestInput(' ')).toEqual([]); 109 + }); 110 + 111 + it('handles single email without separator', () => { 112 + expect(parseGuestInput('solo@example.com')).toEqual(['solo@example.com']); 113 + }); 114 + 115 + it('handles trailing separator', () => { 116 + expect(parseGuestInput('a@b.com,')).toEqual(['a@b.com']); 117 + }); 118 + }); 119 + 120 + // --------------------------------------------------------------------------- 121 + // addGuest 122 + // --------------------------------------------------------------------------- 123 + 124 + describe('addGuest', () => { 125 + it('adds a guest with pending status', () => { 126 + const result = addGuest([], 'alice@example.com'); 127 + expect(result).not.toBeNull(); 128 + expect(result).toHaveLength(1); 129 + expect(result![0]).toEqual({ 130 + email: 'alice@example.com', 131 + name: undefined, 132 + status: 'pending', 133 + }); 134 + }); 135 + 136 + it('adds a guest with a name', () => { 137 + const result = addGuest([], 'alice@example.com', 'Alice'); 138 + expect(result).not.toBeNull(); 139 + expect(result![0]!.name).toBe('Alice'); 140 + }); 141 + 142 + it('normalizes email to lowercase', () => { 143 + const result = addGuest([], 'Alice@Example.COM'); 144 + expect(result).not.toBeNull(); 145 + expect(result![0]!.email).toBe('alice@example.com'); 146 + }); 147 + 148 + it('trims name whitespace', () => { 149 + const result = addGuest([], 'alice@example.com', ' Alice '); 150 + expect(result).not.toBeNull(); 151 + expect(result![0]!.name).toBe('Alice'); 152 + }); 153 + 154 + it('sets name to undefined when empty string provided', () => { 155 + const result = addGuest([], 'alice@example.com', ''); 156 + expect(result).not.toBeNull(); 157 + expect(result![0]!.name).toBeUndefined(); 158 + }); 159 + 160 + it('returns null for invalid email', () => { 161 + expect(addGuest([], 'not-an-email')).toBeNull(); 162 + }); 163 + 164 + it('returns null for empty email', () => { 165 + expect(addGuest([], '')).toBeNull(); 166 + }); 167 + 168 + it('prevents duplicate emails (case-insensitive)', () => { 169 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 170 + const result = addGuest(guests, 'Alice@Example.COM'); 171 + expect(result).toBe(guests); // same reference = no change 172 + }); 173 + 174 + it('preserves existing guests when adding new one', () => { 175 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'accepted' }]; 176 + const result = addGuest(guests, 'bob@example.com'); 177 + expect(result).not.toBeNull(); 178 + expect(result).toHaveLength(2); 179 + expect(result![0]).toEqual(guests[0]); // original unchanged 180 + expect(result![1]!.email).toBe('bob@example.com'); 181 + }); 182 + 183 + it('does not mutate the original array', () => { 184 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 185 + const result = addGuest(guests, 'bob@example.com'); 186 + expect(guests).toHaveLength(1); 187 + expect(result).toHaveLength(2); 188 + }); 189 + }); 190 + 191 + // --------------------------------------------------------------------------- 192 + // removeGuest 193 + // --------------------------------------------------------------------------- 194 + 195 + describe('removeGuest', () => { 196 + it('removes a guest by email', () => { 197 + const guests: Guest[] = [ 198 + { email: 'alice@example.com', status: 'accepted' }, 199 + { email: 'bob@example.com', status: 'pending' }, 200 + ]; 201 + const result = removeGuest(guests, 'alice@example.com'); 202 + expect(result).toHaveLength(1); 203 + expect(result[0]!.email).toBe('bob@example.com'); 204 + }); 205 + 206 + it('removes case-insensitively', () => { 207 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 208 + const result = removeGuest(guests, 'Alice@Example.COM'); 209 + expect(result).toHaveLength(0); 210 + }); 211 + 212 + it('returns same-length array when email not found', () => { 213 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 214 + const result = removeGuest(guests, 'bob@example.com'); 215 + expect(result).toHaveLength(1); 216 + }); 217 + 218 + it('handles empty guest list', () => { 219 + expect(removeGuest([], 'alice@example.com')).toEqual([]); 220 + }); 221 + 222 + it('does not mutate the original array', () => { 223 + const guests: Guest[] = [ 224 + { email: 'alice@example.com', status: 'pending' }, 225 + { email: 'bob@example.com', status: 'pending' }, 226 + ]; 227 + removeGuest(guests, 'alice@example.com'); 228 + expect(guests).toHaveLength(2); 229 + }); 230 + }); 231 + 232 + // --------------------------------------------------------------------------- 233 + // updateRsvp 234 + // --------------------------------------------------------------------------- 235 + 236 + describe('updateRsvp', () => { 237 + it('updates status for a matching guest', () => { 238 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 239 + const result = updateRsvp(guests, 'alice@example.com', 'accepted'); 240 + expect(result[0]!.status).toBe('accepted'); 241 + }); 242 + 243 + it('preserves other guest fields', () => { 244 + const guests: Guest[] = [{ email: 'alice@example.com', name: 'Alice', status: 'pending' }]; 245 + const result = updateRsvp(guests, 'alice@example.com', 'declined'); 246 + expect(result[0]!.name).toBe('Alice'); 247 + expect(result[0]!.email).toBe('alice@example.com'); 248 + }); 249 + 250 + it('matches case-insensitively', () => { 251 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 252 + const result = updateRsvp(guests, 'ALICE@Example.COM', 'tentative'); 253 + expect(result[0]!.status).toBe('tentative'); 254 + }); 255 + 256 + it('returns original array reference when guest not found', () => { 257 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 258 + const result = updateRsvp(guests, 'bob@example.com', 'accepted'); 259 + expect(result).toBe(guests); 260 + }); 261 + 262 + it('only updates the targeted guest', () => { 263 + const guests: Guest[] = [ 264 + { email: 'alice@example.com', status: 'pending' }, 265 + { email: 'bob@example.com', status: 'pending' }, 266 + ]; 267 + const result = updateRsvp(guests, 'alice@example.com', 'accepted'); 268 + expect(result[0]!.status).toBe('accepted'); 269 + expect(result[1]!.status).toBe('pending'); 270 + }); 271 + 272 + it('does not mutate the original array', () => { 273 + const guests: Guest[] = [{ email: 'alice@example.com', status: 'pending' }]; 274 + updateRsvp(guests, 'alice@example.com', 'accepted'); 275 + expect(guests[0]!.status).toBe('pending'); 276 + }); 277 + 278 + it('supports all RSVP statuses', () => { 279 + const statuses = ['pending', 'accepted', 'declined', 'tentative'] as const; 280 + for (const status of statuses) { 281 + const guests: Guest[] = [{ email: 'a@b.com', status: 'pending' }]; 282 + const result = updateRsvp(guests, 'a@b.com', status); 283 + expect(result[0]!.status).toBe(status); 284 + } 285 + }); 286 + }); 287 + 288 + // --------------------------------------------------------------------------- 289 + // getGuestCount 290 + // --------------------------------------------------------------------------- 291 + 292 + describe('getGuestCount', () => { 293 + it('returns all zeros for empty list', () => { 294 + expect(getGuestCount([])).toEqual({ 295 + total: 0, 296 + pending: 0, 297 + accepted: 0, 298 + declined: 0, 299 + tentative: 0, 300 + }); 301 + }); 302 + 303 + it('counts total guests', () => { 304 + const guests: Guest[] = [ 305 + { email: 'a@b.com', status: 'pending' }, 306 + { email: 'c@d.com', status: 'accepted' }, 307 + { email: 'e@f.com', status: 'declined' }, 308 + ]; 309 + expect(getGuestCount(guests).total).toBe(3); 310 + }); 311 + 312 + it('counts by status', () => { 313 + const guests: Guest[] = [ 314 + { email: 'a@b.com', status: 'pending' }, 315 + { email: 'b@b.com', status: 'pending' }, 316 + { email: 'c@b.com', status: 'accepted' }, 317 + { email: 'd@b.com', status: 'declined' }, 318 + { email: 'e@b.com', status: 'tentative' }, 319 + ]; 320 + const counts = getGuestCount(guests); 321 + expect(counts.pending).toBe(2); 322 + expect(counts.accepted).toBe(1); 323 + expect(counts.declined).toBe(1); 324 + expect(counts.tentative).toBe(1); 325 + }); 326 + 327 + it('handles all guests with same status', () => { 328 + const guests: Guest[] = [ 329 + { email: 'a@b.com', status: 'accepted' }, 330 + { email: 'c@d.com', status: 'accepted' }, 331 + ]; 332 + const counts = getGuestCount(guests); 333 + expect(counts.total).toBe(2); 334 + expect(counts.accepted).toBe(2); 335 + expect(counts.pending).toBe(0); 336 + expect(counts.declined).toBe(0); 337 + expect(counts.tentative).toBe(0); 338 + }); 339 + });
+322
tests/media-elements.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseMediaUrl, 4 + isYouTubeUrl, 5 + isVimeoUrl, 6 + toEmbedUrl, 7 + createVideoElement, 8 + createAudioElement, 9 + sanitizeMediaUrl, 10 + } from '../src/slides/media-elements'; 11 + 12 + describe('media-elements', () => { 13 + describe('isYouTubeUrl', () => { 14 + it('detects standard youtube.com/watch URLs', () => { 15 + expect(isYouTubeUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true); 16 + }); 17 + 18 + it('detects youtu.be short URLs', () => { 19 + expect(isYouTubeUrl('https://youtu.be/dQw4w9WgXcQ')).toBe(true); 20 + }); 21 + 22 + it('detects youtube.com/embed URLs', () => { 23 + expect(isYouTubeUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')).toBe(true); 24 + }); 25 + 26 + it('detects youtube-nocookie.com URLs', () => { 27 + expect(isYouTubeUrl('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')).toBe(true); 28 + }); 29 + 30 + it('rejects non-YouTube URLs', () => { 31 + expect(isYouTubeUrl('https://example.com/watch?v=abc')).toBe(false); 32 + expect(isYouTubeUrl('https://vimeo.com/123456')).toBe(false); 33 + }); 34 + 35 + it('rejects empty string', () => { 36 + expect(isYouTubeUrl('')).toBe(false); 37 + }); 38 + 39 + it('rejects malformed URLs', () => { 40 + expect(isYouTubeUrl('not-a-url')).toBe(false); 41 + }); 42 + }); 43 + 44 + describe('isVimeoUrl', () => { 45 + it('detects standard vimeo.com URLs', () => { 46 + expect(isVimeoUrl('https://vimeo.com/123456789')).toBe(true); 47 + }); 48 + 49 + it('detects player.vimeo.com embed URLs', () => { 50 + expect(isVimeoUrl('https://player.vimeo.com/video/123456789')).toBe(true); 51 + }); 52 + 53 + it('rejects non-Vimeo URLs', () => { 54 + expect(isVimeoUrl('https://youtube.com/watch?v=abc')).toBe(false); 55 + expect(isVimeoUrl('https://example.com/vimeo/123')).toBe(false); 56 + }); 57 + 58 + it('rejects empty string', () => { 59 + expect(isVimeoUrl('')).toBe(false); 60 + }); 61 + }); 62 + 63 + describe('toEmbedUrl', () => { 64 + it('converts YouTube watch URL to embed URL', () => { 65 + expect(toEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')) 66 + .toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 67 + }); 68 + 69 + it('converts YouTube short URL to embed URL', () => { 70 + expect(toEmbedUrl('https://youtu.be/dQw4w9WgXcQ')) 71 + .toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 72 + }); 73 + 74 + it('preserves YouTube embed URL (normalizes to nocookie)', () => { 75 + expect(toEmbedUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')) 76 + .toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 77 + }); 78 + 79 + it('preserves youtube-nocookie embed URL as-is', () => { 80 + expect(toEmbedUrl('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')) 81 + .toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 82 + }); 83 + 84 + it('converts Vimeo URL to player embed URL', () => { 85 + expect(toEmbedUrl('https://vimeo.com/123456789')) 86 + .toBe('https://player.vimeo.com/video/123456789'); 87 + }); 88 + 89 + it('preserves Vimeo player embed URL as-is', () => { 90 + expect(toEmbedUrl('https://player.vimeo.com/video/123456789')) 91 + .toBe('https://player.vimeo.com/video/123456789'); 92 + }); 93 + 94 + it('returns the original URL for non-embeddable URLs', () => { 95 + expect(toEmbedUrl('https://example.com/video.mp4')) 96 + .toBe('https://example.com/video.mp4'); 97 + }); 98 + 99 + it('handles YouTube URLs with extra query params', () => { 100 + expect(toEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30')) 101 + .toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 102 + }); 103 + }); 104 + 105 + describe('sanitizeMediaUrl', () => { 106 + it('allows https URLs', () => { 107 + expect(sanitizeMediaUrl('https://example.com/video.mp4')).toBe('https://example.com/video.mp4'); 108 + }); 109 + 110 + it('allows http URLs', () => { 111 + expect(sanitizeMediaUrl('http://example.com/video.mp4')).toBe('http://example.com/video.mp4'); 112 + }); 113 + 114 + it('allows data URLs with media types', () => { 115 + expect(sanitizeMediaUrl('data:video/mp4;base64,abc123')).toBe('data:video/mp4;base64,abc123'); 116 + }); 117 + 118 + it('rejects javascript: protocol', () => { 119 + expect(sanitizeMediaUrl('javascript:alert(1)')).toBe(''); 120 + }); 121 + 122 + it('rejects data URLs with script content type', () => { 123 + expect(sanitizeMediaUrl('data:text/html,<script>alert(1)</script>')).toBe(''); 124 + }); 125 + 126 + it('rejects empty string', () => { 127 + expect(sanitizeMediaUrl('')).toBe(''); 128 + }); 129 + 130 + it('rejects ftp URLs', () => { 131 + expect(sanitizeMediaUrl('ftp://example.com/video.mp4')).toBe(''); 132 + }); 133 + 134 + it('trims whitespace', () => { 135 + expect(sanitizeMediaUrl(' https://example.com/video.mp4 ')).toBe('https://example.com/video.mp4'); 136 + }); 137 + }); 138 + 139 + describe('parseMediaUrl', () => { 140 + it('identifies YouTube URLs as embed type', () => { 141 + const result = parseMediaUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); 142 + expect(result.type).toBe('embed'); 143 + expect(result.embedUrl).toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 144 + }); 145 + 146 + it('identifies Vimeo URLs as embed type', () => { 147 + const result = parseMediaUrl('https://vimeo.com/123456789'); 148 + expect(result.type).toBe('embed'); 149 + expect(result.embedUrl).toBe('https://player.vimeo.com/video/123456789'); 150 + }); 151 + 152 + it('identifies .mp4 URLs as video type', () => { 153 + const result = parseMediaUrl('https://example.com/clip.mp4'); 154 + expect(result.type).toBe('video'); 155 + expect(result.embedUrl).toBe('https://example.com/clip.mp4'); 156 + }); 157 + 158 + it('identifies .webm URLs as video type', () => { 159 + const result = parseMediaUrl('https://example.com/clip.webm'); 160 + expect(result.type).toBe('video'); 161 + expect(result.embedUrl).toBe('https://example.com/clip.webm'); 162 + }); 163 + 164 + it('identifies .ogv URLs as video type', () => { 165 + const result = parseMediaUrl('https://example.com/clip.ogv'); 166 + expect(result.type).toBe('video'); 167 + expect(result.embedUrl).toBe('https://example.com/clip.ogv'); 168 + }); 169 + 170 + it('identifies .mp3 URLs as audio type', () => { 171 + const result = parseMediaUrl('https://example.com/song.mp3'); 172 + expect(result.type).toBe('audio'); 173 + expect(result.embedUrl).toBe('https://example.com/song.mp3'); 174 + }); 175 + 176 + it('identifies .ogg URLs as audio type', () => { 177 + const result = parseMediaUrl('https://example.com/song.ogg'); 178 + expect(result.type).toBe('audio'); 179 + expect(result.embedUrl).toBe('https://example.com/song.ogg'); 180 + }); 181 + 182 + it('identifies .wav URLs as audio type', () => { 183 + const result = parseMediaUrl('https://example.com/clip.wav'); 184 + expect(result.type).toBe('audio'); 185 + expect(result.embedUrl).toBe('https://example.com/clip.wav'); 186 + }); 187 + 188 + it('identifies .flac URLs as audio type', () => { 189 + const result = parseMediaUrl('https://example.com/song.flac'); 190 + expect(result.type).toBe('audio'); 191 + expect(result.embedUrl).toBe('https://example.com/song.flac'); 192 + }); 193 + 194 + it('identifies .aac URLs as audio type', () => { 195 + const result = parseMediaUrl('https://example.com/song.aac'); 196 + expect(result.type).toBe('audio'); 197 + expect(result.embedUrl).toBe('https://example.com/song.aac'); 198 + }); 199 + 200 + it('defaults unknown URLs to video type', () => { 201 + const result = parseMediaUrl('https://example.com/media'); 202 + expect(result.type).toBe('video'); 203 + expect(result.embedUrl).toBe('https://example.com/media'); 204 + }); 205 + 206 + it('sanitizes unsafe URLs', () => { 207 + const result = parseMediaUrl('javascript:alert(1)'); 208 + expect(result.embedUrl).toBe(''); 209 + }); 210 + 211 + it('handles URLs with query strings', () => { 212 + const result = parseMediaUrl('https://example.com/clip.mp4?token=abc'); 213 + expect(result.type).toBe('video'); 214 + expect(result.embedUrl).toBe('https://example.com/clip.mp4?token=abc'); 215 + }); 216 + 217 + it('handles case-insensitive extensions', () => { 218 + const result = parseMediaUrl('https://example.com/clip.MP4'); 219 + expect(result.type).toBe('video'); 220 + }); 221 + }); 222 + 223 + describe('createVideoElement', () => { 224 + it('creates a video element with correct type', () => { 225 + const el = createVideoElement('https://example.com/clip.mp4', 100, 200, 640, 360); 226 + expect(el.type).toBe('video'); 227 + }); 228 + 229 + it('stores the URL as content', () => { 230 + const el = createVideoElement('https://example.com/clip.mp4', 100, 200, 640, 360); 231 + expect(el.content).toBe('https://example.com/clip.mp4'); 232 + }); 233 + 234 + it('sets position and size correctly', () => { 235 + const el = createVideoElement('https://example.com/clip.mp4', 50, 75, 640, 360); 236 + expect(el.x).toBe(50); 237 + expect(el.y).toBe(75); 238 + expect(el.width).toBe(640); 239 + expect(el.height).toBe(360); 240 + }); 241 + 242 + it('sets default style with controls enabled', () => { 243 + const el = createVideoElement('https://example.com/clip.mp4', 0, 0, 640, 360); 244 + expect(el.style.controls).toBe('true'); 245 + expect(el.style.autoplay).toBe('false'); 246 + expect(el.style.loop).toBe('false'); 247 + expect(el.style.muted).toBe('false'); 248 + }); 249 + 250 + it('generates a unique ID', () => { 251 + const a = createVideoElement('https://example.com/a.mp4', 0, 0, 640, 360); 252 + const b = createVideoElement('https://example.com/b.mp4', 0, 0, 640, 360); 253 + expect(a.id).not.toBe(b.id); 254 + }); 255 + 256 + it('has rotation of 0', () => { 257 + const el = createVideoElement('https://example.com/clip.mp4', 0, 0, 640, 360); 258 + expect(el.rotation).toBe(0); 259 + }); 260 + 261 + it('converts YouTube URLs to embed type', () => { 262 + const el = createVideoElement('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 0, 0, 640, 360); 263 + expect(el.type).toBe('embed'); 264 + expect(el.content).toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'); 265 + }); 266 + 267 + it('converts Vimeo URLs to embed type', () => { 268 + const el = createVideoElement('https://vimeo.com/123456789', 0, 0, 640, 360); 269 + expect(el.type).toBe('embed'); 270 + expect(el.content).toBe('https://player.vimeo.com/video/123456789'); 271 + }); 272 + 273 + it('sanitizes dangerous URLs', () => { 274 + const el = createVideoElement('javascript:alert(1)', 0, 0, 640, 360); 275 + expect(el.content).toBe(''); 276 + }); 277 + }); 278 + 279 + describe('createAudioElement', () => { 280 + it('creates an audio element with correct type', () => { 281 + const el = createAudioElement('https://example.com/song.mp3', 100, 200, 300, 60); 282 + expect(el.type).toBe('audio'); 283 + }); 284 + 285 + it('stores the URL as content', () => { 286 + const el = createAudioElement('https://example.com/song.mp3', 100, 200, 300, 60); 287 + expect(el.content).toBe('https://example.com/song.mp3'); 288 + }); 289 + 290 + it('sets position and size correctly', () => { 291 + const el = createAudioElement('https://example.com/song.mp3', 50, 75, 400, 80); 292 + expect(el.x).toBe(50); 293 + expect(el.y).toBe(75); 294 + expect(el.width).toBe(400); 295 + expect(el.height).toBe(80); 296 + }); 297 + 298 + it('sets default style with controls enabled', () => { 299 + const el = createAudioElement('https://example.com/song.mp3', 0, 0, 300, 60); 300 + expect(el.style.controls).toBe('true'); 301 + expect(el.style.autoplay).toBe('false'); 302 + expect(el.style.loop).toBe('false'); 303 + expect(el.style.muted).toBe('false'); 304 + }); 305 + 306 + it('generates a unique ID', () => { 307 + const a = createAudioElement('https://example.com/a.mp3', 0, 0, 300, 60); 308 + const b = createAudioElement('https://example.com/b.mp3', 0, 0, 300, 60); 309 + expect(a.id).not.toBe(b.id); 310 + }); 311 + 312 + it('has rotation of 0', () => { 313 + const el = createAudioElement('https://example.com/song.mp3', 0, 0, 300, 60); 314 + expect(el.rotation).toBe(0); 315 + }); 316 + 317 + it('sanitizes dangerous URLs', () => { 318 + const el = createAudioElement('javascript:alert(1)', 0, 0, 300, 60); 319 + expect(el.content).toBe(''); 320 + }); 321 + }); 322 + });