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