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: document templates gallery (#56)' (#120) from feat/document-templates into main

scott d382305c b7e693fa

+290 -5
+15 -5
src/docs/main.ts
··· 973 973 } 974 974 975 975 // Check for daily note template 976 - const templateKey = `daily-note-template-${docId}`; 977 - const templateHtml = sessionStorage.getItem(templateKey); 978 - if (templateHtml && editor.isEmpty) { 979 - sessionStorage.removeItem(templateKey); 980 - editor.commands.setContent(templateHtml); 976 + const dailyKey = `daily-note-template-${docId}`; 977 + const dailyHtml = sessionStorage.getItem(dailyKey); 978 + if (dailyHtml && editor.isEmpty) { 979 + sessionStorage.removeItem(dailyKey); 980 + editor.commands.setContent(dailyHtml); 981 + } 982 + 983 + // Check for document template content 984 + const tmplKey = `template-content-${docId}`; 985 + const tmplContent = sessionStorage.getItem(tmplKey); 986 + const tmplType = sessionStorage.getItem(`template-type-${docId}`); 987 + if (tmplContent && tmplType === 'doc' && editor.isEmpty) { 988 + sessionStorage.removeItem(tmplKey); 989 + sessionStorage.removeItem(`template-type-${docId}`); 990 + editor.commands.setContent(tmplContent); 981 991 } 982 992 }); 983 993
+47
src/landing.ts
··· 4 4 import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 5 5 import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 6 6 import { exportBackup, importBackup } from './backup.js'; 7 + import { BUILT_IN_TEMPLATES, getTemplate, type DocTemplate } from './templates.js'; 7 8 import { 8 9 sortDocuments, 9 10 toggleStar, ··· 190 191 localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 191 192 192 193 const path = type === 'doc' ? '/docs' : '/sheets'; 194 + window.location.href = `${path}/${id}#${keyStr}`; 195 + } 196 + 197 + async function createFromTemplate(templateId: string): Promise<void> { 198 + const template = getTemplate(templateId); 199 + if (!template) return; 200 + 201 + const key = await generateKey(); 202 + const keyStr = await exportKey(key); 203 + 204 + const nameBytes = new TextEncoder().encode(template.name); 205 + const { encrypt } = await import('./lib/crypto.js'); 206 + const encryptedName = await encrypt(nameBytes, key); 207 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 208 + 209 + const res = await fetch('/api/documents', { 210 + method: 'POST', 211 + headers: { 'Content-Type': 'application/json' }, 212 + body: JSON.stringify({ type: template.type, name_encrypted: nameB64 }), 213 + }); 214 + const { id } = await res.json(); 215 + 216 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 217 + keys[id] = keyStr; 218 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 219 + 220 + if (currentFolderId) { 221 + folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); 222 + localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 223 + } 224 + 225 + // Store template content for the editor to pick up 226 + sessionStorage.setItem(`template-content-${id}`, template.content); 227 + sessionStorage.setItem(`template-type-${id}`, template.type); 228 + 229 + recentIds = trackRecentDoc(recentIds, id); 230 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 231 + 232 + const path = template.type === 'doc' ? '/docs' : '/sheets'; 193 233 window.location.href = `${path}/${id}#${keyStr}`; 194 234 } 195 235 ··· 1104 1144 icon: '\u2912', 1105 1145 action: () => backupImportBtn.click(), 1106 1146 }, 1147 + ...BUILT_IN_TEMPLATES.map(t => ({ 1148 + id: `template-${t.id}`, 1149 + label: `Template: ${t.name}`, 1150 + category: 'action' as const, 1151 + icon: t.icon, 1152 + action: () => createFromTemplate(t.id), 1153 + })), 1107 1154 ], 1108 1155 fetchDocuments: async (): Promise<PaletteAction[]> => { 1109 1156 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}');
+21
src/sheets/main.ts
··· 3116 3116 window.__importInProgress = false; 3117 3117 } 3118 3118 } 3119 + 3120 + // Check for sheet template content 3121 + const tmplKey = `template-content-${docId}`; 3122 + const tmplContent = sessionStorage.getItem(tmplKey); 3123 + const tmplType = sessionStorage.getItem(`template-type-${docId}`); 3124 + if (tmplContent && tmplType === 'sheet') { 3125 + sessionStorage.removeItem(tmplKey); 3126 + sessionStorage.removeItem(`template-type-${docId}`); 3127 + try { 3128 + const cellMap = JSON.parse(tmplContent); 3129 + const cells = getCells(); 3130 + if (cells.size === 0) { 3131 + ydoc.transact(() => { 3132 + for (const [cellId, data] of Object.entries(cellMap)) { 3133 + setCellData(cellId, data as any); 3134 + } 3135 + }); 3136 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3137 + } 3138 + } catch { /* ignore invalid template */ } 3139 + } 3119 3140 }); 3120 3141 3121 3142 // --- Collaboration avatars ---
+101
src/templates.ts
··· 1 + /** 2 + * Document Templates — built-in and user-created templates. 3 + * 4 + * Pure data module: template definitions and lookup functions. 5 + * No DOM dependencies — UI integration is in landing.ts. 6 + */ 7 + 8 + export interface DocTemplate { 9 + id: string; 10 + name: string; 11 + description: string; 12 + type: 'doc' | 'sheet'; 13 + icon: string; 14 + content: string; // HTML for docs, JSON cell data for sheets 15 + } 16 + 17 + export const BUILT_IN_TEMPLATES: DocTemplate[] = [ 18 + // --- Document Templates --- 19 + { 20 + id: 'meeting-notes', 21 + name: 'Meeting Notes', 22 + description: 'Structured meeting notes with attendees, agenda, and action items', 23 + type: 'doc', 24 + icon: '\uD83D\uDCCB', 25 + content: '<h1>Meeting Notes</h1><p><strong>Date:</strong> </p><p><strong>Attendees:</strong> </p><h2>Agenda</h2><ol><li><p></p></li></ol><h2>Discussion</h2><p></p><h2>Action Items</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Next Steps</h2><p></p>', 26 + }, 27 + { 28 + id: 'project-brief', 29 + name: 'Project Brief', 30 + description: 'Project overview with goals, timeline, and stakeholders', 31 + type: 'doc', 32 + icon: '\uD83D\uDCC4', 33 + content: '<h1>Project Brief</h1><h2>Overview</h2><p></p><h2>Goals</h2><ul><li><p></p></li></ul><h2>Scope</h2><p></p><h2>Timeline</h2><table><tr><th>Milestone</th><th>Date</th><th>Status</th></tr><tr><td></td><td></td><td></td></tr></table><h2>Stakeholders</h2><ul><li><p></p></li></ul><h2>Risks</h2><ul><li><p></p></li></ul>', 34 + }, 35 + { 36 + id: 'weekly-planner', 37 + name: 'Weekly Planner', 38 + description: 'Week-at-a-glance planner with daily sections', 39 + type: 'doc', 40 + icon: '\uD83D\uDCC5', 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 + }, 43 + 44 + // --- Sheet Templates --- 45 + { 46 + id: 'budget-tracker', 47 + name: 'Budget Tracker', 48 + description: 'Monthly budget with income, expenses, and totals', 49 + type: 'sheet', 50 + icon: '\uD83D\uDCB0', 51 + content: JSON.stringify({ 52 + A1: { v: 'Category', s: { bold: true } }, 53 + B1: { v: 'Budget', s: { bold: true } }, 54 + C1: { v: 'Actual', s: { bold: true } }, 55 + D1: { v: 'Difference', s: { bold: true } }, 56 + A2: { v: 'Housing' }, B2: { v: 0, s: { format: 'currency' } }, C2: { v: 0, s: { format: 'currency' } }, D2: { f: 'B2-C2', s: { format: 'currency' } }, 57 + A3: { v: 'Food' }, B3: { v: 0, s: { format: 'currency' } }, C3: { v: 0, s: { format: 'currency' } }, D3: { f: 'B3-C3', s: { format: 'currency' } }, 58 + A4: { v: 'Transport' }, B4: { v: 0, s: { format: 'currency' } }, C4: { v: 0, s: { format: 'currency' } }, D4: { f: 'B4-C4', s: { format: 'currency' } }, 59 + A5: { v: 'Utilities' }, B5: { v: 0, s: { format: 'currency' } }, C5: { v: 0, s: { format: 'currency' } }, D5: { f: 'B5-C5', s: { format: 'currency' } }, 60 + A6: { v: 'Other' }, B6: { v: 0, s: { format: 'currency' } }, C6: { v: 0, s: { format: 'currency' } }, D6: { f: 'B6-C6', s: { format: 'currency' } }, 61 + A7: { v: 'Total', s: { bold: true } }, B7: { f: 'SUM(B2:B6)', s: { bold: true, format: 'currency' } }, C7: { f: 'SUM(C2:C6)', s: { bold: true, format: 'currency' } }, D7: { f: 'B7-C7', s: { bold: true, format: 'currency' } }, 62 + }), 63 + }, 64 + { 65 + id: 'invoice', 66 + name: 'Invoice', 67 + description: 'Simple invoice with line items and totals', 68 + type: 'sheet', 69 + icon: '\uD83E\uDDFE', 70 + content: JSON.stringify({ 71 + A1: { v: 'INVOICE', s: { bold: true, fontSize: 18 } }, 72 + A3: { v: 'Bill To:', s: { bold: true } }, 73 + A4: { v: '' }, 74 + A6: { v: 'Item', s: { bold: true } }, 75 + B6: { v: 'Quantity', s: { bold: true } }, 76 + C6: { v: 'Unit Price', s: { bold: true } }, 77 + D6: { v: 'Amount', s: { bold: true } }, 78 + A7: { v: '' }, B7: { v: 0 }, C7: { v: 0, s: { format: 'currency' } }, D7: { f: 'B7*C7', s: { format: 'currency' } }, 79 + A8: { v: '' }, B8: { v: 0 }, C8: { v: 0, s: { format: 'currency' } }, D8: { f: 'B8*C8', s: { format: 'currency' } }, 80 + A9: { v: '' }, B9: { v: 0 }, C9: { v: 0, s: { format: 'currency' } }, D9: { f: 'B9*C9', s: { format: 'currency' } }, 81 + A10: { v: '' }, B10: { v: 0 }, C10: { v: 0, s: { format: 'currency' } }, D10: { f: 'B10*C10', s: { format: 'currency' } }, 82 + C11: { v: 'Subtotal', s: { bold: true } }, D11: { f: 'SUM(D7:D10)', s: { bold: true, format: 'currency' } }, 83 + C12: { v: 'Tax (10%)', s: { bold: true } }, D12: { f: 'D11*0.1', s: { format: 'currency' } }, 84 + C13: { v: 'Total', s: { bold: true } }, D13: { f: 'D11+D12', s: { bold: true, format: 'currency' } }, 85 + }), 86 + }, 87 + ]; 88 + 89 + /** 90 + * Get a template by ID. 91 + */ 92 + export function getTemplate(id: string): DocTemplate | null { 93 + return BUILT_IN_TEMPLATES.find(t => t.id === id) || null; 94 + } 95 + 96 + /** 97 + * Get all templates for a given document type. 98 + */ 99 + export function getTemplatesByType(type: 'doc' | 'sheet'): DocTemplate[] { 100 + return BUILT_IN_TEMPLATES.filter(t => t.type === type); 101 + }
+106
tests/templates.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + BUILT_IN_TEMPLATES, 4 + getTemplate, 5 + getTemplatesByType, 6 + type DocTemplate, 7 + } from '../src/templates.js'; 8 + 9 + describe('Document Templates', () => { 10 + describe('built-in templates', () => { 11 + it('includes meeting notes template', () => { 12 + const t = getTemplate('meeting-notes'); 13 + expect(t).not.toBeNull(); 14 + expect(t!.name).toBe('Meeting Notes'); 15 + expect(t!.type).toBe('doc'); 16 + expect(t!.content).toContain('Attendees'); 17 + }); 18 + 19 + it('includes project brief template', () => { 20 + const t = getTemplate('project-brief'); 21 + expect(t).not.toBeNull(); 22 + expect(t!.name).toBe('Project Brief'); 23 + expect(t!.type).toBe('doc'); 24 + }); 25 + 26 + it('includes weekly planner template', () => { 27 + const t = getTemplate('weekly-planner'); 28 + expect(t).not.toBeNull(); 29 + expect(t!.name).toBe('Weekly Planner'); 30 + expect(t!.type).toBe('doc'); 31 + }); 32 + 33 + it('includes budget tracker template', () => { 34 + const t = getTemplate('budget-tracker'); 35 + expect(t).not.toBeNull(); 36 + expect(t!.name).toBe('Budget Tracker'); 37 + expect(t!.type).toBe('sheet'); 38 + }); 39 + 40 + it('includes invoice template', () => { 41 + const t = getTemplate('invoice'); 42 + expect(t).not.toBeNull(); 43 + expect(t!.name).toBe('Invoice'); 44 + expect(t!.type).toBe('sheet'); 45 + }); 46 + }); 47 + 48 + describe('getTemplate', () => { 49 + it('returns null for unknown template', () => { 50 + expect(getTemplate('nonexistent')).toBeNull(); 51 + }); 52 + 53 + it('returns the template with matching id', () => { 54 + const t = getTemplate('meeting-notes'); 55 + expect(t).not.toBeNull(); 56 + expect(t!.id).toBe('meeting-notes'); 57 + }); 58 + }); 59 + 60 + describe('getTemplatesByType', () => { 61 + it('filters templates by doc type', () => { 62 + const docTemplates = getTemplatesByType('doc'); 63 + expect(docTemplates.length).toBeGreaterThan(0); 64 + expect(docTemplates.every(t => t.type === 'doc')).toBe(true); 65 + }); 66 + 67 + it('filters templates by sheet type', () => { 68 + const sheetTemplates = getTemplatesByType('sheet'); 69 + expect(sheetTemplates.length).toBeGreaterThan(0); 70 + expect(sheetTemplates.every(t => t.type === 'sheet')).toBe(true); 71 + }); 72 + 73 + it('returns empty array for unknown type', () => { 74 + expect(getTemplatesByType('slides' as any)).toEqual([]); 75 + }); 76 + }); 77 + 78 + describe('template content', () => { 79 + it('all templates have non-empty content', () => { 80 + for (const t of BUILT_IN_TEMPLATES) { 81 + expect(t.content.length).toBeGreaterThan(0); 82 + } 83 + }); 84 + 85 + it('all templates have unique ids', () => { 86 + const ids = BUILT_IN_TEMPLATES.map(t => t.id); 87 + expect(new Set(ids).size).toBe(ids.length); 88 + }); 89 + 90 + it('all doc templates have valid HTML content', () => { 91 + const docTemplates = getTemplatesByType('doc'); 92 + for (const t of docTemplates) { 93 + expect(t.content).toContain('<'); 94 + } 95 + }); 96 + 97 + it('all sheet templates have JSON cell data', () => { 98 + const sheetTemplates = getTemplatesByType('sheet'); 99 + for (const t of sheetTemplates) { 100 + // Sheet templates use JSON cell data 101 + const parsed = JSON.parse(t.content); 102 + expect(typeof parsed).toBe('object'); 103 + } 104 + }); 105 + }); 106 + });