feat(linkedin): add ical-generator.mjs — RFC 5545 calendar file generation
Pure-function iCal generator with CRLF endings, line folding at 75 octets, VALARM reminders, VTIMEZONE, and special character escaping. 16 tests green. Standalone CLI mode: node ical-generator.mjs --from-queue --output path.ics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aca312ea57
commit
8e759fb373
2 changed files with 389 additions and 0 deletions
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue