UNPKG

tsdav

Version:

WebDAV, CALDAV, and CARDDAV client for Nodejs and the Browser

1,333 lines 77.1 kB
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } }); //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __exportAll = (all, no_symbols) => { let target = {}; for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" }); return target; }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let debug = require("debug"); debug = __toESM(debug); let xml_js = require("xml-js"); xml_js = __toESM(xml_js); //#region src/consts.ts let DAVNamespace = /* @__PURE__ */ 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:"; return DAVNamespace; }({}); const DAVAttributeMap = { ["urn:ietf:params:xml:ns:caldav"]: "xmlns:c", ["urn:ietf:params:xml:ns:carddav"]: "xmlns:card", ["http://calendarserver.org/ns/"]: "xmlns:cs", ["http://apple.com/ns/ical/"]: "xmlns:ca", ["DAV:"]: "xmlns:d" }; let DAVNamespaceShort = /* @__PURE__ */ function(DAVNamespaceShort) { DAVNamespaceShort["CALDAV"] = "c"; DAVNamespaceShort["CARDDAV"] = "card"; DAVNamespaceShort["CALENDAR_SERVER"] = "cs"; DAVNamespaceShort["CALDAV_APPLE"] = "ca"; DAVNamespaceShort["DAV"] = "d"; return DAVNamespaceShort; }({}); let ICALObjects = /* @__PURE__ */ function(ICALObjects) { ICALObjects["VEVENT"] = "VEVENT"; ICALObjects["VTODO"] = "VTODO"; ICALObjects["VJOURNAL"] = "VJOURNAL"; ICALObjects["VFREEBUSY"] = "VFREEBUSY"; ICALObjects["VTIMEZONE"] = "VTIMEZONE"; ICALObjects["VALARM"] = "VALARM"; return ICALObjects; }({}); //#endregion //#region src/util/camelCase.ts const camelCase = (str) => str.replace(/[-_]+(\w?)/g, (_m, c) => c ? c.toUpperCase() : ""); //#endregion //#region src/util/fetch.ts /** * 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 (() => { 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(); //#endregion //#region src/util/nativeType.ts 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) && (!Number.isInteger(nValue) || Number.isSafeInteger(nValue))) return nValue; } const bValue = value.toLowerCase(); if (bValue === "true") return true; if (bValue === "false") return false; return value; }; //#endregion //#region src/util/requestHelpers.ts var requestHelpers_exports = /* @__PURE__ */ __exportAll({ cleanupFalsy: () => cleanupFalsy, conditionalParam: () => conditionalParam, excludeHeaders: () => excludeHeaders, getDAVAttribute: () => getDAVAttribute, mergeHeaders: () => mergeHeaders, urlContains: () => urlContains, urlEquals: () => urlEquals, urlMatches: () => urlMatches }); 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); }; /** * Compare two DAV hrefs as resource identifiers after resolving relative * hrefs against the same collection or account URL. */ const urlMatches = (urlA, urlB, baseUrl) => { if (!urlA || !urlB || !baseUrl) return urlEquals(urlA, urlB); try { return urlEquals(new URL(urlA, baseUrl).href, new URL(urlB, baseUrl).href); } catch { return urlEquals(urlA, urlB); } }; 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; const excludeSet = new Set(headersToExclude.map((h) => h.toLowerCase())); return Object.fromEntries(Object.entries(headers).filter(([key]) => !excludeSet.has(key.toLowerCase()))); }; /** Merge all valid HeadersInit forms with case-insensitive last-write-wins semantics. */ const mergeHeaders = (...headerSources) => { const headersByLowercaseName = /* @__PURE__ */ new Map(); const setHeader = (name, value) => { headersByLowercaseName.set(name.toLowerCase(), [name, value]); }; for (const source of headerSources) { if (!source) continue; if (Array.isArray(source)) { for (const [name, value] of source) setHeader(name, value); continue; } if (typeof source.forEach === "function") { source.forEach((value, name) => { setHeader(name, value); }); continue; } for (const [name, value] of Object.entries(source)) setHeader(name, value); } return Object.fromEntries(headersByLowercaseName.values()); }; //#endregion //#region src/request.ts var request_exports = /* @__PURE__ */ __exportAll({ createObject: () => createObject, davRequest: () => davRequest, deleteObject: () => deleteObject, propfind: () => propfind, updateObject: () => updateObject }); const debug$6 = (0, debug.default)("tsdav:request"); const parseStatusLine = (statusLine) => { const match = /^\S+\s(?<status>\d+)\s(?<statusText>.+)$/.exec(statusLine ?? ""); const status = match?.groups?.status; const statusText = match?.groups?.statusText; return status && statusText ? { status: Number.parseInt(status, 10), statusText } : void 0; }; const davRequest = async (params) => { const { url, init, convertIncoming = true, parseOutgoing = true, fetchOptions = {}, fetch: fetchOverride } = params; const requestFetch = fetchOverride ?? fetch; const { headers = {}, body, namespace, method, attributes } = init; const xmlBody = convertIncoming && body != null ? xml_js.default.js2xml({ _declaration: { _attributes: { version: "1.0", encoding: "utf-8" } }, _attributes: attributes, ...body }, { compact: true, spaces: 2, elementNameFn: (name) => { if (namespace && !/^.+:.+/.test(name)) return `${namespace}:${name}`; return name; } }) : body; const fetchOptionsWithoutHeaders = { ...fetchOptions }; delete fetchOptionsWithoutHeaders.headers; const mergedHeaders = mergeHeaders({ "Content-Type": "text/xml;charset=UTF-8" }, cleanupFalsy(headers), fetchOptions.headers); const davResponse = await requestFetch(url, { ...fetchOptionsWithoutHeaders, headers: mergedHeaders, body: xmlBody, method }); const resText = await davResponse.text(); if (!davResponse.ok || !davResponse.headers.get("content-type")?.toLowerCase().includes("xml") || !parseOutgoing || !resText) { 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 = xml_js.default.xml2js(resText, { compact: true, trim: true, textFn: (value, parentElement) => { try { const parentOfParent = parentElement._parent; const pOpKeys = Object.keys(parentOfParent); const keyName = pOpKeys[pOpKeys.length - 1]; if (!keyName) return; const arrOfKey = parentOfParent[keyName]; if (arrOfKey.length > 0) { const arr = arrOfKey; const arrIndex = arrOfKey.length - 1; arr[arrIndex] = nativeType(value); } else parentOfParent[keyName] = nativeType(value); } catch (e) { debug$6(e.stack); } }, elementNameFn: (attributeName) => camelCase(attributeName.replace(/^.+:/, "")), attributesFn: (value) => { const newVal = { ...value }; delete newVal.xmlns; return newVal; }, ignoreDeclaration: true }); } catch (e) { debug$6(`Failed to parse DAV response XML: ${e.message}`); return [{ href: davResponse.url, ok: davResponse.ok, status: davResponse.status, statusText: davResponse.statusText, raw: resText }]; } if (!result?.multistatus) return [{ href: davResponse.url, ok: davResponse.ok, status: davResponse.status, statusText: davResponse.statusText, raw: result }]; return (Array.isArray(result.multistatus.response) ? result.multistatus.response : [result.multistatus.response]).map((responseBody) => { if (!responseBody) return { status: davResponse.status, statusText: davResponse.statusText, ok: davResponse.ok }; const parsedStatus = parseStatusLine(responseBody.status); const status = parsedStatus?.status ?? davResponse.status; return { raw: result, href: responseBody.href, status, statusText: parsedStatus?.statusText ?? davResponse.statusText, ok: status >= 200 && status < 300, error: responseBody.error, responsedescription: responseBody.responsedescription, props: (Array.isArray(responseBody.propstat) ? responseBody.propstat : [responseBody.propstat]).reduce((prev, curr) => { const propstatStatus = parseStatusLine(curr?.status)?.status; if (propstatStatus && (propstatStatus < 200 || propstatStatus >= 300)) return prev; return { ...prev, ...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: "d", body: { propfind: { _attributes: getDAVAttribute([ "urn:ietf:params:xml:ns:caldav", "http://apple.com/ns/ical/", "http://calendarserver.org/ns/", "urn:ietf:params:xml:ns:carddav", "DAV:" ]), prop: props } } }, fetchOptions, fetch: fetchOverride }); }; const createObject = async (params) => { const { url, data, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requestFetch = fetchOverride ?? fetch; const { headers: fetchHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions; return requestFetch(url, { ...fetchOptionsWithoutHeaders, method: "PUT", body: data, headers: excludeHeaders(mergeHeaders(headers, fetchHeaders), headersToExclude) }); }; const updateObject = async (params) => { const { url, data, etag, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requestFetch = fetchOverride ?? fetch; const { headers: fetchHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions; return requestFetch(url, { ...fetchOptionsWithoutHeaders, method: "PUT", body: data, headers: excludeHeaders(mergeHeaders(cleanupFalsy({ "If-Match": etag, ...headers }), fetchHeaders), headersToExclude) }); }; const deleteObject = async (params) => { const { url, headers, etag, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requestFetch = fetchOverride ?? fetch; const { headers: fetchHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions; return requestFetch(url, { ...fetchOptionsWithoutHeaders, method: "DELETE", headers: excludeHeaders(mergeHeaders(cleanupFalsy({ "If-Match": etag, ...headers }), fetchHeaders), headersToExclude) }); }; //#endregion //#region src/util/typeHelpers.ts 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()}`, ""); //#endregion //#region src/collection.ts var collection_exports = /* @__PURE__ */ __exportAll({ collectionQuery: () => collectionQuery, isCollectionDirty: () => isCollectionDirty, makeCollection: () => makeCollection, smartCollectionSync: () => smartCollectionSync, smartCollectionSyncDetailed: () => smartCollectionSyncDetailed, supportedReportSet: () => supportedReportSet, syncCollection: () => syncCollection }); const debug$5 = (0, debug.default)("tsdav:collection"); const resolveDAVHref = (href, baseUrl) => { try { return new URL(href, baseUrl).href; } catch { return href; } }; const hrefHasExtension = (href, extension, baseUrl) => { try { return new URL(href, baseUrl).pathname.toLowerCase().endsWith(extension); } catch { return (href.split(/[?#]/, 1)[0] ?? "").toLowerCase().endsWith(extension); } }; const collectionQuery = async (params) => { const { url, body, depth, defaultNamespace = "d", 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}` : ""}`); const firstQueryResult = queryResults[0]; if (queryResults.length === 1 && firstQueryResult && !firstQueryResult.raw && firstQueryResult.status && firstQueryResult.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: "d", body: props ? { mkcol: { set: { prop: props } } } : void 0 }, fetchOptions, fetch: fetchOverride }); }; const supportedReportSet = async (params) => { const { collection, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const supportedReport = (await propfind({ url: collection.url, props: { [`d:supported-report-set`]: {} }, depth: "0", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }))[0]?.props?.supportedReportSet?.supportedReport; if (!supportedReport) return []; return (Array.isArray(supportedReport) ? supportedReport : [supportedReport]).map((sr) => sr?.report ? Object.keys(sr.report)[0] : void 0).filter((name) => typeof name === "string" && name.length > 0); }; const isCollectionDirty = async (params) => { const { collection, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const res = (await propfind({ url: collection.url, props: { [`cs:getctag`]: {} }, depth: "0", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride })).find((r) => urlMatches(collection.url, r.href, collection.url)); if (!res) throw new Error("Collection does not exist on server"); if (!res.ok) throw new Error(`Collection status check failed: ${res.status} ${res.statusText}`); const remoteCtag = res.props?.getctag; return { isDirty: collection.ctag == null || remoteCtag == null || `${collection.ctag}` !== `${remoteCtag}`, newCtag: remoteCtag?.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: "d", headers: excludeHeaders({ ...headers }, headersToExclude), body: { "sync-collection": { _attributes: getDAVAttribute([ "urn:ietf:params:xml:ns:caldav", "urn:ietf:params:xml:ns:carddav", "DAV:" ]), "sync-level": syncLevel, "sync-token": syncToken, [`d:prop`]: props } } }, fetchOptions, fetch: fetchOverride }); }; /** remote collection to local */ const smartCollectionSync = async (params) => { 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 ?? (collection.reports?.includes("syncCollection") ? "webdav" : "basic"); debug$5(`smart collection sync with type ${account.accountType} and method ${syncMethod}`); if (syncMethod === "webdav") { const result = await syncCollection({ url: collection.url, props: { [`d:getetag`]: {}, [`${account.accountType === "caldav" ? "c" : "card"}:${account.accountType === "caldav" ? "calendar-data" : "address-data"}`]: {}, [`d:displayname`]: {} }, syncLevel: 1, syncToken: collection.syncToken, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); const objectResponses = result.filter((r) => { const extName = account.accountType === "caldav" ? ".ics" : ".vcf"; return typeof r.href === "string" && hrefHasExtension(r.href, extName, collection.url); }); 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 objectMultiGet = collection.objectMultiGet; if (changedObjectUrls.length > 0 && !objectMultiGet) throw new Error("collection.objectMultiGet is required for webdav sync changes"); const remoteObjects = (changedObjectUrls.length ? await objectMultiGet?.({ url: collection.url, props: { [`d:getetag`]: {}, [`${account.accountType === "caldav" ? "c" : "card"}:${account.accountType === "caldav" ? "calendar-data" : "address-data"}`]: {} }, objectUrls: changedObjectUrls, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }) ?? [] : []).map((res) => { return { url: resolveDAVHref(res.href ?? "", collection.url), etag: res.props?.getetag == null ? void 0 : String(res.props.getetag), data: account?.accountType === "caldav" ? res.props?.calendarData?._cdata ?? res.props?.calendarData : res.props?.addressData?._cdata ?? res.props?.addressData }; }); const localObjects = collection.objects ?? []; const created = remoteObjects.filter((o) => localObjects.every((lo) => !urlMatches(lo.url, o.url, collection.url))); const updated = localObjects.reduce((prev, curr) => { const found = remoteObjects.find((ro) => urlMatches(ro.url, curr.url, collection.url)); if (found && found.etag && found.etag !== curr.etag) return [...prev, found]; return prev; }, []); const deleted = deletedObjectUrls.map((o) => ({ url: resolveDAVHref(o, collection.url), etag: "" })); const unchanged = localObjects.filter((localObject) => deleted.every((deletedObject) => !urlMatches(localObject.url, deletedObject.url, collection.url)) && updated.every((updatedObject) => !urlMatches(localObject.url, updatedObject.url, collection.url))); return { ...collection, objects: detailedResult ? { created, updated, deleted } : [ ...unchanged, ...created, ...updated ], syncToken: result[0]?.raw?.multistatus?.syncToken ?? collection.syncToken }; } if (syncMethod === "basic") { const { isDirty, newCtag } = await isCollectionDirty({ collection, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); if (!isDirty) return detailedResult ? { ...collection, objects: { created: [], updated: [], deleted: [] } } : collection; const localObjects = collection.objects ?? []; if (!collection.fetchObjects) throw new Error("collection.fetchObjects is required for basic sync changes"); const remoteObjects = await collection.fetchObjects({ collection, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }) ?? []; const created = remoteObjects.filter((ro) => localObjects.every((lo) => !urlMatches(lo.url, ro.url, collection.url))); const updated = localObjects.reduce((prev, curr) => { const found = remoteObjects.find((ro) => urlMatches(ro.url, curr.url, collection.url)); if (found && found.etag && found.etag !== curr.etag) return [...prev, found]; return prev; }, []); const deleted = localObjects.filter((cal) => remoteObjects.every((ro) => !urlMatches(ro.url, cal.url, collection.url))); const unchanged = localObjects.filter((lo) => remoteObjects.some((ro) => urlMatches(lo.url, ro.url, collection.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 }); //#endregion //#region src/addressBook.ts var addressBook_exports = /* @__PURE__ */ __exportAll({ addressBookMultiGet: () => addressBookMultiGet, addressBookQuery: () => addressBookQuery, createVCard: () => createVCard, deleteVCard: () => deleteVCard, fetchAddressBooks: () => fetchAddressBooks, fetchVCards: () => fetchVCards, updateVCard: () => updateVCard }); const debug$4 = (0, debug.default)("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(["urn:ietf:params:xml:ns:carddav", "DAV:"]), [`d:prop`]: props, filter: filters ?? { "prop-filter": { _attributes: { name: "FN" } } } }) }, defaultNamespace: "card", 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(["DAV:", "urn:ietf:params:xml:ns:carddav"]), [`d:prop`]: props, [`d:href`]: objectUrls }) }, defaultNamespace: "card", depth, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); }; const fetchAddressBooks = async (params) => { const { account, headers, props: customProps, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = 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 ?? { [`d:displayname`]: {}, [`cs:getctag`]: {}, [`d:resourcetype`]: {}, [`d:sync-token`]: {} }, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); return Promise.all(res.filter((r) => Object.keys(r.props?.resourcetype ?? {}).includes("addressbook")).map((rs) => { const displayName = rs.props?.displayname?._cdata ?? rs.props?.displayname; debug$4(`Found address book named ${typeof displayName === "string" ? displayName : ""}, props: ${JSON.stringify(rs.props)}`); return { url: new URL(rs.href ?? "", account.rootUrl ?? "").href, ctag: rs.props?.getctag, displayName: typeof displayName === "string" ? displayName : "", resourcetype: Object.keys(rs.props?.resourcetype ?? {}), syncToken: rs.props?.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$4(`Fetching vcards from ${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 ?? (await addressBookQuery({ url: addressBook.url, props: { [`d:getetag`]: {} }, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride })).map((res) => res.href ?? "")).map((url) => url.startsWith("http") || !url ? url : new URL(url, addressBook.url).href).filter((url) => url && !urlEquals(url, addressBook.url)).filter(urlFilter).map((url) => { const parsedUrl = new URL(url); return `${parsedUrl.pathname}${parsedUrl.search}`; }); let vCardResults = []; if (vcardUrls.length > 0) if (useMultiGet) vCardResults = await addressBookMultiGet({ url: addressBook.url, props: { [`d:getetag`]: {}, [`card:address-data`]: {} }, objectUrls: vcardUrls, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); else vCardResults = await addressBookQuery({ url: addressBook.url, props: { [`d:getetag`]: {}, [`card:address-data`]: {} }, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); return vCardResults.map((res) => ({ url: new URL(res.href ?? "", addressBook.url).href, etag: res.props?.getetag == null ? void 0 : String(res.props.getetag), data: res.props?.addressData?._cdata ?? res.props?.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 }); }; //#endregion //#region src/calendar.ts var calendar_exports = /* @__PURE__ */ __exportAll({ calendarMultiGet: () => calendarMultiGet, calendarQuery: () => calendarQuery, createCalendarObject: () => createCalendarObject, deleteCalendarObject: () => deleteCalendarObject, fetchCalendarObjects: () => fetchCalendarObjects, fetchCalendarUserAddresses: () => fetchCalendarUserAddresses, fetchCalendars: () => fetchCalendars, freeBusyQuery: () => freeBusyQuery, makeCalendar: () => makeCalendar, syncCalendars: () => syncCalendars, syncCalendarsDetailed: () => syncCalendarsDetailed, updateCalendarObject: () => updateCalendarObject }); const debug$3 = (0, debug.default)("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; if (!(ISO_8601.test(start) && ISO_8601.test(end) || ISO_8601_FULL.test(start) && ISO_8601_FULL.test(end))) 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"); if (new Date(start).getTime() >= new Date(end).getTime()) throw new Error("invalid timeRange: start must be before end"); }; const extractComponentNames = (compSet) => { let names = []; if (Array.isArray(compSet)) names = compSet.map((sc) => sc?._attributes?.name); else if (compSet && typeof compSet === "object") names = [compSet._attributes?.name]; return names.filter((n) => typeof n === "string" && n.length > 0); }; const fetchCalendarUserAddresses = async (params) => { 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$3(`Fetch user addresses from ${account.principalUrl}`); const matched = (await propfind({ url: account.principalUrl, props: { [`c:calendar-user-address-set`]: {} }, depth: "0", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride })).find((r) => urlMatches(account.principalUrl, r.href, account.rootUrl)); if (!matched || !matched.ok) throw new Error("cannot find calendarUserAddresses"); const rawHrefs = matched?.props?.calendarUserAddressSet?.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$3(`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([ "urn:ietf:params:xml:ns:caldav", "http://calendarserver.org/ns/", "http://apple.com/ns/ical/", "DAV:" ]), [`d:prop`]: props, filter: filters, timezone }) }, defaultNamespace: "c", 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(["DAV:", "urn:ietf:params:xml:ns:caldav"]), [`d:prop`]: props, [`d:href`]: objectUrls, filter: filters, timezone }) }, defaultNamespace: "c", 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: "d", body: { [`c:mkcalendar`]: { _attributes: getDAVAttribute([ "DAV:", "urn:ietf:params:xml:ns:caldav", "http://apple.com/ns/ical/" ]), set: { prop: props } } } }, fetchOptions, fetch: fetchOverride }); }; const fetchCalendars = async (params) => { const { headers, account, props: customProps, projectedProps, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = 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 ?? { [`c:calendar-description`]: {}, [`c:calendar-timezone`]: {}, [`d:displayname`]: {}, [`ca:calendar-color`]: {}, [`cs:getctag`]: {}, [`d:resourcetype`]: {}, [`c:supported-calendar-component-set`]: {}, [`d:sync-token`]: {} }, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); return Promise.all(res.filter((r) => Object.keys(r.props?.resourcetype ?? {}).includes("calendar")).filter((rc) => { const components = extractComponentNames(rc.props?.supportedCalendarComponentSet?.comp); return components.length === 0 || components.some((c) => Object.values(ICALObjects).includes(c)); }).map((rs) => { const description = rs.props?.calendarDescription; const timezone = rs.props?.calendarTimezone; const compSet = rs.props?.supportedCalendarComponentSet?.comp; const projectedEntries = Object.entries(rs.props ?? {}).filter(([key]) => projectedProps?.[key]); return { description: typeof description === "string" ? description : "", timezone: typeof timezone === "string" ? timezone : "", url: new URL(rs.href ?? "", account.rootUrl ?? "").href, ctag: rs.props?.getctag, calendarColor: rs.props?.calendarColor, displayName: rs.props?.displayname?._cdata ?? rs.props?.displayname, components: extractComponentNames(compSet), resourcetype: Object.keys(rs.props?.resourcetype ?? {}), syncToken: rs.props?.syncToken, ...projectedProps && projectedEntries.length > 0 ? { projectedProps: Object.fromEntries(projectedEntries) } : {} }; }).map(async (cal) => ({ ...cal, reports: await supportedReportSet({ collection: cal, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }) }))); }; const fetchCalendarObjects = async (params) => { const { calendar, objectUrls, filters: customFilters, timeRange, headers, expand, urlFilter = (url) => Boolean(url?.includes(".ics")), useMultiGet = true, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; if (timeRange) validateTimeRange(timeRange); debug$3(`Fetching calendar objects from ${calendar?.url}`); const requiredFields = ["url"]; if (!calendar || !hasFields(calendar, requiredFields)) { if (!calendar) throw new Error("cannot fetchCalendarObjects for undefined calendar"); throw new Error(`calendar must have ${findMissingFieldNames(calendar, requiredFields)} before fetchCalendarObjects`); } const filters = customFilters ?? [{ "comp-filter": { _attributes: { name: "VCALENDAR" }, "comp-filter": { _attributes: { name: "VEVENT" }, ...timeRange ? { "time-range": { _attributes: { start: `${new Date(timeRange.start).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z`, end: `${new Date(timeRange.end).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z` } } } : {} } } }]; let initialResponses = []; if (!objectUrls) initialResponses = await calendarQuery({ url: calendar.url, props: { [`d:getetag`]: {}, ...expand && timeRange ? { [`c:calendar-data`]: { [`c:expand`]: { _attributes: { start: `${new Date(timeRange.start).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z`, end: `${new Date(timeRange.end).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z` } } } } : {} }, filters, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); const calendarObjectUrls = (objectUrls ?? initialResponses.map((res) => res.href ?? "")).map((url) => url.startsWith("http") || !url ? url : new URL(url, calendar.url).href).filter(urlFilter).map((url) => { const parsedUrl = new URL(url); return `${parsedUrl.pathname}${parsedUrl.search}`; }); let calendarObjectResults = []; if (calendarObjectUrls.length > 0) if (expand && !objectUrls) calendarObjectResults = initialResponses.filter((res) => { const fullUrl = (res.href ?? "").startsWith("http") ? res.href : new URL(res.href ?? "", calendar.url).href; return urlFilter(fullUrl ?? ""); }); else if (!useMultiGet) calendarObjectResults = await calendarQuery({ url: calendar.url, props: { [`d:getetag`]: {}, [`c:calendar-data`]: { ...expand && timeRange ? { [`c:expand`]: { _attributes: { start: `${new Date(timeRange.start).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z`, end: `${new Date(timeRange.end).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z` } } } : {} } }, filters, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); else calendarObjectResults = await calendarMultiGet({ url: calendar.url, props: { [`d:getetag`]: {}, [`c:calendar-data`]: { ...expand && timeRange ? { [`c:expand`]: { _attributes: { start: `${new Date(timeRange.start).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z`, end: `${new Date(timeRange.end).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z` } } } : {} } }, objectUrls: calendarObjectUrls, depth: "1", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); return calendarObjectResults.map((res) => ({ url: new URL(res.href ?? "", calendar.url).href, etag: res.props?.getetag == null ? void 0 : String(res.props.getetag), data: res.props?.calendarData?._cdata ?? res.props?.calendarData })); }; const createCalendarObject = async (params) => { const { calendar, iCalString, filename, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; return createObject({ url: new URL(filename, calendar.url).href, data: iCalString, headers: excludeHeaders({ "content-type": "text/calendar; charset=utf-8", "If-None-Match": "*", ...headers }, headersToExclude), fetchOptions, fetch: fetchOverride }); }; const updateCalendarObject = async (params) => { const { calendarObject, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; return updateObject({ url: calendarObject.url, data: calendarObject.data, etag: calendarObject.etag, headers: excludeHeaders({ "content-type": "text/calendar; charset=utf-8", ...headers }, headersToExclude), fetchOptions, fetch: fetchOverride }); }; const deleteCalendarObject = async (params) => { const { calendarObject, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; return deleteObject({ url: calendarObject.url, etag: calendarObject.etag, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); }; /** * Sync remote calendars to local */ const syncCalendars = async (params) => { const { oldCalendars, account, detailedResult, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; if (!account) throw new Error("Must have account before syncCalendars"); const localCalendars = oldCalendars ?? account.calendars ?? []; const remoteCalendars = await fetchCalendars({ account, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); const created = remoteCalendars.filter((rc) => localCalendars.every((lc) => !urlMatches(lc.url, rc.url, account.rootUrl))); debug$3(`new calendars: ${created.map((cc) => cc.displayName)}`); const updated = localCalendars.reduce((prev, curr) => { const found = remoteCalendars.find((rc) => urlMatches(rc.url, curr.url, account.rootUrl)); if (found && (found.syncToken && `${found.syncToken}` !== `${curr.syncToken}` || found.ctag && `${found.ctag}` !== `${curr.ctag}`)) return [...prev, { local: curr, remote: found }]; return prev; }, []); debug$3(`updated calendars: ${updated.map(({ remote }) => remote.displayName)}`); const updatedWithObjects = await Promise.all(updated.map(async ({ local, remote }) => { const fetchObjects = async (fetchParams) => { if (!fetchParams) return []; const { collection, ...requestParams } = fetchParams; return fetchCalendarObjects({ ...requestParams, calendar: collection }); }; const result = await smartCollectionSync({ collection: { ...remote, ctag: local.ctag, syncToken: local.syncToken, objects: local.objects, objectMultiGet: calendarMultiGet, fetchObjects }, detailedResult: false, headers: excludeHeaders(headers, headersToExclude), account, fetchOptions, fetch: fetchOverride }); return { ...result, ctag: remote.ctag ?? result.ctag, syncToken: remote.syncToken ?? result.syncToken }; })); const deleted = localCalendars.filter((cal) => remoteCalendars.every((rc) => !urlMatches(rc.url, cal.url, account.rootUrl))); debug$3(`deleted calendars: ${deleted.map((cc) => cc.displayName)}`); const unchanged = localCalendars.filter((cal) => remoteCalendars.some((rc) => { if (!urlMatches(rc.url, cal.url, account.rootUrl)) return false; const syncTokenMatches = !rc.syncToken || `${rc.syncToken}` === `${cal.syncToken}`; const ctagMatches = !rc.ctag || `${rc.ctag}` === `${cal.ctag}`; return syncTokenMatches && ctagMatches; })); debug$3(`unchanged calendars: ${unchanged.map((cc) => cc.displayName)}`); return detailedResult ? { created, updated: updatedWithObjects, deleted } : [ ...unchanged, ...created, ...updatedWithObjects ]; }; const syncCalendarsDetailed = async (params) => syncCalendars({ ...params, detailedResult: true }); const freeBusyQuery = async (params) => { const { url, timeRange, depth, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; if (!timeRange) throw new Error("timeRange is required"); validateTimeRange(timeRange); const response = (await collectionQuery({ url, body: { "free-busy-query": cleanupFalsy({ _attributes: getDAVAttribute(["urn:ietf:params:xml:ns:caldav"]), [`c:time-range`]: { _attributes: { start: `${new Date(timeRange.start).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z`, end: `${new Date(timeRange.end).toISOString().slice(0, 19).replace(/[-:.]/g, "")}Z` } } }) }, defaultNamespace: "c", depth, headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }))[0]; if (!response) throw new Error("freeBusyQuery returned no response"); return response; }; //#endregion //#region src/account.ts var account_exports = /* @__PURE__ */ __exportAll({ createAccount: () => createAccount, fetchHomeUrl: () => fetchHomeUrl, fetchPrincipalUrl: () => fetchPrincipalUrl, serviceDiscovery: () => serviceDiscovery }); const debug$2 = (0, debug.default)("tsdav:account"); const getCandidateRootUrls = (serverUrl, discoveredRootUrl) => { const candidates = [ discoveredRootUrl, serverUrl, new URL("/", serverUrl).href ]; return candidates.filter((url, index) => candidates.indexOf(url) === index); }; const serviceDiscovery = async (params) => { debug$2("Service discovery..."); const { account, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requestFetch = fetchOverride ?? fetch; const endpoint = new URL(account.serverUrl); const { headers: fetchHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions; const uri = new URL(`/.well-known/${account.accountType}`, endpoint); uri.protocol = endpoint.protocol ?? "http"; const extractRedirect = (response) => { if (response.status >= 300 && response.status < 400) { const location = response.headers.get("Location"); if (typeof location === "string" && location.length) { debug$2(`Service discovery redirected to ${location}`); const hasExplicitScheme = /^[a-z][a-z0-9+.-]*:/i.test(location); const serviceURL = new URL(location, endpoint); if (serviceURL.hostname === uri.hostname && uri.port && !serviceURL.port) serviceURL.port = uri.port; if (!hasExplicitScheme) serviceURL.protocol = endpoint.protocol ?? "http"; return serviceURL.href; } } }; try { const redirectUrl = extractRedirect(await requestFetch(uri.href, { ...fetchOptionsWithoutHeaders, method: "PROPFIND", headers: excludeHeaders(mergeHeaders({ "Content-Type": "text/xml;charset=UTF-8" }, headers, fetchHeaders), headersToExclude), body: `<?xml version="1.0" encoding="utf-8" ?> <d:propfind xmlns:d="DAV:"> <d:prop> <d:resourcetype/> </d:prop> </d:propfind>`, redirect: "manual" })); if (redirectUrl) return redirectUrl; } catch (err) { debug$2(`Service discovery PROPFIND failed: ${err.stack}`); } try { const redirectUrl = extractRedirect(await requestFetch(uri.href, { ...fetchOptionsWithoutHeaders, method: "GET", headers: excludeHeaders(mergeHeaders(headers, fetchHeaders), headersToExclude), redirect: "manual" })); if (redirectUrl) return redirectUrl; } catch (err) { debug$2(`Service discovery GET failed: ${err.stack}`); } return endpoint.href; }; const fetchPrincipalUrl = async (params) => { const { account, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requiredFields = ["rootUrl"]; if (!hasFields(account, requiredFields)) throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before fetchPrincipalUrl`); debug$2(`Fetching principal url from path ${account.rootUrl}`); const [response] = await propfind({ url: account.rootUrl, props: { [`d:current-user-principal`]: {} }, depth: "0", headers: excludeHeaders(headers, headersToExclude), fetchOptions, fetch: fetchOverride }); if (!response?.ok) { debug$2(`Fetch principal url failed: ${response?.statusText ?? "empty response"}`); if (response?.status === 401) throw new Error(`Invalid credentials: PROPFIND ${account.rootUrl} returned 401 Unauthorized`); throw new Error("cannot find principalUrl"); } const principalHref = response.props?.currentUserPrincipal?.href; if (typeof principalHref !== "string" || !principalHref.length) { debug$2("Fetch principal url failed: missing current-user-principal href"); throw new Error("cannot find principalUrl"); } debug$2(`Fetched principal url ${principalHref}`); return new URL(principalHref, account.rootUrl).href; }; const fetchHomeUrl = async (params) => { const { account, headers, headersToExclude, fetchOptions = {}, fetch: fetchOverride } = params; const requiredFields = ["principalUrl", "roo