ktg-plugin-marketplace/plugins/linkedin-thought-leadership/hooks/scripts/ical-generator.mjs
Kjell Tore Guttormsen 8e759fb373 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>
2026-04-10 18:38:20 +02:00

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)`);
}