tsdav
Version:
WebDAV, CALDAV, and CARDDAV client for Nodejs and the Browser
1,119 lines (1,103 loc) • 104 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var getLogger = require('debug');
var convert = require('xml-js');
var base64 = require('base-64');
exports.DAVNamespace = void 0;
(function (DAVNamespace) {
DAVNamespace["CALENDAR_SERVER"] = "http://calendarserver.org/ns/";
DAVNamespace["CALDAV_APPLE"] = "http://apple.com/ns/ical/";
DAVNamespace["CALDAV"] = "urn:ietf:params:xml:ns:caldav";
DAVNamespace["CARDDAV"] = "urn:ietf:params:xml:ns:carddav";
DAVNamespace["DAV"] = "DAV:";
})(exports.DAVNamespace || (exports.DAVNamespace = {}));
const DAVAttributeMap = {
[exports.DAVNamespace.CALDAV]: 'xmlns:c',
[exports.DAVNamespace.CARDDAV]: 'xmlns:card',
[exports.DAVNamespace.CALENDAR_SERVER]: 'xmlns:cs',
[exports.DAVNamespace.CALDAV_APPLE]: 'xmlns:ca',
[exports.DAVNamespace.DAV]: 'xmlns:d',
};
exports.DAVNamespaceShort = void 0;
(function (DAVNamespaceShort) {
DAVNamespaceShort["CALDAV"] = "c";
DAVNamespaceShort["CARDDAV"] = "card";
DAVNamespaceShort["CALENDAR_SERVER"] = "cs";
DAVNamespaceShort["CALDAV_APPLE"] = "ca";
DAVNamespaceShort["DAV"] = "d";
})(exports.DAVNamespaceShort || (exports.DAVNamespaceShort = {}));
var ICALObjects;
(function (ICALObjects) {
ICALObjects["VEVENT"] = "VEVENT";
ICALObjects["VTODO"] = "VTODO";
ICALObjects["VJOURNAL"] = "VJOURNAL";
ICALObjects["VFREEBUSY"] = "VFREEBUSY";
ICALObjects["VTIMEZONE"] = "VTIMEZONE";
ICALObjects["VALARM"] = "VALARM";
})(ICALObjects || (ICALObjects = {}));
// Convert hyphen/underscore separated strings to camelCase. Collapses runs of
// consecutive separators so inputs like `foo--bar` and `foo_-bar` both produce
// `fooBar` instead of leaking stray separators into the result.
const camelCase = (str) => str.replace(/[-_]+(\w?)/g, (_m, c) => (c ? c.toUpperCase() : ''));
/**
* Resolve the runtime `fetch` implementation.
*
* All supported runtimes expose a standards-compliant `fetch` on
* `globalThis`:
* - Node.js >= 18 (the minimum declared in package.json#engines)
* - Modern browsers
* - Bun (all versions)
* - Deno (all versions)
* - Cloudflare Workers, Electron, KaiOS 3+
*
* Exotic hosts without a global `fetch` must either install a polyfill on
* `globalThis` before importing tsdav, or pass their own `fetch`
* implementation to `createDAVClient`, the `DAVClient` constructor, or the
* individual request helpers.
*/
const resolveFetch = () => {
if (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') {
return globalThis.fetch.bind(globalThis);
}
// Return a thunk that throws on first invocation rather than at module
// load time, so that consumers who always supply their own `fetch` via
// the public API do not trip this check merely by importing tsdav.
return (() => {
throw new Error('tsdav: global fetch is not available in this runtime. ' +
'Upgrade to Node.js >= 18, run under a browser/Bun/Deno, or install a fetch polyfill ' +
'on globalThis before importing tsdav. You can also pass a custom `fetch` implementation ' +
'to `createDAVClient`, `DAVClient`, or individual request helpers.');
});
};
const fetch = resolveFetch();
// Only coerce strings that are unambiguously numeric. Matches optional sign,
// integer or decimal, and optional exponent. Rejects empty string, whitespace,
// leading zeros like "0755" (sync tokens / ctags often look like numbers but
// must be preserved verbatim), hex, and anything else.
const NUMERIC_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
const nativeType = (value) => {
if (typeof value !== 'string') {
return value;
}
if (NUMERIC_RE.test(value)) {
const nValue = Number(value);
if (!Number.isNaN(nValue) && Number.isFinite(nValue)) {
return nValue;
}
}
const bValue = value.toLowerCase();
if (bValue === 'true') {
return true;
}
if (bValue === 'false') {
return false;
}
return value;
};
const normalizeUrl = (url) => {
const trimmed = url.trim();
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
};
/**
* Strict URL equality after trimming whitespace and a single trailing slash.
* Two URLs are equal if and only if their normalized forms are identical.
*/
const urlEquals = (urlA, urlB) => {
if (!urlA && !urlB) {
return true;
}
if (!urlA || !urlB) {
return false;
}
return normalizeUrl(urlA) === normalizeUrl(urlB);
};
/**
* Loose URL containment check used for matching DAV responses against known
* collection/principal URLs. Tolerates trailing slashes and partial vs. full
* URLs (e.g. "www.example.com" vs. "https://www.example.com/").
*
* NOTE: this is intentionally permissive to accommodate DAV servers that
* return hrefs as paths instead of full URLs. Callers MUST only compare URLs
* at the same hierarchy level (collection-to-collection, object-to-object).
* Comparing a collection URL against an object URL will produce false
* positives because the collection URL is a prefix of the object URL.
*/
const urlContains = (urlA, urlB) => {
if (!urlA && !urlB) {
return true;
}
if (!urlA || !urlB) {
return false;
}
const strippedUrlA = normalizeUrl(urlA);
const strippedUrlB = normalizeUrl(urlB);
return strippedUrlA.includes(strippedUrlB) || strippedUrlB.includes(strippedUrlA);
};
const getDAVAttribute = (nsArr) => nsArr.reduce((prev, curr) => ({ ...prev, [DAVAttributeMap[curr]]: curr }), {});
const cleanupFalsy = (obj) => Object.entries(obj).reduce((prev, [key, value]) => {
if (value)
return { ...prev, [key]: value };
return prev;
}, {});
const conditionalParam = (key, param) => {
if (param) {
return {
[key]: param,
};
}
return {};
};
const excludeHeaders = (headers, headersToExclude) => {
if (!headers) {
return {};
}
if (!headersToExclude || headersToExclude.length === 0) {
return headers;
}
// HTTP headers are case-insensitive, so normalize both sides before comparing
const excludeSet = new Set(headersToExclude.map((h) => h.toLowerCase()));
return Object.fromEntries(Object.entries(headers).filter(([key]) => !excludeSet.has(key.toLowerCase())));
};
var requestHelpers = /*#__PURE__*/Object.freeze({
__proto__: null,
cleanupFalsy: cleanupFalsy,
conditionalParam: conditionalParam,
excludeHeaders: excludeHeaders,
getDAVAttribute: getDAVAttribute,
urlContains: urlContains,
urlEquals: urlEquals
});
const debug$5 = getLogger('tsdav:request');
const davRequest = async (params) => {
var _a;
const { url, init, convertIncoming = true, parseOutgoing = true, fetchOptions = {}, fetch: fetchOverride, } = params;
const requestFetch = fetchOverride !== null && fetchOverride !== void 0 ? fetchOverride : fetch;
const { headers = {}, body, namespace, method, attributes } = init;
const xmlBody = convertIncoming
? convert.js2xml({
_declaration: { _attributes: { version: '1.0', encoding: 'utf-8' } },
// body is spread AFTER _attributes so a body-level `_attributes`
// set by the caller wins over the implicit `attributes` param.
_attributes: attributes,
...body,
}, {
compact: true,
spaces: 2,
elementNameFn: (name) => {
// add namespace to all keys without namespace
if (namespace && !/^.+:.+/.test(name)) {
return `${namespace}:${name}`;
}
return name;
},
})
: body;
const fetchOptionsWithoutHeaders = {
...fetchOptions,
};
delete fetchOptionsWithoutHeaders.headers;
// Merge headers with case-insensitive deduplication. HTTP header names are
// case-insensitive but plain JS objects aren't, so without normalization a
// user-supplied `content-type` would coexist with our `Content-Type`,
// producing undefined provider behavior. Lowercase-keyed map wins, with
// caller-supplied headers overriding the library defaults.
const mergedHeaders = {};
const setHeader = (key, value) => {
if (value == null)
return;
const lower = key.toLowerCase();
// remove any previously-set entries with a different case
Object.keys(mergedHeaders).forEach((existing) => {
if (existing.toLowerCase() === lower) {
delete mergedHeaders[existing];
}
});
mergedHeaders[key] = value;
};
setHeader('Content-Type', 'text/xml;charset=UTF-8');
Object.entries(cleanupFalsy(headers)).forEach(([k, v]) => setHeader(k, v));
Object.entries(fetchOptions.headers || {}).forEach(([k, v]) => setHeader(k, v));
const davResponse = await requestFetch(url, {
...fetchOptionsWithoutHeaders,
headers: mergedHeaders,
body: xmlBody,
method,
});
const resText = await davResponse.text();
// filter out invalid responses
if (!davResponse.ok ||
!((_a = davResponse.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.includes('xml')) ||
!parseOutgoing ||
!resText) {
// Cap raw payload size so that non-XML error pages (HTML, stack traces
// from misconfigured servers) don't blow up downstream error messages or
// leak the entire response into logs/exceptions.
const MAX_RAW = 4096;
const raw = resText.length > MAX_RAW ? `${resText.slice(0, MAX_RAW)}…` : resText;
return [
{
href: davResponse.url,
ok: davResponse.ok,
status: davResponse.status,
statusText: davResponse.statusText,
raw,
},
];
}
let result;
try {
result = convert.xml2js(resText, {
compact: true,
trim: true,
textFn: (value, parentElement) => {
try {
// This is needed for xml-js design reasons
// eslint-disable-next-line no-underscore-dangle
const parentOfParent = parentElement._parent;
const pOpKeys = Object.keys(parentOfParent);
const keyNo = pOpKeys.length;
const keyName = pOpKeys[keyNo - 1];
const arrOfKey = parentOfParent[keyName];
const arrOfKeyLen = arrOfKey.length;
if (arrOfKeyLen > 0) {
const arr = arrOfKey;
const arrIndex = arrOfKey.length - 1;
arr[arrIndex] = nativeType(value);
}
else {
parentOfParent[keyName] = nativeType(value);
}
}
catch (e) {
debug$5(e.stack);
}
},
// remove namespace & camelCase
elementNameFn: (attributeName) => camelCase(attributeName.replace(/^.+:/, '')),
attributesFn: (value) => {
const newVal = { ...value };
delete newVal.xmlns;
return newVal;
},
ignoreDeclaration: true,
});
}
catch (e) {
debug$5(`Failed to parse DAV response XML: ${e.message}`);
return [
{
href: davResponse.url,
ok: davResponse.ok,
status: davResponse.status,
statusText: davResponse.statusText,
raw: resText,
},
];
}
// Non-multistatus XML responses (e.g. a CalDAV error report) would
// otherwise throw `Cannot read properties of undefined (reading 'response')`.
// Return the parsed object as raw so callers can inspect it.
if (!(result === null || result === void 0 ? void 0 : result.multistatus)) {
return [
{
href: davResponse.url,
ok: davResponse.ok,
status: davResponse.status,
statusText: davResponse.statusText,
raw: result,
},
];
}
const responseBodies = Array.isArray(result.multistatus.response)
? result.multistatus.response
: [result.multistatus.response];
return responseBodies.map((responseBody) => {
var _a, _b;
const statusRegex = /^\S+\s(?<status>\d+)\s(?<statusText>.+)$/;
if (!responseBody) {
return {
status: davResponse.status,
statusText: davResponse.statusText,
ok: davResponse.ok,
};
}
const matchArr = statusRegex.exec(responseBody.status);
const status = (matchArr === null || matchArr === void 0 ? void 0 : matchArr.groups)
? Number.parseInt(matchArr.groups.status, 10)
: davResponse.status;
return {
raw: result,
href: responseBody.href,
status,
statusText: (_b = (_a = matchArr === null || matchArr === void 0 ? void 0 : matchArr.groups) === null || _a === void 0 ? void 0 : _a.statusText) !== null && _b !== void 0 ? _b : davResponse.statusText,
// Derive `ok` from the parsed status (per RFC 4918, a 2xx propstat
// means success). The previous implementation read `!responseBody.error`
// which flagged empty `<error/>` elements as failures and ignored
// real non-2xx statuses inside 207 multistatus payloads.
ok: status >= 200 && status < 300,
error: responseBody.error,
responsedescription: responseBody.responsedescription,
props: (Array.isArray(responseBody.propstat)
? responseBody.propstat
: [responseBody.propstat]).reduce((prev, curr) => {
return {
...prev,
...curr === null || curr === void 0 ? void 0 : curr.prop,
};
}, {}),
};
});
};
const propfind = async (params) => {
const { url, props, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return davRequest({
url,
init: {
method: 'PROPFIND',
headers: excludeHeaders(cleanupFalsy({ depth, ...headers }), headersToExclude),
namespace: exports.DAVNamespaceShort.DAV,
body: {
propfind: {
_attributes: getDAVAttribute([
exports.DAVNamespace.CALDAV,
exports.DAVNamespace.CALDAV_APPLE,
exports.DAVNamespace.CALENDAR_SERVER,
exports.DAVNamespace.CARDDAV,
exports.DAVNamespace.DAV,
]),
prop: props,
},
},
},
fetchOptions,
fetch: fetchOverride,
});
};
const createObject = async (params) => {
const { url, data, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
const requestFetch = fetchOverride !== null && fetchOverride !== void 0 ? fetchOverride : fetch;
return requestFetch(url, {
method: 'PUT',
body: data,
headers: excludeHeaders(headers, headersToExclude),
...fetchOptions,
});
};
const updateObject = async (params) => {
const { url, data, etag, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
const requestFetch = fetchOverride !== null && fetchOverride !== void 0 ? fetchOverride : fetch;
return requestFetch(url, {
method: 'PUT',
body: data,
headers: excludeHeaders(cleanupFalsy({ 'If-Match': etag, ...headers }), headersToExclude),
...fetchOptions,
});
};
const deleteObject = async (params) => {
const { url, headers, etag, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
const requestFetch = fetchOverride !== null && fetchOverride !== void 0 ? fetchOverride : fetch;
return requestFetch(url, {
method: 'DELETE',
headers: excludeHeaders(cleanupFalsy({ 'If-Match': etag, ...headers }), headersToExclude),
...fetchOptions,
});
};
var request = /*#__PURE__*/Object.freeze({
__proto__: null,
createObject: createObject,
davRequest: davRequest,
deleteObject: deleteObject,
propfind: propfind,
updateObject: updateObject
});
function hasFields(obj, fields) {
const inObj = (object) => fields.every((f) => object[f]);
if (Array.isArray(obj)) {
return obj.every((o) => inObj(o));
}
return inObj(obj);
}
const findMissingFieldNames = (obj, fields) => fields.reduce((prev, curr) => (obj[curr] ? prev : `${prev.length ? `${prev},` : ''}${curr.toString()}`), '');
/* eslint-disable no-underscore-dangle */
const debug$4 = getLogger('tsdav:collection');
const collectionQuery = async (params) => {
const { url, body, depth, defaultNamespace = exports.DAVNamespaceShort.DAV, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
const queryResults = await davRequest({
url,
init: {
method: 'REPORT',
headers: excludeHeaders(cleanupFalsy({ depth, ...headers }), headersToExclude),
namespace: defaultNamespace,
body,
},
fetchOptions,
fetch: fetchOverride,
});
const errorResponse = queryResults.find((res) => !res.ok || (res.status && res.status >= 400));
if (errorResponse) {
throw new Error(`Collection query failed: ${errorResponse.status} ${errorResponse.statusText}. ${errorResponse.raw ? `Raw response: ${errorResponse.raw}` : ''}`);
}
// empty query result
if (queryResults.length === 1 &&
!queryResults[0].raw &&
queryResults[0].status &&
queryResults[0].status < 300) {
return [];
}
return queryResults;
};
const makeCollection = async (params) => {
const { url, props, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return davRequest({
url,
init: {
method: 'MKCOL',
headers: excludeHeaders(cleanupFalsy({ depth, ...headers }), headersToExclude),
namespace: exports.DAVNamespaceShort.DAV,
body: props
? {
mkcol: {
set: {
prop: props,
},
},
}
: undefined,
},
fetchOptions,
fetch: fetchOverride,
});
};
const supportedReportSet = async (params) => {
var _a, _b, _c;
const { collection, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
const res = await propfind({
url: collection.url,
props: {
[`${exports.DAVNamespaceShort.DAV}:supported-report-set`]: {},
},
depth: '0',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
// xml-js compact output collapses repeated elements into an array, but a
// lone `<supported-report>` element parses to a single object. Normalize to
// an array so downstream `.map` never crashes with "map is not a function".
const supportedReport = (_c = (_b = (_a = res[0]) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.supportedReportSet) === null || _c === void 0 ? void 0 : _c.supportedReport;
if (!supportedReport) {
return [];
}
const reports = Array.isArray(supportedReport) ? supportedReport : [supportedReport];
return reports
.map((sr) => (sr === null || sr === void 0 ? void 0 : sr.report) ? Object.keys(sr.report)[0] : undefined)
.filter((name) => typeof name === 'string' && name.length > 0);
};
const isCollectionDirty = async (params) => {
var _a, _b, _c;
const { collection, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
const responses = await propfind({
url: collection.url,
props: {
[`${exports.DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
},
depth: '0',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
const res = responses.filter((r) => urlContains(collection.url, r.href))[0];
if (!res) {
throw new Error('Collection does not exist on server');
}
return {
isDirty: `${collection.ctag}` !== `${(_a = res.props) === null || _a === void 0 ? void 0 : _a.getctag}`,
newCtag: (_c = (_b = res.props) === null || _b === void 0 ? void 0 : _b.getctag) === null || _c === void 0 ? void 0 : _c.toString(),
};
};
/**
* This is for webdav sync-collection only
*/
const syncCollection = (params) => {
const { url, props, headers, syncLevel, syncToken, headersToExclude, fetchOptions, fetch: fetchOverride, } = params;
return davRequest({
url,
init: {
method: 'REPORT',
namespace: exports.DAVNamespaceShort.DAV,
headers: excludeHeaders({ ...headers }, headersToExclude),
body: {
'sync-collection': {
_attributes: getDAVAttribute([
exports.DAVNamespace.CALDAV,
exports.DAVNamespace.CARDDAV,
exports.DAVNamespace.DAV,
]),
'sync-level': syncLevel,
'sync-token': syncToken,
[`${exports.DAVNamespaceShort.DAV}:prop`]: props,
},
},
},
fetchOptions,
fetch: fetchOverride,
});
};
/** remote collection to local */
const smartCollectionSync = async (params) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
const { collection, method, headers, headersToExclude, account, detailedResult, fetchOptions = {}, fetch: fetchOverride, } = params;
const requiredFields = ['accountType', 'homeUrl'];
if (!account || !hasFields(account, requiredFields)) {
if (!account) {
throw new Error('no account for smartCollectionSync');
}
throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before smartCollectionSync`);
}
const syncMethod = method !== null && method !== void 0 ? method : (((_a = collection.reports) === null || _a === void 0 ? void 0 : _a.includes('syncCollection')) ? 'webdav' : 'basic');
debug$4(`smart collection sync with type ${account.accountType} and method ${syncMethod}`);
if (syncMethod === 'webdav') {
const result = await syncCollection({
url: collection.url,
props: {
[`${exports.DAVNamespaceShort.DAV}:getetag`]: {},
[`${account.accountType === 'caldav' ? exports.DAVNamespaceShort.CALDAV : exports.DAVNamespaceShort.CARDDAV}:${account.accountType === 'caldav' ? 'calendar-data' : 'address-data'}`]: {},
[`${exports.DAVNamespaceShort.DAV}:displayname`]: {},
},
syncLevel: 1,
syncToken: collection.syncToken,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
const objectResponses = result.filter((r) => {
var _a;
const extName = account.accountType === 'caldav' ? '.ics' : '.vcf';
return ((_a = r.href) === null || _a === void 0 ? void 0 : _a.slice(-4)) === extName;
});
const changedObjectUrls = objectResponses.filter((o) => o.status !== 404).map((r) => r.href);
const deletedObjectUrls = objectResponses.filter((o) => o.status === 404).map((r) => r.href);
const multiGetObjectResponse = changedObjectUrls.length
? ((_c = (await ((_b = collection.objectMultiGet) === null || _b === void 0 ? void 0 : _b.call(collection, {
url: collection.url,
props: {
[`${exports.DAVNamespaceShort.DAV}:getetag`]: {},
[`${account.accountType === 'caldav'
? exports.DAVNamespaceShort.CALDAV
: exports.DAVNamespaceShort.CARDDAV}:${account.accountType === 'caldav' ? 'calendar-data' : 'address-data'}`]: {},
},
objectUrls: changedObjectUrls,
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
})))) !== null && _c !== void 0 ? _c : [])
: [];
const remoteObjects = multiGetObjectResponse.map((res) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
return {
url: (_a = res.href) !== null && _a !== void 0 ? _a : '',
etag: (_b = res.props) === null || _b === void 0 ? void 0 : _b.getetag,
data: (account === null || account === void 0 ? void 0 : account.accountType) === 'caldav'
? ((_e = (_d = (_c = res.props) === null || _c === void 0 ? void 0 : _c.calendarData) === null || _d === void 0 ? void 0 : _d._cdata) !== null && _e !== void 0 ? _e : (_f = res.props) === null || _f === void 0 ? void 0 : _f.calendarData)
: ((_j = (_h = (_g = res.props) === null || _g === void 0 ? void 0 : _g.addressData) === null || _h === void 0 ? void 0 : _h._cdata) !== null && _j !== void 0 ? _j : (_k = res.props) === null || _k === void 0 ? void 0 : _k.addressData),
};
});
const localObjects = (_d = collection.objects) !== null && _d !== void 0 ? _d : [];
// no existing url
const created = remoteObjects.filter((o) => localObjects.every((lo) => !urlContains(lo.url, o.url)));
// debug(`created objects: ${created.map((o) => o.url).join('\n')}`);
// have same url, but etag different
const updated = localObjects.reduce((prev, curr) => {
const found = remoteObjects.find((ro) => urlContains(ro.url, curr.url));
if (found && found.etag && found.etag !== curr.etag) {
return [...prev, found];
}
return prev;
}, []);
// debug(`updated objects: ${updated.map((o) => o.url).join('\n')}`);
const deleted = deletedObjectUrls.map((o) => ({
url: o,
etag: '',
}));
// debug(`deleted objects: ${deleted.map((o) => o.url).join('\n')}`);
const unchanged = localObjects.filter((lo) => remoteObjects.some((ro) => urlContains(lo.url, ro.url) && ro.etag === lo.etag));
return {
...collection,
objects: detailedResult
? { created, updated, deleted }
: [...unchanged, ...created, ...updated],
// all syncToken in the results are the same so we use the first one here
syncToken: (_h = (_g = (_f = (_e = result[0]) === null || _e === void 0 ? void 0 : _e.raw) === null || _f === void 0 ? void 0 : _f.multistatus) === null || _g === void 0 ? void 0 : _g.syncToken) !== null && _h !== void 0 ? _h : collection.syncToken,
};
}
if (syncMethod === 'basic') {
const { isDirty, newCtag } = await isCollectionDirty({
collection,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
// If the collection hasn't changed, skip the expensive fetchObjects call
// entirely and return early. The trailing return below handles the
// not-dirty case with an empty diff.
if (!isDirty) {
return detailedResult
? {
...collection,
objects: {
created: [],
updated: [],
deleted: [],
},
}
: collection;
}
const localObjects = (_j = collection.objects) !== null && _j !== void 0 ? _j : [];
// The fetchObjects signature is a union of CalDAV/CardDAV variants that
// TypeScript cannot narrow from `T extends DAVCollection`. Call via an
// explicit any-cast, which preserves runtime behavior. The `fetch`
// override MUST be forwarded here so custom transports (Electron,
// Workers, KaiOS) still work in the basic/ctag-based sync fallback —
// dropping it would silently re-route the request through the global
// fetch, breaking those environments.
const remoteObjects = (_l = (await ((_k = collection.fetchObjects) === null || _k === void 0 ? void 0 : _k.call(collection, {
collection,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
})))) !== null && _l !== void 0 ? _l : [];
// no existing url
const created = remoteObjects.filter((ro) => localObjects.every((lo) => !urlContains(lo.url, ro.url)));
// debug(`created objects: ${created.map((o) => o.url).join('\n')}`);
// have same url, but etag different
const updated = localObjects.reduce((prev, curr) => {
const found = remoteObjects.find((ro) => urlContains(ro.url, curr.url));
if (found && found.etag && found.etag !== curr.etag) {
return [...prev, found];
}
return prev;
}, []);
// debug(`updated objects: ${updated.map((o) => o.url).join('\n')}`);
// does not present in remote
const deleted = localObjects.filter((cal) => remoteObjects.every((ro) => !urlContains(ro.url, cal.url)));
// debug(`deleted objects: ${deleted.map((o) => o.url).join('\n')}`);
const unchanged = localObjects.filter((lo) => remoteObjects.some((ro) => urlContains(lo.url, ro.url) && ro.etag === lo.etag));
return {
...collection,
objects: detailedResult
? { created, updated, deleted }
: [...unchanged, ...created, ...updated],
ctag: newCtag,
};
}
return detailedResult
? {
...collection,
objects: {
created: [],
updated: [],
deleted: [],
},
}
: collection;
};
const smartCollectionSyncDetailed = async (params) => smartCollectionSync({ ...params, detailedResult: true });
var collection = /*#__PURE__*/Object.freeze({
__proto__: null,
collectionQuery: collectionQuery,
isCollectionDirty: isCollectionDirty,
makeCollection: makeCollection,
smartCollectionSync: smartCollectionSync,
smartCollectionSyncDetailed: smartCollectionSyncDetailed,
supportedReportSet: supportedReportSet,
syncCollection: syncCollection
});
/* eslint-disable no-underscore-dangle */
const debug$3 = getLogger('tsdav:addressBook');
const addressBookQuery = async (params) => {
const { url, props, filters, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return collectionQuery({
url,
body: {
'addressbook-query': cleanupFalsy({
_attributes: getDAVAttribute([exports.DAVNamespace.CARDDAV, exports.DAVNamespace.DAV]),
[`${exports.DAVNamespaceShort.DAV}:prop`]: props,
filter: filters !== null && filters !== void 0 ? filters : {
'prop-filter': {
_attributes: {
name: 'FN',
},
},
},
}),
},
defaultNamespace: exports.DAVNamespaceShort.CARDDAV,
depth,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const addressBookMultiGet = async (params) => {
const { url, props, objectUrls, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return collectionQuery({
url,
body: {
'addressbook-multiget': cleanupFalsy({
_attributes: getDAVAttribute([exports.DAVNamespace.DAV, exports.DAVNamespace.CARDDAV]),
[`${exports.DAVNamespaceShort.DAV}:prop`]: props,
[`${exports.DAVNamespaceShort.DAV}:href`]: objectUrls,
}),
},
defaultNamespace: exports.DAVNamespaceShort.CARDDAV,
depth,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const fetchAddressBooks = async (params) => {
const { account, headers, props: customProps, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params !== null && params !== void 0 ? params : {};
const requiredFields = ['homeUrl', 'rootUrl'];
if (!account || !hasFields(account, requiredFields)) {
if (!account) {
throw new Error('no account for fetchAddressBooks');
}
throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before fetchAddressBooks`);
}
const res = await propfind({
url: account.homeUrl,
props: customProps !== null && customProps !== void 0 ? customProps : {
[`${exports.DAVNamespaceShort.DAV}:displayname`]: {},
[`${exports.DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${exports.DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${exports.DAVNamespaceShort.DAV}:sync-token`]: {},
},
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
return Promise.all(res
.filter((r) => { var _a, _b; return Object.keys((_b = (_a = r.props) === null || _a === void 0 ? void 0 : _a.resourcetype) !== null && _b !== void 0 ? _b : {}).includes('addressbook'); })
.map((rs) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
const displayName = (_c = (_b = (_a = rs.props) === null || _a === void 0 ? void 0 : _a.displayname) === null || _b === void 0 ? void 0 : _b._cdata) !== null && _c !== void 0 ? _c : (_d = rs.props) === null || _d === void 0 ? void 0 : _d.displayname;
debug$3(`Found address book named ${typeof displayName === 'string' ? displayName : ''},
props: ${JSON.stringify(rs.props)}`);
return {
url: new URL((_e = rs.href) !== null && _e !== void 0 ? _e : '', (_f = account.rootUrl) !== null && _f !== void 0 ? _f : '').href,
ctag: (_g = rs.props) === null || _g === void 0 ? void 0 : _g.getctag,
displayName: typeof displayName === 'string' ? displayName : '',
resourcetype: Object.keys((_j = (_h = rs.props) === null || _h === void 0 ? void 0 : _h.resourcetype) !== null && _j !== void 0 ? _j : {}),
syncToken: (_k = rs.props) === null || _k === void 0 ? void 0 : _k.syncToken,
};
})
.map(async (addr) => ({
...addr,
reports: await supportedReportSet({
collection: addr,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
}),
})));
};
const fetchVCards = async (params) => {
const { addressBook, headers, objectUrls, headersToExclude, urlFilter = (url) => Boolean(url), useMultiGet = true, fetchOptions = {}, fetch: fetchOverride, } = params;
debug$3(`Fetching vcards from ${addressBook === null || addressBook === void 0 ? void 0 : addressBook.url}`);
const requiredFields = ['url'];
if (!addressBook || !hasFields(addressBook, requiredFields)) {
if (!addressBook) {
throw new Error('cannot fetchVCards for undefined addressBook');
}
throw new Error(`addressBook must have ${findMissingFieldNames(addressBook, requiredFields)} before fetchVCards`);
}
const vcardUrls = (objectUrls !== null && objectUrls !== void 0 ? objectUrls :
// fetch all objects of the calendar
(await addressBookQuery({
url: addressBook.url,
props: { [`${exports.DAVNamespaceShort.DAV}:getetag`]: {} },
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
})).map((res) => { var _a; return (_a = res.href) !== null && _a !== void 0 ? _a : ''; }))
.map((url) => (url.startsWith('http') || !url ? url : new URL(url, addressBook.url).href))
.filter((url) => url && !urlEquals(url, addressBook.url))
.filter(urlFilter)
.map((url) => new URL(url).pathname);
let vCardResults = [];
if (vcardUrls.length > 0) {
if (useMultiGet) {
vCardResults = await addressBookMultiGet({
url: addressBook.url,
props: {
[`${exports.DAVNamespaceShort.DAV}:getetag`]: {},
[`${exports.DAVNamespaceShort.CARDDAV}:address-data`]: {},
},
objectUrls: vcardUrls,
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
}
else {
vCardResults = await addressBookQuery({
url: addressBook.url,
props: {
[`${exports.DAVNamespaceShort.DAV}:getetag`]: {},
[`${exports.DAVNamespaceShort.CARDDAV}:address-data`]: {},
},
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
}
}
return vCardResults.map((res) => {
var _a, _b, _c, _d, _e, _f;
return ({
url: new URL((_a = res.href) !== null && _a !== void 0 ? _a : '', addressBook.url).href,
etag: (_b = res.props) === null || _b === void 0 ? void 0 : _b.getetag,
data: (_e = (_d = (_c = res.props) === null || _c === void 0 ? void 0 : _c.addressData) === null || _d === void 0 ? void 0 : _d._cdata) !== null && _e !== void 0 ? _e : (_f = res.props) === null || _f === void 0 ? void 0 : _f.addressData,
});
});
};
const createVCard = async (params) => {
const { addressBook, vCardString, filename, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return createObject({
url: new URL(filename, addressBook.url).href,
data: vCardString,
headers: excludeHeaders({
'content-type': 'text/vcard; charset=utf-8',
'If-None-Match': '*',
...headers,
}, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const updateVCard = async (params) => {
const { vCard, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
return updateObject({
url: vCard.url,
data: vCard.data,
etag: vCard.etag,
headers: excludeHeaders({
'content-type': 'text/vcard; charset=utf-8',
...headers,
}, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const deleteVCard = async (params) => {
const { vCard, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
return deleteObject({
url: vCard.url,
etag: vCard.etag,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
var addressBook = /*#__PURE__*/Object.freeze({
__proto__: null,
addressBookMultiGet: addressBookMultiGet,
addressBookQuery: addressBookQuery,
createVCard: createVCard,
deleteVCard: deleteVCard,
fetchAddressBooks: fetchAddressBooks,
fetchVCards: fetchVCards,
updateVCard: updateVCard
});
/* eslint-disable no-underscore-dangle */
const debug$2 = getLogger('tsdav:calendar');
const ISO_8601 = /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i;
const ISO_8601_FULL = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(([+-]\d\d:\d\d)|Z)?$/i;
/**
* Validate a time-range input: both endpoints must be ISO-8601 shaped AND
* parse to a real Date (so values like `0000-13-99` get rejected).
*/
const validateTimeRange = (timeRange) => {
const { start, end } = timeRange;
const formatValid = (ISO_8601.test(start) && ISO_8601.test(end)) ||
(ISO_8601_FULL.test(start) && ISO_8601_FULL.test(end));
if (!formatValid) {
throw new Error('invalid timeRange format, not in ISO8601');
}
if (Number.isNaN(new Date(start).getTime()) || Number.isNaN(new Date(end).getTime())) {
throw new Error('invalid timeRange: start or end is not a valid date');
}
};
const extractComponentNames = (compSet) => {
var _a;
let names = [];
if (Array.isArray(compSet)) {
names = compSet.map((sc) => { var _a; return (_a = sc === null || sc === void 0 ? void 0 : sc._attributes) === null || _a === void 0 ? void 0 : _a.name; });
}
else if (compSet && typeof compSet === 'object') {
names = [(_a = compSet._attributes) === null || _a === void 0 ? void 0 : _a.name];
}
return names.filter((n) => typeof n === 'string' && n.length > 0);
};
const fetchCalendarUserAddresses = async (params) => {
var _a, _b;
const { account, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params;
const requiredFields = ['principalUrl', 'rootUrl'];
if (!hasFields(account, requiredFields)) {
throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before fetchUserAddresses`);
}
debug$2(`Fetch user addresses from ${account.principalUrl}`);
const responses = await propfind({
url: account.principalUrl,
props: { [`${exports.DAVNamespaceShort.CALDAV}:calendar-user-address-set`]: {} },
depth: '0',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
const matched = responses.find((r) => urlContains(account.principalUrl, r.href));
if (!matched || !matched.ok) {
throw new Error('cannot find calendarUserAddresses');
}
const rawHrefs = (_b = (_a = matched === null || matched === void 0 ? void 0 : matched.props) === null || _a === void 0 ? void 0 : _a.calendarUserAddressSet) === null || _b === void 0 ? void 0 : _b.href;
let hrefArray = [];
if (Array.isArray(rawHrefs)) {
hrefArray = rawHrefs;
}
else if (rawHrefs) {
hrefArray = [rawHrefs];
}
const addresses = hrefArray.filter((h) => typeof h === 'string' && h.length > 0);
debug$2(`Fetched calendar user addresses ${addresses}`);
return addresses;
};
const calendarQuery = async (params) => {
const { url, props, filters, timezone, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return collectionQuery({
url,
body: {
'calendar-query': cleanupFalsy({
_attributes: getDAVAttribute([
exports.DAVNamespace.CALDAV,
exports.DAVNamespace.CALENDAR_SERVER,
exports.DAVNamespace.CALDAV_APPLE,
exports.DAVNamespace.DAV,
]),
[`${exports.DAVNamespaceShort.DAV}:prop`]: props,
filter: filters,
timezone,
}),
},
defaultNamespace: exports.DAVNamespaceShort.CALDAV,
depth,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const calendarMultiGet = async (params) => {
const { url, props, objectUrls, filters, timezone, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return collectionQuery({
url,
body: {
'calendar-multiget': cleanupFalsy({
_attributes: getDAVAttribute([exports.DAVNamespace.DAV, exports.DAVNamespace.CALDAV]),
[`${exports.DAVNamespaceShort.DAV}:prop`]: props,
[`${exports.DAVNamespaceShort.DAV}:href`]: objectUrls,
filter: filters,
timezone,
}),
},
defaultNamespace: exports.DAVNamespaceShort.CALDAV,
depth,
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
};
const makeCalendar = async (params) => {
const { url, props, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params;
return davRequest({
url,
init: {
method: 'MKCALENDAR',
headers: excludeHeaders(cleanupFalsy({ depth, ...headers }), headersToExclude),
namespace: exports.DAVNamespaceShort.DAV,
body: {
[`${exports.DAVNamespaceShort.CALDAV}:mkcalendar`]: {
_attributes: getDAVAttribute([
exports.DAVNamespace.DAV,
exports.DAVNamespace.CALDAV,
exports.DAVNamespace.CALDAV_APPLE,
]),
set: {
prop: props,
},
},
},
},
fetchOptions,
fetch: fetchOverride,
});
};
const fetchCalendars = async (params) => {
const { headers, account, props: customProps, projectedProps, headersToExclude, fetchOptions = {}, fetch: fetchOverride, } = params !== null && params !== void 0 ? params : {};
const requiredFields = ['homeUrl', 'rootUrl'];
if (!account || !hasFields(account, requiredFields)) {
if (!account) {
throw new Error('no account for fetchCalendars');
}
throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before fetchCalendars`);
}
const res = await propfind({
url: account.homeUrl,
props: customProps !== null && customProps !== void 0 ? customProps : {
[`${exports.DAVNamespaceShort.CALDAV}:calendar-description`]: {},
[`${exports.DAVNamespaceShort.CALDAV}:calendar-timezone`]: {},
[`${exports.DAVNamespaceShort.DAV}:displayname`]: {},
[`${exports.DAVNamespaceShort.CALDAV_APPLE}:calendar-color`]: {},
[`${exports.DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${exports.DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${exports.DAVNamespaceShort.CALDAV}:supported-calendar-component-set`]: {},
[`${exports.DAVNamespaceShort.DAV}:sync-token`]: {},
},
depth: '1',
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride,
});
return Promise.all(res
.filter((r) => { var _a, _b; return Object.keys((_b = (_a = r.props) === null || _a === void 0 ? void 0 : _a.resourcetype) !== null && _b !== void 0 ? _b : {}).includes('calendar'); })
.filter((rc) => {
var _a, _b;
// Filter out non-iCal calendars when components are declared. Some servers
// (e.g. Purelymail) omit `supported-calendar-component-set` despite RFC 4791
// § 5.2.3 requiring it. When no usable component names come back, fall back to
// accepting the calendar — the previous filter already established it's a
// `<calendar/>` resourcetype, so we have independent evidence it's a calendar.
const components = extractComponentNames((_b = (_a = rc.props) === null || _a === void 0 ? void 0 : _a.supportedCalendarComponentSet) === null || _b === void 0 ? void 0 : _b.comp);
return (components.length === 0 ||
components.some((c) => Object.values(ICALObjects).includes(c)));
})
.map((rs) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
// debug(`Found calendar ${rs.props?.displayname}`);
const description = (_a = rs.props) === null || _a === void 0 ? void 0 : _a.calendarDescription;
const timezone = (_b = rs.props) === null || _b === void 0 ? void 0 : _b.calendarTimezone;
const compSet = (_d = (_c = rs.props) === null || _c === void 0 ? void 0 : _c.supportedCalendarComponentSet) === null || _d === void 0 ? void 0 : _d.comp;
const projectedEntries = Object.entries((_e = rs.props) !== null && _e !== void 0 ? _e : {}).filter(([key]) => projectedProps === null || projectedProps === void 0 ? void 0 : projectedProps[key]);
return {
description: typeof description === 'string' ? description : '',
timezone: typeof timezone === 'string' ? timezone : '',
url: new URL((_f = rs.href) !== null && _f !== void 0 ? _f : '', (_g = account.rootUrl) !== null && _g !== void 0 ? _g : '').href,
ctag: (_h = rs.props) === null || _h === void 0 ? void 0 : _h.getctag,
calendarColor: (_j = rs.props) === null || _j === void 0 ? void 0 : _j.calendarColor,
displayName: (_m = (_l = (_k = rs.props) === null || _k === void 0 ? void 0 : _k.displayname) === null || _l === void 0 ? void 0 : _l._cdata) !== null && _m !== void 0 ? _m : (_o = rs.props) === null || _o === void 0 ? void 0 : _o.displayname,
components: extractComponentNames(compSet),
resourcetype: Object.keys((_q = (_p = rs.pr