const xml2js = require('xml2js'); const xmlBuilder = new xml2js.Builder({ xmldec: { version: '1.0', encoding: 'UTF-8' }, renderOpts: { pretty: true, indent: ' ' }, }); const xmlParser = new xml2js.Parser({ explicitArray: false, explicitRoot: true, ignoreAttrs: false, mergeAttrs: true, xmlns: true, }); function buildMultistatus(responses) { const multistatus = { 'D:multistatus': { $: { 'xmlns:D': 'DAV:', 'xmlns:C': 'urn:ietf:params:xml:ns:caldav', }, 'D:response': responses, }, }; return xmlBuilder.buildObject(multistatus); } function buildResponse(href, propstats) { return { 'D:href': href, 'D:propstat': propstats, }; } function buildPropstat(props, status = 'HTTP/1.1 200 OK') { return { 'D:prop': props, 'D:status': status, }; } function buildProp(name, value, namespace = 'D') { return { [`${namespace}:${name}`]: value }; } function buildError(errorType, description) { return { 'D:error': { $: { 'xmlns:D': 'DAV:' }, [`D:${errorType}`]: description || '', }, }; } function parseCalendarQuery(xmlString) { return new Promise((resolve, reject) => { xmlParser.parseString(xmlString, (err, result) => { if (err) { return reject(err); } try { const query = result['C:calendar-query'] || result['calendar-query']; const filter = query?.['C:filter'] || query?.filter; const compFilter = filter?.['C:comp-filter'] || filter?.['comp-filter']; const extractValue = (attr) => { if (!attr) return null; return typeof attr === 'string' ? attr : attr.value; }; const parsedQuery = { props: [], filters: { componentType: extractValue(compFilter?.name) || 'VTODO', timeRange: null, textMatch: null, }, }; if (compFilter) { const timeRange = compFilter['C:time-range'] || compFilter['time-range']; if (timeRange) { parsedQuery.filters.timeRange = { start: extractValue(timeRange.start), end: extractValue(timeRange.end), }; } const propFilter = compFilter['C:prop-filter'] || compFilter['prop-filter']; if (propFilter) { const textMatch = propFilter['C:text-match'] || propFilter['text-match']; if (textMatch) { parsedQuery.filters.textMatch = { property: extractValue(propFilter.name), value: typeof textMatch === 'string' ? textMatch : textMatch._, caseless: extractValue(textMatch['collation']) === 'i;unicode-casefold', }; } } } const prop = query?.['D:prop'] || query?.prop; if (prop) { parsedQuery.props = Object.keys(prop).map((key) => key.replace(/^[^:]+:/, '') ); } resolve(parsedQuery); } catch (parseError) { reject(parseError); } }); }); } function parsePropfind(xmlString) { return new Promise((resolve, reject) => { xmlParser.parseString(xmlString, (err, result) => { if (err) { return reject(err); } try { const propfind = result['D:propfind'] || result.propfind; if (propfind['D:allprop'] || propfind.allprop) { return resolve({ type: 'allprop' }); } if (propfind['D:propname'] || propfind.propname) { return resolve({ type: 'propname' }); } const prop = propfind['D:prop'] || propfind.prop; if (prop) { const requestedProps = Object.keys(prop).map((key) => { const [namespace, name] = key.split(':').reverse(); return { name: name || namespace, namespace: name ? namespace : 'D', }; }); return resolve({ type: 'prop', props: requestedProps }); } resolve({ type: 'allprop' }); } catch (parseError) { reject(parseError); } }); }); } function buildCalendarData(vtodoString) { return { 'C:calendar-data': { _: vtodoString, $: { 'xmlns:C': 'urn:ietf:params:xml:ns:caldav' }, }, }; } function buildHref(username, taskUid = null) { const base = `/caldav/${encodeURIComponent(username)}/tasks/`; return taskUid ? `${base}${encodeURIComponent(taskUid)}.ics` : base; } function escapeXml(unsafe) { if (typeof unsafe !== 'string') { return unsafe; } return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } module.exports = { xmlBuilder, xmlParser, buildMultistatus, buildResponse, buildPropstat, buildProp, buildError, buildCalendarData, buildHref, parseCalendarQuery, parsePropfind, escapeXml, };