mirror of https://github.com/wekan/wekan
The Open Source kanban (built with Meteor). Keep variable/table/field names camelCase. For translations, only add Pull Request changes to wekan/i18n/en.i18n.json , other translations are done at https://transifex.com/wekan/wekan only.
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
285 lines
8.3 KiB
285 lines
8.3 KiB
/**
|
|
* icsImport.js — pure, dependency-free iCalendar (.ics, RFC 5545) parser
|
|
* for WeKan issue #6323 (part 1: import only).
|
|
*
|
|
* This module turns the VEVENT blocks of an iCalendar file into plain
|
|
* JavaScript objects, and then into WeKan card-shaped objects so that the
|
|
* imported events show up on the Calendar / Gantt views (via startAt / dueAt).
|
|
*
|
|
* It intentionally has NO npm / Meteor dependencies so that it is trivial to
|
|
* unit-test and reuse from any context. All parsing is done with plain string
|
|
* operations and small regexes.
|
|
*
|
|
* NOTE: This covers ONE direction only (Google Calendar .ics -> WeKan cards).
|
|
* Two-way Google Calendar sync is out of scope. For read-only export the other
|
|
* direction (WeKan -> iCal feed) see the separate `wekan-ical-server` project.
|
|
*/
|
|
|
|
/**
|
|
* Unfold RFC 5545 folded lines.
|
|
*
|
|
* Long content lines may be split across multiple physical lines; any line
|
|
* that begins with a space or a horizontal tab is a continuation of the
|
|
* previous line and the leading whitespace must be removed when joining.
|
|
*
|
|
* @param {string} icsText raw .ics text
|
|
* @returns {string[]} array of logical (unfolded) lines
|
|
*/
|
|
function unfoldLines(icsText) {
|
|
if (typeof icsText !== 'string' || icsText.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Normalize CRLF / CR to LF, then split into physical lines.
|
|
const physicalLines = icsText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
|
|
const logicalLines = [];
|
|
for (const line of physicalLines) {
|
|
if ((line.startsWith(' ') || line.startsWith('\t')) && logicalLines.length > 0) {
|
|
// Continuation: strip the single leading whitespace char and append.
|
|
logicalLines[logicalLines.length - 1] += line.slice(1);
|
|
} else {
|
|
logicalLines.push(line);
|
|
}
|
|
}
|
|
return logicalLines;
|
|
}
|
|
|
|
/**
|
|
* Split a content line into its name (with parameters) and value.
|
|
*
|
|
* A content line looks like: NAME;PARAM=val;PARAM2=val:VALUE
|
|
* The first unquoted colon separates the property part from the value.
|
|
*
|
|
* @param {string} line a single unfolded content line
|
|
* @returns {{ name: string, params: Object, value: string } | null}
|
|
*/
|
|
function parseContentLine(line) {
|
|
if (!line) {
|
|
return null;
|
|
}
|
|
|
|
// Find the first colon that is not inside a double-quoted parameter value.
|
|
let inQuotes = false;
|
|
let colonIndex = -1;
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (ch === '"') {
|
|
inQuotes = !inQuotes;
|
|
} else if (ch === ':' && !inQuotes) {
|
|
colonIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (colonIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const propertyPart = line.slice(0, colonIndex);
|
|
const value = line.slice(colonIndex + 1);
|
|
|
|
// propertyPart: NAME or NAME;PARAM=val;PARAM2=val
|
|
const propertySegments = propertyPart.split(';');
|
|
const name = propertySegments[0].toUpperCase();
|
|
|
|
const params = {};
|
|
for (let i = 1; i < propertySegments.length; i += 1) {
|
|
const segment = propertySegments[i];
|
|
const eqIndex = segment.indexOf('=');
|
|
if (eqIndex !== -1) {
|
|
const paramName = segment.slice(0, eqIndex).toUpperCase();
|
|
let paramValue = segment.slice(eqIndex + 1);
|
|
// Strip surrounding quotes from parameter values.
|
|
if (paramValue.startsWith('"') && paramValue.endsWith('"')) {
|
|
paramValue = paramValue.slice(1, -1);
|
|
}
|
|
params[paramName] = paramValue;
|
|
}
|
|
}
|
|
|
|
return { name, params, value };
|
|
}
|
|
|
|
/**
|
|
* Unescape an iCalendar TEXT value (RFC 5545 section 3.3.11).
|
|
*
|
|
* Escaped sequences: \\n or \\N -> newline, \\, -> comma, \\; -> semicolon,
|
|
* \\\\ -> backslash.
|
|
*
|
|
* @param {string} text raw escaped TEXT value
|
|
* @returns {string}
|
|
*/
|
|
function unescapeText(text) {
|
|
if (typeof text !== 'string') {
|
|
return '';
|
|
}
|
|
let result = '';
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
if (ch === '\\' && i + 1 < text.length) {
|
|
const next = text[i + 1];
|
|
if (next === 'n' || next === 'N') {
|
|
result += '\n';
|
|
i += 1;
|
|
} else if (next === ',' || next === ';' || next === '\\') {
|
|
result += next;
|
|
i += 1;
|
|
} else {
|
|
// Unknown escape: keep the backslash literally.
|
|
result += ch;
|
|
}
|
|
} else {
|
|
result += ch;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse an iCalendar date or date-time value into a JS Date.
|
|
*
|
|
* Supported forms:
|
|
* - Date only: YYYYMMDD (e.g. all-day events)
|
|
* - Date-time UTC: YYYYMMDDTHHMMSSZ
|
|
* - Date-time local: YYYYMMDDTHHMMSS (treated as UTC, best-effort)
|
|
*
|
|
* @param {string} value the raw property value
|
|
* @returns {Date|null} a Date, or null if it cannot be parsed
|
|
*/
|
|
function parseIcsDate(value) {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
|
|
// Date-time form: YYYYMMDDTHHMMSS with optional trailing Z.
|
|
const dateTimeMatch = trimmed.match(
|
|
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/,
|
|
);
|
|
if (dateTimeMatch) {
|
|
const [, y, mo, d, h, mi, s] = dateTimeMatch;
|
|
// Both UTC ("Z") and floating local times are treated as UTC here so that
|
|
// parsing is deterministic and independent of the server timezone.
|
|
return new Date(
|
|
Date.UTC(
|
|
Number(y),
|
|
Number(mo) - 1,
|
|
Number(d),
|
|
Number(h),
|
|
Number(mi),
|
|
Number(s),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Date-only form: YYYYMMDD (all-day). Anchored at UTC midnight.
|
|
const dateMatch = trimmed.match(/^(\d{4})(\d{2})(\d{2})$/);
|
|
if (dateMatch) {
|
|
const [, y, mo, d] = dateMatch;
|
|
return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), 0, 0, 0));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse an iCalendar (.ics) string into an array of VEVENT objects.
|
|
*
|
|
* @param {string} icsText raw .ics file contents
|
|
* @returns {Array<{ summary: string, description: string, start: Date|null, end: Date|null, uid: string }>}
|
|
*/
|
|
export function parseIcs(icsText) {
|
|
const lines = unfoldLines(icsText);
|
|
if (lines.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const events = [];
|
|
let current = null;
|
|
|
|
for (const rawLine of lines) {
|
|
if (rawLine === '') {
|
|
continue;
|
|
}
|
|
|
|
// Cheap checks first to avoid parsing every single line.
|
|
const upper = rawLine.toUpperCase();
|
|
if (upper === 'BEGIN:VEVENT') {
|
|
current = { summary: '', description: '', start: null, end: null, uid: '' };
|
|
continue;
|
|
}
|
|
if (upper === 'END:VEVENT') {
|
|
if (current) {
|
|
events.push(current);
|
|
}
|
|
current = null;
|
|
continue;
|
|
}
|
|
if (!current) {
|
|
// Outside any VEVENT (e.g. VCALENDAR header / VTIMEZONE) — ignore.
|
|
continue;
|
|
}
|
|
|
|
const parsed = parseContentLine(rawLine);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
switch (parsed.name) {
|
|
case 'SUMMARY':
|
|
current.summary = unescapeText(parsed.value);
|
|
break;
|
|
case 'DESCRIPTION':
|
|
current.description = unescapeText(parsed.value);
|
|
break;
|
|
case 'DTSTART':
|
|
current.start = parseIcsDate(parsed.value);
|
|
break;
|
|
case 'DTEND':
|
|
current.end = parseIcsDate(parsed.value);
|
|
break;
|
|
case 'UID':
|
|
current.uid = parsed.value;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Map a parsed iCalendar event to a WeKan card-shaped object.
|
|
*
|
|
* @param {Object} event a parsed VEVENT (see parseIcs)
|
|
* @param {Object} [opts]
|
|
* @param {string} [opts.boardId]
|
|
* @param {string} [opts.listId]
|
|
* @param {string} [opts.swimlaneId]
|
|
* @returns {{ title: string, description: string, startAt: Date|null, dueAt: Date|null, boardId: (string|undefined), listId: (string|undefined), swimlaneId: (string|undefined) }}
|
|
*/
|
|
export function icsEventToCard(event, { boardId, listId, swimlaneId } = {}) {
|
|
const safeEvent = event || {};
|
|
return {
|
|
title: safeEvent.summary || '',
|
|
description: safeEvent.description || '',
|
|
startAt: safeEvent.start || null,
|
|
// dueAt falls back to the start date when there is no explicit end.
|
|
dueAt: safeEvent.end || safeEvent.start || null,
|
|
boardId,
|
|
listId,
|
|
swimlaneId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse an .ics string and map every VEVENT to a WeKan card-shaped object.
|
|
*
|
|
* @param {string} icsText raw .ics file contents
|
|
* @param {Object} [opts] passed through to icsEventToCard ({ boardId, listId, swimlaneId })
|
|
* @returns {Array<Object>} array of card-shaped objects
|
|
*/
|
|
export function icsToCards(icsText, opts) {
|
|
return parseIcs(icsText).map(event => icsEventToCard(event, opts));
|
|
}
|
|
|