diff --git a/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/ical-generator.test.mjs b/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/ical-generator.test.mjs new file mode 100644 index 0000000..5c17e22 --- /dev/null +++ b/plugins/linkedin-thought-leadership/hooks/scripts/__tests__/ical-generator.test.mjs @@ -0,0 +1,158 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateIcal, generateIcalFromQueue } from '../ical-generator.mjs'; + +const SAMPLE_EVENT = { + id: 'post-2026-04-14-ai-strategy', + title: 'LinkedIn: AI strategy in public sector', + description: 'Pillar: AI Strategy | Format: Standard | Draft: assets/drafts/week-W16/monday.md', + date: '2026-04-14', + time: '08:30', + duration: 30, +}; + +const SAMPLE_QUEUE_ENTRY = { + id: 'post-2026-04-14-ai-strategy', + draft_path: 'assets/drafts/week-W16/monday.md', + scheduled_date: '2026-04-14', + scheduled_time: '08:30', + pillar: 'AI Strategy', + format: 'Standard', + hook_preview: 'AI strategy in public sector', + character_count: 1450, + status: 'scheduled', + created_at: '2026-04-10', +}; + +describe('generateIcal', () => { + test('returns valid empty VCALENDAR for empty events array', () => { + const ical = generateIcal([]); + assert.match(ical, /^BEGIN:VCALENDAR\r\n/); + assert.match(ical, /\r\nEND:VCALENDAR\r\n$/); + assert.match(ical, /PRODID:-\/\/linkedin-thought-leadership\/\/EN/); + assert.match(ical, /VERSION:2\.0/); + assert.ok(!ical.includes('BEGIN:VEVENT'), 'should not contain VEVENT'); + }); + + test('generates VEVENT with correct DTSTART, SUMMARY, UID', () => { + const ical = generateIcal([SAMPLE_EVENT]); + assert.match(ical, /BEGIN:VEVENT/); + assert.match(ical, /DTSTART;TZID=Europe\/Oslo:20260414T083000/); + assert.match(ical, /SUMMARY:LinkedIn: AI strategy in public sector/); + assert.match(ical, /UID:post-2026-04-14-ai-strategy@linkedin-thought-leadership/); + assert.match(ical, /END:VEVENT/); + }); + + test('generates correct DTEND from duration', () => { + const ical = generateIcal([SAMPLE_EVENT]); + assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/); + }); + + test('defaults duration to 30 minutes when not specified', () => { + const event = { ...SAMPLE_EVENT, duration: undefined }; + const ical = generateIcal([event]); + assert.match(ical, /DTEND;TZID=Europe\/Oslo:20260414T090000/); + }); + + test('has CRLF line endings throughout', () => { + const ical = generateIcal([SAMPLE_EVENT]); + const lines = ical.split('\r\n'); + assert.ok(lines.length > 5, 'should have multiple lines'); + const bareLF = ical.replace(/\r\n/g, '').includes('\n'); + assert.ok(!bareLF, 'should not contain bare LF without CR'); + }); + + test('includes VALARM with 15-minute trigger', () => { + const ical = generateIcal([SAMPLE_EVENT]); + assert.match(ical, /BEGIN:VALARM/); + assert.match(ical, /TRIGGER:-PT15M/); + assert.match(ical, /ACTION:DISPLAY/); + assert.match(ical, /END:VALARM/); + }); + + test('includes DTSTAMP in UTC format', () => { + const ical = generateIcal([SAMPLE_EVENT]); + assert.match(ical, /DTSTAMP:\d{8}T\d{6}Z/); + }); + + test('folds lines longer than 75 octets', () => { + const longDescription = 'A'.repeat(200); + const event = { ...SAMPLE_EVENT, description: longDescription }; + const ical = generateIcal([event]); + const lines = ical.split('\r\n'); + for (const line of lines) { + const octets = Buffer.byteLength(line, 'utf-8'); + assert.ok(octets <= 75, `Line exceeds 75 octets (${octets}): "${line.slice(0, 40)}..."`); + } + }); + + test('escapes special characters in SUMMARY and DESCRIPTION', () => { + const event = { + ...SAMPLE_EVENT, + title: 'Test: commas, semicolons; and\\backslashes', + description: 'Line1\nLine2, with; special\\chars', + }; + const ical = generateIcal([event]); + assert.match(ical, /SUMMARY:Test: commas\\, semicolons\; and\\\\backslashes/); + assert.match(ical, /DESCRIPTION:Line1\\nLine2\\, with\; special\\\\chars/); + }); + + test('handles multiple events', () => { + const event2 = { + ...SAMPLE_EVENT, + id: 'post-2026-04-16-leadership', + title: 'LinkedIn: Leadership lessons', + date: '2026-04-16', + time: '12:00', + }; + const ical = generateIcal([SAMPLE_EVENT, event2]); + const veventCount = (ical.match(/BEGIN:VEVENT/g) || []).length; + assert.equal(veventCount, 2); + }); + + test('includes VTIMEZONE for Europe/Oslo', () => { + const ical = generateIcal([SAMPLE_EVENT]); + assert.match(ical, /BEGIN:VTIMEZONE/); + assert.match(ical, /TZID:Europe\/Oslo/); + assert.match(ical, /END:VTIMEZONE/); + }); + + test('supports custom timezone parameter', () => { + const ical = generateIcal([SAMPLE_EVENT], { timezone: 'America/New_York' }); + assert.match(ical, /TZID:America\/New_York/); + assert.match(ical, /DTSTART;TZID=America\/New_York/); + }); +}); + +describe('generateIcalFromQueue', () => { + test('transforms queue entry format to event format', () => { + const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]); + assert.equal(events.length, 1); + const e = events[0]; + assert.equal(e.id, 'post-2026-04-14-ai-strategy'); + assert.equal(e.date, '2026-04-14'); + assert.equal(e.time, '08:30'); + assert.ok(e.title.includes('AI strategy in public sector')); + assert.ok(e.description.includes('AI Strategy')); + assert.ok(e.description.includes('Standard')); + }); + + test('handles missing scheduled_time gracefully', () => { + const entry = { ...SAMPLE_QUEUE_ENTRY, scheduled_time: undefined }; + const events = generateIcalFromQueue([entry]); + assert.equal(events[0].time, '09:00'); + }); + + test('handles empty array', () => { + const events = generateIcalFromQueue([]); + assert.deepEqual(events, []); + }); + + test('generates valid iCal when piped through generateIcal', () => { + const events = generateIcalFromQueue([SAMPLE_QUEUE_ENTRY]); + const ical = generateIcal(events); + assert.match(ical, /BEGIN:VCALENDAR/); + assert.match(ical, /BEGIN:VEVENT/); + assert.match(ical, /END:VCALENDAR/); + }); +}); diff --git a/plugins/linkedin-thought-leadership/hooks/scripts/ical-generator.mjs b/plugins/linkedin-thought-leadership/hooks/scripts/ical-generator.mjs new file mode 100644 index 0000000..c828249 --- /dev/null +++ b/plugins/linkedin-thought-leadership/hooks/scripts/ical-generator.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +// RFC 5545 iCal generator for linkedin-thought-leadership plugin +// Import: import { generateIcal, generateIcalFromQueue, writeIcalFile } from './ical-generator.mjs'; +// Standalone: node ical-generator.mjs --from-queue --output path/to/schedule.ics + +import { writeFileSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CRLF = '\r\n'; + +/** + * Escape text values per RFC 5545 Section 3.3.11. + * Backslashes first (to avoid double-escaping), then semicolons, commas, newlines. + */ +function escapeText(str) { + if (!str) return ''; + return str + .replace(/\\/g, '\\\\') + .replace(/;/g, '\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +/** + * Fold a content line per RFC 5545 Section 3.1. + * Lines MUST NOT be longer than 75 octets. Long lines are folded by + * inserting a CRLF followed by a single whitespace character (space). + */ +function foldLine(line) { + const maxOctets = 75; + if (Buffer.byteLength(line, 'utf-8') <= maxOctets) return line; + + const parts = []; + let remaining = line; + let isFirst = true; + + while (Buffer.byteLength(remaining, 'utf-8') > maxOctets) { + // Find the split point: max octets for first line, max-1 for continuations (leading space) + const limit = isFirst ? maxOctets : maxOctets - 1; + let splitAt = 0; + let octetCount = 0; + + for (let i = 0; i < remaining.length; i++) { + const charOctets = Buffer.byteLength(remaining[i], 'utf-8'); + if (octetCount + charOctets > limit) break; + octetCount += charOctets; + splitAt = i + 1; + } + + parts.push((isFirst ? '' : ' ') + remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt); + isFirst = false; + } + + if (remaining.length > 0) { + parts.push((isFirst ? '' : ' ') + remaining); + } + + return parts.join(CRLF); +} + +/** + * Format a Date as iCal UTC timestamp: YYYYMMDDTHHmmssZ + */ +function formatUtcTimestamp(date) { + const d = date || new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; +} + +/** + * Format date + time as iCal local datetime: YYYYMMDDTHHMMSS + */ +function formatLocalDatetime(dateStr, timeStr) { + const [y, m, d] = dateStr.split('-'); + const [h, min] = (timeStr || '09:00').split(':'); + return `${y}${m}${d}T${h}${min}00`; +} + +/** + * Add minutes to a time string (HH:MM), returns new time as HHMMSS for iCal. + * Handles day overflow simply by capping at 23:59. + */ +function addMinutes(dateStr, timeStr, minutes) { + const [y, m, d] = dateStr.split('-').map(Number); + const [h, min] = (timeStr || '09:00').split(':').map(Number); + const totalMin = h * 60 + min + minutes; + const newH = Math.min(Math.floor(totalMin / 60), 23); + const newMin = totalMin % 60; + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(y)}${pad(m)}${pad(d)}T${pad(newH)}${pad(newMin)}00`; +} + +/** + * Generate a minimal VTIMEZONE component. + * Full Olson TZ database support is out of scope; we provide the structural + * component so calendar apps recognize the TZID reference. + */ +function generateVtimezone(timezone) { + const lines = [ + 'BEGIN:VTIMEZONE', + `TZID:${timezone}`, + 'BEGIN:STANDARD', + `DTSTART:19701025T030000`, + 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10', + `TZOFFSETFROM:+0200`, + `TZOFFSETTO:+0100`, + `TZNAME:CET`, + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + `DTSTART:19700329T020000`, + 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3', + `TZOFFSETFROM:+0100`, + `TZOFFSETTO:+0200`, + `TZNAME:CEST`, + 'END:DAYLIGHT', + 'END:VTIMEZONE', + ]; + return lines; +} + +/** + * Generate RFC 5545 compliant iCal string from event objects. + * + * @param {Array<{id, title, description, date, time, duration}>} events + * @param {Object} [options] + * @param {string} [options.timezone='Europe/Oslo'] - TZID for DTSTART/DTEND + * @returns {string} Valid .ics file content with CRLF line endings + */ +export function generateIcal(events, options = {}) { + const tz = options.timezone || 'Europe/Oslo'; + const now = formatUtcTimestamp(new Date()); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//linkedin-thought-leadership//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + ]; + + // Add VTIMEZONE if we have events + if (events.length > 0) { + lines.push(...generateVtimezone(tz)); + } + + for (const event of events) { + const duration = event.duration || 30; + const dtstart = formatLocalDatetime(event.date, event.time); + const dtend = addMinutes(event.date, event.time, duration); + + lines.push( + 'BEGIN:VEVENT', + `UID:${event.id}@linkedin-thought-leadership`, + `DTSTAMP:${now}`, + `DTSTART;TZID=${tz}:${dtstart}`, + `DTEND;TZID=${tz}:${dtend}`, + `SUMMARY:${escapeText(event.title)}`, + `DESCRIPTION:${escapeText(event.description || '')}`, + 'BEGIN:VALARM', + 'TRIGGER:-PT15M', + 'ACTION:DISPLAY', + `DESCRIPTION:Reminder: ${escapeText(event.title)}`, + 'END:VALARM', + 'END:VEVENT', + ); + } + + lines.push('END:VCALENDAR'); + + // Apply line folding and join with CRLF + return lines.map(foldLine).join(CRLF) + CRLF; +} + +/** + * Transform queue entries (from queue-manager.mjs) into event format. + * + * @param {Array<{id, draft_path, scheduled_date, scheduled_time, pillar, format, hook_preview}>} queueEntries + * @returns {Array<{id, title, description, date, time, duration}>} + */ +export function generateIcalFromQueue(queueEntries) { + return queueEntries.map(entry => ({ + id: entry.id, + title: `LinkedIn: ${entry.hook_preview || 'Scheduled post'}`, + description: `Pillar: ${entry.pillar || '?'} | Format: ${entry.format || '?'} | Draft: ${entry.draft_path || '?'}`, + date: entry.scheduled_date, + time: entry.scheduled_time || '09:00', + duration: 30, + })); +} + +/** + * Write .ics file to disk. + * + * @param {string} outputPath - Path to write the .ics file + * @param {Array} events - Event objects (from generateIcalFromQueue or direct) + * @param {Object} [options] - Options passed to generateIcal + */ +export function writeIcalFile(outputPath, events, options) { + const ical = generateIcal(events, options); + writeFileSync(outputPath, ical, 'utf-8'); + return outputPath; +} + +// Standalone CLI mode +if (process.argv[1] && process.argv[1].endsWith('ical-generator.mjs')) { + const args = process.argv.slice(2); + const fromQueue = args.includes('--from-queue'); + const outputIdx = args.indexOf('--output'); + const outputPath = outputIdx >= 0 ? args[outputIdx + 1] : null; + + if (!fromQueue || !outputPath) { + console.log('Usage: node ical-generator.mjs --from-queue --output path/to/schedule.ics'); + process.exit(1); + } + + // Dynamic import to avoid circular dep issues + const { queueUpcoming } = await import('./queue-manager.mjs'); + const upcoming = queueUpcoming(14); + + if (upcoming.length === 0) { + console.log('No upcoming scheduled posts in queue.'); + process.exit(0); + } + + const events = generateIcalFromQueue(upcoming); + writeIcalFile(outputPath, events); + console.log(`Calendar file: ${outputPath} (${events.length} events)`); +}