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>
231 lines
7.1 KiB
JavaScript
231 lines
7.1 KiB
JavaScript
#!/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)`);
|
|
}
|