tsdav
Version:
WebDAV, CALDAV, and CARDDAV client for Nodejs and the Browser
1,326 lines • 74.8 kB
JavaScript
import getLogger from "debug";
import convert from "xml-js";
//#region \0rolldown/runtime.js
var __defProp = Object.defineProperty;
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;
};
//#endregion
//#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$5 = getLogger("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 ? convert.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 = convert.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$5(e.stack);
}
},
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
}];
}
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$4 = getLogger("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$4(`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$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(["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$3(`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$3(`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$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;
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$2(`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$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([
"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$2(`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$2(`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$2(`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$2(`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$2(`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$1 = getLogger("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$1("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$1(`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$1(`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$1(`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$1(`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$1(`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$1("Fetch principal url failed: missing current-user-principal href");
throw new Error("cannot find principalUrl");
}
debug$1(`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", "rootUrl"];
if (!hasFields(account, requiredFields)) throw new Error(`account must have ${findMissingFieldNames(account, requiredFields)} before fetchHomeUrl`);
debug$1(`Fetch home url from ${account.principalUrl}`);
const responses = await propfind({
url: account.principalUrl,
props: account.accountType === "caldav" ? { [`c:calendar-home-set`]: {} } : { [`card:addressbook-home-set`]: {} },
depth: "0",
headers: excludeHeaders(headers, headersToExclude),
fetchOptions,
fetch: fetchOverride
});
const matched = responses.find((r) => urlMatches(account.principalUrl, r.href, account.rootUrl));
if (!matched || !matched.ok) {
debug$1(`Fetch home url failed with status ${matched?.statusText} and error ${JSON.stringify(responses.map((r) => r.error))}`);
throw new Error("cannot find homeUrl");
}
const homeHref = account.accountType === "caldav" ? matched.props?.calendarHomeSet?.href : matched.props?.addressbookHomeSet?.href;
if (typeof homeHref !== "string" || homeHref.length === 0) {
debug$1(`Fetch home url failed: server did not return a ${account.accountType === "caldav" ? "calendar-home