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 'feat: multi-editor features — TOC, Goal Seek, tables, timezone, export, piping (0.44.0)' (#382) from feat/multi-editor-features-v0.44 into main

scott 9eeaae02 c4592a77

+1338 -27
+15
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.44.0] — 2026-04-15 11 + 12 + ### Added 13 + - Docs: auto-updating Table of Contents block — inserts a live TOC via slash command that refreshes on every edit (#651) 14 + - Docs: 4 new templates — Technical Spec, Letter, Journal Entry, Knowledge Base Article (#653) 15 + - Sheets: Goal Seek solver — secant method with bisection fallback for numerical root-finding (#655) 16 + - Slides: rich table rendering — PPTX tables import as structured `table` elements with styled HTML grid (#659) 17 + - Calendar: per-event timezone support — select IANA timezone per event, display abbreviations in views (#660) 18 + - Forms: CSV and Excel export for form responses with styled headers and auto-fit columns (#664) 19 + - Forms: answer piping — `{{Q1}}` placeholders in question labels resolve to prior answers in real-time (#665) 20 + 21 + ### Fixed 22 + - Docs: TOC generator no longer double-encodes HTML entities (e.g. `&` → `&`) 23 + 10 24 ## [0.43.0] — 2026-04-15 11 25 12 26 ### Added ··· 56 70 - fix: removed dead `fromIdx` variable in slide thumbnail dragover handler (#620) 57 71 58 72 ### Changed 73 + - Sheets: XLOOKUP function (#654) 59 74 - perf: deduplicated cell iteration in grid CF rendering — single pass for color scale + data bars + icon sets (#636) 60 75 61 76 ## [0.42.0] — 2026-04-14
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.43.0", 3 + "version": "0.44.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+54
src/calendar/helpers.ts
··· 20 20 allDay: boolean; 21 21 color: string; 22 22 description: string; 23 + /** IANA timezone ID (e.g. 'America/New_York'). Omitted = browser local. */ 24 + timezone?: string; 23 25 recurrence?: Recurrence; 24 26 reminders?: Reminder[]; 25 27 createdAt: number; ··· 310 312 const ampm = h >= 12 ? 'pm' : 'am'; 311 313 const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; 312 314 return mStr === '00' ? `${h12}${ampm}` : `${h12}:${mStr}${ampm}`; 315 + } 316 + 317 + /** 318 + * Common IANA timezone list for the timezone picker. 319 + * Sorted roughly by UTC offset. 320 + */ 321 + export const COMMON_TIMEZONES = [ 322 + 'Pacific/Honolulu', // UTC-10 323 + 'America/Anchorage', // UTC-9 324 + 'America/Los_Angeles', // UTC-8 325 + 'America/Denver', // UTC-7 326 + 'America/Chicago', // UTC-6 327 + 'America/New_York', // UTC-5 328 + 'America/Halifax', // UTC-4 329 + 'America/Sao_Paulo', // UTC-3 330 + 'Atlantic/Reykjavik', // UTC+0 331 + 'Europe/London', // UTC+0 332 + 'Europe/Paris', // UTC+1 333 + 'Europe/Berlin', // UTC+1 334 + 'Europe/Helsinki', // UTC+2 335 + 'Europe/Moscow', // UTC+3 336 + 'Asia/Dubai', // UTC+4 337 + 'Asia/Kolkata', // UTC+5:30 338 + 'Asia/Dhaka', // UTC+6 339 + 'Asia/Bangkok', // UTC+7 340 + 'Asia/Shanghai', // UTC+8 341 + 'Asia/Tokyo', // UTC+9 342 + 'Australia/Sydney', // UTC+10/11 343 + 'Pacific/Auckland', // UTC+12/13 344 + ] as const; 345 + 346 + /** 347 + * Get a short timezone abbreviation for display (e.g. "EST", "PST"). 348 + */ 349 + export function getTimezoneAbbr(timezone: string): string { 350 + try { 351 + const fmt = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' }); 352 + const parts = fmt.formatToParts(new Date()); 353 + const tzPart = parts.find(p => p.type === 'timeZoneName'); 354 + return tzPart?.value || timezone; 355 + } catch { 356 + return timezone; 357 + } 358 + } 359 + 360 + /** 361 + * Format a time string with optional timezone abbreviation. 362 + */ 363 + export function formatTimeWithTz(time: string, use24Hour: boolean, timezone?: string): string { 364 + const base = formatTime(time, use24Hour); 365 + if (!base || !timezone) return base; 366 + return `${base} ${getTimezoneAbbr(timezone)}`; 313 367 } 314 368 315 369 /** Return DAYS_OF_WEEK rotated so that `startDay` is first. */
+7
src/calendar/index.html
··· 215 215 </div> 216 216 217 217 <div class="event-modal-field"> 218 + <label for="event-timezone">Timezone</label> 219 + <select id="event-timezone" class="event-modal-input"> 220 + <option value="">Local (browser)</option> 221 + </select> 222 + </div> 223 + 224 + <div class="event-modal-field"> 218 225 <label for="event-recurrence">Repeat</label> 219 226 <select id="event-recurrence" class="event-modal-input"> 220 227 <option value="none">Does not repeat</option>
+15
src/calendar/main.ts
··· 50 50 reminderLabel, 51 51 reminderFireTime, 52 52 getUpcomingReminders, 53 + COMMON_TIMEZONES, 54 + getTimezoneAbbr, 53 55 } from './helpers.js'; 54 56 import { parseIcsFile } from './ics-parser.js'; 55 57 import { exportIcsFile } from './ics-export.js'; ··· 146 148 const modalDuplicate = document.getElementById('btn-event-duplicate') as HTMLButtonElement; 147 149 const modalCancel = document.getElementById('btn-event-cancel') as HTMLButtonElement; 148 150 const modalTitleEl = document.getElementById('event-modal-title') as HTMLElement; 151 + const modalTimezone = document.getElementById('event-timezone') as HTMLSelectElement; 149 152 const modalRecurrence = document.getElementById('event-recurrence') as HTMLSelectElement; 150 153 const modalRecurrenceUntil = document.getElementById('event-recurrence-until') as HTMLInputElement; 151 154 const recurrenceUntilField = document.getElementById('recurrence-until-field') as HTMLElement; 155 + 156 + // Populate timezone picker 157 + for (const tz of COMMON_TIMEZONES) { 158 + const opt = document.createElement('option'); 159 + opt.value = tz; 160 + opt.textContent = `${tz.replace(/_/g, ' ')} (${getTimezoneAbbr(tz)})`; 161 + modalTimezone.appendChild(opt); 162 + } 152 163 153 164 // Search refs 154 165 const searchInput = document.getElementById('cal-search-input') as HTMLInputElement; ··· 1329 1340 swatch.classList.toggle('active', (swatch as HTMLElement).dataset.color === activeColor); 1330 1341 }); 1331 1342 1343 + // Timezone field 1344 + modalTimezone.value = (evt as CalendarEvent).timezone ?? ''; 1345 + 1332 1346 // Recurrence fields 1333 1347 const rec = (evt as CalendarEvent).recurrence; 1334 1348 modalRecurrence.value = rec?.type ?? 'none'; ··· 1385 1399 allDay: modalAllDay.checked, 1386 1400 color: (modalColorPicker.querySelector('.event-color-swatch.active') as HTMLElement)?.dataset.color || EVENT_COLORS[0] || '#4a90d9', 1387 1401 description: modalDescription.value.trim(), 1402 + ...(modalTimezone.value ? { timezone: modalTimezone.value } : {}), 1388 1403 ...(recurrence ? { recurrence } : {}), 1389 1404 ...(modalReminders.length > 0 ? { reminders: [...modalReminders] } : {}), 1390 1405 createdAt: editingEventId
+48
src/css/app.css
··· 12096 12096 font-size: 0.85rem; 12097 12097 resize: vertical; 12098 12098 } 12099 + 12100 + /* TOC Block */ 12101 + .toc-block-wrapper { 12102 + position: relative; 12103 + margin: 1em 0; 12104 + padding: 16px 20px; 12105 + border: 1px solid var(--color-border); 12106 + border-radius: var(--radius-sm, 4px); 12107 + background: var(--color-surface); 12108 + } 12109 + .toc-block-wrapper:hover { border-color: var(--color-text-muted); } 12110 + .toc-block-header { 12111 + font-size: 0.75rem; 12112 + font-weight: 600; 12113 + text-transform: uppercase; 12114 + letter-spacing: 0.05em; 12115 + color: var(--color-text-muted); 12116 + margin-bottom: 8px; 12117 + } 12118 + .toc-block-content .toc-list { 12119 + margin: 0; 12120 + padding: 0; 12121 + list-style: none; 12122 + } 12123 + .toc-block-content .toc-list ul { 12124 + margin: 0; 12125 + padding-left: 1.25em; 12126 + list-style: none; 12127 + } 12128 + .toc-block-content .toc-item { margin: 2px 0; } 12129 + .toc-block-content .toc-item a { 12130 + color: var(--color-primary); 12131 + text-decoration: none; 12132 + font-size: 0.9rem; 12133 + line-height: 1.6; 12134 + border-bottom: 1px solid transparent; 12135 + transition: border-color 0.15s; 12136 + } 12137 + .toc-block-content .toc-item a:hover { 12138 + border-bottom-color: var(--color-primary); 12139 + } 12140 + .toc-block-content .toc-level-1 a { font-weight: 600; } 12141 + .toc-block-content .toc-level-2 a { font-weight: 500; } 12142 + .toc-block-empty { 12143 + color: var(--color-text-faint); 12144 + font-style: italic; 12145 + font-size: 0.85rem; 12146 + }
+1 -7
src/docs/extensions/slash-commands.ts
··· 13 13 import type { Editor } from '@tiptap/core'; 14 14 import Suggestion from '@tiptap/suggestion'; 15 15 import type { SlashCommandsConfig, SlashCommandExecutableItem, CommandExecutor, SuggestionCallbackProps } from '../types.js'; 16 - import { generateTocHtml } from '../table-of-contents.js'; 17 - 18 16 interface SlashCommandItemWithId { 19 17 id: string; 20 18 } ··· 138 136 editor.chain().focus().insertPageBreak().run(); 139 137 }, 140 138 tableOfContents: (editor: Editor) => { 141 - const html = editor.getHTML(); 142 - const tocHtml = generateTocHtml(html); 143 - if (tocHtml) { 144 - editor.chain().focus().insertContent(tocHtml).run(); 145 - } 139 + editor.chain().focus().insertTocBlock().run(); 146 140 }, 147 141 toggle: (editor: Editor) => { 148 142 editor.chain().focus().insertToggleBlock().run();
+173
src/docs/extensions/toc-block.ts
··· 1 + /** 2 + * TocBlock TipTap extension — auto-updating Table of Contents. 3 + * 4 + * Renders a navigable TOC from document headings. Updates automatically 5 + * when headings change. Stored as an atom node in the document model. 6 + * 7 + * UX: 8 + * - Inserts via /tableOfContents slash command 9 + * - Auto-refreshes on editor content changes 10 + * - Clickable heading links scroll to the target 11 + * - Nested list structure mirrors heading hierarchy 12 + */ 13 + 14 + import { Node, mergeAttributes } from '@tiptap/core'; 15 + import { extractHeadings, generateHeadingId } from '../outline.js'; 16 + 17 + declare module '@tiptap/core' { 18 + interface Commands<ReturnType> { 19 + tocBlock: { 20 + insertTocBlock: () => ReturnType; 21 + }; 22 + } 23 + } 24 + 25 + interface TocHeading { 26 + level: number; 27 + text: string; 28 + id: string; 29 + } 30 + 31 + function escapeHtml(s: string): string { 32 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 33 + } 34 + 35 + function buildTocDom(headings: TocHeading[]): HTMLElement { 36 + const nav = document.createElement('nav'); 37 + nav.className = 'toc-block-content'; 38 + nav.setAttribute('aria-label', 'Table of Contents'); 39 + 40 + if (headings.length === 0) { 41 + const empty = document.createElement('div'); 42 + empty.className = 'toc-block-empty'; 43 + empty.textContent = 'No headings in document'; 44 + nav.appendChild(empty); 45 + return nav; 46 + } 47 + 48 + const minLevel = Math.min(...headings.map(h => h.level)); 49 + 50 + const root = document.createElement('ul'); 51 + root.className = 'toc-list'; 52 + const stack: { ul: HTMLUListElement; level: number }[] = [{ ul: root, level: minLevel - 1 }]; 53 + 54 + for (const heading of headings) { 55 + // Close deeper levels 56 + while (stack.length > 1 && stack[stack.length - 1].level >= heading.level) { 57 + stack.pop(); 58 + } 59 + 60 + // Open new levels if needed 61 + while (stack[stack.length - 1].level < heading.level - 1) { 62 + const nested = document.createElement('ul'); 63 + const parent = stack[stack.length - 1].ul; 64 + let lastLi = parent.lastElementChild; 65 + if (!lastLi) { 66 + lastLi = document.createElement('li'); 67 + parent.appendChild(lastLi); 68 + } 69 + lastLi.appendChild(nested); 70 + stack.push({ ul: nested, level: stack[stack.length - 1].level + 1 }); 71 + } 72 + 73 + const li = document.createElement('li'); 74 + li.className = `toc-item toc-level-${heading.level}`; 75 + const a = document.createElement('a'); 76 + a.href = `#${heading.id}`; 77 + a.textContent = heading.text; 78 + a.addEventListener('click', (e) => { 79 + e.preventDefault(); 80 + const target = document.getElementById(heading.id); 81 + if (target) { 82 + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 83 + } 84 + }); 85 + li.appendChild(a); 86 + stack[stack.length - 1].ul.appendChild(li); 87 + } 88 + 89 + nav.appendChild(root); 90 + return nav; 91 + } 92 + 93 + export const TocBlock = Node.create({ 94 + name: 'tocBlock', 95 + 96 + group: 'block', 97 + atom: true, 98 + selectable: true, 99 + draggable: true, 100 + 101 + addAttributes() { 102 + return { 103 + title: { default: 'Table of Contents' }, 104 + }; 105 + }, 106 + 107 + parseHTML() { 108 + return [{ tag: 'div[data-toc-block]' }]; 109 + }, 110 + 111 + renderHTML({ HTMLAttributes }) { 112 + return ['div', mergeAttributes(HTMLAttributes, { 113 + 'data-toc-block': '', 114 + class: 'toc-block', 115 + }), 0]; 116 + }, 117 + 118 + addCommands() { 119 + return { 120 + insertTocBlock: 121 + () => 122 + ({ commands }) => { 123 + return commands.insertContent({ 124 + type: this.name, 125 + attrs: { title: 'Table of Contents' }, 126 + }); 127 + }, 128 + }; 129 + }, 130 + 131 + addNodeView() { 132 + return ({ editor }) => { 133 + const dom = document.createElement('div'); 134 + dom.className = 'toc-block-wrapper'; 135 + dom.setAttribute('data-toc-block', ''); 136 + 137 + const header = document.createElement('div'); 138 + header.className = 'toc-block-header'; 139 + header.textContent = 'Table of Contents'; 140 + dom.appendChild(header); 141 + 142 + let contentEl: HTMLElement = buildTocDom([]); 143 + dom.appendChild(contentEl); 144 + 145 + function refresh() { 146 + const json = editor.getJSON(); 147 + const headings = extractHeadings(json as { content?: { type: string; attrs?: { level?: number }; content?: { type: string; text?: string }[] }[] }); 148 + const newContent = buildTocDom(headings); 149 + dom.replaceChild(newContent, contentEl); 150 + contentEl = newContent; 151 + } 152 + 153 + // Initial render 154 + refresh(); 155 + 156 + // Auto-update on content changes 157 + const onUpdate = () => refresh(); 158 + editor.on('update', onUpdate); 159 + 160 + return { 161 + dom, 162 + update(updatedNode) { 163 + if (updatedNode.type.name !== 'tocBlock') return false; 164 + refresh(); 165 + return true; 166 + }, 167 + destroy() { 168 + editor.off('update', onUpdate); 169 + }, 170 + }; 171 + }; 172 + }, 173 + });
+2
src/docs/main.ts
··· 61 61 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; 62 62 import { MermaidBlock } from './extensions/mermaid-block.js'; 63 63 import { MathBlock } from './extensions/math-block.js'; 64 + import { TocBlock } from './extensions/toc-block.js'; 64 65 import { createCommandPalette, type PaletteAction } from '../command-palette.js'; 65 66 66 67 // --- Extracted modules (phase 1) --- ··· 221 222 WikiLink, 222 223 MermaidBlock, 223 224 MathBlock, 225 + TocBlock, 224 226 createSlashCommands({ 225 227 items: (query) => { 226 228 return filterCommands(query).map(item => ({
+14 -1
src/docs/table-of-contents.ts
··· 26 26 27 27 return headings.map(h => ({ 28 28 level: h.level, 29 - text: h.text, 29 + text: decodeEntities(h.text), 30 30 id: h.id, 31 31 depth: h.level - minLevel, 32 32 })); ··· 87 87 result += '</li></ul>'; 88 88 89 89 return result; 90 + } 91 + 92 + /** 93 + * Decode common HTML entities so heading text is plain before re-escaping. 94 + * The minimap extractor returns raw HTML entity strings (e.g. "&amp;"). 95 + */ 96 + function decodeEntities(text: string): string { 97 + return text 98 + .replace(/&amp;/g, '&') 99 + .replace(/&lt;/g, '<') 100 + .replace(/&gt;/g, '>') 101 + .replace(/&quot;/g, '"') 102 + .replace(/&#39;/g, "'"); 90 103 } 91 104 92 105 function escapeHtml(text: string): string {
+62
src/forms/answer-piping.ts
··· 1 + /** 2 + * Answer Piping — resolves {{Q1}}, {{Q2}} placeholders in question text. 3 + * 4 + * Allows form creators to reference previous answers in subsequent questions. 5 + * Placeholders use 1-based question indices: {{Q1}} = first question's answer. 6 + * 7 + * Security: All piped values are HTML-escaped before insertion. 8 + */ 9 + 10 + import type { FormSchema } from './form-builder.js'; 11 + 12 + const PIPE_REGEX = /\{\{Q(\d+)\}\}/g; 13 + 14 + /** 15 + * Resolve piping placeholders in text using current answers. 16 + * 17 + * @param text - Text with {{Q1}}, {{Q2}} placeholders 18 + * @param answers - Current form answers (keyed by question ID) 19 + * @param form - Form schema (for question ID lookup) 20 + * @returns Text with placeholders replaced by answer values 21 + */ 22 + export function resolvePipes( 23 + text: string, 24 + answers: Map<string, unknown>, 25 + form: FormSchema, 26 + ): string { 27 + if (!text.includes('{{Q')) return text; 28 + 29 + return text.replace(PIPE_REGEX, (match, numStr) => { 30 + const idx = parseInt(numStr, 10) - 1; // 1-based to 0-based 31 + if (idx < 0 || idx >= form.questions.length) return match; 32 + 33 + const question = form.questions[idx]; 34 + const answer = answers.get(question.id); 35 + if (answer === undefined || answer === null || answer === '') return match; 36 + 37 + // Format array answers (multiple choice) 38 + if (Array.isArray(answer)) { 39 + // Resolve option labels for choice-based answers 40 + const labels = answer.map(optId => { 41 + const opt = question.options?.find(o => o.id === optId); 42 + return opt ? opt.label : String(optId); 43 + }); 44 + return labels.join(', '); 45 + } 46 + 47 + // Resolve single choice option labels 48 + if (question.type === 'single_choice' || question.type === 'dropdown') { 49 + const opt = question.options?.find(o => o.id === String(answer)); 50 + if (opt) return opt.label; 51 + } 52 + 53 + return String(answer); 54 + }); 55 + } 56 + 57 + /** 58 + * Check if text contains any piping placeholders. 59 + */ 60 + export function hasPipes(text: string): boolean { 61 + return PIPE_REGEX.test(text); 62 + }
+40 -2
src/forms/render-preview.ts
··· 11 11 import { getVisibleQuestions, type ConditionalLogicState } from './conditional-logic.js'; 12 12 import { createResponse, type FormResponse } from './responses.js'; 13 13 import { escapeHtml } from '../lib/ai-chat.js'; 14 + import { resolvePipes } from './answer-piping.js'; 14 15 15 16 export interface PreviewDeps { 16 17 getForm: () => FormSchema; ··· 47 48 qEl.className = 'form-preview-question'; 48 49 49 50 const inputHtml = renderQuestionInput(q); 51 + const resolvedLabel = resolvePipes(q.label, answers, form); 52 + const resolvedDesc = q.description ? resolvePipes(q.description, answers, form) : ''; 50 53 51 54 qEl.innerHTML = ` 52 - <label class="form-preview-label">${escapeHtml(q.label)}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 53 - ${q.description ? `<p class="form-preview-hint">${escapeHtml(q.description)}</p>` : ''} 55 + <label class="form-preview-label" data-pipe-qid="${q.id}" data-pipe-field="label">${escapeHtml(resolvedLabel)}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 56 + ${resolvedDesc ? `<p class="form-preview-hint" data-pipe-qid="${q.id}" data-pipe-field="desc">${escapeHtml(resolvedDesc)}</p>` : ''} 54 57 ${inputHtml} 55 58 <div class="form-preview-error" data-error-qid="${q.id}"></div> 56 59 `; ··· 58 61 } 59 62 60 63 wireRatingAndScaleButtons(pane); 64 + wirePipingUpdates(pane, deps); 61 65 wireSubmitHandler(pane, deps); 62 66 } 63 67 ··· 127 131 }); 128 132 129 133 return formAnswers; 134 + } 135 + 136 + /** 137 + * Wire input change listeners to update piped labels in real-time. 138 + */ 139 + function wirePipingUpdates(pane: HTMLElement, deps: PreviewDeps): void { 140 + const form = deps.getForm(); 141 + const hasAnyPipes = form.questions.some( 142 + q => q.label.includes('{{Q') || (q.description && q.description.includes('{{Q')), 143 + ); 144 + if (!hasAnyPipes) return; 145 + 146 + function updatePipedLabels() { 147 + const currentAnswers = collectAnswers(pane); 148 + pane.querySelectorAll('[data-pipe-field]').forEach(el => { 149 + const qid = (el as HTMLElement).dataset.pipeQid!; 150 + const field = (el as HTMLElement).dataset.pipeField!; 151 + const q = form.questions.find(question => question.id === qid); 152 + if (!q) return; 153 + 154 + const source = field === 'label' ? q.label : q.description; 155 + if (!source.includes('{{Q')) return; 156 + 157 + const resolved = resolvePipes(source, currentAnswers, form); 158 + if (field === 'label') { 159 + (el as HTMLElement).innerHTML = escapeHtml(resolved) + (q.required ? ' <span class="form-required-mark">*</span>' : ''); 160 + } else { 161 + (el as HTMLElement).textContent = resolved; 162 + } 163 + }); 164 + } 165 + 166 + pane.addEventListener('input', updatePipedLabels); 167 + pane.addEventListener('change', updatePipedLabels); 130 168 } 131 169 132 170 function wireSubmitHandler(pane: HTMLElement, deps: PreviewDeps): void {
+15 -1
src/forms/render-responses.ts
··· 13 13 type FormResponse, 14 14 } from './responses.js'; 15 15 import { escapeHtml } from '../lib/ai-chat.js'; 16 + import { exportResponsesCsv, exportResponsesXlsx } from './response-export.js'; 16 17 17 18 export interface ResponsesDeps { 18 19 getForm: () => FormSchema; ··· 54 55 55 56 pane.innerHTML = ` 56 57 <div style="padding:var(--space-md)"> 57 - <h3>${responses.length} Response${responses.length === 1 ? '' : 's'}</h3> 58 + <div style="display:flex;align-items:center;gap:var(--space-sm);margin-bottom:var(--space-md)"> 59 + <h3 style="margin:0">${responses.length} Response${responses.length === 1 ? '' : 's'}</h3> 60 + <span style="flex:1"></span> 61 + <button class="btn-secondary btn-sm" id="btn-export-csv" title="Export as CSV">CSV</button> 62 + <button class="btn-secondary btn-sm" id="btn-export-xlsx" title="Export as Excel">Excel</button> 63 + </div> 58 64 <div style="overflow-x:auto">${tableHtml}</div> 59 65 </div> 60 66 `; 67 + 68 + // Wire export buttons 69 + pane.querySelector('#btn-export-csv')?.addEventListener('click', () => { 70 + exportResponsesCsv(form, responses); 71 + }); 72 + pane.querySelector('#btn-export-xlsx')?.addEventListener('click', () => { 73 + exportResponsesXlsx(form, responses); 74 + }); 61 75 } 62 76 63 77 function parseResponses(yResponses: Y.Array<string>): FormResponse[] {
+121
src/forms/response-export.ts
··· 1 + /** 2 + * Form Response Export — CSV and XLSX export for form responses. 3 + * 4 + * Pure logic module: transforms form responses into downloadable files. 5 + * Reuses the sheets CSV export utilities for RFC 4180 compliance. 6 + */ 7 + 8 + import type { FormSchema } from './form-builder.js'; 9 + import type { FormResponse, ResponsePipelineConfig } from './responses.js'; 10 + import { 11 + createPipelineConfig, 12 + pipelineHeaders, 13 + responseToRow, 14 + } from './responses.js'; 15 + import { escapeField, downloadCsv } from '../sheets/csv-export.js'; 16 + 17 + /** 18 + * Build pipeline config from a form schema. 19 + */ 20 + function buildConfig(form: FormSchema): ResponsePipelineConfig { 21 + return createPipelineConfig( 22 + form.id, 23 + form.targetSheetId ?? '', 24 + form.questions.map(q => q.id), 25 + form.questions.map(q => q.label || 'Untitled'), 26 + ); 27 + } 28 + 29 + /** 30 + * Export form responses as CSV and trigger download. 31 + */ 32 + export function exportResponsesCsv(form: FormSchema, responses: FormResponse[]): void { 33 + if (responses.length === 0) return; 34 + 35 + const config = buildConfig(form); 36 + const headers = pipelineHeaders(config); 37 + const rows = responses.map(r => responseToRow(r, config)); 38 + 39 + const delimiter = ','; 40 + const lines: string[] = []; 41 + 42 + // Header row 43 + lines.push(headers.map(h => escapeField(String(h), delimiter)).join(delimiter)); 44 + 45 + // Data rows 46 + for (const row of rows) { 47 + lines.push(row.map(cell => escapeField(String(cell ?? ''), delimiter)).join(delimiter)); 48 + } 49 + 50 + // UTF-8 BOM + content 51 + const content = '\uFEFF' + lines.join('\r\n'); 52 + const filename = sanitizeFilename(form.title || 'form-responses'); 53 + downloadCsv(content, filename, delimiter); 54 + } 55 + 56 + /** 57 + * Export form responses as XLSX and trigger download. 58 + * Dynamically imports ExcelJS to avoid loading it unless needed. 59 + */ 60 + export async function exportResponsesXlsx(form: FormSchema, responses: FormResponse[]): Promise<void> { 61 + if (responses.length === 0) return; 62 + 63 + const config = buildConfig(form); 64 + const headers = pipelineHeaders(config); 65 + const rows = responses.map(r => responseToRow(r, config)); 66 + 67 + // Dynamic import to keep bundle small 68 + const ExcelJS = await import('exceljs'); 69 + const workbook = new ExcelJS.Workbook(); 70 + const sheet = workbook.addWorksheet('Responses'); 71 + 72 + // Header row with bold styling 73 + const headerRow = sheet.addRow(headers); 74 + headerRow.font = { bold: true }; 75 + headerRow.eachCell((cell) => { 76 + cell.fill = { 77 + type: 'pattern', 78 + pattern: 'solid', 79 + fgColor: { argb: 'FFE8E8E8' }, 80 + }; 81 + }); 82 + 83 + // Data rows 84 + for (const row of rows) { 85 + sheet.addRow(row.map(cell => cell ?? '')); 86 + } 87 + 88 + // Auto-fit column widths (approximate) 89 + for (let i = 0; i < headers.length; i++) { 90 + const maxLen = Math.max( 91 + headers[i].length, 92 + ...rows.map(r => String(r[i] ?? '').length), 93 + ); 94 + sheet.getColumn(i + 1).width = Math.min(Math.max(maxLen + 2, 10), 50); 95 + } 96 + 97 + const buffer = await workbook.xlsx.writeBuffer(); 98 + const filename = sanitizeFilename(form.title || 'form-responses'); 99 + downloadXlsxBlob(buffer as ArrayBuffer, filename); 100 + } 101 + 102 + function downloadXlsxBlob(buffer: ArrayBuffer, filename: string): void { 103 + const blob = new Blob([buffer], { 104 + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 105 + }); 106 + const url = URL.createObjectURL(blob); 107 + const a = document.createElement('a'); 108 + a.href = url; 109 + a.download = `${filename}.xlsx`; 110 + document.body.appendChild(a); 111 + a.click(); 112 + document.body.removeChild(a); 113 + URL.revokeObjectURL(url); 114 + } 115 + 116 + function sanitizeFilename(name: string): string { 117 + return name 118 + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') 119 + .replace(/\s+/g, '-') 120 + .slice(0, 100) || 'export'; 121 + }
+143
src/sheets/goal-seek.ts
··· 1 + /** 2 + * Goal Seek — finds the input value that makes a formula reach a target. 3 + * 4 + * Uses the secant method (no derivative needed) with bisection fallback. 5 + * Works with any single-cell formula by varying a single input cell. 6 + * 7 + * Algorithm: 8 + * 1. Evaluate formula with two initial guesses 9 + * 2. Iterate secant method toward target 10 + * 3. If secant diverges, fall back to bisection on bracketed interval 11 + * 4. Stop when |f(x) - target| < tolerance or max iterations reached 12 + */ 13 + 14 + export interface GoalSeekParams { 15 + /** Function that sets the input cell value and returns the formula cell result */ 16 + evaluate: (inputValue: number) => number; 17 + /** Target value for the formula cell */ 18 + target: number; 19 + /** Initial guess for the input cell (default: 0) */ 20 + initialGuess?: number; 21 + /** Convergence tolerance (default: 1e-7) */ 22 + tolerance?: number; 23 + /** Maximum iterations (default: 100) */ 24 + maxIterations?: number; 25 + } 26 + 27 + export interface GoalSeekResult { 28 + /** Whether the solver converged */ 29 + found: boolean; 30 + /** The input value that produces the target (or best attempt) */ 31 + value: number; 32 + /** The formula result at the found value */ 33 + result: number; 34 + /** Number of iterations used */ 35 + iterations: number; 36 + /** Absolute error: |result - target| */ 37 + error: number; 38 + } 39 + 40 + /** 41 + * Run Goal Seek to find the input value producing the target output. 42 + */ 43 + export function goalSeek(params: GoalSeekParams): GoalSeekResult { 44 + const { 45 + evaluate, 46 + target, 47 + initialGuess = 0, 48 + tolerance = 1e-7, 49 + maxIterations = 100, 50 + } = params; 51 + 52 + const f = (x: number): number => evaluate(x) - target; 53 + 54 + let x0 = initialGuess; 55 + let f0 = f(x0); 56 + 57 + // Check if initial guess is already the answer 58 + if (Math.abs(f0) < tolerance) { 59 + return { found: true, value: x0, result: evaluate(x0), iterations: 0, error: Math.abs(f0) }; 60 + } 61 + 62 + // Second point for secant method 63 + let x1 = initialGuess !== 0 ? initialGuess * 1.0001 : 0.0001; 64 + let f1 = f(x1); 65 + 66 + // Track bracket for bisection fallback 67 + let bracketLo: number | null = null; 68 + let bracketHi: number | null = null; 69 + 70 + if (f0 * f1 < 0) { 71 + if (f0 < f1) { bracketLo = x0; bracketHi = x1; } 72 + else { bracketLo = x1; bracketHi = x0; } 73 + } 74 + 75 + for (let i = 0; i < maxIterations; i++) { 76 + // Check convergence 77 + if (Math.abs(f1) < tolerance) { 78 + return { found: true, value: x1, result: evaluate(x1), iterations: i + 1, error: Math.abs(f1) }; 79 + } 80 + 81 + // Secant step 82 + const denom = f1 - f0; 83 + let x2: number; 84 + 85 + if (Math.abs(denom) < 1e-15) { 86 + // Denominator too small — try bisection if bracketed, else perturb 87 + if (bracketLo !== null && bracketHi !== null) { 88 + x2 = (bracketLo + bracketHi) / 2; 89 + } else { 90 + x2 = x1 + (Math.abs(x1) > 1 ? x1 * 0.01 : 0.01); 91 + } 92 + } else { 93 + x2 = x1 - f1 * (x1 - x0) / denom; 94 + } 95 + 96 + const f2 = f(x2); 97 + 98 + // Update bracket 99 + if (f1 * f2 < 0) { 100 + bracketLo = f2 < 0 ? x2 : x1; 101 + bracketHi = f2 < 0 ? x1 : x2; 102 + } else if (bracketLo !== null && bracketHi !== null) { 103 + // Keep existing bracket, narrow it 104 + const fLo = f(bracketLo); 105 + if (fLo * f2 < 0) { 106 + bracketHi = x2; 107 + } else { 108 + bracketLo = x2; 109 + } 110 + } 111 + 112 + // If secant diverges and we have a bracket, use bisection 113 + if (Math.abs(f2) > Math.abs(f1) * 2 && bracketLo !== null && bracketHi !== null) { 114 + x2 = (bracketLo + bracketHi) / 2; 115 + const fBis = f(x2); 116 + const fLo = f(bracketLo); 117 + if (fLo * fBis < 0) { 118 + bracketHi = x2; 119 + } else { 120 + bracketLo = x2; 121 + } 122 + x0 = x1; 123 + f0 = f1; 124 + x1 = x2; 125 + f1 = fBis; 126 + continue; 127 + } 128 + 129 + x0 = x1; 130 + f0 = f1; 131 + x1 = x2; 132 + f1 = f2; 133 + } 134 + 135 + // Did not converge — return best result 136 + return { 137 + found: false, 138 + value: x1, 139 + result: evaluate(x1), 140 + iterations: maxIterations, 141 + error: Math.abs(f1), 142 + }; 143 + }
+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'; 8 + export type ElementType = 'text' | 'image' | 'shape' | 'code' | 'chart' | 'embed' | 'table'; 9 9 export type ShapeType = 'rectangle' | 'ellipse' | 'triangle' | 'arrow' | 'line'; 10 10 11 11 export interface SlideElement {
+7 -9
src/slides/pptx-import.ts
··· 235 235 function parseGf(gf: Element, conv: Conv, z: number): SlideElement | null { 236 236 const tbl = byName(gf, 'tbl')[0]; 237 237 if (!tbl) return null; 238 - const lines = byName(tbl, 'tr').flatMap((row, i) => { 239 - const cells = byName(row, 'tc').map(tc => { const tb = byName(tc, 'txBody')[0]; return tb ? plain(richText(tb)).trim() : ''; }); 240 - const line = cells.join(' | '); 241 - return i === 0 ? [line, cells.map(() => '---').join(' | ')] : [line]; 238 + const tableRows: string[][] = byName(tbl, 'tr').map(row => { 239 + return byName(row, 'tc').map(tc => { const tb = byName(tc, 'txBody')[0]; return tb ? plain(richText(tb)).trim() : ''; }); 242 240 }); 243 - const text = lines.join('\n'); 244 - if (!text.trim()) return null; 241 + if (tableRows.length === 0 || tableRows.every(r => r.every(c => !c))) return null; 242 + const tableJson = JSON.stringify(tableRows); 245 243 const xfrmEl = byName(gf, 'xfrm')[0]; 246 244 const off = xfrmEl ? ch(xfrmEl, 'off')[0] : null; 247 245 const ext = xfrmEl ? ch(xfrmEl, 'ext')[0] : null; 248 - return { id: eid(), type: 'text', 246 + return { id: eid(), type: 'table' as const, 249 247 x: off ? conv.x(parseInt(attr(off, 'x'), 10) || 0) : 0, 250 248 y: off ? conv.y(parseInt(attr(off, 'y'), 10) || 0) : 0, 251 249 width: Math.max(ext ? conv.w(parseInt(attr(ext, 'cx'), 10) || 0) : SLIDE_WIDTH, 100), 252 250 height: Math.max(ext ? conv.h(parseInt(attr(ext, 'cy'), 10) || 0) : 120, 40), 253 - rotation: 0, zIndex: z, content: text, 254 - style: { fontSize: '12px', fontFamily: 'monospace', color: '#222222', whiteSpace: 'pre', padding: '4px', lineHeight: '1.6', overflow: 'hidden' } }; 251 + rotation: 0, zIndex: z, content: tableJson, 252 + style: { fontSize: '12px', color: '#222222', padding: '4px' } }; 255 253 } 256 254 257 255 // --- Group (recursive, depth-limited) ---
+73
src/slides/rendering.ts
··· 136 136 } 137 137 138 138 /** 139 + * Render a table element as a styled HTML table. 140 + * Content format: JSON array of arrays, or markdown-style pipe-delimited text. 141 + */ 142 + function renderTableElement(content: string, width: number, height: number, theme: ReturnType<typeof getTheme>): HTMLElement { 143 + const wrapper = document.createElement('div'); 144 + wrapper.className = 'slide-el-table'; 145 + wrapper.style.cssText = `width:100%;height:100%;overflow:hidden;`; 146 + 147 + let rows: string[][] = []; 148 + try { 149 + const parsed = JSON.parse(content); 150 + if (Array.isArray(parsed) && parsed.length > 0) { 151 + rows = parsed.map((r: unknown) => Array.isArray(r) ? r.map(String) : [String(r)]); 152 + } 153 + } catch { 154 + // Fall back to pipe-delimited text parsing 155 + const lines = content.split('\n').filter(l => l.trim()); 156 + for (const line of lines) { 157 + // Skip separator lines (---) 158 + if (/^[\s|:-]+$/.test(line.replace(/-/g, ''))) continue; 159 + if (/^-+(\s*\|\s*-+)*$/.test(line.trim())) continue; 160 + const cells = line.split(/\s*\|\s*/).map(c => c.trim()).filter((_, i, arr) => i > 0 || arr[0] !== ''); 161 + if (cells.length > 0) rows.push(cells); 162 + } 163 + } 164 + 165 + if (rows.length === 0) { 166 + wrapper.textContent = content || 'Empty table'; 167 + return wrapper; 168 + } 169 + 170 + const table = document.createElement('table'); 171 + table.className = 'slide-table'; 172 + const textColor = theme?.palette.text || '#1a1815'; 173 + const borderColor = theme?.palette.text ? theme.palette.text + '33' : '#d0d0d0'; 174 + table.style.cssText = `width:100%;border-collapse:collapse;font-family:${theme?.fonts.body || 'system-ui'};font-size:12px;color:${textColor};`; 175 + 176 + // First row as header 177 + const thead = document.createElement('thead'); 178 + const headerRow = document.createElement('tr'); 179 + for (const cell of rows[0]) { 180 + const th = document.createElement('th'); 181 + th.textContent = cell; 182 + th.style.cssText = `padding:4px 8px;border:1px solid ${borderColor};font-weight:600;text-align:left;background:${borderColor};`; 183 + headerRow.appendChild(th); 184 + } 185 + thead.appendChild(headerRow); 186 + table.appendChild(thead); 187 + 188 + // Data rows 189 + if (rows.length > 1) { 190 + const tbody = document.createElement('tbody'); 191 + for (let i = 1; i < rows.length; i++) { 192 + const tr = document.createElement('tr'); 193 + const maxCols = rows[0].length; 194 + for (let j = 0; j < maxCols; j++) { 195 + const td = document.createElement('td'); 196 + td.textContent = rows[i][j] || ''; 197 + td.style.cssText = `padding:4px 8px;border:1px solid ${borderColor};text-align:left;`; 198 + tr.appendChild(td); 199 + } 200 + tbody.appendChild(tr); 201 + } 202 + table.appendChild(tbody); 203 + } 204 + 205 + wrapper.appendChild(table); 206 + return wrapper; 207 + } 208 + 209 + /** 139 210 * Render the main slide canvas with all elements. 140 211 */ 141 212 export function renderCanvas(refs: DOMRefs, actions: AppActions): void { ··· 178 249 img.style.cssText = 'width:100%;height:100%;object-fit:contain;'; 179 250 img.alt = ''; 180 251 div.appendChild(img); 252 + } else if (el.type === 'table') { 253 + div.appendChild(renderTableElement(el.content, el.width, el.height, theme)); 181 254 } 182 255 183 256 // Click to select + start drag
+33
src/templates.ts
··· 41 41 content: '<h1>Weekly Planner</h1><p><strong>Week of:</strong> </p><h2>Monday</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Tuesday</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Wednesday</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Thursday</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Friday</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Notes</h2><p></p>', 42 42 }, 43 43 44 + { 45 + id: 'technical-spec', 46 + name: 'Technical Spec', 47 + description: 'Architecture, API design, and implementation plan', 48 + type: 'doc', 49 + icon: '\u2699', 50 + content: '<h1>Technical Specification</h1><h2>Summary</h2><p>One-paragraph summary of the technical change.</p><h2>Background</h2><p></p><h2>Goals and Non-Goals</h2><h3>Goals</h3><ul><li><p></p></li></ul><h3>Non-Goals</h3><ul><li><p></p></li></ul><h2>Design</h2><h3>Architecture</h3><p></p><h3>Data Model</h3><p></p><h3>API</h3><p></p><h2>Alternatives Considered</h2><p></p><h2>Security Considerations</h2><p></p><h2>Testing Plan</h2><ul><li><p></p></li></ul>', 51 + }, 52 + { 53 + id: 'letter', 54 + name: 'Letter', 55 + description: 'Formal letter with greeting and signature', 56 + type: 'doc', 57 + icon: '\u2709', 58 + content: '<p></p><p>Dear ,</p><p></p><p></p><p></p><p>Sincerely,</p><p></p><p><em>Your Name</em></p>', 59 + }, 60 + { 61 + id: 'journal', 62 + name: 'Journal Entry', 63 + description: 'Daily reflection, gratitude, and goals', 64 + type: 'doc', 65 + icon: '\u263c', 66 + content: '<h1>Journal Entry</h1><h2>Thoughts</h2><p></p><h2>Grateful For</h2><ul><li><p></p></li></ul><h2>Goals for Today</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Notes</h2><p></p>', 67 + }, 68 + { 69 + id: 'knowledge-base', 70 + name: 'Knowledge Base Article', 71 + description: 'How-to guide with prerequisites and steps', 72 + type: 'doc', 73 + icon: '\u2139', 74 + content: '<h1>Article Title</h1><blockquote><p><strong>Summary:</strong> A one-sentence description.</p></blockquote><h2>Prerequisites</h2><ul><li><p></p></li></ul><h2>Steps</h2><ol><li><p>Step 1</p></li><li><p>Step 2</p></li><li><p>Step 3</p></li></ol><h2>Troubleshooting</h2><p><strong>Problem:</strong> </p><p><strong>Solution:</strong> </p><h2>Related Articles</h2><ul><li><p></p></li></ul>', 75 + }, 76 + 44 77 // --- Sheet Templates --- 45 78 { 46 79 id: 'budget-tracker',
+89
tests/answer-piping.test.ts
··· 1 + /** 2 + * Tests for answer piping in form questions. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { resolvePipes, hasPipes } from '../src/forms/answer-piping.js'; 6 + import type { FormSchema } from '../src/forms/form-builder.js'; 7 + 8 + function makeForm(questions: Array<{ id: string; type: string; label: string; options?: Array<{ id: string; label: string }> }>): FormSchema { 9 + return { 10 + id: 'form-1', 11 + title: 'Test Form', 12 + description: '', 13 + questions: questions.map(q => ({ 14 + ...q, 15 + description: '', 16 + required: false, 17 + options: q.options || [], 18 + createdAt: 0, 19 + updatedAt: 0, 20 + })), 21 + targetSheetId: null, 22 + createdAt: 0, 23 + updatedAt: 0, 24 + } as FormSchema; 25 + } 26 + 27 + describe('resolvePipes', () => { 28 + const form = makeForm([ 29 + { id: 'q1', type: 'short_text', label: 'Name' }, 30 + { id: 'q2', type: 'email', label: 'Email' }, 31 + { id: 'q3', type: 'single_choice', label: 'Color', options: [{ id: 'opt-red', label: 'Red' }, { id: 'opt-blue', label: 'Blue' }] }, 32 + { id: 'q4', type: 'multiple_choice', label: 'Foods', options: [{ id: 'opt-pizza', label: 'Pizza' }, { id: 'opt-sushi', label: 'Sushi' }] }, 33 + ]); 34 + 35 + it('replaces text answer placeholder', () => { 36 + const answers = new Map<string, unknown>([['q1', 'Alice']]); 37 + expect(resolvePipes('Hello {{Q1}}!', answers, form)).toBe('Hello Alice!'); 38 + }); 39 + 40 + it('replaces multiple placeholders', () => { 41 + const answers = new Map<string, unknown>([['q1', 'Alice'], ['q2', 'alice@test.com']]); 42 + expect(resolvePipes('{{Q1}} ({{Q2}})', answers, form)).toBe('Alice (alice@test.com)'); 43 + }); 44 + 45 + it('leaves unresolved placeholders when no answer', () => { 46 + const answers = new Map<string, unknown>(); 47 + expect(resolvePipes('Hello {{Q1}}', answers, form)).toBe('Hello {{Q1}}'); 48 + }); 49 + 50 + it('leaves invalid placeholder indices unchanged', () => { 51 + const answers = new Map<string, unknown>(); 52 + expect(resolvePipes('{{Q0}} {{Q99}}', answers, form)).toBe('{{Q0}} {{Q99}}'); 53 + }); 54 + 55 + it('resolves single choice to option label', () => { 56 + const answers = new Map<string, unknown>([['q3', 'opt-red']]); 57 + expect(resolvePipes('You chose {{Q3}}', answers, form)).toBe('You chose Red'); 58 + }); 59 + 60 + it('resolves multiple choice to comma-separated labels', () => { 61 + const answers = new Map<string, unknown>([['q4', ['opt-pizza', 'opt-sushi']]]); 62 + expect(resolvePipes('Foods: {{Q4}}', answers, form)).toBe('Foods: Pizza, Sushi'); 63 + }); 64 + 65 + it('returns text unchanged when no placeholders', () => { 66 + const answers = new Map<string, unknown>([['q1', 'Alice']]); 67 + expect(resolvePipes('No placeholders here', answers, form)).toBe('No placeholders here'); 68 + }); 69 + 70 + it('handles numeric answers', () => { 71 + const numForm = makeForm([{ id: 'q1', type: 'number', label: 'Age' }]); 72 + const answers = new Map<string, unknown>([['q1', 25]]); 73 + expect(resolvePipes('Age: {{Q1}}', answers, numForm)).toBe('Age: 25'); 74 + }); 75 + }); 76 + 77 + describe('hasPipes', () => { 78 + it('returns true for text with pipes', () => { 79 + expect(hasPipes('Hello {{Q1}}')).toBe(true); 80 + }); 81 + 82 + it('returns false for text without pipes', () => { 83 + expect(hasPipes('Hello world')).toBe(false); 84 + }); 85 + 86 + it('returns false for partial pipe syntax', () => { 87 + expect(hasPipes('Hello {Q1}')).toBe(false); 88 + }); 89 + });
+67
tests/calendar-timezone.test.ts
··· 1 + /** 2 + * Tests for calendar timezone helpers. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { getTimezoneAbbr, formatTimeWithTz, COMMON_TIMEZONES } from '../src/calendar/helpers.js'; 6 + 7 + describe('COMMON_TIMEZONES', () => { 8 + it('contains expected major timezones', () => { 9 + expect(COMMON_TIMEZONES).toContain('America/New_York'); 10 + expect(COMMON_TIMEZONES).toContain('Europe/London'); 11 + expect(COMMON_TIMEZONES).toContain('Asia/Tokyo'); 12 + expect(COMMON_TIMEZONES).toContain('America/Los_Angeles'); 13 + }); 14 + 15 + it('has at least 20 timezones', () => { 16 + expect(COMMON_TIMEZONES.length).toBeGreaterThanOrEqual(20); 17 + }); 18 + 19 + it('all entries are valid IANA timezone IDs', () => { 20 + for (const tz of COMMON_TIMEZONES) { 21 + expect(() => new Intl.DateTimeFormat('en-US', { timeZone: tz })).not.toThrow(); 22 + } 23 + }); 24 + }); 25 + 26 + describe('getTimezoneAbbr', () => { 27 + it('returns a short abbreviation for known timezones', () => { 28 + const abbr = getTimezoneAbbr('America/New_York'); 29 + // Could be EST or EDT depending on time of year 30 + expect(abbr).toMatch(/^[A-Z]{2,5}$/); 31 + }); 32 + 33 + it('returns something for UTC', () => { 34 + const abbr = getTimezoneAbbr('UTC'); 35 + expect(abbr.length).toBeGreaterThan(0); 36 + }); 37 + 38 + it('handles invalid timezone gracefully', () => { 39 + const abbr = getTimezoneAbbr('Invalid/Zone'); 40 + expect(typeof abbr).toBe('string'); 41 + }); 42 + }); 43 + 44 + describe('formatTimeWithTz', () => { 45 + it('formats 12-hour time without timezone', () => { 46 + expect(formatTimeWithTz('14:30', false)).toBe('2:30pm'); 47 + }); 48 + 49 + it('formats 24-hour time without timezone', () => { 50 + expect(formatTimeWithTz('14:30', true)).toBe('14:30'); 51 + }); 52 + 53 + it('appends timezone abbreviation when provided', () => { 54 + const result = formatTimeWithTz('09:00', false, 'America/New_York'); 55 + expect(result).toMatch(/^9am [A-Z]{2,5}$/); 56 + }); 57 + 58 + it('does not append timezone when not provided', () => { 59 + const result = formatTimeWithTz('09:00', false); 60 + expect(result).toBe('9am'); 61 + }); 62 + 63 + it('returns empty string for empty time', () => { 64 + expect(formatTimeWithTz('', false)).toBe(''); 65 + expect(formatTimeWithTz('', true, 'UTC')).toBe(''); 66 + }); 67 + });
+130
tests/goal-seek.test.ts
··· 1 + /** 2 + * Tests for Goal Seek solver. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { goalSeek } from '../src/sheets/goal-seek.js'; 6 + 7 + describe('goalSeek', () => { 8 + it('solves simple linear equation (2x = 10 → x = 5)', () => { 9 + const result = goalSeek({ 10 + evaluate: (x) => 2 * x, 11 + target: 10, 12 + initialGuess: 0, 13 + }); 14 + expect(result.found).toBe(true); 15 + expect(result.value).toBeCloseTo(5, 5); 16 + expect(result.error).toBeLessThan(1e-7); 17 + }); 18 + 19 + it('solves quadratic (x² = 25 → x = 5, starting near 5)', () => { 20 + const result = goalSeek({ 21 + evaluate: (x) => x * x, 22 + target: 25, 23 + initialGuess: 3, 24 + }); 25 + expect(result.found).toBe(true); 26 + expect(result.value).toBeCloseTo(5, 5); 27 + }); 28 + 29 + it('solves when initial guess is already correct', () => { 30 + const result = goalSeek({ 31 + evaluate: (x) => x + 1, 32 + target: 6, 33 + initialGuess: 5, 34 + }); 35 + expect(result.found).toBe(true); 36 + expect(result.value).toBeCloseTo(5, 5); 37 + expect(result.iterations).toBeLessThanOrEqual(1); 38 + }); 39 + 40 + it('finds zero crossing (x³ - 8 = 0 → x = 2)', () => { 41 + const result = goalSeek({ 42 + evaluate: (x) => x * x * x, 43 + target: 8, 44 + initialGuess: 1, 45 + }); 46 + expect(result.found).toBe(true); 47 + expect(result.value).toBeCloseTo(2, 4); 48 + }); 49 + 50 + it('solves with negative target (3x = -12 → x = -4)', () => { 51 + const result = goalSeek({ 52 + evaluate: (x) => 3 * x, 53 + target: -12, 54 + initialGuess: 0, 55 + }); 56 + expect(result.found).toBe(true); 57 + expect(result.value).toBeCloseTo(-4, 5); 58 + }); 59 + 60 + it('handles initial guess of 0', () => { 61 + const result = goalSeek({ 62 + evaluate: (x) => x + 10, 63 + target: 15, 64 + }); 65 + expect(result.found).toBe(true); 66 + expect(result.value).toBeCloseTo(5, 5); 67 + }); 68 + 69 + it('solves exponential-like function', () => { 70 + const result = goalSeek({ 71 + evaluate: (x) => Math.exp(x), 72 + target: Math.E * Math.E, // e² ≈ 7.389 73 + initialGuess: 1, 74 + }); 75 + expect(result.found).toBe(true); 76 + expect(result.value).toBeCloseTo(2, 4); 77 + }); 78 + 79 + it('respects custom tolerance', () => { 80 + const result = goalSeek({ 81 + evaluate: (x) => x * x, 82 + target: 100, 83 + initialGuess: 5, 84 + tolerance: 0.1, 85 + }); 86 + expect(result.found).toBe(true); 87 + expect(Math.abs(result.result - 100)).toBeLessThan(0.1); 88 + }); 89 + 90 + it('returns found=false when function is constant (no solution)', () => { 91 + const result = goalSeek({ 92 + evaluate: () => 5, 93 + target: 10, 94 + initialGuess: 0, 95 + maxIterations: 20, 96 + }); 97 + expect(result.found).toBe(false); 98 + expect(result.iterations).toBe(20); 99 + }); 100 + 101 + it('reports iterations count', () => { 102 + const result = goalSeek({ 103 + evaluate: (x) => x, 104 + target: 42, 105 + initialGuess: 0, 106 + }); 107 + expect(result.found).toBe(true); 108 + expect(result.iterations).toBeGreaterThan(0); 109 + expect(result.iterations).toBeLessThan(20); 110 + }); 111 + 112 + it('solves PMT-like financial formula', () => { 113 + // PMT = PV * r / (1 - (1+r)^-n) with PV=100000, n=360 114 + // Find rate where PMT = 500 115 + const n = 360; 116 + const pv = 100000; 117 + const result = goalSeek({ 118 + evaluate: (r) => { 119 + if (r <= 0) return pv / n; 120 + return pv * r / (1 - Math.pow(1 + r, -n)); 121 + }, 122 + target: 500, 123 + initialGuess: 0.005, 124 + }); 125 + expect(result.found).toBe(true); 126 + expect(result.value).toBeGreaterThan(0); 127 + expect(result.value).toBeLessThan(0.01); 128 + expect(Math.abs(result.result - 500)).toBeLessThan(0.01); 129 + }); 130 + });
+4 -5
tests/pptx-import.test.ts
··· 239 239 expect(deck.slides[0]!.notes).toContain('speaker notes'); 240 240 }); 241 241 242 - it('parses table (graphicFrame) as text element', async () => { 242 + it('parses table (graphicFrame) as table element', async () => { 243 243 const gfXml = ` 244 244 <p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 245 245 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> ··· 255 255 </p:graphicFrame>`; 256 256 const buf = await buildPptx([{ xml: slideXml(gfXml) }]); 257 257 const deck = await convertPptxToDeck(buf); 258 - const el = deck.slides[0]!.elements.find(e => e.type === 'text' && e.content.includes('Name')); 258 + const el = deck.slides[0]!.elements.find(e => e.type === 'table'); 259 259 expect(el).toBeDefined(); 260 - expect(el!.content).toContain('Value'); 261 - expect(el!.content).toContain('Alpha'); 262 - expect(el!.content).toContain('---'); // header separator 260 + const rows = JSON.parse(el!.content); 261 + expect(rows).toEqual([['Name', 'Value'], ['Alpha', '42']]); 263 262 }); 264 263 265 264 it('flattens group shapes into elements', async () => {
+90
tests/response-export.test.ts
··· 1 + /** 2 + * Tests for form response export (CSV generation). 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + createPipelineConfig, 7 + pipelineHeaders, 8 + responseToRow, 9 + type FormResponse, 10 + } from '../src/forms/responses.js'; 11 + import { escapeField } from '../src/sheets/csv-export.js'; 12 + 13 + function makeResponse(answers: Record<string, unknown>, submittedAt = Date.now()): FormResponse { 14 + return { 15 + id: `resp-${submittedAt}`, 16 + formId: 'form-1', 17 + answers: new Map(Object.entries(answers)), 18 + submittedAt, 19 + submitterId: '', 20 + }; 21 + } 22 + 23 + describe('response export pipeline', () => { 24 + const config = createPipelineConfig( 25 + 'form-1', 26 + '', 27 + ['q1', 'q2', 'q3'], 28 + ['Name', 'Email', 'Rating'], 29 + ); 30 + 31 + it('generates correct headers including timestamp', () => { 32 + const headers = pipelineHeaders(config); 33 + expect(headers).toEqual(['Name', 'Email', 'Rating', 'Submitted At']); 34 + }); 35 + 36 + it('generates headers without timestamp when disabled', () => { 37 + const noTs = createPipelineConfig('f', '', ['q1'], ['Name'], false); 38 + expect(pipelineHeaders(noTs)).toEqual(['Name']); 39 + }); 40 + 41 + it('converts response to row array', () => { 42 + const resp = makeResponse({ q1: 'Alice', q2: 'alice@example.com', q3: 5 }, 1700000000000); 43 + const row = responseToRow(resp, config); 44 + expect(row[0]).toBe('Alice'); 45 + expect(row[1]).toBe('alice@example.com'); 46 + expect(row[2]).toBe(5); 47 + expect(row[3]).toBe(new Date(1700000000000).toISOString()); 48 + }); 49 + 50 + it('handles missing answers as empty string', () => { 51 + const resp = makeResponse({ q1: 'Bob' }); 52 + const row = responseToRow(resp, config); 53 + expect(row[0]).toBe('Bob'); 54 + expect(row[1]).toBe(''); 55 + expect(row[2]).toBe(''); 56 + }); 57 + 58 + it('handles array answers (multiple choice)', () => { 59 + const resp = makeResponse({ q1: ['opt-a', 'opt-b'] }); 60 + const row = responseToRow(resp, config); 61 + expect(row[0]).toEqual(['opt-a', 'opt-b']); 62 + }); 63 + }); 64 + 65 + describe('CSV escaping', () => { 66 + it('escapes fields with commas', () => { 67 + expect(escapeField('hello, world', ',')).toBe('"hello, world"'); 68 + }); 69 + 70 + it('escapes fields with double quotes', () => { 71 + expect(escapeField('say "hi"', ',')).toBe('"say ""hi"""'); 72 + }); 73 + 74 + it('escapes fields with newlines', () => { 75 + expect(escapeField('line1\nline2', ',')).toBe('"line1\nline2"'); 76 + }); 77 + 78 + it('returns empty string as empty', () => { 79 + expect(escapeField('', ',')).toBe(''); 80 + }); 81 + 82 + it('does not escape plain values', () => { 83 + expect(escapeField('hello', ',')).toBe('hello'); 84 + }); 85 + 86 + it('uses tab delimiter correctly', () => { 87 + expect(escapeField('hello\tworld', '\t')).toBe('"hello\tworld"'); 88 + expect(escapeField('hello, world', '\t')).toBe('hello, world'); 89 + }); 90 + });
+133
tests/toc-block.test.ts
··· 1 + /** 2 + * Tests for Table of Contents utilities. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { buildTocEntries, slugify, generateTocHtml } from '../src/docs/table-of-contents.js'; 6 + import { extractHeadings, generateHeadingId, buildOutlineTree } from '../src/docs/outline.js'; 7 + 8 + describe('slugify', () => { 9 + it('lowercases and hyphenates', () => { 10 + expect(slugify('Hello World')).toBe('hello-world'); 11 + }); 12 + 13 + it('strips non-alphanumeric chars', () => { 14 + expect(slugify('What is this?!')).toBe('what-is-this'); 15 + }); 16 + 17 + it('collapses multiple hyphens', () => { 18 + expect(slugify('a b---c')).toBe('a-b-c'); 19 + }); 20 + 21 + it('trims leading/trailing hyphens', () => { 22 + expect(slugify(' --hello-- ')).toBe('hello'); 23 + }); 24 + 25 + it('handles empty string', () => { 26 + expect(slugify('')).toBe(''); 27 + }); 28 + }); 29 + 30 + describe('generateHeadingId', () => { 31 + it('creates url-safe id', () => { 32 + expect(generateHeadingId('Getting Started')).toBe('getting-started'); 33 + }); 34 + 35 + it('appends index for duplicates', () => { 36 + expect(generateHeadingId('intro', 0)).toBe('intro'); 37 + expect(generateHeadingId('intro', 2)).toBe('intro-2'); 38 + }); 39 + 40 + it('falls back to "heading" for empty text', () => { 41 + expect(generateHeadingId('')).toBe('heading'); 42 + }); 43 + }); 44 + 45 + describe('extractHeadings (from editor JSON)', () => { 46 + it('extracts headings from JSON nodes', () => { 47 + const json = { 48 + content: [ 49 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] }, 50 + { type: 'paragraph', content: [{ type: 'text', text: 'body' }] }, 51 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Section' }] }, 52 + ], 53 + }; 54 + const headings = extractHeadings(json); 55 + expect(headings).toHaveLength(2); 56 + expect(headings[0]).toEqual({ level: 1, text: 'Title', id: 'title' }); 57 + expect(headings[1]).toEqual({ level: 2, text: 'Section', id: 'section' }); 58 + }); 59 + 60 + it('handles duplicate heading text', () => { 61 + const json = { 62 + content: [ 63 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Part' }] }, 64 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Part' }] }, 65 + ], 66 + }; 67 + const headings = extractHeadings(json); 68 + expect(headings[0].id).toBe('part'); 69 + expect(headings[1].id).toBe('part-1'); 70 + }); 71 + 72 + it('returns empty for no content', () => { 73 + expect(extractHeadings({})).toEqual([]); 74 + expect(extractHeadings({ content: [] })).toEqual([]); 75 + }); 76 + }); 77 + 78 + describe('buildOutlineTree', () => { 79 + it('nests headings by level', () => { 80 + const headings = [ 81 + { level: 1, text: 'A', id: 'a' }, 82 + { level: 2, text: 'B', id: 'b' }, 83 + { level: 2, text: 'C', id: 'c' }, 84 + { level: 1, text: 'D', id: 'd' }, 85 + ]; 86 + const tree = buildOutlineTree(headings); 87 + expect(tree).toHaveLength(2); 88 + expect(tree[0].text).toBe('A'); 89 + expect(tree[0].children).toHaveLength(2); 90 + expect(tree[1].text).toBe('D'); 91 + expect(tree[1].children).toHaveLength(0); 92 + }); 93 + 94 + it('returns empty for no headings', () => { 95 + expect(buildOutlineTree([])).toEqual([]); 96 + }); 97 + }); 98 + 99 + describe('buildTocEntries', () => { 100 + it('computes relative depth', () => { 101 + const html = '<h2 id="a">A</h2><h3 id="b">B</h3><h2 id="c">C</h2>'; 102 + const entries = buildTocEntries(html); 103 + expect(entries).toHaveLength(3); 104 + expect(entries[0].depth).toBe(0); // h2 is shallowest 105 + expect(entries[1].depth).toBe(1); // h3 is one deeper 106 + expect(entries[2].depth).toBe(0); 107 + }); 108 + 109 + it('returns empty for no headings', () => { 110 + expect(buildTocEntries('<p>hello</p>')).toEqual([]); 111 + }); 112 + }); 113 + 114 + describe('generateTocHtml', () => { 115 + it('generates nested ul/li with links', () => { 116 + const html = '<h1 id="intro">Introduction</h1><h2 id="setup">Setup</h2>'; 117 + const toc = generateTocHtml(html); 118 + expect(toc).toContain('<ul class="toc-list">'); 119 + expect(toc).toContain('<a href="#intro">Introduction</a>'); 120 + expect(toc).toContain('<a href="#setup">Setup</a>'); 121 + }); 122 + 123 + it('escapes HTML in heading text', () => { 124 + // Entities decoded ("A & B <C>") then re-escaped by escapeHtml → single encoding 125 + const html = '<h1 id="test">A &amp; B &lt;C&gt;</h1>'; 126 + const toc = generateTocHtml(html); 127 + expect(toc).toContain('A &amp; B &lt;C&gt;'); 128 + }); 129 + 130 + it('returns empty string for no headings', () => { 131 + expect(generateTocHtml('<p>no headings</p>')).toBe(''); 132 + }); 133 + });