Skip to content

Instantly share code, notes, and snippets.

@WomB0ComB0
Created January 24, 2025 20:16
Show Gist options
  • Save WomB0ComB0/a02d58fcd2c3272524b075a9cc0b16ea to your computer and use it in GitHub Desktop.
Save WomB0ComB0/a02d58fcd2c3272524b075a9cc0b16ea to your computer and use it in GitHub Desktop.
Advanced, CLI-based ICS parser
/**
* @fileoverview A utility for converting ICS (iCalendar) files to JSON format with customizable output options.
* Supports flattening nested structures, removing fields, renaming keys, and filtering by date range.
*/
import { lines2tree } from 'icalts';
import { $ } from 'bun';
import { Command } from 'commander';
/**
* Represents an alarm component in an iCalendar event
* @interface VAlarm
*/
interface VAlarm {
/** The action to be invoked when an alarm is triggered */
ACTION: string;
/** A more complete description of the alarm */
DESCRIPTION: string;
/** A short summary or subject for the alarm */
SUMMARY?: string;
/** The calendar user who is the target of the alarm */
ATTENDEE?: string;
/** When the alarm will trigger relative to the associated event */
TRIGGER: string;
}
/**
* Represents an event component in an iCalendar
* @interface VEvent
*/
interface VEvent {
/** Start date/time of the event. Can be a string or an object with __value__ property */
DTSTART: string | { __value__: string };
/** End date/time of the event. Can be a string or an object with __value__ property */
DTEND?: string | { __value__: string };
/** Last modification date of the iCalendar object */
DTSTAMP: string;
/** Unique identifier for the event */
UID: string;
/** Creation date of the event */
CREATED: string;
/** Full description of the event */
DESCRIPTION: string;
/** Last modification date of the event */
LAST_MODIFIED: string;
/** Physical location of the event */
LOCATION: string;
/** Sequence number for the event */
SEQUENCE: string;
/** Status of the event (e.g., CONFIRMED, TENTATIVE) */
STATUS: string;
/** Short summary or subject of the event */
SUMMARY: string;
/** Transparency of the event (e.g., OPAQUE, TRANSPARENT) */
TRANSP: string;
/** Optional array of alarm components */
VALARM?: VAlarm[];
}
/**
* Represents daylight savings rules in a timezone
* @interface VTimeZoneDaylight
*/
interface VTimeZoneDaylight {
/** The offset before the change */
TZOFFSETFROM: string;
/** The offset after the change */
TZOFFSETTO: string;
/** The timezone name */
TZNAME: string;
/** When the change takes effect */
DTSTART: string;
/** Recurrence rule for when the change happens */
RRULE: string;
}
/**
* Represents standard time rules in a timezone
* @interface VTimeZoneStandard
*/
interface VTimeZoneStandard {
/** The offset before the change */
TZOFFSETFROM: string;
/** The offset after the change */
TZOFFSETTO: string;
/** The timezone name */
TZNAME: string;
/** When the change takes effect */
DTSTART: string;
/** Optional recurrence rule for when the change happens */
RRULE?: string;
}
/**
* Represents a timezone definition in an iCalendar
* @interface VTimeZone
*/
interface VTimeZone {
/** Timezone identifier */
TZID: string;
/** Geographic location of the timezone */
X_LIC_LOCATION: string;
/** Optional array of daylight savings rules */
DAYLIGHT?: VTimeZoneDaylight[];
/** Optional array of standard time rules */
STANDARD?: VTimeZoneStandard[];
}
/**
* Represents a calendar component in an iCalendar
* @interface VCalendar
*/
interface VCalendar {
/** Identifier for the product that created the iCalendar object */
PRODID: string;
/** Version of the iCalendar specification */
VERSION: string;
/** Calendar scale used for the calendar */
CALSCALE: string;
/** Method associated with the iCalendar object */
METHOD?: string;
/** Display name of the calendar */
X_WR_CALNAME: string;
/** Default timezone for the calendar */
X_WR_TIMEZONE: string;
/** Optional array of timezone definitions */
VTIMEZONE?: VTimeZone[];
/** Optional array of events */
VEVENT?: VEvent[];
}
/**
* Represents the root structure of an iCalendar file
* @interface ICSTree
*/
interface ICSTree {
/** Array of calendar components */
VCALENDAR: VCalendar[];
}
/**
* Truncates a string to a specified maximum length
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} The truncated string with ellipsis if needed
*/
const truncateString = (str: string, maxLength: number): string =>
str.length > maxLength ? `${str.substring(0, maxLength)}...` : str;
/**
* Recursively truncates all string values in an object
* @param {any} obj - The object to process
* @param {number} maxLength - Maximum length for string values
* @returns {any} A new object with truncated string values
*/
const truncateObjectValues = (obj: any, maxLength: number): any => {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map((item) => truncateObjectValues(item, maxLength));
return Object.keys(obj).reduce((acc, key) => {
acc[key] = typeof obj[key] === 'string' ? truncateString(obj[key], maxLength) : truncateObjectValues(obj[key], maxLength);
return acc;
}, {} as any);
};
/**
* Removes carriage returns from object keys
* @param {any} obj - The object to sanitize
* @returns {any} A new object with sanitized keys
*/
const sanitizeKeys = (obj: any): any => {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(sanitizeKeys);
return Object.keys(obj).reduce((acc, key) => {
const sanitizedKey = key.replace(/\r/g, '');
acc[sanitizedKey] = sanitizeKeys(obj[key]);
return acc;
}, {} as any);
};
/**
* Parses an iCalendar date string into a JavaScript Date object
* @param {string | { __value__: string } | undefined} dateStr - The date string to parse
* @returns {Date | null} The parsed Date object or null if parsing fails
*/
const parseICalDate = (dateStr: string | { __value__: string } | undefined): Date | null => {
if (typeof dateStr === 'object' && dateStr !== null && '__value__' in dateStr) {
dateStr = dateStr.__value__;
}
if (!dateStr || typeof dateStr !== 'string') {
console.warn(`Invalid date string: ${JSON.stringify(dateStr)}`);
return null;
}
try {
const [year, month, day, hour, minute, second] = [
dateStr.substring(0, 4),
dateStr.substring(4, 6),
dateStr.substring(6, 8),
dateStr.substring(9, 11),
dateStr.substring(11, 13),
dateStr.substring(13, 15),
].map((val) => parseInt(val, 10));
return new Date(Date.UTC(year, month - 1, day, hour, minute, second));
} catch (error) {
console.error(`Error parsing date string: ${dateStr}`, error);
return null;
}
};
/**
* Flattens nested structures in the iCalendar tree
* @param {ICSTree} tree - The tree to flatten
* @returns {ICSTree} A new flattened tree
*/
const flattenTree = (tree: ICSTree): ICSTree => {
if (!tree?.VCALENDAR) {
console.warn('Warning: Invalid tree structure received in flattenTree');
return { VCALENDAR: [] };
}
return {
VCALENDAR: tree.VCALENDAR.map((calendar) => ({
PRODID: calendar.PRODID ?? '',
VERSION: calendar.VERSION ?? '',
CALSCALE: calendar.CALSCALE ?? '',
METHOD: calendar.METHOD,
X_WR_CALNAME: calendar.X_WR_CALNAME ?? '',
X_WR_TIMEZONE: calendar.X_WR_TIMEZONE ?? '',
VTIMEZONE: calendar.VTIMEZONE?.map((timezone) => ({
TZID: timezone.TZID ?? '',
X_LIC_LOCATION: timezone.X_LIC_LOCATION ?? '',
DAYLIGHT: timezone.DAYLIGHT?.map((daylight) => ({
TZOFFSETFROM: daylight.TZOFFSETFROM ?? '',
TZOFFSETTO: daylight.TZOFFSETTO ?? '',
TZNAME: daylight.TZNAME ?? '',
DTSTART: daylight.DTSTART ?? '',
RRULE: daylight.RRULE ?? '',
})),
STANDARD: timezone.STANDARD?.map((standard) => ({
TZOFFSETFROM: standard.TZOFFSETFROM ?? '',
TZOFFSETTO: standard.TZOFFSETTO ?? '',
TZNAME: standard.TZNAME ?? '',
DTSTART: standard.DTSTART ?? '',
RRULE: standard.RRULE,
})),
})),
VEVENT: calendar.VEVENT?.map((event) => ({
DTSTART: event.DTSTART ?? '',
DTEND: event.DTEND,
DTSTAMP: event.DTSTAMP ?? '',
UID: event.UID ?? '',
CREATED: event.CREATED ?? '',
DESCRIPTION: event.DESCRIPTION ?? '',
LAST_MODIFIED: event.LAST_MODIFIED ?? '',
LOCATION: event.LOCATION ?? '',
SEQUENCE: event.SEQUENCE ?? '',
STATUS: event.STATUS ?? '',
SUMMARY: event.SUMMARY ?? '',
TRANSP: event.TRANSP ?? '',
VALARM: event.VALARM?.map((alarm) => ({
ACTION: alarm.ACTION ?? '',
DESCRIPTION: alarm.DESCRIPTION ?? '',
SUMMARY: alarm.SUMMARY,
ATTENDEE: alarm.ATTENDEE,
TRIGGER: alarm.TRIGGER ?? '',
})),
})),
})),
};
};
/**
* Removes specified fields from the iCalendar tree
* @param {ICSTree} tree - The tree to modify
* @param {string[]} fields - Array of field names to remove
* @returns {ICSTree} A new tree with specified fields removed
*/
const removeFields = (tree: ICSTree, fields: string[]): ICSTree => ({
VCALENDAR: tree.VCALENDAR.map((calendar) => ({
...calendar,
VTIMEZONE: fields.includes('VTIMEZONE') ? undefined : calendar.VTIMEZONE,
VEVENT: fields.includes('VEVENT') ? undefined : calendar.VEVENT,
})),
});
/**
* Renames keys in the iCalendar tree according to a mapping
* @param {ICSTree} tree - The tree to modify
* @param {Record<string, string>} keyMap - Object mapping old keys to new keys
* @returns {ICSTree} A new tree with renamed keys
*/
const renameKeys = (tree: ICSTree, keyMap: Record<string, string>): ICSTree => ({
VCALENDAR: tree.VCALENDAR.map((calendar) => ({
...calendar,
VTIMEZONE: calendar.VTIMEZONE?.map((timezone) => ({
...timezone,
...Object.fromEntries(Object.entries(timezone).map(([key, value]) => [keyMap[key] || key, value])),
})),
VEVENT: calendar.VEVENT?.map((event) => ({
...event,
...Object.fromEntries(Object.entries(event).map(([key, value]) => [keyMap[key] || key, value])),
})),
})),
});
/**
* Filters events in the tree by a date range
* @param {ICSTree} tree - The tree to filter
* @param {string} dateRange - Date range in format "YYYY-MM-DD,YYYY-MM-DD"
* @returns {ICSTree} A new tree with filtered events
*/
const filterByDateRange = (tree: ICSTree, dateRange: string): ICSTree => {
const [startDateStr, endDateStr] = dateRange.split(',').map((date) => date.trim());
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
console.error('Invalid date range. Please provide dates in the format "YYYY-MM-DD,YYYY-MM-DD".');
return tree;
}
return {
VCALENDAR: tree.VCALENDAR.map((calendar) => ({
...calendar,
VEVENT: calendar.VEVENT?.filter((event) => {
const eventStartDate = parseICalDate(event.DTSTART);
const eventEndDate = parseICalDate(event.DTEND || event.DTSTART);
return eventStartDate && eventEndDate && eventStartDate >= startDate && eventEndDate <= endDate;
}),
})),
};
};
/**
* Main function to process ICS files and convert them to JSON
* @param {string} filePath - Path to the ICS file or directory
* @param {boolean} useRoot - Whether to search in root directory
* @param {any} options - Processing options (flatten, removeFields, renameKeys, dateRange)
*/
const processICSFiles = async (filePath: string, useRoot = false, options: any) => {
try {
const icsFiles = useRoot
? (await $`find . -type d \( -name 'node_modules' -o -name '.git' \) -prune -o -type f -name '*.ics' -print`.text())
.split('\n')
.filter(Boolean)
: [filePath];
if (!icsFiles.length) {
console.error('No .ics files found.');
return;
}
for (const file of icsFiles) {
try {
const content = await Bun.file(file).text();
if (!content.trim()) {
console.error(`File is empty: ${file}`);
continue;
}
const rawTree = lines2tree(content.split('\n'));
console.log('Parsed Tree:', JSON.stringify(rawTree, null, 2));
const sanitizedTree = sanitizeKeys(rawTree);
if (!sanitizedTree?.VCALENDAR) {
console.error(`Invalid ICS file: missing VCALENDAR property in ${file}`);
continue;
}
let processedTree: ICSTree = sanitizedTree;
if (options.flatten) processedTree = flattenTree(processedTree);
if (options.removeFields) processedTree = removeFields(processedTree, options.removeFields.split(','));
if (options.renameKeys) processedTree = renameKeys(processedTree, JSON.parse(options.renameKeys));
if (options.dateRange) processedTree = filterByDateRange(processedTree, options.dateRange);
const outputFilePath = file.replace(/\.ics$/, '.json');
await Bun.write(outputFilePath, JSON.stringify(processedTree, null, 2));
console.log(`JSON saved to: ${outputFilePath}`);
} catch (error) {
console.error(`Error processing file ${file}:`, error);
}
}
} catch (error) {
console.error('Error processing .ics files:', error);
}
};
const program = new Command();
program
.name('ics-to-json')
.description('Convert .ics files to JSON with customizable output.')
.version('1.0.0')
.argument('[filePath]', 'Path to the .ics file or directory containing .ics files')
.option('--root', 'Search for .ics files in the root directory')
.option('--flatten', 'Flatten nested structures in the JSON output')
.option('--remove-fields <fields>', 'Comma-separated list of fields to remove from the JSON output')
.option('--rename-keys <keyMap>', 'JSON object mapping old keys to new keys')
.option('--date-range <range>', 'Filter events by date range (format: "YYYY-MM-DD,YYYY-MM-DD")')
.action((filePath, options) => {
if (!filePath && !options.root) {
console.error('Please provide a file path or use the --root flag to search in the root directory.');
process.exit(1);
}
processICSFiles(filePath, options.root, options);
});
program.parse(process.argv);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment