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(calendar): add iCal (.ics) export' (#320) from feat/ics-export into main

scott 82be475e 274aab87

+520 -1
+5
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.26.0] — 2026-04-09 9 + 10 + ### Added 11 + - Add iCal (.ics) export to calendar with Export toolbar button (#1) 12 + 8 13 ## [0.25.0] — 2026-04-08 9 14 10 15 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.25.0", 3 + "version": "0.26.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+156
src/calendar/ics-export.ts
··· 1 + /** 2 + * iCal (.ics) file generator. 3 + * 4 + * Converts CalendarEvent[] to a valid RFC 5545 .ics file string. 5 + * Handles: 6 + * 7 + * - Timed events (DTSTART/DTEND with time) 8 + * - All-day events (DTSTART;VALUE=DATE / DTEND;VALUE=DATE) 9 + * - Text escaping (commas, semicolons, newlines, backslashes) 10 + * - Line folding (max 75 octets per line, RFC 5545 §3.1) 11 + * - CRLF line endings 12 + */ 13 + 14 + import type { CalendarEvent } from './helpers.js'; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Constants 18 + // --------------------------------------------------------------------------- 19 + 20 + const CRLF = '\r\n'; 21 + const MAX_LINE_LENGTH = 75; 22 + 23 + // --------------------------------------------------------------------------- 24 + // Text escaping (RFC 5545 §3.3.11) 25 + // --------------------------------------------------------------------------- 26 + 27 + /** Escape special characters in iCal TEXT values. */ 28 + function escapeIcsText(text: string): string { 29 + return text 30 + .replace(/\\/g, '\\\\') 31 + .replace(/;/g, '\\;') 32 + .replace(/,/g, '\\,') 33 + .replace(/\n/g, '\\n'); 34 + } 35 + 36 + // --------------------------------------------------------------------------- 37 + // Line folding (RFC 5545 §3.1) 38 + // --------------------------------------------------------------------------- 39 + 40 + /** 41 + * Fold a content line so no single line exceeds 75 octets. 42 + * Continuation lines start with a single space character. 43 + */ 44 + function foldLine(line: string): string { 45 + if (line.length <= MAX_LINE_LENGTH) { 46 + return line; 47 + } 48 + 49 + const parts: string[] = []; 50 + // First line gets the full 75 chars 51 + parts.push(line.slice(0, MAX_LINE_LENGTH)); 52 + let pos = MAX_LINE_LENGTH; 53 + 54 + // Continuation lines: space prefix eats 1 char, so 74 chars of content 55 + while (pos < line.length) { 56 + const chunk = line.slice(pos, pos + MAX_LINE_LENGTH - 1); 57 + parts.push(' ' + chunk); 58 + pos += MAX_LINE_LENGTH - 1; 59 + } 60 + 61 + return parts.join(CRLF); 62 + } 63 + 64 + // --------------------------------------------------------------------------- 65 + // Date formatting 66 + // --------------------------------------------------------------------------- 67 + 68 + /** Convert "YYYY-MM-DD" to "YYYYMMDD" */ 69 + function toIcsDate(dateStr: string): string { 70 + return dateStr.replace(/-/g, ''); 71 + } 72 + 73 + /** Convert "YYYY-MM-DD" + "HH:MM" to "YYYYMMDDTHHMMSS" */ 74 + function toIcsDateTime(dateStr: string, timeStr: string): string { 75 + const datePart = toIcsDate(dateStr); 76 + const [h, m] = timeStr.split(':'); 77 + return `${datePart}T${h}${m}00`; 78 + } 79 + 80 + /** 81 + * Compute the next day for an all-day event DTEND. 82 + * RFC 5545 specifies DTEND is exclusive for DATE values, so a single-day 83 + * all-day event on April 15 needs DTEND of April 16. 84 + */ 85 + function nextDay(dateStr: string): string { 86 + const parts = dateStr.split('-').map(Number); 87 + const y = parts[0] ?? 0; 88 + const m = parts[1] ?? 1; 89 + const d = parts[2] ?? 1; 90 + const date = new Date(y, m - 1, d + 1); 91 + const ny = date.getFullYear(); 92 + const nm = String(date.getMonth() + 1).padStart(2, '0'); 93 + const nd = String(date.getDate()).padStart(2, '0'); 94 + return `${ny}-${nm}-${nd}`; 95 + } 96 + 97 + // --------------------------------------------------------------------------- 98 + // VEVENT generation 99 + // --------------------------------------------------------------------------- 100 + 101 + function eventToVEvent(event: CalendarEvent): string { 102 + const lines: string[] = []; 103 + 104 + lines.push('BEGIN:VEVENT'); 105 + lines.push(`UID:${event.id}@tools`); 106 + 107 + if (event.allDay) { 108 + lines.push(`DTSTART;VALUE=DATE:${toIcsDate(event.date)}`); 109 + lines.push(`DTEND;VALUE=DATE:${toIcsDate(nextDay(event.date))}`); 110 + } else { 111 + lines.push(`DTSTART:${toIcsDateTime(event.date, event.startTime)}`); 112 + lines.push(`DTEND:${toIcsDateTime(event.date, event.endTime)}`); 113 + } 114 + 115 + lines.push(`SUMMARY:${escapeIcsText(event.title)}`); 116 + 117 + if (event.description) { 118 + lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`); 119 + } 120 + 121 + lines.push('END:VEVENT'); 122 + 123 + return lines.map(foldLine).join(CRLF); 124 + } 125 + 126 + // --------------------------------------------------------------------------- 127 + // Public API 128 + // --------------------------------------------------------------------------- 129 + 130 + /** 131 + * Generate a valid RFC 5545 .ics file string from CalendarEvent objects. 132 + * 133 + * @param events - Array of calendar events to export 134 + * @param calendarName - Display name for the calendar (X-WR-CALNAME) 135 + * @returns Complete .ics file content with CRLF line endings 136 + */ 137 + export function exportIcsFile(events: CalendarEvent[], calendarName: string): string { 138 + const lines: string[] = []; 139 + 140 + // VCALENDAR header 141 + lines.push('BEGIN:VCALENDAR'); 142 + lines.push('VERSION:2.0'); 143 + lines.push('PRODID:-//Tools//Calendar//EN'); 144 + lines.push(`X-WR-CALNAME:${escapeIcsText(calendarName)}`); 145 + 146 + // VEVENT blocks 147 + for (const event of events) { 148 + lines.push(eventToVEvent(event)); 149 + } 150 + 151 + // Close wrapper 152 + lines.push('END:VCALENDAR'); 153 + 154 + // Join with CRLF; trailing CRLF is conventional 155 + return lines.join(CRLF) + CRLF; 156 + }
+1
src/calendar/index.html
··· 60 60 <span class="topbar-spacer"></span> 61 61 <button class="cal-nav-btn" id="btn-import" title="Import .ics calendar file">&#8679; Import</button> 62 62 <input type="file" id="ics-import-input" accept=".ics,.ical,.ifb,.icalendar" style="display:none"> 63 + <button class="cal-nav-btn" id="btn-export" title="Export calendar as .ics file">&#8681; Export</button> 63 64 <div class="cal-view-group"> 64 65 <button class="cal-view-btn active" data-view="month" title="Month view (M)">Month</button> 65 66 <button class="cal-view-btn" data-view="week" title="Week view (W)">Week</button>
+30
src/calendar/main.ts
··· 33 33 eventsOnDate as eventsOnDateHelper, 34 34 } from './helpers.js'; 35 35 import { parseIcsFile } from './ics-parser.js'; 36 + import { exportIcsFile } from './ics-export.js'; 36 37 import { showToast } from '../landing-toast.js'; 37 38 38 39 // --------------------------------------------------------------------------- ··· 1114 1115 1115 1116 // Reset input so the same file can be re-imported 1116 1117 importInput.value = ''; 1118 + }); 1119 + 1120 + // --------------------------------------------------------------------------- 1121 + // iCal export 1122 + // --------------------------------------------------------------------------- 1123 + 1124 + const exportBtn = document.getElementById('btn-export'); 1125 + 1126 + exportBtn?.addEventListener('click', () => { 1127 + if (state.events.length === 0) { 1128 + showToast('No events to export', 3000); 1129 + return; 1130 + } 1131 + 1132 + const calName = (document.getElementById('calendar-title') as HTMLInputElement | null)?.value || 'Calendar'; 1133 + const icsContent = exportIcsFile(state.events, calName); 1134 + 1135 + // Create a download via Blob + temporary anchor 1136 + const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); 1137 + const url = URL.createObjectURL(blob); 1138 + const a = document.createElement('a'); 1139 + a.href = url; 1140 + a.download = `${calName.replace(/[^a-zA-Z0-9_-]/g, '_')}.ics`; 1141 + document.body.appendChild(a); 1142 + a.click(); 1143 + document.body.removeChild(a); 1144 + URL.revokeObjectURL(url); 1145 + 1146 + showToast(`Exported ${state.events.length} events`, 3000); 1117 1147 }); 1118 1148 1119 1149 // ---------------------------------------------------------------------------
+327
tests/ics-export.test.ts
··· 1 + /** 2 + * Tests for the iCal (.ics) exporter. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { exportIcsFile } from '../src/calendar/ics-export.js'; 6 + import type { CalendarEvent } from '../src/calendar/helpers.js'; 7 + 8 + // --------------------------------------------------------------------------- 9 + // Helpers 10 + // --------------------------------------------------------------------------- 11 + 12 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 13 + return { 14 + id: 'test-id-1', 15 + title: 'Test Event', 16 + date: '2026-04-15', 17 + startTime: '09:00', 18 + endTime: '10:00', 19 + allDay: false, 20 + color: '#3a8a7a', 21 + description: '', 22 + createdAt: 1744700000000, 23 + updatedAt: 1744700000000, 24 + ...overrides, 25 + }; 26 + } 27 + 28 + /** Split an ICS string into its CRLF-delimited lines. */ 29 + function icsLines(ics: string): string[] { 30 + return ics.split('\r\n'); 31 + } 32 + 33 + // --------------------------------------------------------------------------- 34 + // Structure 35 + // --------------------------------------------------------------------------- 36 + 37 + describe('exportIcsFile structure', () => { 38 + it('produces a valid VCALENDAR wrapper', () => { 39 + const ics = exportIcsFile([], 'My Calendar'); 40 + const lines = icsLines(ics); 41 + expect(lines[0]).toBe('BEGIN:VCALENDAR'); 42 + expect(lines).toContain('VERSION:2.0'); 43 + expect(lines).toContain('PRODID:-//Tools//Calendar//EN'); 44 + expect(lines).toContain('X-WR-CALNAME:My Calendar'); 45 + // Last non-empty line should be END:VCALENDAR 46 + const nonEmpty = lines.filter(l => l.length > 0); 47 + expect(nonEmpty[nonEmpty.length - 1]).toBe('END:VCALENDAR'); 48 + }); 49 + 50 + it('uses CRLF line endings throughout', () => { 51 + const ics = exportIcsFile([makeEvent()], 'Cal'); 52 + // Every line break should be CRLF (no bare LF) 53 + expect(ics).not.toMatch(/[^\r]\n/); 54 + // Should contain at least some CRLF 55 + expect(ics).toContain('\r\n'); 56 + }); 57 + 58 + it('returns only the wrapper when given an empty array', () => { 59 + const ics = exportIcsFile([], 'Empty'); 60 + expect(ics).not.toContain('BEGIN:VEVENT'); 61 + expect(ics).not.toContain('END:VEVENT'); 62 + }); 63 + }); 64 + 65 + // --------------------------------------------------------------------------- 66 + // Timed events 67 + // --------------------------------------------------------------------------- 68 + 69 + describe('exportIcsFile timed events', () => { 70 + it('exports a simple timed event', () => { 71 + const events = [makeEvent({ id: 'evt-1', title: 'Lunch', date: '2026-04-15', startTime: '12:00', endTime: '13:00' })]; 72 + const ics = exportIcsFile(events, 'Cal'); 73 + const lines = icsLines(ics); 74 + 75 + expect(lines).toContain('BEGIN:VEVENT'); 76 + expect(lines).toContain('END:VEVENT'); 77 + expect(lines).toContain('SUMMARY:Lunch'); 78 + expect(lines).toContain('DTSTART:20260415T120000'); 79 + expect(lines).toContain('DTEND:20260415T130000'); 80 + // UID should contain the event id 81 + expect(lines.find(l => l.startsWith('UID:'))).toContain('evt-1'); 82 + }); 83 + 84 + it('exports midnight start and end correctly', () => { 85 + const events = [makeEvent({ startTime: '00:00', endTime: '01:30' })]; 86 + const ics = exportIcsFile(events, 'Cal'); 87 + const lines = icsLines(ics); 88 + expect(lines).toContain('DTSTART:20260415T000000'); 89 + expect(lines).toContain('DTEND:20260415T013000'); 90 + }); 91 + }); 92 + 93 + // --------------------------------------------------------------------------- 94 + // All-day events 95 + // --------------------------------------------------------------------------- 96 + 97 + describe('exportIcsFile all-day events', () => { 98 + it('exports an all-day event with VALUE=DATE', () => { 99 + const events = [makeEvent({ allDay: true, startTime: '', endTime: '' })]; 100 + const ics = exportIcsFile(events, 'Cal'); 101 + const lines = icsLines(ics); 102 + expect(lines).toContain('DTSTART;VALUE=DATE:20260415'); 103 + // All-day events should NOT have a DTEND with time 104 + expect(lines.find(l => l.startsWith('DTEND;VALUE=DATE:'))).toBeDefined(); 105 + }); 106 + 107 + it('sets DTEND to the next day for single all-day event', () => { 108 + const events = [makeEvent({ allDay: true, date: '2026-04-15', startTime: '', endTime: '' })]; 109 + const ics = exportIcsFile(events, 'Cal'); 110 + const lines = icsLines(ics); 111 + expect(lines).toContain('DTEND;VALUE=DATE:20260416'); 112 + }); 113 + 114 + it('handles month boundary for all-day DTEND', () => { 115 + const events = [makeEvent({ allDay: true, date: '2026-01-31', startTime: '', endTime: '' })]; 116 + const ics = exportIcsFile(events, 'Cal'); 117 + const lines = icsLines(ics); 118 + expect(lines).toContain('DTEND;VALUE=DATE:20260201'); 119 + }); 120 + 121 + it('handles year boundary for all-day DTEND', () => { 122 + const events = [makeEvent({ allDay: true, date: '2026-12-31', startTime: '', endTime: '' })]; 123 + const ics = exportIcsFile(events, 'Cal'); 124 + const lines = icsLines(ics); 125 + expect(lines).toContain('DTEND;VALUE=DATE:20270101'); 126 + }); 127 + }); 128 + 129 + // --------------------------------------------------------------------------- 130 + // Description and escaping 131 + // --------------------------------------------------------------------------- 132 + 133 + describe('exportIcsFile escaping', () => { 134 + it('escapes commas in summary', () => { 135 + const events = [makeEvent({ title: 'Hello, World' })]; 136 + const ics = exportIcsFile(events, 'Cal'); 137 + const lines = icsLines(ics); 138 + expect(lines).toContain('SUMMARY:Hello\\, World'); 139 + }); 140 + 141 + it('escapes semicolons in description', () => { 142 + const events = [makeEvent({ description: 'a;b;c' })]; 143 + const ics = exportIcsFile(events, 'Cal'); 144 + const lines = icsLines(ics); 145 + expect(lines).toContain('DESCRIPTION:a\\;b\\;c'); 146 + }); 147 + 148 + it('escapes newlines in description', () => { 149 + const events = [makeEvent({ description: 'Line 1\nLine 2\nLine 3' })]; 150 + const ics = exportIcsFile(events, 'Cal'); 151 + const lines = icsLines(ics); 152 + expect(lines).toContain('DESCRIPTION:Line 1\\nLine 2\\nLine 3'); 153 + }); 154 + 155 + it('escapes backslashes', () => { 156 + const events = [makeEvent({ description: 'path\\to\\file' })]; 157 + const ics = exportIcsFile(events, 'Cal'); 158 + const lines = icsLines(ics); 159 + expect(lines).toContain('DESCRIPTION:path\\\\to\\\\file'); 160 + }); 161 + 162 + it('omits DESCRIPTION line when description is empty', () => { 163 + const events = [makeEvent({ description: '' })]; 164 + const ics = exportIcsFile(events, 'Cal'); 165 + const lines = icsLines(ics); 166 + expect(lines.find(l => l.startsWith('DESCRIPTION:'))).toBeUndefined(); 167 + }); 168 + }); 169 + 170 + // --------------------------------------------------------------------------- 171 + // Line folding (RFC 5545 requires max 75 octets per line) 172 + // --------------------------------------------------------------------------- 173 + 174 + describe('exportIcsFile line folding', () => { 175 + it('folds lines exceeding 75 characters', () => { 176 + const longTitle = 'A'.repeat(100); 177 + const events = [makeEvent({ title: longTitle })]; 178 + const ics = exportIcsFile(events, 'Cal'); 179 + // After folding, no raw line (split by CRLF) should exceed 75 chars 180 + const rawLines = ics.split('\r\n'); 181 + for (const line of rawLines) { 182 + expect(line.length).toBeLessThanOrEqual(75); 183 + } 184 + }); 185 + 186 + it('continuation lines start with a space', () => { 187 + const longTitle = 'B'.repeat(100); 188 + const events = [makeEvent({ title: longTitle })]; 189 + const ics = exportIcsFile(events, 'Cal'); 190 + const rawLines = ics.split('\r\n'); 191 + // Find continuation lines (not the first line of SUMMARY) 192 + const summaryIdx = rawLines.findIndex(l => l.startsWith('SUMMARY:')); 193 + expect(summaryIdx).toBeGreaterThan(-1); 194 + // The next line should be a continuation (starts with space) 195 + expect(rawLines[summaryIdx + 1]![0]).toBe(' '); 196 + }); 197 + }); 198 + 199 + // --------------------------------------------------------------------------- 200 + // Multiple events 201 + // --------------------------------------------------------------------------- 202 + 203 + describe('exportIcsFile multiple events', () => { 204 + it('exports multiple events in a single VCALENDAR', () => { 205 + const events = [ 206 + makeEvent({ id: 'a', title: 'Event A', date: '2026-04-15' }), 207 + makeEvent({ id: 'b', title: 'Event B', date: '2026-04-16' }), 208 + makeEvent({ id: 'c', title: 'Event C', date: '2026-04-17' }), 209 + ]; 210 + const ics = exportIcsFile(events, 'Multi'); 211 + const lines = icsLines(ics); 212 + 213 + const beginCount = lines.filter(l => l === 'BEGIN:VEVENT').length; 214 + const endCount = lines.filter(l => l === 'END:VEVENT').length; 215 + expect(beginCount).toBe(3); 216 + expect(endCount).toBe(3); 217 + 218 + expect(lines).toContain('SUMMARY:Event A'); 219 + expect(lines).toContain('SUMMARY:Event B'); 220 + expect(lines).toContain('SUMMARY:Event C'); 221 + }); 222 + 223 + it('each event gets a unique UID', () => { 224 + const events = [ 225 + makeEvent({ id: 'x1', title: 'One' }), 226 + makeEvent({ id: 'x2', title: 'Two' }), 227 + ]; 228 + const ics = exportIcsFile(events, 'Cal'); 229 + const lines = icsLines(ics); 230 + const uids = lines.filter(l => l.startsWith('UID:')); 231 + expect(uids).toHaveLength(2); 232 + expect(uids[0]).not.toBe(uids[1]); 233 + }); 234 + }); 235 + 236 + // --------------------------------------------------------------------------- 237 + // Roundtrip: export then import 238 + // --------------------------------------------------------------------------- 239 + 240 + describe('export-import roundtrip', () => { 241 + it('roundtrips a timed event through export and import', async () => { 242 + // Dynamic import so the test file stays clean 243 + const { parseIcsFile } = await import('../src/calendar/ics-parser.js'); 244 + 245 + const original = makeEvent({ 246 + id: 'rt-1', 247 + title: 'Roundtrip Meeting', 248 + date: '2026-06-20', 249 + startTime: '14:30', 250 + endTime: '15:45', 251 + description: 'Important discussion', 252 + }); 253 + 254 + const ics = exportIcsFile([original], 'Roundtrip Test'); 255 + const result = parseIcsFile(ics); 256 + 257 + expect(result.errors).toHaveLength(0); 258 + expect(result.events).toHaveLength(1); 259 + 260 + const imported = result.events[0]!; 261 + expect(imported.title).toBe('Roundtrip Meeting'); 262 + expect(imported.date).toBe('2026-06-20'); 263 + expect(imported.startTime).toBe('14:30'); 264 + expect(imported.endTime).toBe('15:45'); 265 + expect(imported.description).toBe('Important discussion'); 266 + expect(imported.allDay).toBe(false); 267 + }); 268 + 269 + it('roundtrips an all-day event', async () => { 270 + const { parseIcsFile } = await import('../src/calendar/ics-parser.js'); 271 + 272 + const original = makeEvent({ 273 + id: 'rt-2', 274 + title: 'Conference', 275 + date: '2026-09-10', 276 + startTime: '', 277 + endTime: '', 278 + allDay: true, 279 + }); 280 + 281 + const ics = exportIcsFile([original], 'Roundtrip'); 282 + const result = parseIcsFile(ics); 283 + 284 + expect(result.errors).toHaveLength(0); 285 + expect(result.events).toHaveLength(1); 286 + expect(result.events[0]!.title).toBe('Conference'); 287 + expect(result.events[0]!.date).toBe('2026-09-10'); 288 + expect(result.events[0]!.allDay).toBe(true); 289 + }); 290 + 291 + it('roundtrips special characters in title and description', async () => { 292 + const { parseIcsFile } = await import('../src/calendar/ics-parser.js'); 293 + 294 + const original = makeEvent({ 295 + id: 'rt-3', 296 + title: 'Design Review, Phase 2', 297 + description: 'Topics:\nPerformance; Accessibility\nSecurity\\Auth', 298 + }); 299 + 300 + const ics = exportIcsFile([original], 'Roundtrip'); 301 + const result = parseIcsFile(ics); 302 + 303 + expect(result.errors).toHaveLength(0); 304 + expect(result.events[0]!.title).toBe('Design Review, Phase 2'); 305 + expect(result.events[0]!.description).toBe('Topics:\nPerformance; Accessibility\nSecurity\\Auth'); 306 + }); 307 + 308 + it('roundtrips multiple events preserving order', async () => { 309 + const { parseIcsFile } = await import('../src/calendar/ics-parser.js'); 310 + 311 + const events = [ 312 + makeEvent({ id: 'a', title: 'First', date: '2026-04-10', startTime: '08:00', endTime: '09:00' }), 313 + makeEvent({ id: 'b', title: 'Second', date: '2026-04-11', startTime: '10:00', endTime: '11:00' }), 314 + makeEvent({ id: 'c', title: 'Third', date: '2026-04-12', allDay: true, startTime: '', endTime: '' }), 315 + ]; 316 + 317 + const ics = exportIcsFile(events, 'Multi'); 318 + const result = parseIcsFile(ics); 319 + 320 + expect(result.errors).toHaveLength(0); 321 + expect(result.events).toHaveLength(3); 322 + expect(result.events[0]!.title).toBe('First'); 323 + expect(result.events[1]!.title).toBe('Second'); 324 + expect(result.events[2]!.title).toBe('Third'); 325 + expect(result.events[2]!.allDay).toBe(true); 326 + }); 327 + });