├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── logo.png │ │ ├── favicon.ico │ │ ├── tutorial │ │ ├── localeDropdown.png │ │ └── docsVersionDropdown.png │ │ ├── undraw_cloud_files_wmo8.svg │ │ └── logo.svg ├── docs │ ├── types │ │ ├── _category_.json │ │ ├── DAVVCard.md │ │ ├── DAVCalendarObject.md │ │ ├── DAVAddressBook.md │ │ ├── DAVProp.md │ │ ├── DAVCalendar.md │ │ ├── DAVObject.md │ │ ├── DAVRequest.md │ │ ├── DAVDepth.md │ │ ├── DAVFilter.md │ │ ├── DAVTokens.md │ │ ├── DAVAccount.md │ │ ├── DAVCredentials.md │ │ ├── DAVCollection.md │ │ └── DAVResponse.md │ ├── caldav │ │ ├── _category_.json │ │ ├── fetchCalendars.md │ │ ├── deleteCalendarObject.md │ │ ├── makeCalendar.md │ │ ├── fetchCalendarObjects.md │ │ ├── updateCalendarObject.md │ │ ├── createCalendarObject.md │ │ ├── calendarQuery.md │ │ ├── calendarMultiGet.md │ │ └── syncCalendars.md │ ├── carddav │ │ ├── _category_.json │ │ ├── fetchAddressBooks.md │ │ ├── deleteVCard.md │ │ ├── fetchVCards.md │ │ ├── updateVCard.md │ │ ├── createVCard.md │ │ ├── addressBookQuery.md │ │ └── addressBookMultiGet.md │ ├── helpers │ │ ├── _category_.json │ │ ├── constants.md │ │ ├── requestHelpers.md │ │ └── authHelpers.md │ ├── webdav │ │ ├── _category_.json │ │ ├── account │ │ │ ├── _category_.json │ │ │ ├── serviceDiscovery.md │ │ │ ├── fetchPrincipalUrl.md │ │ │ ├── fetchHomeUrl.md │ │ │ └── createAccount.md │ │ ├── collection │ │ │ ├── _category_.json │ │ │ ├── supportedReportSet.md │ │ │ ├── isCollectionDirty.md │ │ │ ├── makeCollection.md │ │ │ ├── collectionQuery.md │ │ │ ├── syncCollection.md │ │ │ └── smartCollectionSync.md │ │ ├── deleteObject.md │ │ ├── propfind.md │ │ ├── createObject.md │ │ ├── updateObject.md │ │ └── davRequest.md │ ├── contributing.md │ └── intro.md ├── tsconfig.json ├── babel.config.js ├── src │ ├── pages │ │ ├── markdown-page.md │ │ ├── index.module.css │ │ └── index.tsx │ ├── components │ │ ├── HomepageFeatures.module.css │ │ └── HomepageFeatures.tsx │ └── css │ │ └── custom.css ├── .gitignore ├── sidebars.js ├── README.md ├── package.json └── docusaurus.config.js ├── .eslintignore ├── .prettierignore ├── .gitattributes ├── tsconfig.build.json ├── src ├── util │ ├── camelCase.ts │ ├── nativeType.ts │ ├── camelCase.test.ts │ ├── nativeType.test.ts │ ├── typeHelpers.test.ts │ ├── typeHelpers.ts │ ├── authHelpers.test.ts │ ├── requestHelpers.ts │ ├── authHelpers.ts │ └── requestHelpers.test.ts ├── consts.ts ├── types │ ├── functionsOverloads.ts │ ├── DAVTypes.ts │ └── models.ts ├── collection.test.ts ├── index.ts ├── addressBook.test.ts ├── account.test.ts ├── account.ts ├── request.ts ├── addressBook.ts ├── request.test.ts ├── calendar.test.ts ├── collection.ts └── calendar.ts ├── .prettierrc.json ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .eslintrc.json ├── CHANGELOG.md ├── LICENSE ├── .vscode ├── cspell.json └── settings.json ├── .gitignore ├── package.json └── README.md /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | built -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules/ 3 | doc/ 4 | built/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/docs/types/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Types", 3 | "position": 5 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/caldav/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "CALDAV", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/carddav/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "CARDDAV", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/helpers/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Helpers", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/webdav/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "WEBDAV", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/webdav/account/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Account", 3 | "position": 7 4 | } 5 | -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calcom/tsDAV/HEAD/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calcom/tsDAV/HEAD/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/docs/webdav/collection/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Collection", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "include": ["./src/"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/types/DAVVCard.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVVCard = DAVObject; 3 | ``` 4 | 5 | alias of [DAVObject](DAVObject.md) -------------------------------------------------------------------------------- /docs/static/img/tutorial/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calcom/tsDAV/HEAD/docs/static/img/tutorial/localeDropdown.png -------------------------------------------------------------------------------- /src/util/camelCase.ts: -------------------------------------------------------------------------------- 1 | export const camelCase = (str: string): string => 2 | str.replace(/([-_]\w)/g, (g) => g[1].toUpperCase()); 3 | -------------------------------------------------------------------------------- /docs/docs/types/DAVCalendarObject.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVCalendarObject = DAVObject; 3 | ``` 4 | 5 | alias of [DAVObject](DAVObject.md) -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /docs/docs/types/DAVAddressBook.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVAddressBook = DAVCollection; 3 | ``` 4 | 5 | alias of [DAVCollection](DAVCollection.md) -------------------------------------------------------------------------------- /docs/static/img/tutorial/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calcom/tsDAV/HEAD/docs/static/img/tutorial/docsVersionDropdown.png -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "es2015", 6 | "outDir": "dist-esm" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | transform: { 4 | '^.+\\.(js|ts|jsx|tsx)$': 'ts-jest', 5 | }, 6 | setupFiles: ['dotenv/config'], 7 | testTimeout: 60000, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/docs/types/DAVProp.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVProp = { 3 | name: string; 4 | namespace?: DAVNamespace; 5 | value?: string | number; 6 | }; 7 | ``` 8 | 9 | props of the webdav requests and response 10 | 11 | - `name` name of the prop 12 | - `namespace` xml namespace of the prop 13 | - `value` value of the prop -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/docs/types/DAVCalendar.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVCalendar = { 3 | components?: string[]; 4 | timezone?: string; 5 | } & DAVCollection; 6 | ``` 7 | alias of [DAVCollection](DAVCollection.md) with 8 | - `timezone` iana timezone name of calendar 9 | - `components` array of calendar components defined in [rfc5455](https://datatracker.ietf.org/doc/html/rfc5545#section-3.6) -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | .features { 4 | display: flex; 5 | align-items: center; 6 | padding: 2rem 0; 7 | width: 100%; 8 | } 9 | 10 | .featureSvg { 11 | height: 200px; 12 | width: 200px; 13 | } 14 | 15 | .headline { 16 | margin-left: auto; 17 | margin-right: auto; 18 | } 19 | -------------------------------------------------------------------------------- /src/util/nativeType.ts: -------------------------------------------------------------------------------- 1 | export const nativeType = (value: string): unknown => { 2 | const nValue = Number(value); 3 | if (!Number.isNaN(nValue)) { 4 | return nValue; 5 | } 6 | const bValue = value.toLowerCase(); 7 | if (bValue === 'true') { 8 | return true; 9 | } 10 | if (bValue === 'false') { 11 | return false; 12 | } 13 | return value; 14 | }; 15 | -------------------------------------------------------------------------------- /docs/docs/types/DAVObject.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVObject = { 3 | data?: any; 4 | etag: string; 5 | url: string; 6 | }; 7 | ``` 8 | 9 | - `data` the raw content of an WEBDAV object. 10 | - `etag` the version string of content, if [etag](https://tools.ietf.org/id/draft-reschke-http-etag-on-write-08.html) changed, `data` must have been changed. 11 | - `url` url of the WEBDAV object. -------------------------------------------------------------------------------- /docs/docs/types/DAVRequest.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVRequest = { 3 | headers?: Record; 4 | method: DAVMethods | HTTPMethods; 5 | body: any; 6 | namespace?: string; 7 | attributes?: Record; 8 | }; 9 | ``` 10 | 11 | [davRequest](../webdav/davRequest.md) init body 12 | 13 | - `headers` request headers 14 | - `method` request method 15 | - `body` request body 16 | - `namespace` default namespace for all xml nodes 17 | - `attributes` root node xml attributes 18 | -------------------------------------------------------------------------------- /docs/docs/types/DAVDepth.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVDepth = '0' | '1' | 'infinity'; 3 | ``` 4 | 5 | [depth header](https://datatracker.ietf.org/doc/html/rfc4918#section-10.2) of webdav requests 6 | 7 | | Depth | Action apply to | 8 | | -------- | ------------------------------------------ | 9 | | 0 | only the resource | 10 | | 1 | the resource and its internal members only | 11 | | infinity | the resource and all its members | 12 | -------------------------------------------------------------------------------- /docs/docs/types/DAVFilter.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVFilter = { 3 | type: string; 4 | attributes: Record; 5 | value?: string | number; 6 | children?: DAVFilter[]; 7 | }; 8 | ``` 9 | 10 | a [filter](https://datatracker.ietf.org/doc/html/rfc4791#section-9.7) to limit the set of calendar components returned by the server. 11 | - `type` prop-filter, comp-filter and param-filter 12 | - `attributes` xml attributes of the filter 13 | - `value` value of the filter 14 | - `children` child filters -------------------------------------------------------------------------------- /docs/docs/types/DAVTokens.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVTokens = { 3 | access_token?: string; 4 | refresh_token?: string; 5 | expires_in?: number; 6 | id_token?: string; 7 | token_type?: string; 8 | scope?: string; 9 | }; 10 | ``` 11 | 12 | oauth token response 13 | 14 | - `access_token` oauth access token 15 | - `refresh_token` oauth refresh token 16 | - `expires_in` token expires time in ms 17 | - `id_token` oauth id token 18 | - `token_type` oauth token type, usually `Bearer` 19 | - `scope` oauth token scope -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | -------------------------------------------------------------------------------- /src/util/camelCase.test.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from './camelCase'; 2 | 3 | test('camelCase should convert snakeCase to camelCase', () => { 4 | const snakeString1 = 'snake-name'; 5 | const snakeString2 = 'snake-Name'; 6 | const snakeString3 = 'snake_Name'; 7 | const snakeString4 = 'snake_name'; 8 | 9 | expect(camelCase(snakeString1)).toEqual('snakeName'); 10 | expect(camelCase(snakeString2)).toEqual('snakeName'); 11 | expect(camelCase(snakeString3)).toEqual('snakeName'); 12 | expect(camelCase(snakeString4)).toEqual('snakeName'); 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "esModuleInterop": true, 5 | "allowJs": false, 6 | "strict": true, 7 | "resolveJsonModule": true, 8 | "moduleResolution": "node", 9 | "lib": ["es2015", "dom"], 10 | "module": "commonjs", 11 | "target": "es2015", 12 | "declaration": true, 13 | "isolatedModules": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "*": ["node_modules/*"] 17 | }, 18 | "outDir": "./dist" 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /docs/docs/caldav/fetchCalendars.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | ## `fetchCalendars` 6 | 7 | get all calendars of the passed in CALDAV account 8 | 9 | ```ts 10 | const calendars = await fetchCalendars({ 11 | account, 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `account` [DAVAccount](../types/DAVAccount.md) 21 | - `header` request headers 22 | 23 | ### Return Value 24 | 25 | array of [DAVCalendar](../types/DAVCalendar.md) of the account 26 | 27 | ### Behavior 28 | 29 | use `PROPFIND` to get all the basic info about calendars on certain account 30 | -------------------------------------------------------------------------------- /docs/docs/carddav/fetchAddressBooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | ## `fetchAddressBooks` 6 | 7 | get all addressBooks of the passed in CARDDAV account 8 | 9 | ```ts 10 | const addressBooks = await fetchAddressBooks({ 11 | account, 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `account` [DAVAccount](../types/DAVAccount.md) 21 | - `headers` request headers 22 | 23 | ### Return Value 24 | 25 | array of [DAVAddressBook](../types/DAVAddressBook.md) 26 | 27 | ### Behavior 28 | 29 | use `PROPFIND` and to get all the basic info about addressBook on certain account 30 | -------------------------------------------------------------------------------- /docs/docs/webdav/account/serviceDiscovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ## `serviceDiscovery` 6 | 7 | automatically discover service root url 8 | 9 | ```ts 10 | const url = await serviceDiscovery({ 11 | account: { serverUrl: 'https://caldav.icloud.com/', accountType: 'caldav' }, 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `account` **required**, account with `serverUrl` and `accountType` 21 | - `headers` request headers 22 | 23 | ### Return Value 24 | 25 | root url 26 | 27 | ### Behavior 28 | 29 | use `/.well-known/` request to follow redirects to find redirected url 30 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 15 | 16 | // But you can create a sidebar manually 17 | /* 18 | tutorialSidebar: [ 19 | { 20 | type: 'category', 21 | label: 'Tutorial', 22 | items: ['hello'], 23 | }, 24 | ], 25 | */ 26 | }; 27 | -------------------------------------------------------------------------------- /docs/docs/carddav/deleteVCard.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | ## `deleteVCard` 6 | 7 | delete one vcard on the target addressBook 8 | 9 | ```ts 10 | const result = await deleteCalendarObject({ 11 | vCard: { 12 | url: 'https://contacts.icloud.com/123456/carddavhome/card/test.vcf', 13 | etag: '"63758758580"', 14 | }, 15 | headers: { 16 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 17 | }, 18 | }); 19 | ``` 20 | 21 | ### Arguments 22 | 23 | - `vCard` **required**, [DAVVCard](../types/DAVVCard.md) to delete 24 | - `headers` request headers 25 | 26 | ### Return Value 27 | 28 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 29 | 30 | ### Behavior 31 | 32 | use DELETE request to delete a new vcard 33 | -------------------------------------------------------------------------------- /docs/docs/carddav/fetchVCards.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | ## `fetchVCards` 6 | 7 | get all/specified vcards of the passed in addressBook 8 | 9 | ```ts 10 | const vcards = await fetchVCards({ 11 | addressBook: addressBooks[0], 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `addressBook` **required**, [DAVAddressBook](../types/DAVAddressBook.md) to fetch vcards from 21 | - `objectUrls` vcard urls to fetch 22 | - `headers` request headers 23 | 24 | ### Return Value 25 | 26 | array of [DAVVCard](../types/DAVVCard.md) 27 | 28 | ### Behavior 29 | 30 | a mix of [addressBookMultiGet](addressBookMultiGet.md) and [addressBookQuery](addressBookQuery.md), you can specify objectUrls here. 31 | -------------------------------------------------------------------------------- /docs/docs/webdav/account/fetchPrincipalUrl.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | ## `fetchPrincipalUrl` 6 | 7 | fetch resource principle collection url 8 | 9 | ```ts 10 | const url = await fetchPrincipalUrl({ 11 | account: { 12 | serverUrl: 'https://caldav.icloud.com/', 13 | rootUrl: 'https://caldav.icloud.com/', 14 | accountType: 'caldav', 15 | }, 16 | headers: { 17 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 18 | }, 19 | }); 20 | ``` 21 | 22 | ### Arguments 23 | 24 | - `account` **required**, account with `rootUrl` and `accountType` 25 | - `headers` request headers 26 | 27 | ### Return Value 28 | 29 | principle collection url 30 | 31 | ### Behavior 32 | 33 | send current-user-principal PROPFIND request and extract principle collection url from xml response 34 | -------------------------------------------------------------------------------- /docs/docs/webdav/collection/supportedReportSet.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | ## `supportedReportSet` 6 | 7 | identifies the [reports that are supported by the resource](https://datatracker.ietf.org/doc/html/rfc3253#section-3.1.5) 8 | 9 | ```ts 10 | const reports = await supportedReportSet({ 11 | collection, 12 | headers: { 13 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `collection` **required**, [DAVCollection](../../types/DAVCollection.md) to query on 21 | - `headers` request headers 22 | 23 | ### Return Value 24 | 25 | array of supported REPORT name in camelCase 26 | 27 | ### Behavior 28 | 29 | send supported-report-set PROPFIND request and parse response xml to extract names and convert them to camel case. 30 | -------------------------------------------------------------------------------- /docs/docs/webdav/account/fetchHomeUrl.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | ## `fetchHomeUrl` 6 | 7 | fetch resource home set url 8 | 9 | ```ts 10 | const url = await fetchHomeUrl({ 11 | account: { 12 | principalUrl, 13 | serverUrl: 'https://caldav.icloud.com/', 14 | rootUrl: 'https://caldav.icloud.com/', 15 | accountType: 'caldav', 16 | }, 17 | headers: { 18 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 19 | }, 20 | }); 21 | ``` 22 | 23 | ### Arguments 24 | 25 | - `account` **required**, account with `principalUrl` and `accountType` 26 | - `headers` request headers 27 | 28 | ### Return Value 29 | 30 | resource home set url 31 | 32 | ### Behavior 33 | 34 | send calendar-home-set or addressbook-home-set (based on accountType) PROPFIND request and extract resource home set url from xml response 35 | -------------------------------------------------------------------------------- /docs/docs/webdav/collection/isCollectionDirty.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | ## `isCollectionDirty` 6 | 7 | detect if the collection have changed 8 | 9 | ```ts 10 | const { isDirty, newCtag } = await isCollectionDirty({ 11 | collection: calendars[1], 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `collection` **required**, [DAVCollection](../../types/DAVCollection.md) to detect 21 | - `headers` request headers 22 | 23 | ### Return Value 24 | 25 | - `isDirty` a boolean indicate if the collection is dirty 26 | - `newCtag` if collection is dirty, new ctag of the collection 27 | 28 | ### Behavior 29 | 30 | use PROPFIND to fetch new ctag of the collection and compare it with current ctag, if the ctag changed, it means collection changed. 31 | -------------------------------------------------------------------------------- /docs/docs/types/DAVAccount.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVAccount = { 3 | accountType: 'caldav' | 'carddav'; 4 | serverUrl: string; 5 | credentials?: DAVCredentials; 6 | rootUrl?: string; 7 | principalUrl?: string; 8 | homeUrl?: string; 9 | calendars?: DAVCalendar[]; 10 | addressBooks?: DAVAddressBook[]; 11 | }; 12 | ``` 13 | 14 | - `accountType` can be `caldav` or `carddav` 15 | - `serverUrl` server url of the account 16 | - `credentials` [DAVCredentials](DAVCredentials.md) 17 | - `rootUrl` root url of the account 18 | - `principalUrl` principal resource url 19 | - `homeUrl` resource home set url 20 | - `calendars` calendars of the account, will only be populated by [createAccount](../webdav/account/createAccount.md) 21 | - `addressBooks` addressBooks of the account, will only be populated by [createAccount](../webdav/account/createAccount.md) 22 | -------------------------------------------------------------------------------- /docs/docs/caldav/deleteCalendarObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | --- 4 | 5 | ## `deleteCalendarObject` 6 | 7 | delete one calendar object on the target calendar 8 | 9 | ```ts 10 | const result = await deleteCalendarObject({ 11 | calendarObject: { 12 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 13 | etag: '"63758758580"', 14 | }, 15 | headers: { 16 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 17 | }, 18 | }); 19 | ``` 20 | 21 | ### Arguments 22 | 23 | - `calendarObject` **required**, [DAVCalendarObject](../types/DAVCalendarObject.md) to delete 24 | - `headers` request headers 25 | 26 | ### Return Value 27 | 28 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 29 | 30 | ### Behavior 31 | 32 | use DELETE request to delete a new calendar object 33 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #77acf1; 11 | --ifm-color-primary-dark: #5698ee; 12 | --ifm-color-primary-darker: #468eec; 13 | --ifm-color-primary-darkest: #1871e4; 14 | --ifm-color-primary-light: #98c0f4; 15 | --ifm-color-primary-lighter: #a8caf6; 16 | --ifm-color-primary-lightest: #d9e8fb; 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | -------------------------------------------------------------------------------- /docs/docs/types/DAVCredentials.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVCredentials = { 3 | username?: string; 4 | password?: string; 5 | clientId?: string; 6 | clientSecret?: string; 7 | authorizationCode?: string; 8 | redirectUrl?: string; 9 | tokenUrl?: string; 10 | accessToken?: string; 11 | refreshToken?: string; 12 | expiration?: number; 13 | }; 14 | ``` 15 | 16 | refer to [this page](https://developers.google.com/identity/protocols/oauth2) for more on what these fields mean 17 | 18 | - `username` basic auth username 19 | - `password` basic auth password 20 | - `clientId` oauth client id 21 | - `clientSecret` oauth client secret 22 | - `authorizationCode` oauth callback auth code 23 | - `redirectUrl` oauth callback redirect url 24 | - `tokenUrl` oauth api token url 25 | - `accessToken` oauth access token 26 | - `refreshToken` oauth refresh token 27 | - `expiration` oauth access token expiration time 28 | -------------------------------------------------------------------------------- /docs/docs/webdav/deleteObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | ## `deleteObject` 6 | 7 | delete an object 8 | 9 | ```ts 10 | const response = await deleteObject({ 11 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 12 | etag: '"63758758580"', 13 | headers: { 14 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 15 | }, 16 | }); 17 | ``` 18 | 19 | ### Arguments 20 | 21 | - `url` **required**, url of object to delete 22 | - `etag` the version string of content, if [etag](https://tools.ietf.org/id/draft-reschke-http-etag-on-write-08.html) changed, `data` must have been changed. 23 | - `headers` request headers 24 | 25 | ### Return Value 26 | 27 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 28 | 29 | ### Behavior 30 | 31 | send DELETE request with etag header 32 | object will not be deleted if etag do not match 33 | -------------------------------------------------------------------------------- /docs/docs/carddav/updateVCard.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | ## `updateVCard` 6 | 7 | create one vcard 8 | 9 | ```ts 10 | const result = updateVCard({ 11 | vCard: { 12 | url: 'https://contacts.icloud.com/123456/carddavhome/card/test.vcf', 13 | data: 'BEGIN:VCARD\nVERSION:3.0\nN:;Test BBB;;;\nFN:Test BBB\nUID:0976cf06-a0e8-44bd-9217-327f6907242c\nPRODID:-//Apple Inc.//iCloud Web Address Book 2109B35//EN\nREV:2021-06-16T01:28:23Z\nEND:VCARD', 14 | etag: '"63758758580"', 15 | }, 16 | headers: { 17 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 18 | }, 19 | }); 20 | ``` 21 | 22 | ### Arguments 23 | 24 | - `vCard` **required**, [DAVVCard](../types/DAVVCard.md) to update 25 | - `headers` request headers 26 | 27 | ### Return Value 28 | 29 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 30 | 31 | ### Behavior 32 | 33 | use PUT request to update a new vcard 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: ["opened"] 6 | branches: [master] 7 | 8 | jobs: 9 | 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: ['14'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Use Node.js ${{ matrix.node_version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node_version }} 24 | 25 | - name: Install dependencies 26 | run: yarn --frozen-lockfile 27 | 28 | - name: Run `typecheck` 29 | run: yarn typecheck 30 | 31 | - name: Run `lint` 32 | run: yarn lint 33 | 34 | - name: Run `test` 35 | run: yarn test 36 | env: 37 | ICLOUD_USERNAME: ${{secrets.ICLOUD_USERNAME}} 38 | ICLOUD_APP_SPECIFIC_PASSWORD: ${{secrets.ICLOUD_APP_SPECIFIC_PASSWORD}} 39 | -------------------------------------------------------------------------------- /docs/docs/webdav/propfind.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ## `propfind` 6 | 7 | The [PROPFIND](https://datatracker.ietf.org/doc/html/rfc4918#section-9.1) method retrieves properties defined on the resource identified by the Request-URI 8 | 9 | ```ts 10 | const [result] = await propfind({ 11 | url: 'https://caldav.icloud.com/', 12 | props: [{ name: 'current-user-principal', namespace: DAVNamespace.DAV }], 13 | headers: { 14 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 15 | }, 16 | }); 17 | ``` 18 | 19 | ### Arguments 20 | 21 | - `url` **required**, request url 22 | - `props` **required**, array of [DAVProp](../types/DAVProp.md) to find 23 | - `depth` [DAVDepth](../types/DAVDepth.md) 24 | - `headers` request headers 25 | 26 | ### Return Value 27 | 28 | array of [DAVResponse](../types/DAVResponse.md) 29 | 30 | ### Behavior 31 | 32 | send a prop find request, parse the response xml to an array of [DAVResponse](../types/DAVResponse.md). 33 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export enum DAVNamespace { 2 | CALENDAR_SERVER = 'http://calendarserver.org/ns/', 3 | CALDAV_APPLE = 'http://apple.com/ns/ical/', 4 | CALDAV = 'urn:ietf:params:xml:ns:caldav', 5 | CARDDAV = 'urn:ietf:params:xml:ns:carddav', 6 | DAV = 'DAV:', 7 | } 8 | 9 | export const DAVAttributeMap = { 10 | [DAVNamespace.CALDAV]: 'xmlns:c', 11 | [DAVNamespace.CARDDAV]: 'xmlns:card', 12 | [DAVNamespace.CALENDAR_SERVER]: 'xmlns:cs', 13 | [DAVNamespace.CALDAV_APPLE]: 'xmlns:ca', 14 | [DAVNamespace.DAV]: 'xmlns:d', 15 | }; 16 | 17 | export const DAVNamespaceShorthandMap = { 18 | [DAVNamespace.CALDAV]: 'c', 19 | [DAVNamespace.CARDDAV]: 'card', 20 | [DAVNamespace.CALENDAR_SERVER]: 'cs', 21 | [DAVNamespace.CALDAV_APPLE]: 'ca', 22 | [DAVNamespace.DAV]: 'd', 23 | }; 24 | 25 | export enum ICALObjects { 26 | VEVENT = 'VEVENT', 27 | VTODO = 'VTODO', 28 | VJOURNAL = 'VJOURNAL', 29 | VFREEBUSY = 'VFREEBUSY', 30 | VTIMEZONE = 'VTIMEZONE', 31 | VALARM = 'VALARM', 32 | } 33 | -------------------------------------------------------------------------------- /docs/docs/carddav/createVCard.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | ## `createVCard` 6 | 7 | create one vcard on the target addressBook 8 | 9 | ```ts 10 | const result = await createVCard({ 11 | addressBook: addressBooks[0], 12 | filename: 'test.vcf' 13 | vCardString: 'BEGIN:VCARD\nVERSION:3.0\nN:;Test BBB;;;\nFN:Test BBB\nUID:0976cf06-a0e8-44bd-9217-327f6907242c\nPRODID:-//Apple Inc.//iCloud Web Address Book 2109B35//EN\nREV:2021-06-16T01:28:23Z\nEND:VCARD', 14 | headers: { 15 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 16 | }, 17 | }); 18 | ``` 19 | 20 | ### Arguments 21 | 22 | - `addressBook` **required**, [DAVAddressBook](../types/DAVAddressBook.md) 23 | - `filename` **required**, file name of the new vcard, should end in `.vcf` 24 | - `vCardString` **required**, vcard file data 25 | - `headers` request headers 26 | 27 | ### Return Value 28 | 29 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 30 | 31 | ### Behavior 32 | 33 | use PUT request to create a new vcard 34 | -------------------------------------------------------------------------------- /docs/docs/caldav/makeCalendar.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | ## `makeCalendar` 6 | 7 | create a new calendar on target account 8 | 9 | ```ts 10 | const result = await makeCalendar({ 11 | url: 'https://caldav.icloud.com/12345676/calendars/c623f6be-a2d4-4c60-932a-043e67025dde/', 12 | props: [ 13 | { name: 'displayname', value: builtInCalendars.APP }, 14 | { 15 | name: 'calendar-description', 16 | value: 'random calendar description', 17 | namespace: DAVNamespace.CALDAV, 18 | }, 19 | ], 20 | headers: { 21 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 22 | }, 23 | }); 24 | ``` 25 | 26 | ### Arguments 27 | 28 | - `url` **required**, the target url 29 | - `props` **required**, array of [DAVProp](../types/DAVProp.md) 30 | - `depth` [DAVDepth](../types/DAVDepth.md) 31 | - `headers` request headers 32 | 33 | ### Return Value 34 | 35 | array of [DAVResponse](../types/DAVResponse.md) 36 | 37 | ### Behavior 38 | 39 | send a MKCALENDAR request and calendar data that creates a new calendar 40 | -------------------------------------------------------------------------------- /docs/docs/types/DAVCollection.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVCollection = { 3 | objects?: DAVObject[]; 4 | ctag?: string; 5 | description?: string; 6 | displayName?: string; 7 | reports?: any; 8 | resourcetype?: any; 9 | syncToken?: string; 10 | url: string; 11 | }; 12 | ``` 13 | 14 | - `objects` objects of the 15 | - `ctag` [Collection Entity Tag](https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt) 16 | - `description` description of the collection 17 | - `displayName` [display name of the collection](https://datatracker.ietf.org/doc/html/rfc2518#section-13.2) 18 | - `reports` list of [reports that are supported by the 19 | resource](https://datatracker.ietf.org/doc/html/rfc3253#section-3.1.5). (in camel case), usually a string array 20 | - `resourcetype` [type of the resource](https://datatracker.ietf.org/doc/html/rfc2518#section-13.9), usually a string array 21 | - `syncToken` [the value of the synchronization token](https://datatracker.ietf.org/doc/html/rfc6578#section-4) 22 | - `url` url of the resource -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "airbnb-typescript/base", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "globals": { 13 | "Atomics": "readonly", 14 | "SharedArrayBuffer": "readonly" 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaVersion": 2018, 19 | "sourceType": "module", 20 | "project": "./tsconfig.json" 21 | }, 22 | "rules": { 23 | "import/prefer-default-export": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "import/extensions": [ 26 | "error", 27 | "ignorePackages", 28 | { 29 | "js": "never", 30 | "jsx": "never", 31 | "ts": "never", 32 | "tsx": "never" 33 | } 34 | ], 35 | "import/no-extraneous-dependencies": [ 36 | "error", 37 | { 38 | "devDependencies": ["**/*.test.ts"], 39 | "optionalDependencies": false, 40 | "peerDependencies": false 41 | } 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ##### v1.1.0 2 | 3 | **breaking changes** 4 | 5 | - `DAVClient` is no longer a type returned by `createDAVClient`, instead it's a class that can be instantiated. 6 | - `timeRange` in `fetchCalendarObjects` is now validated against `ISO_8601` standard and invalid format will throw an error. 7 | - typescript target changed to `es2015`, if you are on `node >= 10` and `browsers that are not IE and have been updated since 2015`, you should be fine. support for `es5` output is not possible with added `esm` support. 8 | 9 | **features** 10 | 11 | - added a new way to create `DAVClient` by `new DAVClient(...params)`. 12 | - added support for `esm`. 13 | 14 | ##### improvements 15 | 16 | - typescript checks are now with `strict` enabled, which means better types and less bugs. 17 | - added more exports, now all internal functions are exported. 18 | - multiple documentation improvements. 19 | 20 | ##### v1.0.6 21 | 22 | Fixed a bug where timeRange filter sometimes might be in the wrong format. 23 | 24 | ##### v1.0.3 25 | 26 | Fixed a bug where calendar objects with `http` in its id would cause operations on it to fail. 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nathaniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /docs/docs/webdav/account/createAccount.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## `createAccount` 6 | 7 | construct webdav account information need to requests 8 | 9 | ```ts 10 | const account = await createAccount({ 11 | account: { 12 | serverUrl: 'https://caldav.icloud.com/', 13 | accountType: 'caldav', 14 | }, 15 | headers: { 16 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 17 | }, 18 | }); 19 | ``` 20 | 21 | ### Arguments 22 | 23 | - `account` **required**, account with `serverUrl` and `accountType` 24 | - `headers` request headers 25 | - `loadCollections` defaults to false, whether to load all collections of the account 26 | - `loadObjects` defaults to false, whether to load all objects of collections as well, must be used with `loadCollections` set to `true` 27 | 28 | ### Return Value 29 | 30 | created [DAVAccount](../../types/DAVAccount.md) 31 | 32 | ### Behavior 33 | 34 | perform [serviceDiscovery](serviceDiscovery.md),[fetchHomeUrl](fetchHomeUrl.md), [fetchPrincipleUrl](fetchPrincipalUrl.md) for account information gathering. 35 | make fetch collections & fetch objects requests depend on options. 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: auto-release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: ["14"] 14 | 15 | environment: 16 | name: Production 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: use node.js ${{ matrix.node_version }} 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node_version }} 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: install dependencies 28 | run: yarn --frozen-lockfile 29 | 30 | - name: check types 31 | run: yarn typecheck 32 | 33 | - name: lint 34 | run: yarn lint 35 | 36 | - name: test 37 | run: yarn test 38 | env: 39 | ICLOUD_USERNAME: ${{secrets.ICLOUD_USERNAME}} 40 | ICLOUD_APP_SPECIFIC_PASSWORD: ${{secrets.ICLOUD_APP_SPECIFIC_PASSWORD}} 41 | 42 | - name: publish package 43 | run: yarn run publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} -------------------------------------------------------------------------------- /docs/docs/webdav/collection/makeCollection.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ## `makeCollection` 6 | 7 | [create a new collection](https://datatracker.ietf.org/doc/html/rfc5689#section-3) 8 | 9 | ```ts 10 | const response = await makeCollection({ 11 | url: 'https://caldav.icloud.com/12345676/calendars/c623f6be-a2d4-4c60-932a-043e67025dde/', 12 | props: [ 13 | { name: 'displayname', value: builtInCalendars.APP }, 14 | { 15 | name: 'calendar-description', 16 | value: 'random calendar description', 17 | namespace: DAVNamespace.CALDAV, 18 | }, 19 | ], 20 | headers: { 21 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 22 | }, 23 | }); 24 | ``` 25 | 26 | ### Arguments 27 | 28 | - `url` **required**, url of the collection to create 29 | - `props` array of [DAVProp](../../types/DAVProp.md) for the collection 30 | - `depth` [DAVDepth](../../types/DAVDepth.md) 31 | - `headers` request headers 32 | 33 | ### Return Value 34 | 35 | array of [DAVResponse](../../types/DAVResponse.md) 36 | 37 | ### Behavior 38 | 39 | send MKCOL request and parse response xml into array of [DAVResponse](../../types/DAVResponse.md) 40 | -------------------------------------------------------------------------------- /docs/docs/caldav/fetchCalendarObjects.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | ## `fetchCalendarObjects` 6 | 7 | get all/specified calendarObjects of the passed in calendar 8 | 9 | ```ts 10 | const objects = await fetchCalendarObjects({ 11 | calendar: calendars[1], 12 | headers: { 13 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 14 | }, 15 | }); 16 | ``` 17 | 18 | ### Arguments 19 | 20 | - `calendar` **required**, [DAVCalendar](../types/DAVCalendar.md) to fetch calendar objects from 21 | - `objectUrls` calendar object urls to fetch 22 | - `filters` array of [DAVFilter](../types/DAVFilter.md) 23 | - `timeRange` time range in iso format 24 | - `start` start time in [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601), format that's not in ISO 8601 will cause an error be thrown. 25 | - `end` end time in [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601), format that's not in ISO 8601 will cause an error be thrown. 26 | - `headers` request headers 27 | 28 | ### Return Value 29 | 30 | array of [DAVCalendarObject](../types/DAVCalendarObject.md) 31 | 32 | ### Behavior 33 | 34 | a mix of [calendarMultiGet](calendarMultiGet.md) and [calendarQuery](calendarQuery.md), you can specify both filters and objectUrls here. 35 | -------------------------------------------------------------------------------- /src/util/nativeType.test.ts: -------------------------------------------------------------------------------- 1 | import { nativeType } from './nativeType'; 2 | 3 | test('nativeType should be able to handle numbers', () => { 4 | const a = nativeType('123'); 5 | const b = nativeType('1.2'); 6 | const c = nativeType('9999999999'); 7 | const d = nativeType('-123'); 8 | expect(typeof a).toEqual('number'); 9 | expect(typeof b).toEqual('number'); 10 | expect(typeof c).toEqual('number'); 11 | expect(typeof d).toEqual('number'); 12 | }); 13 | 14 | test('nativeType should be able to handle booleans', () => { 15 | const a = nativeType('true'); 16 | const b = nativeType('false'); 17 | const c = nativeType('TRUE'); 18 | const d = nativeType('FALSE'); 19 | expect(typeof a).toEqual('boolean'); 20 | expect(typeof b).toEqual('boolean'); 21 | expect(typeof c).toEqual('boolean'); 22 | expect(typeof d).toEqual('boolean'); 23 | }); 24 | 25 | test('nativeType should keep other types as string', () => { 26 | const a = nativeType('{}'); 27 | const b = nativeType('test'); 28 | const c = nativeType('{ a: b}'); 29 | const d = nativeType('NaN'); 30 | expect(typeof a).toEqual('string'); 31 | expect(typeof b).toEqual('string'); 32 | expect(typeof c).toEqual('string'); 33 | expect(typeof d).toEqual('string'); 34 | }); 35 | -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "language": "en", 4 | "words": [ 5 | "addressbook", 6 | "addressbooks", 7 | "Bundlephobia", 8 | "caldav", 9 | "CALSCALE", 10 | "CARDDAV", 11 | "clsx", 12 | "consts", 13 | "copyfiles", 14 | "ctag", 15 | "DAVV", 16 | "displayname", 17 | "DTEND", 18 | "DTSTAMP", 19 | "DTSTART", 20 | "etag", 21 | "getctag", 22 | "getetag", 23 | "ICAL", 24 | "icloud", 25 | "MKCALENDAR", 26 | "MKCOL", 27 | "multiget", 28 | "multistatus", 29 | "PRODID", 30 | "propfind", 31 | "PROPPATCH", 32 | "propstat", 33 | "Quickstart", 34 | "readonly", 35 | "resourcetype", 36 | "respawn", 37 | "responsedescription", 38 | "tsdav", 39 | "typecheck", 40 | "VALARM", 41 | "VCALENDAR", 42 | "vcard", 43 | "vcards", 44 | "VEVENT", 45 | "VFREEBUSY", 46 | "VJOURNAL", 47 | "VTIMEZONE", 48 | "VTODO", 49 | "webdav" 50 | ], 51 | "ignorePaths": [ 52 | "node_modules/**" 53 | ] 54 | } -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import Layout from '@theme/Layout'; 7 | 8 | import HomepageFeatures from '../components/HomepageFeatures'; 9 | import styles from './index.module.css'; 10 | 11 | function HomepageHeader() { 12 | const { siteConfig } = useDocusaurusContext(); 13 | return ( 14 |
15 |
16 |

{siteConfig.title}

17 |

{siteConfig.tagline}

18 |
19 | 20 | Start using in 5min ⏱️ 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default function Home() { 29 | const { siteConfig } = useDocusaurusContext(); 30 | return ( 31 | 32 | 33 |
34 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /docs/docs/carddav/addressBookQuery.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## `addressBookQuery` 6 | 7 | performs a search for all address object resources that match a specified filter 8 | 9 | The response of this report will contain all the WebDAV properties and address 10 | object resource data specified in the request. 11 | 12 | In the case of the `addressData` element, one can explicitly specify the 13 | vCard properties that should be returned in the address object 14 | resource data that matches the filter. 15 | 16 | ```ts 17 | const addressbooks = await addressBookQuery({ 18 | url: 'https://contacts.icloud.com/123456/carddavhome/card/', 19 | props: [{ name: 'getetag', namespace: DAVNamespace.DAV }], 20 | depth: '1', 21 | headers: { 22 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 23 | }, 24 | }); 25 | ``` 26 | 27 | ### Arguments 28 | 29 | - `url` **required**, request target url 30 | - `props` **required**, array of [DAVProp](../types/DAVProp.md) 31 | - `depth` [DAVDepth](../types/DAVDepth.md) 32 | - `headers` request headers 33 | 34 | ### Return Value 35 | 36 | array of [DAVResponse](../types/DAVResponse.md) 37 | 38 | ### Behavior 39 | 40 | send a addressbook-query REPORT request, after server applies the filters and parse the response xml to an array of [DAVResponse](../types/DAVResponse.md). 41 | -------------------------------------------------------------------------------- /src/types/functionsOverloads.ts: -------------------------------------------------------------------------------- 1 | import { DAVAccount, DAVCalendar, DAVCollection, DAVObject } from './models'; 2 | 3 | export interface SmartCollectionSync { 4 | (param: { 5 | collection: T; 6 | method?: 'basic' | 'webdav'; 7 | headers?: Record; 8 | account?: DAVAccount; 9 | detailedResult: true; 10 | }): Promise< 11 | | Omit & { 12 | objects: { 13 | created: DAVObject[]; 14 | updated: DAVObject[]; 15 | deleted: DAVObject[]; 16 | }; 17 | } 18 | >; 19 | (param: { 20 | collection: T; 21 | method?: 'basic' | 'webdav'; 22 | headers?: Record; 23 | account?: DAVAccount; 24 | detailedResult?: false; 25 | }): Promise; 26 | } 27 | 28 | export interface SyncCalendars { 29 | (params: { 30 | oldCalendars: DAVCalendar[]; 31 | headers?: Record; 32 | account?: DAVAccount; 33 | detailedResult: true; 34 | }): Promise<{ 35 | created: DAVCalendar[]; 36 | updated: DAVCalendar[]; 37 | deleted: DAVCalendar[]; 38 | }>; 39 | (params: { 40 | oldCalendars: DAVCalendar[]; 41 | headers?: Record; 42 | account?: DAVAccount; 43 | detailedResult?: false; 44 | }): Promise; 45 | } 46 | -------------------------------------------------------------------------------- /docs/docs/webdav/collection/collectionQuery.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## `collectionQuery` 6 | 7 | query on [DAVCollection](../../types/DAVCollection.md) 8 | 9 | ```ts 10 | const result = await collectionQuery({ 11 | url: 'https://contacts.icloud.com/123456/carddavhome/card/', 12 | body: { 13 | 'addressbook-query': { 14 | _attributes: getDAVAttribute([DAVNamespace.CARDDAV, DAVNamespace.DAV]), 15 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 16 | filter: { 17 | 'prop-filter': { 18 | _attributes: { 19 | name: 'FN', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | defaultNamespace: DAVNamespace.CARDDAV, 26 | depth: '1', 27 | headers: { 28 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 29 | }, 30 | }); 31 | ``` 32 | 33 | ### Arguments 34 | 35 | - `url` **required**, collection url 36 | - `body` **required**, query request body 37 | - `depth` [DAVDepth](../../types/DAVDepth.md) 38 | - `defaultNamespace` defaults to `DAVNamespace.DAV`, default namespace for the the request body 39 | - `headers` request headers 40 | 41 | ### Return Value 42 | 43 | array of [DAVResponse](../../types/DAVResponse.md) 44 | 45 | ### Behavior 46 | 47 | send REPORT request on the target collection, parse response xml into array of [DAVResponse](../../types/DAVResponse.md) 48 | -------------------------------------------------------------------------------- /src/util/typeHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { findMissingFieldNames, hasFields } from './typeHelpers'; 2 | 3 | test('hasFields should detect missing fields', () => { 4 | const obj = { 5 | test1: 123, 6 | test2: 'abc', 7 | }; 8 | const arr = ['a', 'b', 'c']; 9 | expect(hasFields(obj, ['test1'])).toBe(true); 10 | expect(hasFields(obj, ['test2'])).toBe(true); 11 | // @ts-expect-error need to ignore the ts error 12 | expect(hasFields(obj, ['test3'])).toBe(false); 13 | // @ts-expect-error need to ignore the ts error 14 | expect(hasFields(obj, [0])).toBe(false); 15 | expect(hasFields(arr, ['length'])).toBe(true); 16 | }); 17 | 18 | test('findMissingFieldNames should find missing fields', () => { 19 | type ObjType = { 20 | test1?: number; 21 | test2?: string; 22 | test3?: string; 23 | }; 24 | const obj: ObjType = { 25 | test1: 123, 26 | test2: 'abc', 27 | test3: 'cde', 28 | }; 29 | const obj1: ObjType = { 30 | test2: 'abc', 31 | }; 32 | const obj2: ObjType = { 33 | test3: 'abc', 34 | }; 35 | expect(findMissingFieldNames(obj, ['test1', 'test2', 'test3'])).toEqual(''); 36 | expect(findMissingFieldNames(obj1, ['test1', 'test2', 'test3'])).toEqual('test1,test3'); 37 | expect(findMissingFieldNames(obj2, ['test1', 'test2', 'test3'])).toEqual('test1,test2'); 38 | expect(findMissingFieldNames(obj1, ['test2', 'test3'])).toEqual('test3'); 39 | }); 40 | -------------------------------------------------------------------------------- /docs/docs/webdav/createObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | ## `createObject` 6 | 7 | create an object 8 | 9 | ```ts 10 | const response = await createObject({ 11 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 12 | data: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 13 | headers: { 14 | 'content-type': 'text/calendar; charset=utf-8', 15 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 16 | }, 17 | }); 18 | ``` 19 | 20 | ### Arguments 21 | 22 | - `url` **required**, object url 23 | - `data` **required**, object data 24 | - `headers` request headers 25 | 26 | ### Return Value 27 | 28 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 29 | 30 | ### Behavior 31 | 32 | send PUT request to target url with body of data 33 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.4", 18 | "@docusaurus/preset-classic": "2.0.0-beta.4", 19 | "@easyops-cn/docusaurus-search-local": "0.19.1", 20 | "@mdx-js/react": "^1.6.22", 21 | "@svgr/webpack": "^5.5.0", 22 | "clsx": "^1.1.1", 23 | "file-loader": "^6.2.0", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "url-loader": "^4.1.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@docusaurus/module-type-aliases": "2.0.0-beta.4", 42 | "@tsconfig/docusaurus": "1.0.4", 43 | "@types/react": "17.0.19", 44 | "@types/react-helmet": "6.1.2", 45 | "@types/react-router-dom": "5.1.8", 46 | "typescript": "4.3.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/docs/caldav/updateCalendarObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 9 3 | --- 4 | 5 | ## `updateCalendarObject` 6 | 7 | create one calendar object 8 | 9 | ```ts 10 | const result = updateCalendarObject({ 11 | calendarObject: { 12 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 13 | data: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 14 | etag: '"63758758580"', 15 | }, 16 | headers: { 17 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 18 | }, 19 | }); 20 | ``` 21 | 22 | ### Arguments 23 | 24 | - `calendarObject` **required**, [DAVCalendarObject](../types/DAVCalendarObject.md) to update 25 | - `headers` request headers 26 | 27 | ### Return Value 28 | 29 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 30 | 31 | ### Behavior 32 | 33 | use PUT request to update a new calendar object 34 | -------------------------------------------------------------------------------- /docs/docs/webdav/collection/syncCollection.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | ## `syncCollection` 6 | 7 | [One way to synchronize data between two entities is to use some form of synchronization token](https://datatracker.ietf.org/doc/html/rfc6578#section-3.2) 8 | 9 | ```ts 10 | const result = await syncCollection({ 11 | url: 'https://caldav.icloud.com/12345676/calendars/c623f6be-a2d4-4c60-932a-043e67025dde/', 12 | props: [ 13 | { name: 'getetag', namespace: DAVNamespace.DAV }, 14 | { name: 'calendar-data', namespace: DAVNamespace.CALDAV }, 15 | { name: 'displayname', namespace: DAVNamespace.DAV }, 16 | ], 17 | syncLevel: 1, 18 | syncToken: 'bb399205ff6ff07', 19 | headers: { 20 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 21 | }, 22 | }); 23 | ``` 24 | 25 | ### Arguments 26 | 27 | - `url` **required**, target collection url 28 | - `props` **required**, [DAVProp](../../types/DAVProp.md) of the request 29 | - `syncLevel` [Indicates the "scope" of the synchronization report request](https://datatracker.ietf.org/doc/html/rfc6578#section-6.3) 30 | - `syncToken` [The synchronization token provided by the server and returned by the client](https://datatracker.ietf.org/doc/html/rfc6578#section-6.2) 31 | - `headers` request headers 32 | 33 | ### Return Value 34 | 35 | array of [DAVResponse](../../types/DAVResponse.md) 36 | 37 | ### Behavior 38 | 39 | send sync-collection REPORT to target collection url, parse response xml into array of [DAVResponse](../../types/DAVResponse.md) 40 | -------------------------------------------------------------------------------- /docs/docs/helpers/constants.md: -------------------------------------------------------------------------------- 1 | 2 | # Constants 3 | 4 | ### DAVNamespace 5 | xml namespace enum for convenience 6 | 7 | | Name | Value | Description | 8 | | --------------- | ------------------------------ | ---------------------------- | 9 | | CALENDAR_SERVER | http://calendarserver.org/ns/ | calendarserver.org namespace | 10 | | CALDAV_APPLE | http://apple.com/ns/ical/ | Apple CALDAV namespace | 11 | | CALDAV | urn:ietf:params:xml:ns:caldav | CALDAV namespace | 12 | | CARDDAV | urn:ietf:params:xml:ns:carddav | CARDDAV namespace | 13 | | DAV | DAV: | WEBDAV namespace | 14 | 15 | 16 | 17 | ### DAVNamespaceShorthandMap 18 | 19 | map WEBDAV namespace to shorthands to allow better readability when dealing with raw xml data 20 | 21 | | Name | Value | 22 | | --------------- | ----- | 23 | | CALDAV | c | 24 | | CARDDAV | card | 25 | | CALENDAR_SERVER | cs | 26 | | CALDAV_APPLE | ca | 27 | | DAV | d | 28 | 29 | 30 | ### DAVAttributeMap 31 | 32 | map WEBDAV namespace to attributes to allow better readability when dealing with raw xml data 33 | 34 | | Name | Value | 35 | | --------------- | ---------- | 36 | | CALDAV | xmlns:c | 37 | | CARDDAV | xmlns:card | 38 | | CALENDAR_SERVER | xmlns:cs | 39 | | CALDAV_APPLE | xmlns:ca | 40 | | DAV | xmlns:d | 41 | 42 | -------------------------------------------------------------------------------- /src/types/DAVTypes.ts: -------------------------------------------------------------------------------- 1 | import { DAVNamespace } from '../consts'; 2 | 3 | export type DAVProp = { 4 | name: string; 5 | namespace?: DAVNamespace; 6 | value?: string | number; 7 | }; 8 | 9 | export type DAVFilter = { 10 | type: string; 11 | attributes: Record; 12 | value?: string | number; 13 | children?: DAVFilter[]; 14 | }; 15 | 16 | export type DAVDepth = '0' | '1' | 'infinity'; 17 | 18 | export type DAVMethods = 19 | | 'COPY' 20 | | 'LOCK' 21 | | 'MKCOL' 22 | | 'MOVE' 23 | | 'PROPFIND' 24 | | 'PROPPATCH' 25 | | 'UNLOCK' 26 | | 'REPORT' 27 | | 'SEARCH' 28 | | 'MKCALENDAR'; 29 | 30 | export type HTTPMethods = 31 | | 'GET' 32 | | 'HEAD' 33 | | 'POST' 34 | | 'PUT' 35 | | 'DELETE' 36 | | 'CONNECT' 37 | | 'OPTIONS' 38 | | 'TRACE' 39 | | 'PATCH'; 40 | 41 | export type DAVResponse = { 42 | raw?: any; 43 | href?: string; 44 | status: number; 45 | statusText: string; 46 | ok: boolean; 47 | error?: { [key: string]: any }; 48 | responsedescription?: string; 49 | props?: { [key: string]: { status: number; statusText: string; ok: boolean; value: any } | any }; 50 | }; 51 | 52 | export type DAVRequest = { 53 | headers?: Record; 54 | method: DAVMethods | HTTPMethods; 55 | body: any; 56 | namespace?: string; 57 | attributes?: Record; 58 | }; 59 | 60 | export type DAVTokens = { 61 | access_token?: string; 62 | refresh_token?: string; 63 | expires_in?: number; 64 | id_token?: string; 65 | token_type?: string; 66 | scope?: string; 67 | }; 68 | -------------------------------------------------------------------------------- /docs/docs/caldav/createCalendarObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | ## `createCalendarObject` 6 | 7 | create one calendar object on the target calendar 8 | 9 | ```ts 10 | const result = await createCalendarObject({ 11 | calendar: calendars[1], 12 | filename: 'test.ics' 13 | iCalString: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 14 | headers: { 15 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 16 | }, 17 | }); 18 | ``` 19 | 20 | ### Arguments 21 | 22 | - `calendar` **required**, the [DAVCalendar](../types/DAVCalendar.md) which the client wish to create object on. 23 | - `filename` **required**, file name of the new calendar object, should end in `.ics` 24 | - `iCalString` **required**, calendar file data 25 | - `headers` request headers 26 | 27 | ### Return Value 28 | 29 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 30 | 31 | ### Behavior 32 | 33 | use PUT request to create a new calendar object 34 | -------------------------------------------------------------------------------- /src/util/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | export type Optional = Pick, K> & Omit; 2 | export type NoUndefinedField = { [P in keyof T]-?: NoUndefinedField> }; 3 | export type Await = T extends PromiseLike ? U : T; 4 | export type ValueOf = T[keyof T]; 5 | export type RequiredAndNotNull = { 6 | [P in keyof T]-?: Exclude; 7 | }; 8 | export type RequireAndNotNullSome = RequiredAndNotNull> & 9 | Omit; 10 | 11 | export type RequireAtLeastOne = Pick> & 12 | { 13 | [K in Keys]-?: Required> & Partial>>; 14 | }[Keys]; 15 | 16 | export function hasFields( 17 | obj: Array>, 18 | fields: K[] 19 | ): obj is Array>; 20 | export function hasFields( 21 | obj: T | RequireAndNotNullSome, 22 | fields: K[] 23 | ): obj is RequireAndNotNullSome; 24 | export function hasFields(obj: T | Array, fields: K[]): boolean { 25 | const inObj: { (obj: T | RequireAndNotNullSome): boolean } = (object) => 26 | fields.every((f) => object[f]); 27 | 28 | if (Array.isArray(obj)) { 29 | return obj.every((o) => inObj(o)); 30 | } 31 | return inObj(obj); 32 | } 33 | 34 | export const findMissingFieldNames = (obj: T, fields: Array): string => 35 | fields.reduce((prev, curr) => (obj[curr] ? prev : `${prev.length ? `${prev},` : ''}${curr}`), ''); 36 | -------------------------------------------------------------------------------- /docs/docs/caldav/calendarQuery.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## `calendarQuery` 6 | 7 | calendarQuery performs a search for all calendar object resources that match a specified filter. 8 | 9 | The response of this report will contain all the WebDAV properties and calendar object resource data specified in the request. 10 | 11 | In the case of the `calendarData` element, one can explicitly specify the calendar components and properties that should be returned in the calendar object resource data that matches the filter. 12 | 13 | ```ts 14 | // fetch all objects of the calendar 15 | const results = await calendarQuery({ 16 | url: 'https://caldav.icloud.com/1234567/calendars/personal/', 17 | props: [{ name: 'getetag', namespace: DAVNamespace.DAV }], 18 | filters: [ 19 | { 20 | type: 'comp-filter', 21 | attributes: { name: 'VCALENDAR' }, 22 | }, 23 | ], 24 | depth: '1', 25 | headers: { 26 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 27 | }, 28 | }); 29 | ``` 30 | 31 | ### Arguments 32 | 33 | - `url` **required**, request target url 34 | - `props` **required**, array of [DAVProp](../types/DAVProp.md) 35 | - `filters` array of [DAVFilter](../types/DAVFilter.md) 36 | - `depth` [DAVDepth](../types/DAVDepth.md) 37 | - `timezone` iana timezone name, like `America/Los_Angeles` 38 | - `headers` request headers 39 | 40 | ### Return Value 41 | 42 | array of [DAVResponse](../types/DAVResponse.md) 43 | 44 | ### Behavior 45 | 46 | send a calendar-query REPORT request, after server applies the filters and parse the response xml to an array of [DAVResponse](../types/DAVResponse.md). 47 | -------------------------------------------------------------------------------- /docs/docs/types/DAVResponse.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export type DAVResponse = { 3 | raw?: any; 4 | href?: string; 5 | status: number; 6 | statusText: string; 7 | ok: boolean; 8 | error?: { [key: string]: any }; 9 | responsedescription?: string; 10 | props?: { [key: string]: { status: number; statusText: string; ok: boolean; value: any } | any }; 11 | }; 12 | ``` 13 | 14 | sample DAVResponse 15 | 16 | ```json 17 | { 18 | "raw": { 19 | "multistatus": { 20 | "response": { 21 | "href": "/", 22 | "propstat": { 23 | "prop": { 24 | "currentUserPrincipal": { "href": "/123456/principal/" } 25 | }, 26 | "status": "HTTP/1.1 200 OK" 27 | } 28 | } 29 | } 30 | }, 31 | "href": "/", 32 | "status": 207, 33 | "statusText": "Multi-Status", 34 | "ok": true, 35 | "props": { "currentUserPrincipal": { "href": "/123456/principal/" } } 36 | } 37 | ``` 38 | 39 | response type of [davRequest](../webdav/davRequest.md) 40 | 41 | - `raw` the entire [response](https://datatracker.ietf.org/doc/html/rfc4918#section-14.24) object, useful when need something that is not a prop or href 42 | - `href` [content element URI](https://datatracker.ietf.org/doc/html/rfc2518#section-12.3) 43 | - `status` fetch response status 44 | - `statusText` fetch response statusText 45 | - `ok` fetch response ok 46 | - `error` error object from error response 47 | - `responsedescription` [information about a status response within a 48 | Multi-Status](https://datatracker.ietf.org/doc/html/rfc4918#section-14.25) 49 | - `props` response [propstat](https://datatracker.ietf.org/doc/html/rfc4918#section-14.22) props with camel case names. 50 | -------------------------------------------------------------------------------- /docs/docs/carddav/addressBookMultiGet.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ## `addressBookMultiGet` 6 | 7 | addressBookMultiGet is used to retrieve specific 8 | address object resources from within a collection. 9 | 10 | If the Request-URI is an address object resource. This report is similar to the [addressBookQuery](addressBookQuery.md) except that it takes a list of vcard urls, instead of a filter, to determine which vcards to return. 11 | 12 | ```ts 13 | // fetch 2 specific vcards from one addressBook 14 | const vcards = await addressBookMultiGet({ 15 | url: 'https://contacts.icloud.com/1234567/carddavhome/card/', 16 | props: [ 17 | { name: 'getetag', namespace: DAVNamespace.DAV }, 18 | { name: 'address-data', namespace: DAVNamespace.CARDDAV }, 19 | ], 20 | objectUrls: [ 21 | 'https://contacts.icloud.com/1234567/carddavhome/card/1.vcf', 22 | 'https://contacts.icloud.com/1234567/carddavhome/card/2.vcf', 23 | ], 24 | depth: '1', 25 | headers: { 26 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 27 | }, 28 | }); 29 | ``` 30 | 31 | ### Arguments 32 | 33 | - `url` **required**, url of CARDDAV server 34 | - `objectUrls` **required**, urls of vcards to get 35 | - `props` **required**, [DAVProp](../types/DAVProp.md) the client needs 36 | - `filters` [DAVFilter](../types/DAVFilter.md) the filter on the vcards 37 | - `depth` **required**, [DAVDepth](../types/DAVDepth.md) of the request 38 | - `headers` request headers 39 | 40 | ### Return Value 41 | 42 | array of [DAVVCard](../types/DAVVCard.md) 43 | 44 | ### Behavior 45 | 46 | send carddav:addressbook-multiget REPORT request and parse the response xml to extract an array of [DAVVCard](../types/DAVVCard.md) data. 47 | -------------------------------------------------------------------------------- /docs/docs/webdav/updateObject.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | ## `updateObject` 6 | 7 | update an object 8 | 9 | ```ts 10 | const response = await updateObject({ 11 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 12 | data: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 13 | etag: '"63758758580"', 14 | headers: { 15 | 'content-type': 'text/calendar; charset=utf-8', 16 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 17 | }, 18 | }); 19 | ``` 20 | 21 | ### Arguments 22 | 23 | - `url` **required**, url of object to update 24 | - `data` **required**, new object content 25 | - `etag` **required**, the version string of content, if [etag](https://tools.ietf.org/id/draft-reschke-http-etag-on-write-08.html) changed, `data` must have been changed. 26 | - `headers` request headers 27 | 28 | ### Return Value 29 | 30 | [fetch api response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 31 | 32 | ### Behavior 33 | 34 | send PUT request to with data body and etag header 35 | object will not be updated if etag do not match 36 | -------------------------------------------------------------------------------- /docs/docs/webdav/davRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## `davRequest` 6 | 7 | core request function of the library, 8 | based on `cross-fetch`, so the api should work across browser and Node.js 9 | using `xml-js` so that js objects can be passed as request. 10 | 11 | ```ts 12 | const [result] = await davRequest({ 13 | url: 'https://caldav.icloud.com/', 14 | init: { 15 | method: 'PROPFIND', 16 | namespace: 'd', 17 | body: { 18 | propfind: { 19 | _attributes: { 20 | 'xmlns:d': 'DAV:', 21 | }, 22 | prop: { 'd:current-user-principal': {} }, 23 | }, 24 | }, 25 | headers: { 26 | authorization: 'Basic x0C9uFWd9Vz8OwS0DEAtkAlj', 27 | }, 28 | }, 29 | }); 30 | ``` 31 | 32 | ### Arguments 33 | 34 | - `url` **required**, request url 35 | - `init` **required**, [DAVRequest](davRequest.md) Object 36 | - `convertIncoming` defaults to `true`, whether to convert the passed in init object request body, if `false`, davRequest would expect `init->body` is `xml` string, and would send it directly to target `url` without processing. 37 | - `parseOutgoing` defaults to `true`, whether to parse the return value in response body, if `false`, the response `raw` would be raw `xml` string returned from server. 38 | 39 | ### Return Value 40 | 41 | array of [DAVResponse](../types/DAVResponse.md) 42 | 43 | - response-> raw will be `string` if `parseOutgoing` is `false` or request failed. 44 | 45 | ### Behavior 46 | 47 | depend on options, use `xml-js` to convert passed in json object into valid xml request, 48 | also use `xml-js` to convert received xml response into json object. 49 | if request failed, response-> raw will be raw response text returned from server. 50 | -------------------------------------------------------------------------------- /src/util/authHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultParam, 3 | fetchOauthTokens, 4 | getBasicAuthHeaders, 5 | refreshAccessToken, 6 | } from './authHelpers'; 7 | 8 | test('defaultParam should be able to add default param', () => { 9 | const fn1 = (params: { a?: number; b?: number }) => { 10 | const { a = 0, b = 0 } = params; 11 | return a + b; 12 | }; 13 | const fn2 = defaultParam(fn1, { b: 10 }); 14 | const result = fn2({ a: 1 }); 15 | expect(result).toEqual(11); 16 | }); 17 | 18 | test('defaultParam added param should be able to be overridden', () => { 19 | const fn1 = (params: { a?: number; b?: number }) => { 20 | const { a = 0, b = 0 } = params; 21 | return a + b; 22 | }; 23 | const fn2 = defaultParam(fn1, { b: 10 }); 24 | const result = fn2({ a: 1, b: 3 }); 25 | expect(result).toEqual(4); 26 | }); 27 | 28 | test('getBasicAuthHeaders should return correct hash', () => { 29 | const { authorization } = getBasicAuthHeaders({ 30 | username: 'test', 31 | password: '12345', 32 | }); 33 | expect(authorization).toEqual('Basic dGVzdDoxMjM0NQ=='); 34 | }); 35 | 36 | test('fetchOauthTokens should rejects when missing args', async () => { 37 | const t = async () => 38 | fetchOauthTokens({ 39 | authorizationCode: '123', 40 | }); 41 | expect(t).rejects.toThrow( 42 | 'Oauth credentials missing: redirectUrl,clientId,clientSecret,tokenUrl' 43 | ); 44 | }); 45 | 46 | test('refreshAccessToken should rejects when missing args', async () => { 47 | const t = async () => 48 | refreshAccessToken({ 49 | authorizationCode: '123', 50 | }); 51 | expect(t).rejects.toThrow( 52 | 'Oauth credentials missing: refreshToken,clientId,clientSecret,tokenUrl' 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | import styles from './HomepageFeatures.module.css'; 5 | 6 | const FeatureList = [ 7 | { 8 | title: 'WEBDAV', 9 | Svg: require('../../static/img/undraw_cloud_files_wmo8.svg').default, 10 | description: <>webdav can help you sync any file and collections of files with the cloud, 11 | }, 12 | { 13 | title: 'CALDAV', 14 | Svg: require('../../static/img/undraw_events_2p66.svg').default, 15 | description: <>CALDAV allows you to sync your calendars with multiple cloud providers, 16 | }, 17 | { 18 | title: 'CARDDAV', 19 | Svg: require('../../static/img/undraw_People_search_re_5rre.svg').default, 20 | description: <>CARDDAV allows you to sync contacts with multiple cloud providers, 21 | }, 22 | ]; 23 | 24 | function Feature({ Svg, title, description }) { 25 | return ( 26 |
27 |
28 | 29 |
30 |
31 |

{title}

32 |

{description}

33 |
34 |
35 | ); 36 | } 37 | 38 | export default function HomepageFeatures() { 39 | return ( 40 |
41 |
42 |
43 |

Supported protocols

44 |
45 |
46 | {FeatureList.map((props, idx) => ( 47 | 48 | ))} 49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /docs/docs/caldav/calendarMultiGet.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | ## `calendarMultiGet` 6 | 7 | calendarMultiGet is used to retrieve specific calendar object resources from within a collection. 8 | 9 | If the Request-URI is a calendar object resource. This method is similar to the [calendarQuery](./calendarQuery.md), except that it takes a list of calendar object urls, instead of a filter, to determine which calendar objects to return. 10 | 11 | ```ts 12 | // fetch 2 specific objects from one calendar 13 | const calendarObjects = await calendarMultiGet({ 14 | url: 'https://caldav.icloud.com/1234567/calendars/personal/', 15 | props: [ 16 | { name: 'getetag', namespace: DAVNamespace.DAV }, 17 | { name: 'calendar-data', namespace: DAVNamespace.CALDAV }, 18 | ], 19 | objectUrls: [ 20 | 'https://caldav.icloud.com/1234567/calendars/personal/1.ics', 21 | 'https://caldav.icloud.com/1234567/calendars/personal/2.ics', 22 | ], 23 | depth: '1', 24 | headers: { 25 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 26 | }, 27 | }); 28 | ``` 29 | 30 | ### Arguments 31 | 32 | - `url` **required**, url of CALDAV server 33 | - `objectUrls` **required**, urls of calendar object to get 34 | - `props` [DAVProp](../types/DAVProp.md) the client needs 35 | - `filters` [DAVFilter](../types/DAVFilter.md) the filter on the calendar objects 36 | - `depth` **required**, [DAVDepth](../types/DAVDepth.md) of the request 37 | - `timezone` timezone of the calendar 38 | - `headers` request headers 39 | 40 | ### Return Value 41 | 42 | array of [DAVCalendarObject](../types/DAVCalendarObject.md) 43 | 44 | ### Behavior 45 | 46 | send caldav:calendar-multiget REPORT request and parse the response xml to extract an array of [DAVCalendarObject](../types/DAVCalendarObject.md) data. 47 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 2 | module.exports = { 3 | title: 'tsdav', 4 | tagline: 'webdav request made easy', 5 | url: 'https://your-docusaurus-test-site.com', 6 | baseUrl: '/', 7 | onBrokenLinks: 'throw', 8 | onBrokenMarkdownLinks: 'warn', 9 | favicon: 'img/favicon.ico', 10 | organizationName: 'llldar', // Usually your GitHub org/user name. 11 | projectName: 'tsDAV', // Usually your repo name. 12 | themeConfig: { 13 | colorMode: { 14 | defaultMode: 'dark', 15 | respectPrefersColorScheme: true, 16 | }, 17 | navbar: { 18 | title: 'tsdav', 19 | logo: { 20 | alt: 'tsdav logo', 21 | src: 'img/logo.svg', 22 | }, 23 | items: [ 24 | { 25 | type: 'doc', 26 | docId: 'intro', 27 | position: 'left', 28 | label: 'Docs', 29 | }, 30 | { 31 | href: 'https://github.com/llldar/tsDAV', 32 | label: 'GitHub', 33 | position: 'right', 34 | }, 35 | ], 36 | }, 37 | footer: { 38 | style: 'dark', 39 | copyright: `© ${new Date().getFullYear()} Nathaniel Lin. MIT licensed.`, 40 | }, 41 | }, 42 | presets: [ 43 | [ 44 | '@docusaurus/preset-classic', 45 | { 46 | docs: { 47 | sidebarPath: require.resolve('./sidebars.js'), 48 | editUrl: 'https://github.com/llldar/tsDAV/edit/master/docs/', 49 | }, 50 | theme: { 51 | customCss: require.resolve('./src/css/custom.css'), 52 | }, 53 | }, 54 | ], 55 | ], 56 | plugins: [ 57 | [ 58 | require.resolve('@easyops-cn/docusaurus-search-local'), 59 | { 60 | hashed: true, 61 | }, 62 | ], 63 | ], 64 | }; 65 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Place your settings in this file to overwrite default and user settings. 3 | // Controls after how many characters the editor will wrap to the next line. Setting this to 0 turns on viewport width wrapping 4 | // When enabled, will trim trailing whitespace when you save a file. 5 | "files.trimTrailingWhitespace": true, 6 | // Controls if the editor should automatically close brackets after opening them 7 | "editor.autoClosingBrackets": "languageDefined", 8 | // Configure glob patterns for excluding files and folders in searches. Inherits all glob patterns from the file.exclude setting. 9 | "search.exclude": { 10 | "**/node_modules": true, 11 | "**/dist": true, 12 | "**/lib": true, 13 | "**/lib-amd": true, 14 | "**/test/**/temp": false, 15 | "**/temp": true, 16 | "**/coverage": true 17 | }, 18 | "files.associations": { 19 | "**/package.json": "json", 20 | "**/*.json": "jsonc" 21 | }, 22 | "emeraldwalk.runonsave": { 23 | "commands": [ 24 | { 25 | "match": "package.json", 26 | "isAsync": true, 27 | "cmd": "yarn sort-package-json ${file}" 28 | } 29 | ] 30 | }, 31 | "emmet.showExpandedAbbreviation": "never", 32 | "importSorter.generalConfiguration.sortOnBeforeSave": true, 33 | "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne", 34 | "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 100, 35 | "importSorter.importStringConfiguration.tabSize": 2, 36 | "importSorter.importStringConfiguration.quoteMark": "single", 37 | "importSorter.importStringConfiguration.trailingComma": "multiLine", 38 | "typescript.tsdk": "node_modules/typescript/lib", 39 | "jestrunner.configPath": "./jest.config.js" 40 | } 41 | -------------------------------------------------------------------------------- /src/types/models.ts: -------------------------------------------------------------------------------- 1 | import { DAVDepth, DAVFilter, DAVProp, DAVResponse } from './DAVTypes'; 2 | 3 | export type DAVCollection = { 4 | objects?: DAVObject[]; 5 | ctag?: string; 6 | description?: string; 7 | displayName?: string; 8 | reports?: any; 9 | resourcetype?: any; 10 | syncToken?: string; 11 | url: string; 12 | // should only be used for smartCollectionSync 13 | fetchObjects?: 14 | | ((params?: { 15 | collection: DAVCalendar; 16 | headers?: Record; 17 | }) => Promise) 18 | | ((params?: { 19 | collection: DAVAddressBook; 20 | headers?: Record; 21 | }) => Promise); 22 | objectMultiGet?: (params: { 23 | url: string; 24 | props: DAVProp[]; 25 | objectUrls: string[]; 26 | filters?: DAVFilter[]; 27 | timezone?: string; 28 | depth: DAVDepth; 29 | headers?: Record; 30 | }) => Promise; 31 | }; 32 | 33 | export type DAVObject = { 34 | data?: any; 35 | etag: string; 36 | url: string; 37 | }; 38 | 39 | export type DAVCredentials = { 40 | username?: string; 41 | password?: string; 42 | clientId?: string; 43 | clientSecret?: string; 44 | authorizationCode?: string; 45 | redirectUrl?: string; 46 | tokenUrl?: string; 47 | accessToken?: string; 48 | refreshToken?: string; 49 | expiration?: number; 50 | }; 51 | 52 | export type DAVAccount = { 53 | accountType: 'caldav' | 'carddav'; 54 | serverUrl: string; 55 | credentials?: DAVCredentials; 56 | rootUrl?: string; 57 | principalUrl?: string; 58 | homeUrl?: string; 59 | calendars?: DAVCalendar[]; 60 | addressBooks?: DAVAddressBook[]; 61 | }; 62 | 63 | export type DAVVCard = DAVObject; 64 | export type DAVCalendarObject = DAVObject; 65 | 66 | export type DAVAddressBook = DAVCollection; 67 | export type DAVCalendar = { 68 | components?: string[]; 69 | timezone?: string; 70 | } & DAVCollection; 71 | -------------------------------------------------------------------------------- /docs/docs/helpers/requestHelpers.md: -------------------------------------------------------------------------------- 1 | # RequestHelpers 2 | 3 | :::caution 4 | All functions below are intended to only be used internally 5 | and may subject to change without notice. 6 | ::: 7 | 8 | ### urlEquals 9 | 10 | Check if two urls are mostly equal 11 | 12 | Will first trim out white spaces, and can omit the last `/` 13 | 14 | For example, `https://www.example.com/` and ` https://www.example.com` are considered equal 15 | 16 | ### urlContains 17 | 18 | Basically `urlEqual`, but without length constraint. 19 | 20 | For example, `https://www.example.com/` and ` www.example.com` are considered contains. 21 | 22 | ### mergeObjectDupKeyArray 23 | 24 | merge two objects, same key property become array 25 | 26 | For example: 27 | 28 | ```ts 29 | { 30 | test1: 123, 31 | test2: 'aaa', 32 | test4: { 33 | test5: { 34 | test6: 'bbb', 35 | }, 36 | }, 37 | test7: 'ooo', 38 | } 39 | ``` 40 | 41 | and 42 | 43 | ```ts 44 | { 45 | test1: 234, 46 | test2: 'ccc', 47 | test4: { 48 | test5: { 49 | test6: 'ddd', 50 | }, 51 | }, 52 | test8: 'ttt', 53 | } 54 | ``` 55 | 56 | After merge, becomes: 57 | 58 | ```ts 59 | { 60 | test1: [234, 123], 61 | test2: ['ccc', 'aaa'], 62 | test4: [{ test5: { test6: 'ddd' } }, { test5: { test6: 'bbb' } }], 63 | test7: 'ooo', 64 | test8: 'ttt', 65 | } 66 | ``` 67 | 68 | ### getDAVAttribute 69 | 70 | Convert `DAVNamespace` to intended format to be consumed by `xml-js` to be used as `xml` attributes. 71 | 72 | ### formatProps 73 | 74 | Format `DAVProp` to intended format to be consumed by `xml-js` and converted to correct format for `WEBDAV` standard. 75 | 76 | ### formatFilters 77 | 78 | Format `DAVFilter` to intended format to be consumed by `xml-js` and converted to correct format for `WEBDAV` standard. 79 | 80 | ### cleanupFalsy 81 | 82 | Clean up `falsy` values within an object, this is useful when sending headers, 83 | 84 | Where undefined object property will cause an error. 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | built 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | dist-esm 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | .DS_Store 109 | -------------------------------------------------------------------------------- /src/collection.test.ts: -------------------------------------------------------------------------------- 1 | import { createAccount } from './account'; 2 | import { fetchCalendars } from './calendar'; 3 | import { isCollectionDirty } from './collection'; 4 | import { createObject, deleteObject } from './request'; 5 | import { DAVAccount, DAVCalendar } from './types/models'; 6 | import { getBasicAuthHeaders } from './util/authHelpers'; 7 | 8 | let authHeaders: { 9 | authorization?: string; 10 | }; 11 | 12 | let account: DAVAccount; 13 | let calendars: DAVCalendar[]; 14 | 15 | beforeAll(async () => { 16 | authHeaders = getBasicAuthHeaders({ 17 | username: process.env.ICLOUD_USERNAME, 18 | password: process.env.ICLOUD_APP_SPECIFIC_PASSWORD, 19 | }); 20 | account = await createAccount({ 21 | account: { 22 | serverUrl: 'https://caldav.icloud.com/', 23 | accountType: 'caldav', 24 | }, 25 | headers: authHeaders, 26 | }); 27 | calendars = await fetchCalendars({ 28 | account, 29 | headers: authHeaders, 30 | }); 31 | }); 32 | 33 | test('isCollectionDirty should be able to tell if a collection have changed', async () => { 34 | const iCalString = `BEGIN:VCALENDAR 35 | VERSION:2.0 36 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 37 | CALSCALE:GREGORIAN 38 | BEGIN:VEVENT 39 | DTSTART:20210201T090800Z 40 | DTEND:20210201T100800Z 41 | DTSTAMP:20210201T090944Z 42 | UID:6a3ac536-5b42-4529-ae92-5ef21c37be51 43 | CREATED:20210201T090944Z 44 | SEQUENCE:0 45 | SUMMARY:Test 46 | STATUS:CONFIRMED 47 | TRANSP:OPAQUE 48 | END:VEVENT 49 | END:VCALENDAR`; 50 | 51 | const objectUrl = new URL('testCollection.ics', calendars[1].url).href; 52 | const createResponse = await createObject({ 53 | url: objectUrl, 54 | data: iCalString, 55 | headers: { 56 | 'content-type': 'text/calendar; charset=utf-8', 57 | ...authHeaders, 58 | }, 59 | }); 60 | 61 | expect(createResponse.ok).toBe(true); 62 | 63 | const { isDirty, newCtag } = await isCollectionDirty({ 64 | collection: calendars[1], 65 | headers: authHeaders, 66 | }); 67 | expect(isDirty).toBe(true); 68 | expect(newCtag.length > 0).toBe(true); 69 | expect(newCtag).not.toEqual(calendars[1].ctag); 70 | 71 | const deleteResult = await deleteObject({ 72 | url: objectUrl, 73 | headers: authHeaders, 74 | }); 75 | 76 | expect(deleteResult.ok).toBe(true); 77 | }); 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsdav", 3 | "version": "1.1.0", 4 | "description": "WebDAV, CALDAV, and CARDDAV client for Nodejs and the Browser", 5 | "keywords": [ 6 | "dav", 7 | "caldav", 8 | "carddav", 9 | "webdav", 10 | "ical", 11 | "vcard", 12 | "addressbook", 13 | "calendar", 14 | "calendars", 15 | "contacts", 16 | "contact", 17 | "sync", 18 | "nodejs", 19 | "browser", 20 | "typescript" 21 | ], 22 | "homepage": "https://tsdav.vercel.app/", 23 | "repository": "https://github.com/llldar/tsDAV", 24 | "license": "MIT", 25 | "author": "linlilulll@gmail.com", 26 | "main": "dist", 27 | "module": "dist-esm", 28 | "types": "dist/index.d.ts", 29 | "files": [ 30 | "dist", 31 | "dist-esm", 32 | "package.json" 33 | ], 34 | "scripts": { 35 | "clean": "rimraf dist*", 36 | "typecheck": "tsc --noEmit", 37 | "lint": "eslint src --ext .ts", 38 | "lintFix": "eslint src --ext .ts --fix", 39 | "test": "jest", 40 | "prepublishOnly": "yarn build", 41 | "build": "yarn -s clean && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && copyfiles package.json LICENSE README.md ./dist && copyfiles package.json LICENSE README.md ./dist-esm", 42 | "watch": "tsc --watch --outDir ./dist" 43 | }, 44 | "dependencies": { 45 | "base-64": "^1.0.0", 46 | "cross-fetch": "^3.1.4", 47 | "debug": "^4.3.2", 48 | "xml-js": "^1.6.11" 49 | }, 50 | "devDependencies": { 51 | "@types/base-64": "^1.0.0", 52 | "@types/debug": "^4.1.7", 53 | "@types/jest": "^27.0.1", 54 | "@types/node": "^16.7.1", 55 | "@typescript-eslint/eslint-plugin": "^4.29.2", 56 | "@typescript-eslint/parser": "4.29.2", 57 | "concurrently": "^6.2.1", 58 | "copyfiles": "^2.4.1", 59 | "cross-env": "^7.0.3", 60 | "dotenv": "10.0.0", 61 | "eslint": "^7.32.0", 62 | "eslint-config-airbnb": "18.2.1", 63 | "eslint-config-airbnb-typescript": "^13.0.0", 64 | "eslint-config-prettier": "^8.3.0", 65 | "eslint-plugin-import": "^2.24.1", 66 | "eslint-plugin-prettier": "^3.4.1", 67 | "jest": "^27.0.6", 68 | "prettier": "^2.3.2", 69 | "rimraf": "^3.0.2", 70 | "sort-package-json": "1.50.0", 71 | "ts-jest": "27.0.5", 72 | "typescript": "^4.3.5" 73 | }, 74 | "engines": { 75 | "node": ">=10" 76 | } 77 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as account from './account'; 2 | import * as addressBook from './addressBook'; 3 | import * as calendar from './calendar'; 4 | import * as client from './client'; 5 | import * as collection from './collection'; 6 | import { DAVAttributeMap, DAVNamespace, DAVNamespaceShorthandMap } from './consts'; 7 | import * as request from './request'; 8 | import * as authHelpers from './util/authHelpers'; 9 | import * as requestHelpers from './util/requestHelpers'; 10 | 11 | export type { 12 | DAVProp, 13 | DAVFilter, 14 | DAVDepth, 15 | DAVMethods, 16 | DAVRequest, 17 | DAVResponse, 18 | DAVTokens, 19 | } from './types/DAVTypes'; 20 | export type { 21 | DAVAccount, 22 | DAVAddressBook, 23 | DAVCalendar, 24 | DAVCalendarObject, 25 | DAVCollection, 26 | DAVCredentials, 27 | DAVObject, 28 | DAVVCard, 29 | } from './types/models'; 30 | 31 | export { DAVClient } from './client'; 32 | 33 | export { createDAVClient } from './client'; 34 | export { createAccount } from './account'; 35 | export { davRequest, propfind, createObject, updateObject, deleteObject } from './request'; 36 | 37 | export { 38 | collectionQuery, 39 | supportedReportSet, 40 | isCollectionDirty, 41 | syncCollection, 42 | smartCollectionSync, 43 | } from './collection'; 44 | 45 | export { 46 | calendarQuery, 47 | calendarMultiGet, 48 | makeCalendar, 49 | fetchCalendars, 50 | fetchCalendarObjects, 51 | createCalendarObject, 52 | updateCalendarObject, 53 | deleteCalendarObject, 54 | syncCalendars, 55 | } from './calendar'; 56 | 57 | export { 58 | addressBookQuery, 59 | fetchAddressBooks, 60 | fetchVCards, 61 | createVCard, 62 | updateVCard, 63 | deleteVCard, 64 | } from './addressBook'; 65 | 66 | export { 67 | getBasicAuthHeaders, 68 | getOauthHeaders, 69 | fetchOauthTokens, 70 | refreshAccessToken, 71 | } from './util/authHelpers'; 72 | export { 73 | mergeObjectDupKeyArray, 74 | urlContains, 75 | urlEquals, 76 | getDAVAttribute, 77 | formatFilters, 78 | formatProps, 79 | cleanupFalsy, 80 | } from './util/requestHelpers'; 81 | export { DAVNamespace, DAVNamespaceShorthandMap, DAVAttributeMap } from './consts'; 82 | export default { 83 | DAVNamespace, 84 | DAVNamespaceShorthandMap, 85 | DAVAttributeMap, 86 | ...client, 87 | ...request, 88 | ...collection, 89 | ...account, 90 | ...addressBook, 91 | ...calendar, 92 | ...authHelpers, 93 | ...requestHelpers, 94 | }; 95 | -------------------------------------------------------------------------------- /src/addressBook.test.ts: -------------------------------------------------------------------------------- 1 | import { createAccount } from './account'; 2 | import { createVCard, fetchAddressBooks, fetchVCards } from './addressBook'; 3 | import { deleteObject } from './request'; 4 | import { DAVAccount } from './types/models'; 5 | import { getBasicAuthHeaders } from './util/authHelpers'; 6 | 7 | let authHeaders: { 8 | authorization?: string; 9 | }; 10 | 11 | let account: DAVAccount; 12 | 13 | beforeAll(async () => { 14 | authHeaders = getBasicAuthHeaders({ 15 | username: process.env.ICLOUD_USERNAME, 16 | password: process.env.ICLOUD_APP_SPECIFIC_PASSWORD, 17 | }); 18 | account = await createAccount({ 19 | account: { 20 | serverUrl: 'https://contacts.icloud.com', 21 | accountType: 'carddav', 22 | }, 23 | headers: authHeaders, 24 | }); 25 | }); 26 | 27 | test('fetchAddressBooks should be able to fetch addressBooks', async () => { 28 | const addressBooks = await fetchAddressBooks({ 29 | account, 30 | headers: authHeaders, 31 | }); 32 | expect(addressBooks.length > 0).toBe(true); 33 | expect(addressBooks.every((a) => a.url.length > 0)).toBe(true); 34 | }); 35 | 36 | test('createVCard should be able to create vcard', async () => { 37 | const addressBooks = await fetchAddressBooks({ 38 | account, 39 | headers: authHeaders, 40 | }); 41 | const createResult = await createVCard({ 42 | addressBook: addressBooks[0], 43 | vCardString: `BEGIN:VCARD 44 | VERSION:3.0 45 | N:;Test BBB;;; 46 | FN:Test BBB 47 | UID:0976cf06-a0e8-44bd-9217-327f6907242c 48 | PRODID:-//Apple Inc.//iCloud Web Address Book 2109B35//EN 49 | REV:2021-06-16T01:28:23Z 50 | END:VCARD`, 51 | filename: 'test.vcf', 52 | headers: authHeaders, 53 | }); 54 | 55 | expect(createResult.ok).toBe(true); 56 | 57 | const deleteResult = await deleteObject({ 58 | url: new URL('test.vcf', addressBooks[0].url).href, 59 | headers: authHeaders, 60 | }); 61 | 62 | expect(deleteResult.ok).toBe(true); 63 | }); 64 | 65 | test('fetchVCards should be able to fetch vcards', async () => { 66 | const addressBooks = await fetchAddressBooks({ 67 | account, 68 | headers: authHeaders, 69 | }); 70 | const vcards = await fetchVCards({ 71 | addressBook: addressBooks[0], 72 | headers: authHeaders, 73 | }); 74 | 75 | expect(vcards.length > 0).toBe(true); 76 | expect(vcards.every((o) => o.data.length > 0 && o.etag.length > 0 && o.url.length > 0)).toBe( 77 | true 78 | ); 79 | }); 80 | -------------------------------------------------------------------------------- /src/account.test.ts: -------------------------------------------------------------------------------- 1 | import { createAccount, fetchHomeUrl, fetchPrincipalUrl, serviceDiscovery } from './account'; 2 | import { getBasicAuthHeaders } from './util/authHelpers'; 3 | 4 | let authHeaders: { 5 | authorization?: string; 6 | }; 7 | 8 | beforeAll(async () => { 9 | authHeaders = getBasicAuthHeaders({ 10 | username: process.env.ICLOUD_USERNAME, 11 | password: process.env.ICLOUD_APP_SPECIFIC_PASSWORD, 12 | }); 13 | }); 14 | 15 | test('serviceDiscovery should be able to discover the caldav service', async () => { 16 | const url = await serviceDiscovery({ 17 | account: { serverUrl: 'https://caldav.icloud.com/', accountType: 'caldav' }, 18 | headers: authHeaders, 19 | }); 20 | expect(url).toEqual('https://caldav.icloud.com/'); 21 | }); 22 | 23 | test('fetchPrincipalUrl should be able to fetch the url of principle collection', async () => { 24 | const url = await fetchPrincipalUrl({ 25 | account: { 26 | serverUrl: 'https://caldav.icloud.com/', 27 | rootUrl: 'https://caldav.icloud.com/', 28 | accountType: 'caldav', 29 | }, 30 | headers: authHeaders, 31 | }); 32 | expect(url).toMatch(/https:\/\/.*caldav.icloud.com\/[0-9]+\/principal/); 33 | }); 34 | 35 | test('fetchHomeUrl should be able to fetch the url of home set', async () => { 36 | const principalUrl = await fetchPrincipalUrl({ 37 | account: { 38 | serverUrl: 'https://caldav.icloud.com/', 39 | rootUrl: 'https://caldav.icloud.com/', 40 | accountType: 'caldav', 41 | }, 42 | headers: authHeaders, 43 | }); 44 | const url = await fetchHomeUrl({ 45 | account: { 46 | principalUrl, 47 | serverUrl: 'https://caldav.icloud.com/', 48 | rootUrl: 'https://caldav.icloud.com/', 49 | accountType: 'caldav', 50 | }, 51 | headers: authHeaders, 52 | }); 53 | expect(url).toMatch(/https:\/\/p[0-9]+-caldav.icloud.com\/[0-9]+\/calendars/); 54 | }); 55 | 56 | test('createAccount should be able to create account', async () => { 57 | const account = await createAccount({ 58 | account: { 59 | serverUrl: 'https://caldav.icloud.com/', 60 | accountType: 'caldav', 61 | }, 62 | headers: authHeaders, 63 | }); 64 | expect(account.rootUrl).toEqual('https://caldav.icloud.com/'); 65 | expect(account.principalUrl).toMatch(/https:\/\/.*caldav.icloud.com\/[0-9]+\/principal/); 66 | expect(account.homeUrl).toMatch(/https:\/\/p[0-9]+-caldav.icloud.com\/[0-9]+\/calendars/); 67 | }); 68 | -------------------------------------------------------------------------------- /docs/docs/helpers/authHelpers.md: -------------------------------------------------------------------------------- 1 | # AuthHelpers 2 | 3 | ### getBasicAuthHeaders 4 | 5 | convert the `username:password` into base64 auth header string: 6 | 7 | ```ts 8 | const result = getBasicAuthHeaders({ 9 | username: 'test', 10 | password: '12345', 11 | }); 12 | ``` 13 | 14 | #### Return Value 15 | 16 | ```ts 17 | { 18 | authorization: 'Basic dGVzdDoxMjM0NQ=='; 19 | } 20 | ``` 21 | 22 | ### fetchOauthTokens 23 | 24 | fetch oauth token using code obtained from oauth2 authorization code grant 25 | 26 | ```ts 27 | const tokens = await fetchOauthTokens({ 28 | authorizationCode: '123', 29 | clientId: 'clientId', 30 | clientSecret: 'clientSecret', 31 | tokenUrl: 'https://oauth.example.com/tokens', 32 | redirectUrl: 'https://yourdomain.com/oauth-callback', 33 | }); 34 | ``` 35 | 36 | #### Return Value 37 | 38 | ```ts 39 | { 40 | access_token: 'kTKGQ2TBEqn03KJMM9AqIA'; 41 | refresh_token: 'iHwWwqytfW3AfOjNbM1HLg'; 42 | expires_in: 12800; 43 | id_token: 'TKfsafGQ2JMM9AqIA'; 44 | token_type: 'bearer'; 45 | scope: 'openid email'; 46 | } 47 | ``` 48 | 49 | ### refreshAccessToken 50 | 51 | using refresh token to fetch access token from given token endpoint 52 | 53 | ```ts 54 | const result = await refreshAccessToken({ 55 | clientId: 'clientId', 56 | clientSecret: 'clientSecret', 57 | tokenUrl: 'https://oauth.example.com/tokens', 58 | refreshToken: 'iHwWwqytfW3AfOjNbM1HLg', 59 | }); 60 | ``` 61 | 62 | #### Return Value 63 | 64 | ```ts 65 | { 66 | access_token: 'eeMCxYgdCF3xfLxgd1NE8A'; 67 | expires_in: 12800; 68 | } 69 | ``` 70 | 71 | ### getOauthHeaders 72 | 73 | the combination of `fetchOauthTokens` and `refreshAccessToken`, it will return the authorization header needed for authorizing the requests as well as automatically renewing the access token using refresh token obtained from server when it expires. 74 | 75 | ```ts 76 | const result = await getOauthHeaders({ 77 | authorizationCode: '123', 78 | clientId: 'clientId', 79 | clientSecret: 'clientSecret', 80 | tokenUrl: 'https://oauth.example.com/tokens', 81 | redirectUrl: 'https://yourdomain.com/oauth-callback', 82 | }); 83 | ``` 84 | 85 | #### Return Value 86 | 87 | ```ts 88 | { 89 | tokens: { 90 | access_token: 'kTKGQ2TBEqn03KJMM9AqIA'; 91 | refresh_token: 'iHwWwqytfW3AfOjNbM1HLg'; 92 | expires_in: 12800; 93 | id_token: 'TKfsafGQ2JMM9AqIA'; 94 | token_type: 'bearer'; 95 | scope: 'openid email'; 96 | }, 97 | headers: { 98 | authorization: `Bearer q-2OCH2g3RctZOJOG9T2Q`, 99 | }, 100 | } 101 | ``` 102 | 103 | ### defaultParam 104 | 105 | :::caution 106 | Internal function, not intended to be used outside. 107 | ::: 108 | 109 | Provide default parameter for passed in function and allows default parameters be overridden when the function was actually passed with same parameters. 110 | would only work on functions that have only one object style parameter. 111 | 112 | ```ts 113 | const fn1 = (params: { a?: number; b?: number }) => { 114 | const { a = 0, b = 0 } = params; 115 | return a + b; 116 | }; 117 | const fn2 = defaultParam(fn1, { b: 10 }); 118 | ``` 119 | -------------------------------------------------------------------------------- /docs/docs/caldav/syncCalendars.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | ## `syncCalendars` 6 | 7 | sync local version of calendars with remote. 8 | 9 | ```ts 10 | const { created, updated, deleted } = await syncCalendars({ 11 | oldCalendars: [ 12 | { 13 | displayName: 'personal calendar', 14 | syncToken: 'HwoQEgwAAAAAAAAAAAAAAAAYARgAIhsI4pnF4erDm4CsARDdl6K9rqa9/pYBKAA=', 15 | ctag: '63758742166', 16 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/', 17 | objects: [ 18 | { 19 | etag: '"63758758580"', 20 | id: '0003ffbe-cb71-49f5-bc7b-9fafdd756784', 21 | data: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 22 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 23 | }, 24 | ], 25 | }, 26 | ], 27 | detailedResult: true, 28 | headers: { 29 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 30 | }, 31 | }); 32 | ``` 33 | 34 | ### Arguments 35 | 36 | - `oldCalendars` **required**, locally version of calendars of this account, should contain [calendar objects](../types/DAVCalendarObject.md) as well if `detailedResult` is `false` 37 | - `account` the account which calendars belong to, 38 | - `detailedResult` if falsy, the result would be latest version of the calendars of this account, otherwise they would be separated into three groups of `created`, `updated`, and `deleted`. 39 | - `headers` request headers 40 | 41 | :::info 42 | `objects` inside `oldCalendars` are not needed when `detailedResult` is `true`. 43 | ::: 44 | 45 | ### Return Value 46 | 47 | depend on `detailedResult` option 48 | 49 | if `detailedResult` is falsy, 50 | 51 | array of [DAVCalendar](../types/DAVCalendar.md) with calendar objects. 52 | 53 | if `detailedResult` is `true`, 54 | 55 | an object of 56 | 57 | - `created` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. 58 | - `updated` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. 59 | - `deleted` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. 60 | 61 | ### Behavior 62 | 63 | fetch the latest list of [DAVCalendar](../types/DAVCalendar.md) from remote, 64 | 65 | compare the provided list and the latest list to find out `created`, `updated`, and `deleted` calendars. 66 | 67 | if `detailedResult` is falsy, 68 | 69 | fetch the latest list of [DAVCalendarObject](../types/DAVCalendarObject.md) from updated calendars using [rfc6578 webdav sync](https://datatracker.ietf.org/doc/html/rfc6578) and [calendarMultiGet](calendarMultiGet.md) 70 | 71 | return latest list of calendars with latest list of objects for `updated` calendars. 72 | 73 | if `detailedResult` is `true`, 74 | 75 | return three list of separate calendars without objects for `created`, `updated`, and `deleted`. 76 | -------------------------------------------------------------------------------- /docs/docs/webdav/collection/smartCollectionSync.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | ## `smartCollectionSync` 6 | 7 | smart version of collection sync that combines ctag based sync with webdav sync. 8 | 9 | ```ts 10 | const { created, updated, deleted } = ( 11 | await smartCollectionSync({ 12 | collection: { 13 | url: 'https://caldav.icloud.com/12345676/calendars/c623f6be-a2d4-4c60-932a-043e67025dde/', 14 | ctag: 'eWd9Vz8OwS0DE==', 15 | syncToken: 'eWdLSfo8439Vz8OwS0DE==', 16 | objects: [ 17 | { 18 | etag: '"63758758580"', 19 | id: '0003ffbe-cb71-49f5-bc7b-9fafdd756784', 20 | data: 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ZContent.net//Zap Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nSUMMARY:Abraham Lincoln\nUID:c7614cff-3549-4a00-9152-d25cc1fe077d\nSEQUENCE:0\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nRRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=12\nDTSTART:20080212\nDTEND:20080213\nDTSTAMP:20150421T141403\nCATEGORIES:U.S. Presidents,Civil War People\nLOCATION:Hodgenville, Kentucky\nGEO:37.5739497;-85.7399606\nDESCRIPTION:Born February 12, 1809\nSixteenth President (1861-1865)\n\n\n\n \nhttp://AmericanHistoryCalendar.com\nURL:http://americanhistorycalendar.com/peoplecalendar/1,328-abraham-lincol\n n\nEND:VEVENT\nEND:VCALENDAR', 21 | url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/test.ics', 22 | }, 23 | ], 24 | objectMultiGet: calendarMultiGet, 25 | }, 26 | method: 'webdav', 27 | detailedResult: true, 28 | account: { 29 | accountType: 'caldav', 30 | homeUrl: 'https://caldav.icloud.com/123456/calendars/', 31 | }, 32 | headers: { 33 | authorization: 'Basic x0C9ueWd9Vz8OwS0DEAtkAlj', 34 | }, 35 | }) 36 | ).objects; 37 | ``` 38 | 39 | ### Arguments 40 | 41 | - `collection` **required**, the target collection to sync 42 | - `method` defaults to auto detect, one of `basic` and `webdav` 43 | - `account` [DAVAccount](../../types/DAVAccount.md) to sync 44 | - `detailedResult` boolean indicate whether the return value should be detailed or not 45 | - `headers` request headers 46 | 47 | :::info 48 | `objects` inside `collection` are not needed when `detailedResult` is `true`. 49 | ::: 50 | 51 | ### Return Value 52 | 53 | depend on `detailedResult` option 54 | 55 | if `detailedResult` is falsy, 56 | 57 | array of latest [DAVObject](../../types/DAVObject.md) 58 | 59 | if `detailedResult` is `true`, 60 | 61 | an object of 62 | 63 | - `objects` 64 | - `created` array of [DAVObject](../../types/DAVObject.md) 65 | - `updated` array of [DAVObject](../../types/DAVObject.md) 66 | - `deleted` array of [DAVObject](../../types/DAVObject.md) 67 | 68 | ### Behavior 69 | 70 | detect if collection support sync-collection REPORT 71 | 72 | if they supports, use [rfc6578 webdav sync](https://datatracker.ietf.org/doc/html/rfc6578) to detect if collection changed, 73 | 74 | else use ctag to detect if collection changed. 75 | 76 | if collection changed, 77 | fetch the latest list of [DAVObject](../../types/DAVObject.md) from remote, 78 | 79 | compare the provided list and the latest list to find out `created`, `updated`, and `deleted` objects. 80 | 81 | if `detailedResult` is falsy, 82 | 83 | fetch the latest list of [DAVObject](../../types/DAVObject.md) from changed collection using [rfc6578 webdav sync](https://datatracker.ietf.org/doc/html/rfc6578) and `objectMultiGet` 84 | 85 | if `detailedResult` is `true`, 86 | 87 | return three list of separate objects for `created`, `updated`, and `deleted` 88 | -------------------------------------------------------------------------------- /src/util/requestHelpers.ts: -------------------------------------------------------------------------------- 1 | import { DAVAttributeMap, DAVNamespace, DAVNamespaceShorthandMap } from '../consts'; 2 | import { DAVFilter, DAVProp } from '../types/DAVTypes'; 3 | 4 | import type { NoUndefinedField } from './typeHelpers'; 5 | 6 | export const urlEquals = (urlA?: string, urlB?: string): boolean => { 7 | if (!urlA && !urlB) { 8 | return true; 9 | } 10 | if (!urlA || !urlB) { 11 | return false; 12 | } 13 | 14 | const trimmedUrlA = urlA.trim(); 15 | const trimmedUrlB = urlB.trim(); 16 | 17 | if (Math.abs(trimmedUrlA.length - trimmedUrlB.length) > 1) { 18 | return false; 19 | } 20 | 21 | const strippedUrlA = trimmedUrlA.slice(-1) === '/' ? trimmedUrlA.slice(0, -1) : trimmedUrlA; 22 | const strippedUrlB = trimmedUrlB.slice(-1) === '/' ? trimmedUrlB.slice(0, -1) : trimmedUrlB; 23 | return urlA.includes(strippedUrlB) || urlB.includes(strippedUrlA); 24 | }; 25 | 26 | export const urlContains = (urlA?: string, urlB?: string): boolean => { 27 | if (!urlA && !urlB) { 28 | return true; 29 | } 30 | if (!urlA || !urlB) { 31 | return false; 32 | } 33 | 34 | const trimmedUrlA = urlA.trim(); 35 | const trimmedUrlB = urlB.trim(); 36 | 37 | const strippedUrlA = trimmedUrlA.slice(-1) === '/' ? trimmedUrlA.slice(0, -1) : trimmedUrlA; 38 | const strippedUrlB = trimmedUrlB.slice(-1) === '/' ? trimmedUrlB.slice(0, -1) : trimmedUrlB; 39 | return urlA.includes(strippedUrlB) || urlB.includes(strippedUrlA); 40 | }; 41 | 42 | // merge two objects, same key property become array 43 | type ShallowMergeDupKeyArray = { 44 | [key in keyof A | keyof B]: key extends keyof A & keyof B 45 | ? Array 46 | : key extends keyof A 47 | ? A[key] 48 | : key extends keyof B 49 | ? B[key] 50 | : never; 51 | }; 52 | export const mergeObjectDupKeyArray = (objA: A, objB: B): ShallowMergeDupKeyArray => { 53 | return (Object.entries(objA) as Array<[keyof A | keyof B, unknown]>).reduce( 54 | ( 55 | merged: ShallowMergeDupKeyArray, 56 | [currKey, currValue] 57 | ): ShallowMergeDupKeyArray => { 58 | if (merged[currKey] && Array.isArray(merged[currKey])) { 59 | // is array 60 | return { 61 | ...merged, 62 | [currKey]: [...(merged[currKey] as unknown as unknown[]), currValue], 63 | }; 64 | } 65 | if (merged[currKey] && !Array.isArray(merged[currKey])) { 66 | // not array 67 | return { ...merged, [currKey]: [merged[currKey], currValue] }; 68 | } 69 | // not exist 70 | return { ...merged, [currKey]: currValue }; 71 | }, 72 | objB as ShallowMergeDupKeyArray 73 | ); 74 | }; 75 | 76 | export const getDAVAttribute = (nsArr: DAVNamespace[]): { [key: string]: DAVNamespace } => 77 | nsArr.reduce((prev, curr) => ({ ...prev, [DAVAttributeMap[curr]]: curr }), {}); 78 | 79 | export const formatProps = (props?: DAVProp[]): { [key: string]: any } | undefined => 80 | props?.reduce((prev, curr) => { 81 | if (curr.namespace) { 82 | return { 83 | ...prev, 84 | [`${DAVNamespaceShorthandMap[curr.namespace]}:${curr.name}`]: curr.value ?? {}, 85 | }; 86 | } 87 | return { ...prev, [`${curr.name}`]: curr.value ?? {} }; 88 | }, {}); 89 | 90 | export const formatFilters = (filters?: DAVFilter[]): { [key: string]: any } | undefined => 91 | filters?.map((f) => ({ 92 | [f.type]: { 93 | _attributes: f.attributes, 94 | ...(f.children ? formatFilters(f.children) : [])?.reduce( 95 | (prev: any, curr: any) => mergeObjectDupKeyArray(prev, curr), 96 | {} as any 97 | ), 98 | _text: f.value ?? '', 99 | }, 100 | })); 101 | 102 | export const cleanupFalsy = (obj: T): NoUndefinedField => 103 | Object.entries(obj).reduce((prev, [key, value]) => { 104 | if (value) return { ...prev, [key]: value }; 105 | return prev; 106 | }, {} as NoUndefinedField); 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | webdav request made easy 6 |

7 | 8 |

9 | 10 | Bundlephobia 11 | 12 | 13 | Types 14 | 15 | 16 | NPM Version 17 | 18 | 19 | MIT License 20 | 21 |

22 | 23 | ### Features 24 | 25 | - Easy to use, well documented JSON based WEBDAV API 26 | - Works in both `Browsers` and `Node.js` 27 | - Supports Both `commonjs` and `esm` 28 | - OAuth2 & Basic Auth helpers built-in 29 | - Native typescript, fully linted and well tested 30 | - Supports WEBDAV, CALDAV, CARDDAV 31 | - End to end tested with Apple & Google Cloud. 32 | 33 | ### Install 34 | 35 | ```bash 36 | npm install tsdav 37 | ``` 38 | 39 | or 40 | 41 | ```bash 42 | yarn add tsdav 43 | ``` 44 | 45 | ### Quickstart 46 | 47 | ##### Google CALDAV 48 | 49 | ```ts 50 | import { createDAVClient } from 'tsdav'; 51 | 52 | (async () => { 53 | const client = await createDAVClient({ 54 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 55 | credentials: { 56 | refreshToken: 'YOUR_REFRESH_TOKEN_WITH_CALDAV_PERMISSION', 57 | }, 58 | authMethod: 'Oauth', 59 | defaultAccountType: 'caldav', 60 | }); 61 | 62 | const calendars = await client.fetchCalendars(); 63 | 64 | const calendarObjects = await client.fetchCalendarObjects({ 65 | calendar: calendars[0], 66 | }); 67 | })(); 68 | ``` 69 | 70 | ##### Apple CARDDAV 71 | 72 | ```ts 73 | import { createDAVClient } from 'tsdav'; 74 | 75 | (async () => { 76 | const client = await createDAVClient({ 77 | serverUrl: 'https://contacts.icloud.com', 78 | credentials: { 79 | username: 'YOUR_APPLE_ID', 80 | password: 'YOUR_APP_SPECIFIC_PASSWORD', 81 | }, 82 | authMethod: 'Basic', 83 | defaultAccountType: 'carddav', 84 | }); 85 | 86 | const addressBooks = await client.fetchAddressBooks(); 87 | 88 | const vcards = await client.fetchVCards({ 89 | addressBook: addressBooks[0], 90 | }); 91 | })(); 92 | ``` 93 | 94 | After `v1.1.0`, you have a new way of creating clients. 95 | 96 | ##### Google CALDAV 97 | 98 | ```ts 99 | import { DAVClient } from 'tsdav'; 100 | 101 | const client = new DAVClient({ 102 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 103 | credentials: { 104 | refreshToken: 'YOUR_REFRESH_TOKEN_WITH_CALDAV_PERMISSION', 105 | }, 106 | authMethod: 'Oauth', 107 | defaultAccountType: 'caldav', 108 | }); 109 | 110 | (async () => { 111 | await googleClient.login(); 112 | 113 | const calendars = await client.fetchCalendars(); 114 | 115 | const calendarObjects = await client.fetchCalendarObjects({ 116 | calendar: calendars[0], 117 | }); 118 | })(); 119 | ``` 120 | 121 | ##### Apple CARDDAV 122 | 123 | ```ts 124 | import { DAVClient } from 'tsdav'; 125 | 126 | const client = new DAVClient({ 127 | serverUrl: 'https://contacts.icloud.com', 128 | credentials: { 129 | username: 'YOUR_APPLE_ID', 130 | password: 'YOUR_APP_SPECIFIC_PASSWORD', 131 | }, 132 | authMethod: 'Basic', 133 | defaultAccountType: 'carddav', 134 | }); 135 | 136 | (async () => { 137 | await client.login(); 138 | 139 | const addressBooks = await client.fetchAddressBooks(); 140 | 141 | const vcards = await client.fetchVCards({ 142 | addressBook: addressBooks[0], 143 | }); 144 | })(); 145 | ``` 146 | 147 | ### Documentation 148 | 149 | Check out the [Documentation](https://tsdav.vercel.app/) 150 | 151 | ### License 152 | 153 | [MIT](https://github.com/llldar/tsDAV/blob/master/LICENSE) 154 | 155 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fllldar%2FtsDAV.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fllldar%2FtsDAV?ref=badge_large) 156 | 157 | ### Changelog 158 | 159 | refers to [Changelog](./CHANGELOG.md) 160 | 161 | ### Debugging 162 | 163 | this package uses `debug` package, 164 | add `tsdav:*` to `DEBUG` env variable to enable debug logs 165 | -------------------------------------------------------------------------------- /src/util/authHelpers.ts: -------------------------------------------------------------------------------- 1 | import { encode } from 'base-64'; 2 | import fetch from 'cross-fetch'; 3 | import getLogger from 'debug'; 4 | 5 | import { DAVTokens } from '../types/DAVTypes'; 6 | import { DAVCredentials } from '../types/models'; 7 | import { findMissingFieldNames, hasFields } from './typeHelpers'; 8 | 9 | const debug = getLogger('tsdav:authHelper'); 10 | 11 | /** 12 | * Provide given params as default params to given function with optional params. 13 | * 14 | * suitable only for one param functions 15 | * params are shallow merged 16 | */ 17 | export const defaultParam = 18 | any>(fn: F, params: Partial[0]>) => 19 | (...args: Parameters): ReturnType => { 20 | return fn({ ...params, ...args[0] }); 21 | }; 22 | 23 | export const getBasicAuthHeaders = (credentials: DAVCredentials): { authorization?: string } => { 24 | debug(`Basic auth token generated: ${encode(`${credentials.username}:${credentials.password}`)}`); 25 | return { 26 | authorization: `Basic ${encode(`${credentials.username}:${credentials.password}`)}`, 27 | }; 28 | }; 29 | 30 | export const fetchOauthTokens = async (credentials: DAVCredentials): Promise => { 31 | const requireFields: Array = [ 32 | 'authorizationCode', 33 | 'redirectUrl', 34 | 'clientId', 35 | 'clientSecret', 36 | 'tokenUrl', 37 | ]; 38 | if (!hasFields(credentials, requireFields)) { 39 | throw new Error( 40 | `Oauth credentials missing: ${findMissingFieldNames(credentials, requireFields)}` 41 | ); 42 | } 43 | 44 | const param = new URLSearchParams({ 45 | grant_type: 'authorization_code', 46 | code: credentials.authorizationCode, 47 | redirect_uri: credentials.redirectUrl, 48 | client_id: credentials.clientId, 49 | client_secret: credentials.clientSecret, 50 | }); 51 | 52 | debug(credentials.tokenUrl); 53 | debug(param.toString()); 54 | 55 | const response = await fetch(credentials.tokenUrl, { 56 | method: 'POST', 57 | body: param.toString(), 58 | headers: { 59 | 'content-length': `${param.toString().length}`, 60 | 'content-type': 'application/x-www-form-urlencoded', 61 | }, 62 | }); 63 | 64 | if (response.ok) { 65 | const tokens: { 66 | access_token: string; 67 | refresh_token: string; 68 | expires_in: number; 69 | } = await response.json(); 70 | return tokens; 71 | } 72 | debug(`Fetch Oauth tokens failed: ${await response.text()}`); 73 | return {}; 74 | }; 75 | 76 | export const refreshAccessToken = async ( 77 | credentials: DAVCredentials 78 | ): Promise<{ 79 | access_token?: string; 80 | expires_in?: number; 81 | }> => { 82 | const requireFields: Array = [ 83 | 'refreshToken', 84 | 'clientId', 85 | 'clientSecret', 86 | 'tokenUrl', 87 | ]; 88 | if (!hasFields(credentials, requireFields)) { 89 | throw new Error( 90 | `Oauth credentials missing: ${findMissingFieldNames(credentials, requireFields)}` 91 | ); 92 | } 93 | const param = new URLSearchParams({ 94 | client_id: credentials.clientId, 95 | client_secret: credentials.clientSecret, 96 | refresh_token: credentials.refreshToken, 97 | grant_type: 'refresh_token', 98 | }); 99 | const response = await fetch(credentials.tokenUrl, { 100 | method: 'POST', 101 | body: param.toString(), 102 | headers: { 103 | 'Content-Type': 'application/x-www-form-urlencoded', 104 | }, 105 | }); 106 | 107 | if (response.ok) { 108 | const tokens: { 109 | access_token: string; 110 | expires_in: number; 111 | } = await response.json(); 112 | return tokens; 113 | } 114 | debug(`Refresh access token failed: ${await response.text()}`); 115 | return {}; 116 | }; 117 | 118 | export const getOauthHeaders = async ( 119 | credentials: DAVCredentials 120 | ): Promise<{ tokens: DAVTokens; headers: { authorization?: string } }> => { 121 | debug('Fetching oauth headers'); 122 | let tokens: DAVTokens = {}; 123 | if (!credentials.refreshToken) { 124 | // No refresh token, fetch new tokens 125 | tokens = await fetchOauthTokens(credentials); 126 | } else if ( 127 | (credentials.refreshToken && !credentials.accessToken) || 128 | Date.now() > (credentials.expiration ?? 0) 129 | ) { 130 | // have refresh token, but no accessToken, fetch access token only 131 | // or have both, but accessToken was expired 132 | tokens = await refreshAccessToken(credentials); 133 | } 134 | // now we should have valid access token 135 | debug(`Oauth tokens fetched: ${tokens.access_token}`); 136 | 137 | return { 138 | tokens, 139 | headers: { 140 | authorization: `Bearer ${tokens.access_token}`, 141 | }, 142 | }; 143 | }; 144 | -------------------------------------------------------------------------------- /docs/docs/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # Contributing 6 | 7 | First you need to clone the repo and 8 | 9 | ### Build 10 | 11 | ```bash 12 | npm run build 13 | ``` 14 | 15 | or 16 | 17 | ```bash 18 | yarn build 19 | ``` 20 | 21 | #### Test 22 | 23 | to run tests locally, you need to setup environnement variables using `.env` file 24 | 25 | ``` 26 | ICLOUD_USERNAME= 27 | ICLOUD_APP_SPECIFIC_PASSWORD= 28 | ``` 29 | 30 | ### WEBDAV quick guide 31 | 32 | WEBDAV uses xml for all its data when communicating, the basic element is `object`, multiple `object`s can form a `collection`, webdav server have `account`s and an `account` have a `principal` resource (i.e the default, main resource) and under that principle resource we have `home set` of the said resource where your actual resources are. 33 | 34 | `syncToken` and `ctag` are basically like hash of the object/collection, if anything in it changes, this token will change. 35 | 36 | For caldav, the calendar data in caldav are in `rfc5545` ical format, there's `iCal2Js` and `js2iCal` function with my other project [pretty-jcal](https://github.com/llldar/pretty-jcal) to help your convert them from/to js objects. 37 | 38 | Here's cheat sheet on webdav operations compared with rest: 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 59 | 65 | 66 | 67 | 68 | 76 | 77 | 78 | 79 | 85 | 93 | 94 | 95 | 96 | 102 | 103 | 104 | 105 | 111 | 112 | 113 | 114 | 120 | 126 | 127 | 128 | 129 | 135 | 141 | 142 | 143 | 144 | 150 | 151 | 152 | 153 | 158 | 164 | 165 | 166 |
OperationWebdavREST
Create collection 52 |
    53 |
  • predefined id: `MKCOL /entities/$predefined_id`
  • 54 |
  • no predefined id: not possible
  • 55 |
  • can set attributes right away with extended-mkcol extension
  • 56 |
  • Status: 201 Created
  • 57 |
58 |
60 |
    61 |
  • `POST /entities` with JSON body with attributes, response contains new id
  • 62 |
  • Status: 200 with new id and optionally the whole object
  • 63 |
64 |
Create entity 69 |
    70 |
  • predefined id: `PUT /entities/$predefined_id` with body, empty response
  • 71 |
  • no predefined id: `POST /entities`, receive id as part of Content-Location header
  • 72 |
  • can't set attributes right away, need subsequent PROPPATCH
  • 73 |
  • Status: 201 Created
  • 74 |
75 |
Update entity body 80 |
    81 |
  • `PUT /entities/$predefined_id` with new body (no attributes)
  • 82 |
  • Status: 204 No Content
  • 83 |
84 |
86 |
    87 |
  • Full: `PUT /entities/$id` with full JSON body with attributes
  • 88 |
  • Status 200, receive full object back
  • 89 |
  • Partial: `PATCH /entities/$id` with partial JSON containing only attributes to update.
  • 90 |
  • Status 200, full/partial object returned
  • 91 |
92 |
Update entity attributes 97 |
    98 |
  • `PROPPATCH /entities/$id` with XML body of attributes to change
  • 99 |
  • Status: 207, XML body with accepted attributes
  • 100 |
101 |
Delete entity 106 |
    107 |
  • `DELETE /entities/$id`
  • 108 |
  • Sattus: 204 no content
  • 109 |
110 |
List entities 115 |
    116 |
  • `PROPFIND /entities` with XML body of attributes to fetch
  • 117 |
  • Status 207 multi-status XML response with multiple entities and their respective attributes
  • 118 |
119 |
121 |
    122 |
  • `GET /entities`
  • 123 |
  • Status: 200 OK, receive JSON response array with JSON body of entity attributes
  • 124 |
125 |
Get entity 130 |
    131 |
  • `GET /entities/$id`
  • 132 |
  • Status: 200 OK with entitiy body
  • 133 |
134 |
136 |
    137 |
  • `GET /entities/$id`
  • 138 |
  • Status 200 OK, receive JSON body of entity attributes
  • 139 |
140 |
Get entity attributes 145 |
    146 |
  • `PROPFIND /entities/$id` with XML body of attributes to fetch
  • 147 |
  • Status 207 multi-status XML response with entity attributes
  • 148 |
149 |
Notes 154 |
    155 |
  • cannot always set attributes right away at creation time, need subsequent `PROPPATCH`
  • 156 |
157 |
159 |
    160 |
  • no concept of body vs attributes
  • 161 |
  • entity can be either collection or model (for collection `/entities/$collectionId/$itemId`)
  • 162 |
163 |
167 | -------------------------------------------------------------------------------- /docs/static/img/undraw_cloud_files_wmo8.svg: -------------------------------------------------------------------------------- 1 | cloud_files -------------------------------------------------------------------------------- /docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Intro 6 | 7 | [WEBDAV](https://tools.ietf.org/html/rfc4918), `Web Distributed Authoring and Versioning`, is an extension of the HTTP to allow handling distributed authoring, versioning of various resources. 8 | 9 | It's very common to be used for cloud storage, as well as calendar, contacts information syncing. 10 | 11 | ### Prepare credentials from cloud providers 12 | 13 | ##### Apple 14 | 15 | For apple you want to go to [this page](https://support.apple.com/en-us/HT204397) and after following the guide, you will have Apple ID and app-specific password. 16 | 17 | ##### Google 18 | 19 | For google you want to go to Google Cloud Platform/Credentials page, then create a credential that suite your use case. You want `clientId` ,`client secret` and after this. Also you need to enable Google CALDAV/CARDDAV for your project. 20 | 21 | Also you need to setup oauth screen, use proper oauth2 grant flow and you might need to get your application verified by google in order to be able to use CALDAV/CARDDAV api. Refer to [this page](https://developers.google.com/identity/protocols/oauth2) for more details. 22 | 23 | After the oauth2 offline grant you should be able to obtain oauth2 refresh token. 24 | 25 | :::info 26 | Other cloud providers are not currently tested, in theory any cloud with basic auth and oauth2 should work, stay tuned for updates. 27 | ::: 28 | 29 | ### Install 30 | 31 | ```bash 32 | yarn add tsdav 33 | ``` 34 | 35 | or 36 | 37 | ```bash 38 | npm install tsdav 39 | ``` 40 | 41 | ### Basic usage 42 | 43 | #### Import the dependency 44 | 45 | ```ts 46 | import { createDAVClient } from 'tsdav'; 47 | ``` 48 | 49 | or 50 | 51 | ```ts 52 | import tsdav from 'tsdav'; 53 | ``` 54 | 55 | #### Create Client 56 | 57 | By creating a client, you can now use all tsdav methods without supplying authentication header or accounts. 58 | 59 | However, you can always pass in custom header or account to override the default for each request. 60 | 61 | For Google 62 | 63 | ```ts 64 | const client = await createDAVClient({ 65 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 66 | credentials: { 67 | refreshToken: 'YOUR_REFRESH_TOKEN_WITH_CALDAV_PERMISSION', 68 | }, 69 | authMethod: 'Oauth', 70 | defaultAccountType: 'caldav', 71 | }); 72 | ``` 73 | 74 | or 75 | 76 | ```ts 77 | const client = await createDAVClient({ 78 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 79 | credentials: { 80 | authorizationCode: 'AUTH_CODE_OBTAINED_FROM_OAUTH_CALLBACK', 81 | tokenUrl: 'https://oauth2.googleapis.com/token', 82 | clientId: 'YOUR_CLIENT_ID', 83 | clientSecret: 'YOUR_CLIENT_TOKEN', 84 | }, 85 | authMethod: 'Oauth', 86 | defaultAccountType: 'caldav', 87 | }); 88 | ``` 89 | 90 | For Apple 91 | 92 | ```ts 93 | const client = await createDAVClient({ 94 | serverUrl: 'https://caldav.icloud.com', 95 | credentials: { 96 | username: 'YOUR_APPLE_ID', 97 | password: 'YOUR_APP_SPECIFIC_PASSWORD', 98 | }, 99 | authMethod: 'Basic', 100 | defaultAccountType: 'caldav', 101 | }); 102 | ``` 103 | 104 | After `v1.1.0`, you have a new way of creating clients. 105 | 106 | :::info 107 | 108 | You need to call `client.login()` with this method before using the functions 109 | 110 | ::: 111 | 112 | For Google 113 | 114 | ```ts 115 | const client = new DAVClient({ 116 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 117 | credentials: { 118 | refreshToken: 'YOUR_REFRESH_TOKEN_WITH_CALDAV_PERMISSION', 119 | }, 120 | authMethod: 'Oauth', 121 | defaultAccountType: 'caldav', 122 | }); 123 | ``` 124 | 125 | or 126 | 127 | ```ts 128 | const client = new DAVClient({ 129 | serverUrl: 'https://apidata.googleusercontent.com/caldav/v2/', 130 | credentials: { 131 | authorizationCode: 'AUTH_CODE_OBTAINED_FROM_OAUTH_CALLBACK', 132 | tokenUrl: 'https://oauth2.googleapis.com/token', 133 | clientId: 'YOUR_CLIENT_ID', 134 | clientSecret: 'YOUR_CLIENT_TOKEN', 135 | }, 136 | authMethod: 'Oauth', 137 | defaultAccountType: 'caldav', 138 | }); 139 | ``` 140 | 141 | For Apple 142 | 143 | ```ts 144 | const client = new DAVClient({ 145 | serverUrl: 'https://caldav.icloud.com', 146 | credentials: { 147 | username: 'YOUR_APPLE_ID', 148 | password: 'YOUR_APP_SPECIFIC_PASSWORD', 149 | }, 150 | authMethod: 'Basic', 151 | defaultAccountType: 'caldav', 152 | }); 153 | ``` 154 | 155 | #### Get calendars 156 | 157 | ```ts 158 | const calendars = await client.fetchCalendars(); 159 | ``` 160 | 161 | #### Get calendar objects on calendars 162 | 163 | ```ts 164 | const calendarObjects = await client.fetchCalendarObjects({ 165 | calendar: myCalendar, 166 | }); 167 | ``` 168 | 169 | #### Get specific calendar objects on calendar using urls 170 | 171 | ```ts 172 | const calendarObjects = await client.fetchCalendarObjects({ 173 | calendar: myCalendar, 174 | calendarObjectUrls: urlArray, 175 | }); 176 | ``` 177 | 178 | ##### Get calendars changes from remote 179 | 180 | ```ts 181 | const { created, updated, deleted } = await client.syncCalendars({ 182 | calendars: myCalendars, 183 | detailedResult: true, 184 | }); 185 | ``` 186 | 187 | #### Get calendar object changes on a calendar from remote 188 | 189 | ```ts 190 | const { created, updated, deleted } = ( 191 | await client.smartCollectionSync({ 192 | collection: { 193 | url: localCalendar.url, 194 | ctag: localCalendar.ctag, 195 | syncToken: localCalendar.syncToken, 196 | objects: localCalendarObjects, 197 | objectMultiGet: client.calendarMultiGet, 198 | }, 199 | method: 'webdav', 200 | detailedResult: true, 201 | }) 202 | ).objects; 203 | ``` 204 | -------------------------------------------------------------------------------- /src/util/requestHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { DAVNamespace } from '../consts'; 2 | import { 3 | cleanupFalsy, 4 | formatFilters, 5 | formatProps, 6 | getDAVAttribute, 7 | mergeObjectDupKeyArray, 8 | urlContains, 9 | urlEquals, 10 | } from './requestHelpers'; 11 | 12 | test('cleanupFalsy should clean undefined from object', () => { 13 | const objA = { 14 | test1: 123, 15 | test2: 'abc', 16 | test3: undefined, 17 | test4: undefined, 18 | test5: null, 19 | test6: '', 20 | test7: 0, 21 | test8: {}, 22 | test9: '0', 23 | }; 24 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test1')).toBe(true); 25 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test2')).toBe(true); 26 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test3')).toBe(false); 27 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test4')).toBe(false); 28 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test5')).toBe(false); 29 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test6')).toBe(false); 30 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test7')).toBe(false); 31 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test8')).toBe(true); 32 | expect(Object.prototype.hasOwnProperty.call(cleanupFalsy(objA), 'test9')).toBe(true); 33 | }); 34 | 35 | test('urlEquals should handle almost identical urls', () => { 36 | const url = 'https://www.example.com'; 37 | const url1 = 'https://www.example.com/'; 38 | const url2 = 'https://www.example.com '; 39 | const url3 = 'https://www.example.com/ '; 40 | const url4 = 'www.example.com/'; 41 | const url5 = 'www.example.com'; 42 | const url6 = 'example.com'; 43 | expect(urlEquals('', '')).toBe(true); 44 | expect(urlEquals('', url1)).toBe(false); 45 | expect(urlEquals(url, url1)).toBe(true); 46 | expect(urlEquals(url, url2)).toBe(true); 47 | expect(urlEquals(url, url3)).toBe(true); 48 | expect(urlEquals(url, url4)).toBe(false); 49 | expect(urlEquals(url, url5)).toBe(false); 50 | expect(urlEquals(url, url6)).toBe(false); 51 | }); 52 | 53 | test('urlContains should handle almost substring of urls', () => { 54 | const url = 'https://www.example.com'; 55 | const url1 = 'https://www.example.com/'; 56 | const url2 = 'https://www.example.com '; 57 | const url3 = 'https://www.example.com/ '; 58 | const url4 = 'www.example.com/'; 59 | const url5 = 'www.example.com'; 60 | const url6 = 'example.com'; 61 | const url7 = 'blog.example.com'; 62 | expect(urlContains('', '')).toBe(true); 63 | expect(urlContains('', url1)).toBe(false); 64 | expect(urlContains(url, url1)).toBe(true); 65 | expect(urlContains(url, url2)).toBe(true); 66 | expect(urlContains(url, url3)).toBe(true); 67 | expect(urlContains(url, url4)).toBe(true); 68 | expect(urlContains(url, url5)).toBe(true); 69 | expect(urlContains(url, url6)).toBe(true); 70 | expect(urlContains(url, url7)).toBe(false); 71 | }); 72 | 73 | test('mergeObjectDupKeyArray should be able to merge objects', () => { 74 | const objA = { 75 | test1: 123, 76 | test2: 'aaa', 77 | test4: { 78 | test5: { 79 | test6: 'bbb', 80 | }, 81 | }, 82 | test7: 'ooo', 83 | }; 84 | const objB = { 85 | test1: 234, 86 | test2: 'ccc', 87 | test4: { 88 | test5: { 89 | test6: 'ddd', 90 | }, 91 | }, 92 | test8: 'ttt', 93 | }; 94 | const mergedObj = mergeObjectDupKeyArray(objA, objB); 95 | expect(mergedObj).toEqual({ 96 | test1: [234, 123], 97 | test2: ['ccc', 'aaa'], 98 | test4: [{ test5: { test6: 'ddd' } }, { test5: { test6: 'bbb' } }], 99 | test7: 'ooo', 100 | test8: 'ttt', 101 | }); 102 | }); 103 | 104 | test('getDAVAttribute can extract dav attribute values', () => { 105 | const attributes = getDAVAttribute([ 106 | DAVNamespace.CALDAV, 107 | DAVNamespace.CALENDAR_SERVER, 108 | DAVNamespace.CALDAV_APPLE, 109 | DAVNamespace.DAV, 110 | ]); 111 | 112 | expect(attributes).toEqual({ 113 | 'xmlns:c': 'urn:ietf:params:xml:ns:caldav', 114 | 'xmlns:cs': 'http://calendarserver.org/ns/', 115 | 'xmlns:ca': 'http://apple.com/ns/ical/', 116 | 'xmlns:d': 'DAV:', 117 | }); 118 | }); 119 | 120 | test('formatProps should be able to format props to expected format', () => { 121 | const formattedProps = formatProps([ 122 | { name: 'calendar-description', namespace: DAVNamespace.CALDAV }, 123 | { name: 'calendar-timezone', namespace: DAVNamespace.CALDAV }, 124 | { name: 'displayname', namespace: DAVNamespace.DAV }, 125 | { name: 'getctag', namespace: DAVNamespace.CALENDAR_SERVER }, 126 | { name: 'resourcetype', namespace: DAVNamespace.DAV }, 127 | { name: 'supported-calendar-component-set', namespace: DAVNamespace.CALDAV }, 128 | { name: 'sync-token', namespace: DAVNamespace.DAV }, 129 | ]); 130 | expect(formattedProps).toEqual({ 131 | 'c:calendar-description': {}, 132 | 'c:calendar-timezone': {}, 133 | 'd:displayname': {}, 134 | 'cs:getctag': {}, 135 | 'd:resourcetype': {}, 136 | 'c:supported-calendar-component-set': {}, 137 | 'd:sync-token': {}, 138 | }); 139 | }); 140 | 141 | test('formatFilters should be able to format filters to expected format', () => { 142 | const formattedFilters = formatFilters([ 143 | { 144 | type: 'comp-filter', 145 | attributes: { name: 'VCALENDAR' }, 146 | children: [ 147 | { 148 | type: 'comp-filter', 149 | attributes: { name: 'VEVENT' }, 150 | }, 151 | ], 152 | }, 153 | ]); 154 | expect(formattedFilters).toEqual([ 155 | { 156 | 'comp-filter': { 157 | _attributes: { name: 'VCALENDAR' }, 158 | 'comp-filter': { _attributes: { name: 'VEVENT' }, _text: '' }, 159 | _text: '', 160 | }, 161 | }, 162 | ]); 163 | }); 164 | -------------------------------------------------------------------------------- /src/account.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from 'cross-fetch'; 2 | import getLogger from 'debug'; 3 | 4 | import { fetchAddressBooks, fetchVCards } from './addressBook'; 5 | import { fetchCalendarObjects, fetchCalendars } from './calendar'; 6 | import { DAVNamespace } from './consts'; 7 | import { propfind } from './request'; 8 | import { DAVAccount } from './types/models'; 9 | import { urlContains } from './util/requestHelpers'; 10 | import { findMissingFieldNames, hasFields } from './util/typeHelpers'; 11 | 12 | const debug = getLogger('tsdav:account'); 13 | 14 | export const serviceDiscovery = async (params: { 15 | account: DAVAccount; 16 | headers?: Record; 17 | }): Promise => { 18 | debug('Service discovery...'); 19 | const { account, headers } = params; 20 | const endpoint = new URL(account.serverUrl); 21 | 22 | const uri = new URL(`/.well-known/${account.accountType}`, endpoint); 23 | uri.protocol = endpoint.protocol ?? 'http'; 24 | 25 | try { 26 | const response = await fetch(uri.href, { 27 | headers, 28 | method: 'GET', 29 | redirect: 'manual', 30 | }); 31 | 32 | if (response.status >= 300 && response.status < 400) { 33 | // http redirect. 34 | const location = response.headers.get('Location'); 35 | if (typeof location === 'string' && location.length) { 36 | debug(`Service discovery redirected to ${location}`); 37 | const serviceURL = new URL(location, endpoint); 38 | serviceURL.protocol = endpoint.protocol ?? 'http'; 39 | return serviceURL.href; 40 | } 41 | } 42 | } catch (err) { 43 | debug(`Service discovery failed: ${err.stack}`); 44 | } 45 | 46 | return endpoint.href; 47 | }; 48 | 49 | export const fetchPrincipalUrl = async (params: { 50 | account: DAVAccount; 51 | headers?: Record; 52 | }): Promise => { 53 | const { account, headers } = params; 54 | const requiredFields: Array<'rootUrl'> = ['rootUrl']; 55 | if (!hasFields(account, requiredFields)) { 56 | throw new Error( 57 | `account must have ${findMissingFieldNames(account, requiredFields)} before fetchPrincipalUrl` 58 | ); 59 | } 60 | debug(`Fetching principal url from path ${account.rootUrl}`); 61 | const [response] = await propfind({ 62 | url: account.rootUrl, 63 | props: [{ name: 'current-user-principal', namespace: DAVNamespace.DAV }], 64 | depth: '0', 65 | headers, 66 | }); 67 | if (!response.ok) { 68 | debug(`Fetch principal url failed: ${response.statusText}`); 69 | if (response.status === 401) { 70 | throw new Error('Invalid credentials'); 71 | } 72 | } 73 | debug(`Fetched principal url ${response.props?.currentUserPrincipal.href}`); 74 | return new URL(response.props?.currentUserPrincipal.href ?? '', account.rootUrl).href; 75 | }; 76 | 77 | export const fetchHomeUrl = async (params: { 78 | account: DAVAccount; 79 | headers?: Record; 80 | }): Promise => { 81 | const { account, headers } = params; 82 | const requiredFields: Array<'principalUrl' | 'rootUrl'> = ['principalUrl', 'rootUrl']; 83 | if (!hasFields(account, requiredFields)) { 84 | throw new Error( 85 | `account must have ${findMissingFieldNames(account, requiredFields)} before fetchHomeUrl` 86 | ); 87 | } 88 | 89 | debug(`Fetch home url from ${account.principalUrl}`); 90 | const responses = await propfind({ 91 | url: account.principalUrl, 92 | props: [ 93 | account.accountType === 'caldav' 94 | ? { name: 'calendar-home-set', namespace: DAVNamespace.CALDAV } 95 | : { name: 'addressbook-home-set', namespace: DAVNamespace.CARDDAV }, 96 | ], 97 | depth: '0', 98 | headers, 99 | }); 100 | 101 | const matched = responses.find((r) => urlContains(account.principalUrl, r.href)); 102 | if (!matched || !matched.ok) { 103 | throw new Error('cannot find homeUrl'); 104 | } 105 | 106 | const result = new URL( 107 | account.accountType === 'caldav' 108 | ? matched?.props?.calendarHomeSet.href 109 | : matched?.props?.addressbookHomeSet.href, 110 | account.rootUrl 111 | ).href; 112 | debug(`Fetched home url ${result}`); 113 | return result; 114 | }; 115 | 116 | export const createAccount = async (params: { 117 | account: DAVAccount; 118 | headers?: Record; 119 | loadCollections?: boolean; 120 | loadObjects?: boolean; 121 | }): Promise => { 122 | const { account, headers, loadCollections = false, loadObjects = false } = params; 123 | const newAccount: DAVAccount = { ...account }; 124 | newAccount.rootUrl = await serviceDiscovery({ account, headers }); 125 | newAccount.principalUrl = await fetchPrincipalUrl({ account: newAccount, headers }); 126 | newAccount.homeUrl = await fetchHomeUrl({ account: newAccount, headers }); 127 | // to load objects you must first load collections 128 | if (loadCollections || loadObjects) { 129 | if (account.accountType === 'caldav') { 130 | newAccount.calendars = await fetchCalendars({ headers, account: newAccount }); 131 | } else if (account.accountType === 'carddav') { 132 | newAccount.addressBooks = await fetchAddressBooks({ headers, account: newAccount }); 133 | } 134 | } 135 | if (loadObjects) { 136 | if (account.accountType === 'caldav' && newAccount.calendars) { 137 | newAccount.calendars = await Promise.all( 138 | newAccount.calendars.map(async (cal) => ({ 139 | ...cal, 140 | objects: await fetchCalendarObjects({ calendar: cal, headers }), 141 | })) 142 | ); 143 | } else if (account.accountType === 'carddav' && newAccount.addressBooks) { 144 | newAccount.addressBooks = await Promise.all( 145 | newAccount.addressBooks.map(async (addr) => ({ 146 | ...addr, 147 | objects: await fetchVCards({ addressBook: addr, headers }), 148 | })) 149 | ); 150 | } 151 | } 152 | 153 | return newAccount; 154 | }; 155 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from 'cross-fetch'; 2 | import getLogger from 'debug'; 3 | import convert from 'xml-js'; 4 | 5 | import { DAVNamespace, DAVNamespaceShorthandMap } from './consts'; 6 | import { DAVDepth, DAVProp, DAVRequest, DAVResponse } from './types/DAVTypes'; 7 | import { camelCase } from './util/camelCase'; 8 | import { nativeType } from './util/nativeType'; 9 | import { cleanupFalsy, formatProps, getDAVAttribute } from './util/requestHelpers'; 10 | 11 | const debug = getLogger('tsdav:request'); 12 | 13 | type RawProp = { prop: { [key: string]: any }; status: string; responsedescription?: string }; 14 | type RawResponse = { 15 | href: string; 16 | status: string; 17 | ok: boolean; 18 | error: { [key: string]: any }; 19 | responsedescription: string; 20 | propstat: RawProp | RawProp[]; 21 | }; 22 | 23 | export const davRequest = async (params: { 24 | url: string; 25 | init: DAVRequest; 26 | convertIncoming?: boolean; 27 | parseOutgoing?: boolean; 28 | }): Promise => { 29 | const { url, init, convertIncoming = true, parseOutgoing = true } = params; 30 | const { headers, body, namespace, method, attributes } = init; 31 | const xmlBody = convertIncoming 32 | ? convert.js2xml( 33 | { ...body, _attributes: attributes }, 34 | { 35 | compact: true, 36 | spaces: 2, 37 | elementNameFn: (name) => { 38 | // add namespace to all keys without namespace 39 | if (namespace && !/^.+:.+/.test(name)) { 40 | return `${namespace}:${name}`; 41 | } 42 | return name; 43 | }, 44 | } 45 | ) 46 | : body; 47 | 48 | debug('outgoing xml:'); 49 | debug(xmlBody); 50 | debug({...cleanupFalsy(headers)}) 51 | const davResponse = await fetch(url, { 52 | headers: { 53 | 'Content-Type': 'text/xml;charset=UTF-8', 54 | ...cleanupFalsy(headers), 55 | }, 56 | body: xmlBody, 57 | method, 58 | }); 59 | 60 | const resText = await davResponse.text(); 61 | 62 | // filter out invalid responses 63 | debug('response xml:'); 64 | debug(resText); 65 | debug(davResponse); 66 | if ( 67 | !davResponse.ok || 68 | !davResponse.headers.get('content-type')?.includes('xml') || 69 | !parseOutgoing 70 | ) { 71 | return [ 72 | { 73 | href: davResponse.url, 74 | ok: davResponse.ok, 75 | status: davResponse.status, 76 | statusText: davResponse.statusText, 77 | raw: resText, 78 | }, 79 | ]; 80 | } 81 | 82 | const result: any = convert.xml2js(resText, { 83 | compact: true, 84 | trim: true, 85 | textFn: (value: any, parentElement: any) => { 86 | try { 87 | // This is needed for xml-js design reasons 88 | // eslint-disable-next-line no-underscore-dangle 89 | const parentOfParent = parentElement._parent; 90 | const pOpKeys = Object.keys(parentOfParent); 91 | const keyNo = pOpKeys.length; 92 | const keyName = pOpKeys[keyNo - 1]; 93 | const arrOfKey = parentOfParent[keyName]; 94 | const arrOfKeyLen = arrOfKey.length; 95 | if (arrOfKeyLen > 0) { 96 | const arr = arrOfKey; 97 | const arrIndex = arrOfKey.length - 1; 98 | arr[arrIndex] = nativeType(value); 99 | } else { 100 | parentOfParent[keyName] = nativeType(value); 101 | } 102 | } catch (e) { 103 | debug(e.stack); 104 | } 105 | }, 106 | // remove namespace & camelCase 107 | elementNameFn: (attributeName) => camelCase(attributeName.replace(/^.+:/, '')), 108 | attributesFn: (value: any) => { 109 | const newVal = { ...value }; 110 | delete newVal.xmlns; 111 | return newVal; 112 | }, 113 | ignoreDeclaration: true, 114 | }); 115 | 116 | const responseBodies: RawResponse[] = Array.isArray(result.multistatus.response) 117 | ? result.multistatus.response 118 | : [result.multistatus.response]; 119 | 120 | return responseBodies.map((responseBody) => { 121 | const statusRegex = /^\S+\s(?\d+)\s(?.+)$/; 122 | if (!responseBody) { 123 | return { 124 | status: davResponse.status, 125 | statusText: davResponse.statusText, 126 | ok: davResponse.ok, 127 | }; 128 | } 129 | 130 | const matchArr = statusRegex.exec(responseBody.status); 131 | 132 | return { 133 | raw: result, 134 | href: responseBody.href, 135 | status: matchArr?.groups ? Number.parseInt(matchArr?.groups.status, 10) : davResponse.status, 136 | statusText: matchArr?.groups?.statusText ?? davResponse.statusText, 137 | ok: !responseBody.error, 138 | error: responseBody.error, 139 | responsedescription: responseBody.responsedescription, 140 | props: (Array.isArray(responseBody.propstat) 141 | ? responseBody.propstat 142 | : [responseBody.propstat] 143 | ).reduce((prev, curr) => { 144 | return { 145 | ...prev, 146 | ...curr?.prop, 147 | }; 148 | }, {}), 149 | }; 150 | }); 151 | }; 152 | 153 | export const propfind = async (params: { 154 | url: string; 155 | props: DAVProp[]; 156 | depth?: DAVDepth; 157 | headers?: Record; 158 | }): Promise => { 159 | const { url, props, depth, headers } = params; 160 | return davRequest({ 161 | url, 162 | init: { 163 | method: 'PROPFIND', 164 | headers: cleanupFalsy({ ...headers, depth }), 165 | namespace: DAVNamespaceShorthandMap[DAVNamespace.DAV], 166 | body: { 167 | propfind: { 168 | _attributes: getDAVAttribute([ 169 | DAVNamespace.CALDAV, 170 | DAVNamespace.CALDAV_APPLE, 171 | DAVNamespace.CALENDAR_SERVER, 172 | DAVNamespace.CARDDAV, 173 | DAVNamespace.DAV, 174 | ]), 175 | prop: formatProps(props), 176 | }, 177 | }, 178 | }, 179 | }); 180 | }; 181 | 182 | export const createObject = async (params: { 183 | url: string; 184 | data: BodyInit; 185 | headers?: Record; 186 | }): Promise => { 187 | const { url, data, headers } = params; 188 | return fetch(url, { method: 'PUT', body: data, headers }); 189 | }; 190 | 191 | export const updateObject = async (params: { 192 | url: string; 193 | data: BodyInit; 194 | etag: string; 195 | headers?: Record; 196 | }): Promise => { 197 | const { url, data, etag, headers } = params; 198 | return fetch(url, { 199 | method: 'PUT', 200 | body: data, 201 | headers: cleanupFalsy({ ...headers, 'If-Match': etag }), 202 | }); 203 | }; 204 | 205 | export const deleteObject = async (params: { 206 | url: string; 207 | etag?: string; 208 | headers?: Record; 209 | }): Promise => { 210 | const { url, headers, etag } = params; 211 | return fetch(url, { 212 | method: 'DELETE', 213 | headers: cleanupFalsy({ ...headers, 'If-Match': etag }), 214 | }); 215 | }; 216 | -------------------------------------------------------------------------------- /src/addressBook.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import getLogger from 'debug'; 3 | 4 | import { collectionQuery, supportedReportSet } from './collection'; 5 | import { DAVNamespace, DAVNamespaceShorthandMap } from './consts'; 6 | import { createObject, deleteObject, propfind, updateObject } from './request'; 7 | import { DAVDepth, DAVFilter, DAVProp, DAVResponse } from './types/DAVTypes'; 8 | import { DAVAccount, DAVAddressBook, DAVVCard } from './types/models'; 9 | import { formatFilters, formatProps, getDAVAttribute } from './util/requestHelpers'; 10 | import { findMissingFieldNames, hasFields } from './util/typeHelpers'; 11 | 12 | const debug = getLogger('tsdav:addressBook'); 13 | 14 | export const addressBookQuery = async (params: { 15 | url: string; 16 | props: DAVProp[]; 17 | depth?: DAVDepth; 18 | headers?: Record; 19 | }): Promise => { 20 | const { url, props, depth, headers } = params; 21 | return collectionQuery({ 22 | url, 23 | body: { 24 | 'addressbook-query': { 25 | _attributes: getDAVAttribute([DAVNamespace.CARDDAV, DAVNamespace.DAV]), 26 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 27 | filter: { 28 | 'prop-filter': { 29 | _attributes: { 30 | name: 'FN', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | defaultNamespace: DAVNamespace.CARDDAV, 37 | depth, 38 | headers, 39 | }); 40 | }; 41 | 42 | export const addressBookMultiGet = async (params: { 43 | url: string; 44 | props: DAVProp[]; 45 | objectUrls: string[]; 46 | filters?: DAVFilter[]; 47 | depth: DAVDepth; 48 | headers?: Record; 49 | }): Promise => { 50 | const { url, props, objectUrls, filters, depth, headers } = params; 51 | return collectionQuery({ 52 | url, 53 | body: { 54 | 'addressbook-multiget': { 55 | _attributes: getDAVAttribute([DAVNamespace.DAV, DAVNamespace.CARDDAV]), 56 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 57 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:href`]: objectUrls, 58 | filter: formatFilters(filters), 59 | }, 60 | }, 61 | defaultNamespace: DAVNamespace.CARDDAV, 62 | depth, 63 | headers, 64 | }); 65 | }; 66 | 67 | export const fetchAddressBooks = async (params?: { 68 | account?: DAVAccount; 69 | headers?: Record; 70 | }): Promise => { 71 | const { account, headers } = params ?? {}; 72 | const requiredFields: Array = ['homeUrl', 'rootUrl']; 73 | if (!account || !hasFields(account, requiredFields)) { 74 | if (!account) { 75 | throw new Error('no account for fetchAddressBooks'); 76 | } 77 | throw new Error( 78 | `account must have ${findMissingFieldNames(account, requiredFields)} before fetchAddressBooks` 79 | ); 80 | } 81 | const res = await propfind({ 82 | url: account.homeUrl, 83 | props: [ 84 | { name: 'displayname', namespace: DAVNamespace.DAV }, 85 | { name: 'getctag', namespace: DAVNamespace.CALENDAR_SERVER }, 86 | { name: 'resourcetype', namespace: DAVNamespace.DAV }, 87 | { name: 'sync-token', namespace: DAVNamespace.DAV }, 88 | ], 89 | depth: '1', 90 | headers, 91 | }); 92 | return Promise.all( 93 | res 94 | .filter((r) => Object.keys(r.props?.resourcetype ?? {}).includes('addressbook')) 95 | .map((rs) => { 96 | const displayName = rs.props?.displayname; 97 | debug(`Found address book named ${typeof displayName === 'string' ? displayName : ''}, 98 | props: ${JSON.stringify(rs.props)}`); 99 | return { 100 | url: new URL(rs.href ?? '', account.rootUrl ?? '').href, 101 | ctag: rs.props?.getctag, 102 | displayName: typeof displayName === 'string' ? displayName : '', 103 | resourcetype: Object.keys(rs.props?.resourcetype), 104 | syncToken: rs.props?.syncToken, 105 | }; 106 | }) 107 | .map(async (addr) => ({ 108 | ...addr, 109 | reports: await supportedReportSet({ collection: addr, headers }), 110 | })) 111 | ); 112 | }; 113 | 114 | export const fetchVCards = async (params: { 115 | addressBook: DAVAddressBook; 116 | headers?: Record; 117 | objectUrls?: string[]; 118 | }): Promise => { 119 | const { addressBook, headers, objectUrls } = params; 120 | debug(`Fetching vcards from ${addressBook?.url}`); 121 | const requiredFields: Array<'url'> = ['url']; 122 | if (!addressBook || !hasFields(addressBook, requiredFields)) { 123 | if (!addressBook) { 124 | throw new Error('cannot fetchVCards for undefined addressBook'); 125 | } 126 | throw new Error( 127 | `addressBook must have ${findMissingFieldNames( 128 | addressBook, 129 | requiredFields 130 | )} before fetchVCards` 131 | ); 132 | } 133 | 134 | const vcardUrls = ( 135 | objectUrls ?? 136 | // fetch all objects of the calendar 137 | ( 138 | await addressBookQuery({ 139 | url: addressBook.url, 140 | props: [{ name: 'getetag', namespace: DAVNamespace.DAV }], 141 | depth: '1', 142 | headers, 143 | }) 144 | ).map((res) => res.href ?? '') 145 | ) 146 | .map((url) => (url.includes('http') ? url : new URL(url, addressBook.url).href)) 147 | .map((url) => new URL(url).pathname) 148 | .filter((url): url is string => Boolean(url?.includes('.vcf'))); 149 | 150 | const vCardResults = await addressBookMultiGet({ 151 | url: addressBook.url, 152 | props: [ 153 | { name: 'getetag', namespace: DAVNamespace.DAV }, 154 | { name: 'address-data', namespace: DAVNamespace.CARDDAV }, 155 | ], 156 | objectUrls: vcardUrls, 157 | depth: '1', 158 | headers, 159 | }); 160 | 161 | return vCardResults.map((res) => ({ 162 | url: new URL(res.href ?? '', addressBook.url).href, 163 | etag: res.props?.getetag, 164 | data: res.props?.addressData?._cdata ?? res.props?.addressData, 165 | })); 166 | }; 167 | 168 | export const createVCard = async (params: { 169 | addressBook: DAVAddressBook; 170 | vCardString: string; 171 | filename: string; 172 | headers?: Record; 173 | }): Promise => { 174 | const { addressBook, vCardString, filename, headers } = params; 175 | return createObject({ 176 | url: new URL(filename, addressBook.url).href, 177 | data: vCardString, 178 | headers: { 179 | 'content-type': 'text/vcard; charset=utf-8', 180 | ...headers, 181 | }, 182 | }); 183 | }; 184 | 185 | export const updateVCard = async (params: { 186 | vCard: DAVVCard; 187 | headers?: Record; 188 | }): Promise => { 189 | const { vCard, headers } = params; 190 | return updateObject({ 191 | url: vCard.url, 192 | data: vCard.data, 193 | etag: vCard.etag, 194 | headers: { 195 | 'content-type': 'text/vcard; charset=utf-8', 196 | ...headers, 197 | }, 198 | }); 199 | }; 200 | 201 | export const deleteVCard = async (params: { 202 | vCard: DAVVCard; 203 | headers?: Record; 204 | }): Promise => { 205 | const { vCard, headers } = params; 206 | return deleteObject({ 207 | url: vCard.url, 208 | etag: vCard.etag, 209 | headers, 210 | }); 211 | }; 212 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/request.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'cross-fetch'; 2 | 3 | import { createAccount } from './account'; 4 | import { fetchCalendarObjects, fetchCalendars } from './calendar'; 5 | import { DAVNamespace } from './consts'; 6 | import { createObject, davRequest, deleteObject, propfind, updateObject } from './request'; 7 | import { DAVAccount, DAVCalendar } from './types/models'; 8 | import { getBasicAuthHeaders } from './util/authHelpers'; 9 | 10 | let authHeaders: { 11 | authorization?: string; 12 | }; 13 | let account: DAVAccount; 14 | let calendars: DAVCalendar[]; 15 | 16 | beforeAll(async () => { 17 | authHeaders = getBasicAuthHeaders({ 18 | username: process.env.ICLOUD_USERNAME, 19 | password: process.env.ICLOUD_APP_SPECIFIC_PASSWORD, 20 | }); 21 | account = await createAccount({ 22 | account: { 23 | serverUrl: 'https://caldav.icloud.com/', 24 | accountType: 'caldav', 25 | }, 26 | headers: authHeaders, 27 | }); 28 | calendars = await fetchCalendars({ 29 | account, 30 | headers: authHeaders, 31 | }); 32 | }); 33 | 34 | test('davRequest should be able to send normal webdav requests', async () => { 35 | const [result] = await davRequest({ 36 | url: 'https://caldav.icloud.com/', 37 | init: { 38 | method: 'PROPFIND', 39 | headers: authHeaders, 40 | namespace: 'd', 41 | body: { 42 | propfind: { 43 | _attributes: { 44 | 'xmlns:d': 'DAV:', 45 | }, 46 | prop: { 'd:current-user-principal': {} }, 47 | }, 48 | }, 49 | }, 50 | }); 51 | expect(result.href?.length).toBeTruthy(); 52 | expect(result.status).toBe(207); 53 | expect(result.statusText).toBe('Multi-Status'); 54 | expect(result.ok).toBe(true); 55 | expect(result.props?.currentUserPrincipal.href).toMatch(/\/[0-9]+\/principal\//); 56 | expect(Object.prototype.hasOwnProperty.call(result, 'raw')).toBe(true); 57 | }); 58 | 59 | test('davRequest should be able to send raw xml requests', async () => { 60 | const xml = ` 61 | 62 | 63 | 64 | 65 | `; 66 | const [result] = await davRequest({ 67 | url: 'https://caldav.icloud.com/', 68 | init: { 69 | method: 'PROPFIND', 70 | headers: authHeaders, 71 | body: xml, 72 | }, 73 | convertIncoming: false, 74 | }); 75 | expect(result.href?.length).toBeTruthy(); 76 | expect(result.status).toBe(207); 77 | expect(result.statusText).toBe('Multi-Status'); 78 | expect(result.ok).toBe(true); 79 | expect(result.props?.currentUserPrincipal.href).toMatch(/\/[0-9]+\/principal\//); 80 | expect(Object.prototype.hasOwnProperty.call(result, 'raw')).toBe(true); 81 | }); 82 | 83 | test('davRequest should be able to get raw xml response', async () => { 84 | const xml = ` 85 | 86 | 87 | 88 | 89 | `; 90 | const [result] = await davRequest({ 91 | url: 'https://caldav.icloud.com/', 92 | init: { 93 | method: 'PROPFIND', 94 | headers: authHeaders, 95 | body: xml, 96 | }, 97 | convertIncoming: false, 98 | parseOutgoing: false, 99 | }); 100 | expect(result.href?.length).toBeTruthy(); 101 | expect(result.status).toBe(207); 102 | expect(result.statusText).toBe('Multi-Status'); 103 | expect(result.ok).toBe(true); 104 | expect(result.raw).toMatch( 105 | /\/[0-9]+\/principal\/<\/href><\/current-user-principal>/ 106 | ); 107 | }); 108 | 109 | test('propfind should be able to find props', async () => { 110 | const [result] = await propfind({ 111 | url: 'https://caldav.icloud.com/', 112 | props: [{ name: 'current-user-principal', namespace: DAVNamespace.DAV }], 113 | headers: authHeaders, 114 | }); 115 | expect(result.href?.length).toBeTruthy(); 116 | expect(result.status).toBe(207); 117 | expect(result.statusText).toBe('Multi-Status'); 118 | expect(result.ok).toBe(true); 119 | expect(result.props?.currentUserPrincipal.href).toMatch(/\/[0-9]+\/principal\//); 120 | expect(Object.prototype.hasOwnProperty.call(result, 'raw')).toBe(true); 121 | }); 122 | 123 | test('createObject should be able to create object', async () => { 124 | const iCalString = `BEGIN:VCALENDAR 125 | VERSION:2.0 126 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 127 | CALSCALE:GREGORIAN 128 | BEGIN:VEVENT 129 | DTSTART:20210307T090800Z 130 | DTEND:20210307T100800Z 131 | DTSTAMP:20210307T090944Z 132 | UID:b53b6846-ede3-4689-b744-aa33963e1586 133 | CREATED:20210307T090944Z 134 | SEQUENCE:0 135 | SUMMARY:Test 136 | STATUS:CONFIRMED 137 | TRANSP:OPAQUE 138 | END:VEVENT 139 | END:VCALENDAR`; 140 | 141 | const objectUrl = new URL('test.ics', calendars[1].url).href; 142 | const response = await createObject({ 143 | url: objectUrl, 144 | data: iCalString, 145 | headers: { 146 | 'content-type': 'text/calendar; charset=utf-8', 147 | ...authHeaders, 148 | }, 149 | }); 150 | 151 | const [calendarObject] = await fetchCalendarObjects({ 152 | calendar: calendars[1], 153 | objectUrls: [objectUrl], 154 | headers: authHeaders, 155 | }); 156 | 157 | expect(response.ok).toBe(true); 158 | expect(calendarObject.url.length > 0).toBe(true); 159 | expect(calendarObject.etag.length > 0).toBe(true); 160 | expect(calendarObject.data.split('\r\n').join('\n')).toEqual(iCalString); 161 | 162 | const deleteResult = await deleteObject({ 163 | url: objectUrl, 164 | headers: authHeaders, 165 | }); 166 | 167 | expect(deleteResult.ok).toBe(true); 168 | }); 169 | 170 | test('updateObject should be able to update object', async () => { 171 | const iCalString = `BEGIN:VCALENDAR 172 | VERSION:2.0 173 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 174 | CALSCALE:GREGORIAN 175 | BEGIN:VEVENT 176 | DTSTART:20210308T090800Z 177 | DTEND:20210308T100800Z 178 | DTSTAMP:20210308T090944Z 179 | UID:fbc5a3fe-e77d-4c3f-adf2-00bba5cf90b2 180 | CREATED:20210308T090944Z 181 | SEQUENCE:0 182 | SUMMARY:Test 183 | STATUS:CONFIRMED 184 | TRANSP:OPAQUE 185 | END:VEVENT 186 | END:VCALENDAR`; 187 | 188 | const updatedICalString = `BEGIN:VCALENDAR 189 | VERSION:2.0 190 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 191 | CALSCALE:GREGORIAN 192 | BEGIN:VEVENT 193 | DTSTART:20210308T090800Z 194 | DTEND:20210308T100800Z 195 | DTSTAMP:20210308T090944Z 196 | UID:fbc5a3fe-e77d-4c3f-adf2-00bba5cf90b2 197 | CREATED:20210308T090944Z 198 | SEQUENCE:0 199 | SUMMARY:updated summary 200 | STATUS:CONFIRMED 201 | TRANSP:OPAQUE 202 | END:VEVENT 203 | END:VCALENDAR 204 | `; 205 | 206 | const objectUrl = new URL('test2.ics', calendars[1].url).href; 207 | const createResult = await createObject({ 208 | url: objectUrl, 209 | data: iCalString, 210 | headers: { 211 | 'content-type': 'text/calendar; charset=utf-8', 212 | ...authHeaders, 213 | }, 214 | }); 215 | expect(createResult.ok).toBe(true); 216 | 217 | const [calendarObject] = await fetchCalendarObjects({ 218 | calendar: calendars[1], 219 | objectUrls: [objectUrl], 220 | headers: authHeaders, 221 | }); 222 | 223 | const updateResult = await updateObject({ 224 | url: objectUrl, 225 | data: updatedICalString, 226 | etag: calendarObject.etag, 227 | headers: { 228 | 'content-type': 'text/calendar; charset=utf-8', 229 | ...authHeaders, 230 | }, 231 | }); 232 | 233 | expect(updateResult.ok).toBe(true); 234 | 235 | const result = await fetch(objectUrl, { 236 | headers: authHeaders, 237 | }); 238 | 239 | expect(result.ok).toBe(true); 240 | expect((await result.text()).split('\r\n').join('\n')).toEqual(updatedICalString); 241 | 242 | const deleteResult = await deleteObject({ 243 | url: objectUrl, 244 | headers: authHeaders, 245 | }); 246 | 247 | expect(deleteResult.ok).toBe(true); 248 | }); 249 | 250 | test('deleteObject should be able to delete object', async () => { 251 | const iCalString = `BEGIN:VCALENDAR 252 | VERSION:2.0 253 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 254 | CALSCALE:GREGORIAN 255 | BEGIN:VEVENT 256 | DTSTART:20210309T090800Z 257 | DTEND:20210309T100800Z 258 | DTSTAMP:20210309T090944Z 259 | UID:0398667c-2292-4576-9751-a445f88394ab 260 | CREATED:20210309T090944Z 261 | SEQUENCE:0 262 | SUMMARY:Test 263 | STATUS:CONFIRMED 264 | TRANSP:OPAQUE 265 | END:VEVENT 266 | END:VCALENDAR`; 267 | 268 | const objectUrl = new URL('test3.ics', calendars[1].url).href; 269 | const createResult = await createObject({ 270 | url: objectUrl, 271 | data: iCalString, 272 | headers: { 273 | 'content-type': 'text/calendar; charset=utf-8', 274 | ...authHeaders, 275 | }, 276 | }); 277 | expect(createResult.ok).toBe(true); 278 | 279 | const deleteResult = await deleteObject({ 280 | url: objectUrl, 281 | headers: authHeaders, 282 | }); 283 | 284 | expect(deleteResult.ok).toBe(true); 285 | }); 286 | -------------------------------------------------------------------------------- /src/calendar.test.ts: -------------------------------------------------------------------------------- 1 | import { createAccount } from './account'; 2 | import { calendarMultiGet, fetchCalendarObjects, fetchCalendars } from './calendar'; 3 | import { DAVNamespace } from './consts'; 4 | import { createObject, deleteObject } from './request'; 5 | import { DAVAccount } from './types/models'; 6 | import { getBasicAuthHeaders } from './util/authHelpers'; 7 | 8 | let authHeaders: { 9 | authorization?: string; 10 | }; 11 | 12 | let account: DAVAccount; 13 | 14 | beforeAll(async () => { 15 | authHeaders = getBasicAuthHeaders({ 16 | username: process.env.ICLOUD_USERNAME, 17 | password: process.env.ICLOUD_APP_SPECIFIC_PASSWORD, 18 | }); 19 | account = await createAccount({ 20 | account: { 21 | serverUrl: 'https://caldav.icloud.com/', 22 | accountType: 'caldav', 23 | }, 24 | headers: authHeaders, 25 | }); 26 | }); 27 | 28 | test('fetchCalendars should be able to fetch calendars', async () => { 29 | const calendars = await fetchCalendars({ 30 | account, 31 | headers: authHeaders, 32 | }); 33 | expect(calendars.length > 0).toBe(true); 34 | expect(calendars.every((c) => c.url.length > 0)).toBe(true); 35 | }); 36 | 37 | test('calendarMultiGet should be able to get information about multiple calendar objects', async () => { 38 | const iCalString1 = `BEGIN:VCALENDAR 39 | VERSION:2.0 40 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 41 | CALSCALE:GREGORIAN 42 | BEGIN:VEVENT 43 | DTSTART:20210401T090800Z 44 | DTEND:20210401T100800Z 45 | DTSTAMP:20210401T090944Z 46 | UID:4e3ce4c2-02c7-4fbc-ace0-f2b7d579eed6 47 | CREATED:20210401T090944Z 48 | SEQUENCE:0 49 | SUMMARY:Test 50 | STATUS:CONFIRMED 51 | TRANSP:OPAQUE 52 | END:VEVENT 53 | END:VCALENDAR`; 54 | const iCalString2 = `BEGIN:VCALENDAR 55 | VERSION:2.0 56 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 57 | CALSCALE:GREGORIAN 58 | BEGIN:VEVENT 59 | DTSTART:20210402T090800Z 60 | DTEND:20210402T100800Z 61 | DTSTAMP:20210402T090944Z 62 | UID:1f28015d-e140-4484-900b-0fa15e10210e 63 | CREATED:20210402T090944Z 64 | SEQUENCE:0 65 | SUMMARY:Test 66 | STATUS:CONFIRMED 67 | TRANSP:OPAQUE 68 | END:VEVENT 69 | END:VCALENDAR`; 70 | const iCalString3 = `BEGIN:VCALENDAR 71 | VERSION:2.0 72 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 73 | CALSCALE:GREGORIAN 74 | BEGIN:VEVENT 75 | DTSTART:20210502T090800Z 76 | DTEND:20210502T100800Z 77 | DTSTAMP:20210402T090944Z 78 | UID:6aefd54f-c038-409a-8f9c-bf3413efd611 79 | CREATED:20210502T090944Z 80 | SEQUENCE:0 81 | SUMMARY:Test 82 | STATUS:CONFIRMED 83 | TRANSP:OPAQUE 84 | END:VEVENT 85 | END:VCALENDAR`; 86 | const calendars = await fetchCalendars({ 87 | account, 88 | headers: authHeaders, 89 | }); 90 | 91 | const objectUrl1 = new URL('test11.ics', calendars[1].url).href; 92 | const objectUrl2 = new URL('test22.ics', calendars[1].url).href; 93 | const objectUrl3 = new URL('http.ics', calendars[1].url).href; 94 | 95 | const response1 = await createObject({ 96 | url: objectUrl1, 97 | data: iCalString1, 98 | headers: { 99 | 'content-type': 'text/calendar; charset=utf-8', 100 | ...authHeaders, 101 | }, 102 | }); 103 | 104 | const response2 = await createObject({ 105 | url: objectUrl2, 106 | data: iCalString2, 107 | headers: { 108 | 'content-type': 'text/calendar; charset=utf-8', 109 | ...authHeaders, 110 | }, 111 | }); 112 | 113 | const response3 = await createObject({ 114 | url: objectUrl3, 115 | data: iCalString3, 116 | headers: { 117 | 'content-type': 'text/calendar; charset=utf-8', 118 | ...authHeaders, 119 | }, 120 | }); 121 | 122 | expect(response1.ok).toBe(true); 123 | expect(response2.ok).toBe(true); 124 | expect(response3.ok).toBe(true); 125 | 126 | const calendarObjects = await calendarMultiGet({ 127 | url: calendars[1].url, 128 | props: [ 129 | { name: 'getetag', namespace: DAVNamespace.DAV }, 130 | { name: 'calendar-data', namespace: DAVNamespace.CALDAV }, 131 | ], 132 | depth: '1', 133 | headers: authHeaders, 134 | }); 135 | 136 | expect(calendarObjects.length > 0); 137 | 138 | const deleteResult1 = await deleteObject({ 139 | url: objectUrl1, 140 | headers: authHeaders, 141 | }); 142 | 143 | const deleteResult2 = await deleteObject({ 144 | url: objectUrl2, 145 | headers: authHeaders, 146 | }); 147 | 148 | const deleteResult3 = await deleteObject({ 149 | url: objectUrl3, 150 | headers: authHeaders, 151 | }); 152 | 153 | expect(deleteResult1.ok).toBe(true); 154 | expect(deleteResult2.ok).toBe(true); 155 | expect(deleteResult3.ok).toBe(true); 156 | }); 157 | 158 | test('fetchCalendarObjects should be able to fetch calendar objects', async () => { 159 | const calendars = await fetchCalendars({ 160 | account, 161 | headers: authHeaders, 162 | }); 163 | const objects = await fetchCalendarObjects({ 164 | calendar: calendars[1], 165 | headers: authHeaders, 166 | }); 167 | 168 | expect(objects.length > 0).toBe(true); 169 | expect(objects.every((o) => o.data.length > 0 && o.etag.length > 0 && o.url.length > 0)).toBe( 170 | true 171 | ); 172 | }); 173 | 174 | test('fetchCalendarObjects should be able to fetch target calendar objects when specified timeRange', async () => { 175 | const iCalString1 = `BEGIN:VCALENDAR 176 | VERSION:2.0 177 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 178 | CALSCALE:GREGORIAN 179 | BEGIN:VEVENT 180 | DTSTART:20210401T090800Z 181 | DTEND:20210401T100800Z 182 | DTSTAMP:20210401T090944Z 183 | UID:4e3ce4c2-02c7-4fbc-ace0-f2b7d579eed6 184 | CREATED:20210401T090944Z 185 | SEQUENCE:0 186 | SUMMARY:Test 187 | STATUS:CONFIRMED 188 | TRANSP:OPAQUE 189 | END:VEVENT 190 | END:VCALENDAR`; 191 | const iCalString2 = `BEGIN:VCALENDAR 192 | VERSION:2.0 193 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 194 | CALSCALE:GREGORIAN 195 | BEGIN:VEVENT 196 | DTSTART:20210402T090800Z 197 | DTEND:20210402T100800Z 198 | DTSTAMP:20210402T090944Z 199 | UID:1f28015d-e140-4484-900b-0fa15e10210e 200 | CREATED:20210402T090944Z 201 | SEQUENCE:0 202 | SUMMARY:Test 203 | STATUS:CONFIRMED 204 | TRANSP:OPAQUE 205 | END:VEVENT 206 | END:VCALENDAR`; 207 | const iCalString3 = `BEGIN:VCALENDAR 208 | VERSION:2.0 209 | PRODID:-//Caldav test./tsdav test 1.0.0//EN 210 | CALSCALE:GREGORIAN 211 | BEGIN:VEVENT 212 | DTSTART:20210502T090800Z 213 | DTEND:20210502T100800Z 214 | DTSTAMP:20210402T090944Z 215 | UID:6aefd54f-c038-409a-8f9c-bf3413efd611 216 | CREATED:20210502T090944Z 217 | SEQUENCE:0 218 | SUMMARY:Test 219 | STATUS:CONFIRMED 220 | TRANSP:OPAQUE 221 | END:VEVENT 222 | END:VCALENDAR`; 223 | const calendars = await fetchCalendars({ 224 | account, 225 | headers: authHeaders, 226 | }); 227 | 228 | const objectUrl1 = new URL('test11.ics', calendars[1].url).href; 229 | const objectUrl2 = new URL('test22.ics', calendars[1].url).href; 230 | const objectUrl3 = new URL('http.ics', calendars[1].url).href; 231 | 232 | const response1 = await createObject({ 233 | url: objectUrl1, 234 | data: iCalString1, 235 | headers: { 236 | 'content-type': 'text/calendar; charset=utf-8', 237 | ...authHeaders, 238 | }, 239 | }); 240 | 241 | const response2 = await createObject({ 242 | url: objectUrl2, 243 | data: iCalString2, 244 | headers: { 245 | 'content-type': 'text/calendar; charset=utf-8', 246 | ...authHeaders, 247 | }, 248 | }); 249 | 250 | const response3 = await createObject({ 251 | url: objectUrl3, 252 | data: iCalString3, 253 | headers: { 254 | 'content-type': 'text/calendar; charset=utf-8', 255 | ...authHeaders, 256 | }, 257 | }); 258 | 259 | expect(response1.ok).toBe(true); 260 | expect(response2.ok).toBe(true); 261 | expect(response3.ok).toBe(true); 262 | 263 | const objects = await fetchCalendarObjects({ 264 | calendar: calendars[1], 265 | headers: authHeaders, 266 | timeRange: { 267 | start: '2021-05-01T00:00:00.000Z', 268 | end: '2021-05-04T00:00:00.000Z', 269 | }, 270 | }); 271 | 272 | expect(objects.length).toBe(1); 273 | expect(objects[0].url).toEqual(objectUrl3); 274 | 275 | const deleteResult1 = await deleteObject({ 276 | url: objectUrl1, 277 | headers: authHeaders, 278 | }); 279 | 280 | const deleteResult2 = await deleteObject({ 281 | url: objectUrl2, 282 | headers: authHeaders, 283 | }); 284 | 285 | const deleteResult3 = await deleteObject({ 286 | url: objectUrl3, 287 | headers: authHeaders, 288 | }); 289 | 290 | expect(deleteResult1.ok).toBe(true); 291 | expect(deleteResult2.ok).toBe(true); 292 | expect(deleteResult3.ok).toBe(true); 293 | }); 294 | 295 | test('fetchCalendarObjects should fail when passed timeRange is invalid', async () => { 296 | const calendars = await fetchCalendars({ 297 | account, 298 | headers: authHeaders, 299 | }); 300 | const t = () => 301 | fetchCalendarObjects({ 302 | calendar: calendars[1], 303 | headers: authHeaders, 304 | timeRange: { 305 | start: 'Sat May 01 2021 00:00:00 GMT+0800', 306 | end: 'Sat May 04 2021 00:00:00 GMT+0800', 307 | }, 308 | }); 309 | 310 | expect(t()).rejects.toEqual(new Error('invalid timeRange format, not in ISO8601')); 311 | }); 312 | -------------------------------------------------------------------------------- /src/collection.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import getLogger from 'debug'; 3 | 4 | import { DAVNamespace, DAVNamespaceShorthandMap } from './consts'; 5 | import { davRequest, propfind } from './request'; 6 | import { DAVDepth, DAVProp, DAVResponse } from './types/DAVTypes'; 7 | import { SmartCollectionSync } from './types/functionsOverloads'; 8 | import { DAVAccount, DAVCollection, DAVObject } from './types/models'; 9 | import { cleanupFalsy, formatProps, getDAVAttribute, urlContains } from './util/requestHelpers'; 10 | import { findMissingFieldNames, hasFields, RequireAndNotNullSome } from './util/typeHelpers'; 11 | 12 | const debug = getLogger('tsdav:collection'); 13 | 14 | export const collectionQuery = async (params: { 15 | url: string; 16 | body: any; 17 | depth?: DAVDepth; 18 | defaultNamespace?: DAVNamespace; 19 | headers?: Record; 20 | }): Promise => { 21 | const { url, body, depth, defaultNamespace = DAVNamespace.DAV, headers } = params; 22 | return davRequest({ 23 | url, 24 | init: { 25 | method: 'REPORT', 26 | headers: cleanupFalsy({ ...headers, depth }), 27 | namespace: DAVNamespaceShorthandMap[defaultNamespace], 28 | body, 29 | }, 30 | }); 31 | }; 32 | 33 | export const makeCollection = async (params: { 34 | url: string; 35 | props?: DAVProp[]; 36 | depth?: DAVDepth; 37 | headers?: Record; 38 | }): Promise => { 39 | const { url, props, depth, headers } = params; 40 | return davRequest({ 41 | url, 42 | init: { 43 | method: 'MKCOL', 44 | headers: cleanupFalsy({ ...headers, depth }), 45 | namespace: DAVNamespaceShorthandMap[DAVNamespace.DAV], 46 | body: props 47 | ? { 48 | mkcol: { 49 | set: { 50 | prop: formatProps(props), 51 | }, 52 | }, 53 | } 54 | : undefined, 55 | }, 56 | }); 57 | }; 58 | 59 | export const supportedReportSet = async (params: { 60 | collection: DAVCollection; 61 | headers?: Record; 62 | }): Promise => { 63 | const { collection, headers } = params; 64 | const res = await propfind({ 65 | url: collection.url, 66 | props: [{ name: 'supported-report-set', namespace: DAVNamespace.DAV }], 67 | depth: '1', 68 | headers, 69 | }); 70 | return res[0]?.props?.supportedReportSet.supportedReport.map( 71 | (sr: { report: any }) => Object.keys(sr.report)[0] 72 | ); 73 | }; 74 | 75 | export const isCollectionDirty = async (params: { 76 | collection: DAVCollection; 77 | headers?: Record; 78 | }): Promise<{ 79 | isDirty: boolean; 80 | newCtag: string; 81 | }> => { 82 | const { collection, headers } = params; 83 | const responses = await propfind({ 84 | url: collection.url, 85 | props: [{ name: 'getctag', namespace: DAVNamespace.CALENDAR_SERVER }], 86 | depth: '0', 87 | headers, 88 | }); 89 | const res = responses.filter((r) => urlContains(collection.url, r.href))[0]; 90 | if (!res) { 91 | throw new Error('Collection does not exist on server'); 92 | } 93 | return { isDirty: collection.ctag !== res.props?.getctag, newCtag: res.props?.getctag }; 94 | }; 95 | 96 | /** 97 | * This is for webdav sync-collection only 98 | */ 99 | export const syncCollection = (params: { 100 | url: string; 101 | props: DAVProp[]; 102 | headers?: Record; 103 | syncLevel?: number; 104 | syncToken?: string; 105 | }): Promise => { 106 | const { url, props, headers, syncLevel, syncToken } = params; 107 | return davRequest({ 108 | url, 109 | init: { 110 | method: 'REPORT', 111 | namespace: DAVNamespaceShorthandMap[DAVNamespace.DAV], 112 | headers: { ...headers }, 113 | body: { 114 | 'sync-collection': { 115 | _attributes: getDAVAttribute([ 116 | DAVNamespace.CALDAV, 117 | DAVNamespace.CARDDAV, 118 | DAVNamespace.DAV, 119 | ]), 120 | 'sync-level': syncLevel, 121 | 'sync-token': syncToken, 122 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 123 | }, 124 | }, 125 | }, 126 | }); 127 | }; 128 | 129 | /** remote collection to local */ 130 | export const smartCollectionSync: SmartCollectionSync = async (params: { 131 | collection: T; 132 | method?: 'basic' | 'webdav'; 133 | headers?: Record; 134 | account?: DAVAccount; 135 | detailedResult?: boolean; 136 | }): Promise => { 137 | const { collection, method, headers, account, detailedResult } = params; 138 | const requiredFields: Array<'accountType' | 'homeUrl'> = ['accountType', 'homeUrl']; 139 | if (!account || !hasFields(account, requiredFields)) { 140 | if (!account) { 141 | throw new Error('no account for smartCollectionSync'); 142 | } 143 | throw new Error( 144 | `account must have ${findMissingFieldNames( 145 | account, 146 | requiredFields 147 | )} before smartCollectionSync` 148 | ); 149 | } 150 | 151 | const syncMethod = 152 | method ?? (collection.reports?.includes('syncCollection') ? 'webdav' : 'basic'); 153 | debug(`smart collection sync with type ${account.accountType} and method ${syncMethod}`); 154 | 155 | if (syncMethod === 'webdav') { 156 | const result = await syncCollection({ 157 | url: collection.url, 158 | props: [ 159 | { name: 'getetag', namespace: DAVNamespace.DAV }, 160 | { 161 | name: account.accountType === 'caldav' ? 'calendar-data' : 'address-data', 162 | namespace: account.accountType === 'caldav' ? DAVNamespace.CALDAV : DAVNamespace.CARDDAV, 163 | }, 164 | { 165 | name: 'displayname', 166 | namespace: DAVNamespace.DAV, 167 | }, 168 | ], 169 | syncLevel: 1, 170 | syncToken: collection.syncToken, 171 | headers, 172 | }); 173 | 174 | const objectResponses = result.filter((r): r is RequireAndNotNullSome => { 175 | const extName = account.accountType === 'caldav' ? '.ics' : '.vcf'; 176 | return r.href?.slice(-4) === extName; 177 | }); 178 | 179 | const changedObjectUrls = objectResponses.filter((o) => o.status !== 404).map((r) => r.href); 180 | 181 | const deletedObjectUrls = objectResponses.filter((o) => o.status === 404).map((r) => r.href); 182 | 183 | const multiGetObjectResponse = changedObjectUrls.length 184 | ? (await collection?.objectMultiGet?.({ 185 | url: collection.url, 186 | props: [ 187 | { name: 'getetag', namespace: DAVNamespace.DAV }, 188 | { 189 | name: account.accountType === 'caldav' ? 'calendar-data' : 'address-data', 190 | namespace: 191 | account.accountType === 'caldav' ? DAVNamespace.CALDAV : DAVNamespace.CARDDAV, 192 | }, 193 | ], 194 | objectUrls: changedObjectUrls, 195 | depth: '1', 196 | headers, 197 | })) ?? [] 198 | : []; 199 | 200 | const remoteObjects = multiGetObjectResponse.map((res) => { 201 | return { 202 | url: res.href ?? '', 203 | etag: res.props?.getetag, 204 | data: 205 | account?.accountType === 'caldav' 206 | ? res.props?.calendarData?._cdata ?? res.props?.calendarData 207 | : res.props?.addressData?._cdata ?? res.props?.addressData, 208 | }; 209 | }); 210 | 211 | const localObjects = collection.objects ?? []; 212 | 213 | // no existing url 214 | const created: DAVObject[] = remoteObjects.filter((o) => 215 | localObjects.every((lo) => !urlContains(lo.url, o.url)) 216 | ); 217 | // debug(`created objects: ${created.map((o) => o.url).join('\n')}`); 218 | 219 | // have same url, but etag different 220 | const updated = localObjects.reduce((prev, curr) => { 221 | const found = remoteObjects.find((ro) => urlContains(ro.url, curr.url)); 222 | if (found && found.etag && found.etag !== curr.etag) { 223 | return [...prev, found]; 224 | } 225 | return prev; 226 | }, []); 227 | // debug(`updated objects: ${updated.map((o) => o.url).join('\n')}`); 228 | 229 | const deleted: DAVObject[] = deletedObjectUrls.map((o) => ({ 230 | url: o, 231 | etag: '', 232 | })); 233 | // debug(`deleted objects: ${deleted.map((o) => o.url).join('\n')}`); 234 | const unchanged = localObjects.filter((lo) => 235 | remoteObjects.some((ro) => urlContains(lo.url, ro.url) && ro.etag === lo.etag) 236 | ); 237 | 238 | return { 239 | ...collection, 240 | objects: detailedResult 241 | ? { created, updated, deleted } 242 | : [...unchanged, ...created, ...updated], 243 | // all syncToken in the results are the same so we use the first one here 244 | syncToken: result[0]?.raw?.multistatus?.syncToken ?? collection.syncToken, 245 | }; 246 | } 247 | 248 | if (syncMethod === 'basic') { 249 | const { isDirty, newCtag } = await isCollectionDirty({ 250 | collection, 251 | headers, 252 | }); 253 | const localObjects = collection.objects ?? []; 254 | const remoteObjects = (await collection.fetchObjects?.({ collection, headers })) ?? []; 255 | 256 | // no existing url 257 | const created = remoteObjects.filter((ro) => 258 | localObjects.every((lo) => !urlContains(lo.url, ro.url)) 259 | ); 260 | // debug(`created objects: ${created.map((o) => o.url).join('\n')}`); 261 | 262 | // have same url, but etag different 263 | const updated = localObjects.reduce((prev, curr) => { 264 | const found = remoteObjects.find((ro) => urlContains(ro.url, curr.url)); 265 | if (found && found.etag && found.etag !== curr.etag) { 266 | return [...prev, found]; 267 | } 268 | return prev; 269 | }, []); 270 | // debug(`updated objects: ${updated.map((o) => o.url).join('\n')}`); 271 | 272 | // does not present in remote 273 | const deleted = localObjects.filter((cal) => 274 | remoteObjects.every((ro) => !urlContains(ro.url, cal.url)) 275 | ); 276 | // debug(`deleted objects: ${deleted.map((o) => o.url).join('\n')}`); 277 | 278 | const unchanged = localObjects.filter((lo) => 279 | remoteObjects.some((ro) => urlContains(lo.url, ro.url) && ro.etag === lo.etag) 280 | ); 281 | 282 | if (isDirty) { 283 | return { 284 | ...collection, 285 | objects: detailedResult 286 | ? { created, updated, deleted } 287 | : [...unchanged, ...created, ...updated], 288 | ctag: newCtag, 289 | }; 290 | } 291 | } 292 | 293 | return detailedResult 294 | ? { 295 | ...collection, 296 | objects: { 297 | created: [], 298 | updated: [], 299 | deleted: [], 300 | }, 301 | } 302 | : collection; 303 | }; 304 | -------------------------------------------------------------------------------- /src/calendar.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import getLogger from 'debug'; 3 | 4 | import { collectionQuery, smartCollectionSync, supportedReportSet } from './collection'; 5 | import { DAVNamespace, DAVNamespaceShorthandMap, ICALObjects } from './consts'; 6 | import { createObject, davRequest, deleteObject, propfind, updateObject } from './request'; 7 | import { DAVDepth, DAVFilter, DAVProp, DAVResponse } from './types/DAVTypes'; 8 | import { SyncCalendars } from './types/functionsOverloads'; 9 | import { DAVAccount, DAVCalendar, DAVCalendarObject } from './types/models'; 10 | import { 11 | cleanupFalsy, 12 | formatFilters, 13 | formatProps, 14 | getDAVAttribute, 15 | urlContains, 16 | } from './util/requestHelpers'; 17 | import { findMissingFieldNames, hasFields } from './util/typeHelpers'; 18 | 19 | const debug = getLogger('tsdav:calendar'); 20 | 21 | export const calendarQuery = async (params: { 22 | url: string; 23 | props: DAVProp[]; 24 | filters?: DAVFilter[]; 25 | timezone?: string; 26 | depth?: DAVDepth; 27 | headers?: Record; 28 | }): Promise => { 29 | const { url, props, filters, timezone, depth, headers } = params; 30 | return collectionQuery({ 31 | url, 32 | body: { 33 | 'calendar-query': { 34 | _attributes: getDAVAttribute([ 35 | DAVNamespace.CALDAV, 36 | DAVNamespace.CALENDAR_SERVER, 37 | DAVNamespace.CALDAV_APPLE, 38 | DAVNamespace.DAV, 39 | ]), 40 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 41 | filter: formatFilters(filters), 42 | timezone, 43 | }, 44 | }, 45 | defaultNamespace: DAVNamespace.CALDAV, 46 | depth, 47 | headers, 48 | }); 49 | }; 50 | 51 | export const calendarMultiGet = async (params: { 52 | url: string; 53 | props: DAVProp[]; 54 | objectUrls?: string[]; 55 | filters?: DAVFilter[]; 56 | timezone?: string; 57 | depth: DAVDepth; 58 | headers?: Record; 59 | }): Promise => { 60 | const { url, props, objectUrls, filters, timezone, depth, headers } = params; 61 | return collectionQuery({ 62 | url, 63 | body: { 64 | 'calendar-multiget': { 65 | _attributes: getDAVAttribute([DAVNamespace.DAV, DAVNamespace.CALDAV]), 66 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:prop`]: formatProps(props), 67 | [`${DAVNamespaceShorthandMap[DAVNamespace.DAV]}:href`]: objectUrls, 68 | filter: formatFilters(filters), 69 | timezone, 70 | }, 71 | }, 72 | defaultNamespace: DAVNamespace.CALDAV, 73 | depth, 74 | headers, 75 | }); 76 | }; 77 | 78 | export const makeCalendar = async (params: { 79 | url: string; 80 | props: DAVProp[]; 81 | depth?: DAVDepth; 82 | headers?: Record; 83 | }): Promise => { 84 | const { url, props, depth, headers } = params; 85 | return davRequest({ 86 | url, 87 | init: { 88 | method: 'MKCALENDAR', 89 | headers: cleanupFalsy({ ...headers, depth }), 90 | namespace: DAVNamespaceShorthandMap[DAVNamespace.DAV], 91 | body: { 92 | [`${DAVNamespaceShorthandMap[DAVNamespace.CALDAV]}:mkcalendar`]: { 93 | _attributes: getDAVAttribute([DAVNamespace.DAV, DAVNamespace.CALDAV]), 94 | set: { 95 | prop: formatProps(props), 96 | }, 97 | }, 98 | }, 99 | }, 100 | }); 101 | }; 102 | 103 | export const fetchCalendars = async (params?: { 104 | account?: DAVAccount; 105 | headers?: Record; 106 | }): Promise => { 107 | const { headers, account } = params ?? {}; 108 | const requiredFields: Array<'homeUrl' | 'rootUrl'> = ['homeUrl', 'rootUrl']; 109 | if (!account || !hasFields(account, requiredFields)) { 110 | if (!account) { 111 | throw new Error('no account for fetchCalendars'); 112 | } 113 | throw new Error( 114 | `account must have ${findMissingFieldNames(account, requiredFields)} before fetchCalendars` 115 | ); 116 | } 117 | 118 | const res = await propfind({ 119 | url: account.homeUrl, 120 | props: [ 121 | { name: 'calendar-description', namespace: DAVNamespace.CALDAV }, 122 | { name: 'calendar-timezone', namespace: DAVNamespace.CALDAV }, 123 | { name: 'displayname', namespace: DAVNamespace.DAV }, 124 | { name: 'getctag', namespace: DAVNamespace.CALENDAR_SERVER }, 125 | { name: 'resourcetype', namespace: DAVNamespace.DAV }, 126 | { name: 'supported-calendar-component-set', namespace: DAVNamespace.CALDAV }, 127 | { name: 'sync-token', namespace: DAVNamespace.DAV }, 128 | ], 129 | depth: '1', 130 | headers, 131 | }); 132 | 133 | return Promise.all( 134 | res 135 | .filter((r) => Object.keys(r.props?.resourcetype ?? {}).includes('calendar')) 136 | .filter((rc) => { 137 | // filter out none iCal format calendars. 138 | const components: ICALObjects[] = Array.isArray( 139 | rc.props?.supportedCalendarComponentSet.comp 140 | ) 141 | ? rc.props?.supportedCalendarComponentSet.comp.map((sc: any) => sc._attributes.name) 142 | : [rc.props?.supportedCalendarComponentSet.comp._attributes.name] || []; 143 | return components.some((c) => Object.values(ICALObjects).includes(c)); 144 | }) 145 | .map((rs) => { 146 | // debug(`Found calendar ${rs.props?.displayname}`); 147 | const description = rs.props?.calendarDescription; 148 | const timezone = rs.props?.calendarTimezone; 149 | return { 150 | description: typeof description === 'string' ? description : '', 151 | url: new URL(rs.href ?? '', account.rootUrl ?? '').href, 152 | ctag: rs.props?.getctag, 153 | displayName: rs.props?.displayname, 154 | components: Array.isArray(rs.props?.supportedCalendarComponentSet.comp) 155 | ? rs.props?.supportedCalendarComponentSet.comp.map((sc: any) => sc._attributes.name) 156 | : [rs.props?.supportedCalendarComponentSet.comp._attributes.name], 157 | resourcetype: Object.keys(rs.props?.resourcetype), 158 | syncToken: rs.props?.syncToken, 159 | }; 160 | }) 161 | .map(async (cal) => ({ 162 | ...cal, 163 | reports: await supportedReportSet({ collection: cal, headers }), 164 | })) 165 | ); 166 | }; 167 | 168 | export const fetchCalendarObjects = async (params: { 169 | calendar: DAVCalendar; 170 | objectUrls?: string[]; 171 | filters?: DAVFilter[]; 172 | timeRange?: { start: string; end: string }; 173 | headers?: Record; 174 | }): Promise => { 175 | const { calendar, objectUrls, filters: defaultFilters, timeRange, headers } = params; 176 | 177 | if (timeRange) { 178 | // validate timeRange 179 | const ISO_8601 = /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i; 180 | const ISO_8601_FULL = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(([+-]\d\d:\d\d)|Z)?$/i; 181 | if ( 182 | (!ISO_8601.test(timeRange.start) || !ISO_8601.test(timeRange.end)) && 183 | (!ISO_8601_FULL.test(timeRange.start) || !ISO_8601_FULL.test(timeRange.end)) 184 | ) { 185 | throw new Error('invalid timeRange format, not in ISO8601'); 186 | } 187 | } 188 | debug(`Fetching calendar objects from ${calendar?.url}`); 189 | const requiredFields: Array<'url'> = ['url']; 190 | if (!calendar || !hasFields(calendar, requiredFields)) { 191 | if (!calendar) { 192 | throw new Error('cannot fetchCalendarObjects for undefined calendar'); 193 | } 194 | throw new Error( 195 | `calendar must have ${findMissingFieldNames( 196 | calendar, 197 | requiredFields 198 | )} before fetchCalendarObjects` 199 | ); 200 | } 201 | 202 | debug(`actually`); 203 | // default to fetch all 204 | const filters: DAVFilter[] = defaultFilters ?? [ 205 | { 206 | type: 'comp-filter', 207 | attributes: { name: 'VCALENDAR' }, 208 | children: [ 209 | { 210 | type: 'comp-filter', 211 | attributes: { name: 'VEVENT' }, 212 | children: timeRange 213 | ? [ 214 | { 215 | type: 'time-range', 216 | attributes: { 217 | start: timeRange?.start.replace(/[-:.]/g, ''), 218 | end: timeRange?.end.replace(/[-:.]/g, ''), 219 | }, 220 | }, 221 | ] 222 | : undefined, 223 | }, 224 | ], 225 | }, 226 | ]; 227 | 228 | const calendarObjectUrls = ( 229 | objectUrls ?? 230 | // fetch all objects of the calendar 231 | ( 232 | await calendarQuery({ 233 | url: calendar.url, 234 | props: [{ name: 'getetag', namespace: DAVNamespace.DAV }], 235 | filters, 236 | depth: '1', 237 | headers, 238 | }) 239 | ).map((res) => res.href ?? '') 240 | ) 241 | .map((url) => (url.startsWith('http') ? url : new URL(url, calendar.url).href)) // patch up to full url if url is not full 242 | .map((url) => new URL(url).pathname) // obtain pathname of the url 243 | .filter((url): url is string => Boolean(url?.includes('.ics'))); // filter out non ics calendar objects since apple calendar might have those 244 | 245 | const calendarObjectResults = await calendarMultiGet({ 246 | url: calendar.url, 247 | props: [ 248 | { name: 'getetag', namespace: DAVNamespace.DAV }, 249 | { name: 'calendar-data', namespace: DAVNamespace.CALDAV }, 250 | ], 251 | objectUrls: calendarObjectUrls, 252 | depth: '1', 253 | headers, 254 | }); 255 | 256 | return calendarObjectResults.map((res) => ({ 257 | url: new URL(res.href ?? '', calendar.url).href, 258 | etag: res.props?.getetag, 259 | data: res.props?.calendarData?._cdata ?? res.props?.calendarData, 260 | })); 261 | }; 262 | 263 | export const createCalendarObject = async (params: { 264 | calendar: DAVCalendar; 265 | iCalString: string; 266 | filename: string; 267 | headers?: Record; 268 | }): Promise => { 269 | const { calendar, iCalString, filename, headers } = params; 270 | return createObject({ 271 | url: new URL(filename, calendar.url).href, 272 | data: iCalString, 273 | headers: { 274 | 'content-type': 'text/calendar; charset=utf-8', 275 | ...headers, 276 | }, 277 | }); 278 | }; 279 | 280 | export const updateCalendarObject = async (params: { 281 | calendarObject: DAVCalendarObject; 282 | headers?: Record; 283 | }): Promise => { 284 | const { calendarObject, headers } = params; 285 | return updateObject({ 286 | url: calendarObject.url, 287 | data: calendarObject.data, 288 | etag: calendarObject.etag, 289 | headers: { 290 | 'content-type': 'text/calendar; charset=utf-8', 291 | ...headers, 292 | }, 293 | }); 294 | }; 295 | 296 | export const deleteCalendarObject = async (params: { 297 | calendarObject: DAVCalendarObject; 298 | headers?: Record; 299 | }): Promise => { 300 | const { calendarObject, headers } = params; 301 | return deleteObject({ url: calendarObject.url, etag: calendarObject.etag, headers }); 302 | }; 303 | 304 | /** 305 | * Sync remote calendars to local 306 | */ 307 | export const syncCalendars: SyncCalendars = async (params: { 308 | oldCalendars: DAVCalendar[]; 309 | headers?: Record; 310 | account?: DAVAccount; 311 | detailedResult?: boolean; 312 | }): Promise => { 313 | const { oldCalendars, account, detailedResult, headers } = params; 314 | if (!account) { 315 | throw new Error('Must have account before syncCalendars'); 316 | } 317 | 318 | const localCalendars = oldCalendars ?? account.calendars ?? []; 319 | const remoteCalendars = await fetchCalendars({ account, headers }); 320 | 321 | // no existing url 322 | const created = remoteCalendars.filter((rc) => 323 | localCalendars.every((lc) => !urlContains(lc.url, rc.url)) 324 | ); 325 | debug(`new calendars: ${created.map((cc) => cc.displayName)}`); 326 | 327 | // have same url, but syncToken/ctag different 328 | const updated = localCalendars.reduce((prev, curr) => { 329 | const found = remoteCalendars.find((rc) => urlContains(rc.url, curr.url)); 330 | if ( 331 | found && 332 | ((found.syncToken && found.syncToken !== curr.syncToken) || 333 | (found.ctag && found.ctag !== curr.ctag)) 334 | ) { 335 | return [...prev, found]; 336 | } 337 | return prev; 338 | }, []); 339 | debug(`updated calendars: ${updated.map((cc) => cc.displayName)}`); 340 | 341 | const updatedWithObjects: DAVCalendar[] = await Promise.all( 342 | updated.map(async (u) => { 343 | const result = await smartCollectionSync({ 344 | collection: { ...u, objectMultiGet: calendarMultiGet }, 345 | method: 'webdav', 346 | headers, 347 | account, 348 | }); 349 | return result; 350 | }) 351 | ); 352 | // does not present in remote 353 | const deleted = localCalendars.filter((cal) => 354 | remoteCalendars.every((rc) => !urlContains(rc.url, cal.url)) 355 | ); 356 | debug(`deleted calendars: ${deleted.map((cc) => cc.displayName)}`); 357 | 358 | const unchanged = localCalendars.filter((cal) => 359 | remoteCalendars.some( 360 | (rc) => 361 | urlContains(rc.url, cal.url) && 362 | ((rc.syncToken && rc.syncToken !== cal.syncToken) || (rc.ctag && rc.ctag !== cal.ctag)) 363 | ) 364 | ); 365 | // debug(`unchanged calendars: ${unchanged.map((cc) => cc.displayName)}`); 366 | 367 | return detailedResult 368 | ? { 369 | created, 370 | updated, 371 | deleted, 372 | } 373 | : [...unchanged, ...created, ...updatedWithObjects]; 374 | }; 375 | --------------------------------------------------------------------------------