From 0e02d02652dddbba73a106421d92e07b1570b977 Mon Sep 17 00:00:00 2001 From: Nathaniel Lin Date: Thu, 1 Oct 2026 09:28:01 +0800 Subject: [PATCH 1/2] fix: harden DAV parsing, synchronization, and authentication --- .github/workflows/ci.yml | 19 +- .github/workflows/release.yml | 13 +- .gitignore | 2 + biome.jsonc | 2 +- docs/README.md | 16 +- docs/agent-architecture-overview.md | 7 +- docs/docs/caldav/fetchCalendarObjects.md | 6 + docs/docs/caldav/freeBusyQuery.md | 7 +- docs/docs/caldav/import-ical-feed.md | 99 ++- docs/docs/caldav/syncCalendars.md | 16 +- docs/docs/helper.mdx | 55 +- docs/docs/helpers/authHelpers.md | 7 + docs/docs/intro.md | 27 +- docs/docs/smart calendar sync.md | 330 +++------ docs/docs/types/DAVResponse.md | 16 +- docs/docs/webdav/account/serviceDiscovery.md | 14 +- .../webdav/collection/isCollectionDirty.md | 2 +- .../webdav/collection/smartCollectionSync.md | 9 +- docs/docs/webdav/davRequest.md | 5 + docs/docusaurus.config.js | 12 +- docs/docusuarusWebpack5Plugin.js | 1 + docs/package.json | 9 +- docs/plans/review-fixes.md | 95 +++ docs/pnpm-lock.yaml | 52 +- docs/pnpm-workspace.yaml | 3 + docs/src/components/Converter.module.css | 39 +- docs/src/components/Converter.tsx | 100 ++- docs/src/components/ConverterBase.ts | 103 +-- docs/tsconfig.json | 13 +- knip.json | 40 ++ package.json | 9 +- pnpm-lock.yaml | 667 +++++++++++++++--- scripts/check-doc-examples.cjs | 41 ++ scripts/clean-dist.cjs | 9 +- src/__tests__/unit/calendar.test.ts | 1 + src/__tests__/unit/review-regressions.test.ts | 503 +++++++++++++ src/__tests__/unit/serviceDiscovery.test.ts | 85 +++ src/account.ts | 32 +- src/addressBook.ts | 42 +- src/calendar.ts | 138 ++-- src/client.ts | 604 +++++----------- src/collection.ts | 173 ++--- src/index.ts | 10 +- src/request.ts | 115 ++- src/types/DAVTypes.ts | 12 + src/types/models.ts | 3 + src/util/authHelpers.ts | 30 +- src/util/responseHelpers.ts | 76 ++ src/util/syncHelpers.ts | 39 + src/util/typeHelpers.ts | 4 + src/util/xml.ts | 179 +++++ tests/consumer/index.ts | 63 ++ tests/consumer/tsconfig.json | 12 + tests/docs-examples.test.ts | 219 ++++++ vitest.config.ts | 7 +- 55 files changed, 2839 insertions(+), 1353 deletions(-) create mode 100644 docs/plans/review-fixes.md create mode 100644 knip.json create mode 100644 scripts/check-doc-examples.cjs create mode 100644 src/__tests__/unit/review-regressions.test.ts create mode 100644 src/__tests__/unit/serviceDiscovery.test.ts create mode 100644 src/util/responseHelpers.ts create mode 100644 src/util/syncHelpers.ts create mode 100644 src/util/xml.ts create mode 100644 tests/consumer/index.ts create mode 100644 tests/consumer/tsconfig.json create mode 100644 tests/docs-examples.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50b05eab..94495314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,10 @@ jobs: if: matrix.node-version >= 22 run: pnpm lint + - name: Build package for entry tests + if: matrix.node-version >= 22 + run: pnpm build + - name: Run `test` if: matrix.node-version >= 22 run: pnpm test @@ -59,6 +63,13 @@ jobs: CREDENTIAL_NEXTCLOUD_PASSWORD: ${{secrets.CREDENTIAL_NEXTCLOUD_PASSWORD}} CREDENTIAL_NEXTCLOUD_SERVER_URL: ${{secrets.CREDENTIAL_NEXTCLOUD_SERVER_URL}} + - name: Check public consumers and examples + if: matrix.node-version >= 22 + run: | + pnpm test:consumer + pnpm test:examples + pnpm exec knip --dependencies --workspace . + - name: Build package for runtime smoke test if: matrix.node-version < 22 run: | @@ -232,6 +243,12 @@ jobs: cache: 'pnpm' cache-dependency-path: docs/pnpm-lock.yaml - run: pnpm install --frozen-lockfile + - name: Install shared-source verification tools + working-directory: . + run: pnpm install --frozen-lockfile + - name: Check docs dependencies + working-directory: . + run: pnpm exec knip --dependencies --workspace docs - run: pnpm test - - run: pnpm exec tsc --noEmit + - run: pnpm typecheck - run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7c5c6ee..f4122f2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,11 @@ jobs: - name: check types run: pnpm typecheck - # - name: lint - # run: pnpm lint + - name: lint + run: pnpm lint + + - name: build package for verification + run: pnpm build - name: test run: pnpm test @@ -57,6 +60,12 @@ jobs: CREDENTIAL_NEXTCLOUD_PASSWORD: ${{secrets.CREDENTIAL_NEXTCLOUD_PASSWORD}} CREDENTIAL_NEXTCLOUD_SERVER_URL: ${{secrets.CREDENTIAL_NEXTCLOUD_SERVER_URL}} + - name: check public consumers and examples + run: | + pnpm test:consumer + pnpm test:examples + pnpm exec knip --dependencies --workspace . + # Publish to npm with provenance statements # Requires NPM_TOKEN secret to be configured in repository secrets # or in the Production environment secrets diff --git a/.gitignore b/.gitignore index 52889a96..5e77ce17 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,5 @@ typings/ .DS_Store .vercel .vscode/ + +/tests/.examples-*/ diff --git a/biome.jsonc b/biome.jsonc index a2fcdf53..cf978dc2 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/docs/README.md b/docs/README.md index 231a499c..8caa6974 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,17 +1,17 @@ # Website -This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. +This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator. ## Installation ```console -yarn install +pnpm install ``` ## Local Development ```console -yarn start +pnpm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. @@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo ## Build ```console -yarn build +pnpm build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. @@ -27,7 +27,13 @@ This command generates static content into the `build` directory and can be serv ## Deployment ```console -GIT_USER= USE_SSH=true yarn deploy +GIT_USER= USE_SSH=true pnpm deploy ``` 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. + +Run `pnpm test` and `pnpm typecheck` here before building. The repository root also provides +`pnpm test:examples`, `pnpm test:consumer`, and `pnpm check:dependencies`. + +The Knip configuration retains Docusaurus type packages required by the inherited TypeScript +configuration, React Router types used by those aliases, and the virtual `@docusaurus/router` module. diff --git a/docs/agent-architecture-overview.md b/docs/agent-architecture-overview.md index 10b2abeb..bf4d4b56 100644 --- a/docs/agent-architecture-overview.md +++ b/docs/agent-architecture-overview.md @@ -16,6 +16,7 @@ workflow, project guardrails, and verification guidance. - `serviceDiscovery`, `fetchPrincipalUrl`, and `fetchHomeUrl` locate account roots via `/.well-known/` redirects (`docs/docs/webdav/account`). - `createAccount` enriches a `DAVAccount` with discovered URLs plus calendars/address books when supplied credentials. +- `src/util/xml.ts` normalizes XML after parsing with `xml-js`, preserving strings, CDATA order, and namespace collisions. `DAVResponse.propStats` retains individual property statuses and namespace maps. - `davRequest` is the low-level fetch wrapper shared by WebDAV, CalDAV, and CardDAV helpers (`docs/docs/webdav/davRequest.md`). - Object helpers map to HTTP verbs: `createObject`, `updateObject`, and `deleteObject` issue PUT/PATCH/DELETE with concurrency headers; `propfind` reads WebDAV metadata (`docs/docs/webdav`). @@ -24,7 +25,7 @@ workflow, project guardrails, and verification guidance. - Enumerate calendars with `fetchCalendars`; filter or retrieve objects via `fetchCalendarObjects`, `calendarMultiGet`, and `calendarQuery` (`docs/docs/caldav`). - Write data with `createCalendarObject`, `updateCalendarObject`, and `deleteCalendarObject`; the helpers enforce proper `If-Match`/`If-None-Match` usage. - `syncCalendars` and `smartCollectionSync` surface server-side `sync-token`/`ctag` deltas for incremental syncs. -- `smart calendar sync.md` documents end-to-end two-way sync: recommended database schema, handling created/updated/deleted objects, and when to fall back to multi-get. +- `smart calendar sync.md` documents transactional remote-to-local sync with explicit storage adapters, complete component queries, and checked local-to-remote writes. ## CardDAV Workflow Highlights @@ -33,7 +34,7 @@ workflow, project guardrails, and verification guidance. ## Helpers and Types -- `docs/docs/helper.mdx` exposes the XML ⇄ JS converter to assemble ElementCompact request bodies without manual XML string building. +- `docs/docs/helper.mdx` exposes a JSON/XML converter for compact request bodies and normalized response inspection. It uses the same XML normalization as the library. - Typed shapes such as `DAVAccount`, `DAVCalendar`, `DAVCalendarObject`, `DAVAddressBook`, `ElementCompact`, and credential tokens are documented in `docs/docs/types/`. - Most high-level functions accept `headers`, `headersToExclude`, and `fetchOptions` overrides—ensure custom auth headers align with provider requirements. - You can override the underlying `fetch` implementation for custom transports (Electron, KaiOS, Workers); see `docs/docs/intro.md` and `docs/docs/cloud providers.md`. @@ -45,7 +46,7 @@ workflow, project guardrails, and verification guidance. ## Field Tips for Agents -- Always normalize calendar object URLs using `URL.resolve` when combining parent collection URLs with relative paths (see smart sync example). +- Always normalize calendar object URLs using `new URL(href, collectionUrl).href` when combining parent collection URLs with relative paths (see smart sync example). - When overriding CalDAV/CardDAV `props`, keep required fields (`supported-calendar-component-set`, `resourcetype`) to prevent server errors. - Reuse helper predicates (`urlFilter`, `useMultiGet`) for providers that emit non-`.ics` or non-`.vcf` keys. - Integration tests (`pnpm test:`) hit live services—guard credentials via environment variables and skip in CI unless configured. diff --git a/docs/docs/caldav/fetchCalendarObjects.md b/docs/docs/caldav/fetchCalendarObjects.md index 7b3849b7..ec1e85e9 100644 --- a/docs/docs/caldav/fetchCalendarObjects.md +++ b/docs/docs/caldav/fetchCalendarObjects.md @@ -32,6 +32,7 @@ const objects = await fetchCalendarObjects({ in this case, you need to pass in your own calendar object name filter so that you can have results you need. ::: - `urlFilter` **default: function which only keep .ics objects** predicate function to filter urls from the calendar objects before fetching +- `expand` requires `timeRange`; requests without it reject. - `expand` whether to [expand](https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5) the calendar objects, forcing the server to expand recurring components into individual calendar objects. :::info some calendar providers may not support calendarMultiGet, then it's necessary to use calendarQuery to fetch calendar object data. @@ -45,3 +46,8 @@ array of [DAVCalendarObject](../types/DAVCalendarObject.md) ### Behavior a mix of [calendarMultiGet](calendarMultiGet.md) and [calendarQuery](calendarQuery.md), you can specify both filters and objectUrls here. + +The default query selects `VEVENT` resources. For a complete collection snapshot, pass +`filters: { "comp-filter": { _attributes: { name: "VCALENDAR" } } }` and a `urlFilter` that +accepts every object URL except the collection URL, including extensionless resources. Built-in +calendar sync and account loading use this complete query. diff --git a/docs/docs/caldav/freeBusyQuery.md b/docs/docs/caldav/freeBusyQuery.md index 90c4d0e3..faf10d54 100644 --- a/docs/docs/caldav/freeBusyQuery.md +++ b/docs/docs/caldav/freeBusyQuery.md @@ -7,12 +7,13 @@ sidebar_position: 10 query free busy data on calendar :::caution -a lot of caldav providers do not support this method like google, apple. -use with caution. +Support depends on the server. Check its supported reports and provider documentation before use. ::: ```ts -const freeBusyQuery = await freeBusyQuery({ +import { freeBusyQuery } from 'tsdav'; + +const result = await freeBusyQuery({ url: 'https://caldav.icloud.com/123456/calendars/A5639426-B73B-4F90-86AB-D70F7F603E75/', timeRange: { start: '2022-01-28T16:25:33.125Z', diff --git a/docs/docs/caldav/import-ical-feed.md b/docs/docs/caldav/import-ical-feed.md index 2f6207b0..c0ab6823 100644 --- a/docs/docs/caldav/import-ical-feed.md +++ b/docs/docs/caldav/import-ical-feed.md @@ -2,73 +2,62 @@ sidebar_position: 15 --- -# Importing iCal Feeds (Airbnb, Booking.com, etc.) +# Importing iCal feeds -`tsdav` is a CalDAV and CardDAV client, which means it is designed to interact with DAV servers. It does not natively support ingesting and syncing iCal subscription feeds (like those provided by Airbnb or Booking.com) directly. - -However, you can easily implement this functionality by combining `tsdav` with an iCal parser. - -## Recommended Approach - -1. **Fetch the iCal feed**: Use a standard HTTP client (like `fetch` or `axios`) to download the `.ics` file from the source URL. -2. **Parse the feed**: Use a library like [ical.js](https://github.com/mozilla-comm/ical.js) or [node-ical](https://github.com/peterbraden/node-ical) to parse the iCal data. -3. **Push to CalDAV**: Iterate through the events in the parsed feed and use `createCalendarObject` to upload them to your target CalDAV calendar. - -## Example Integration - -Here is a conceptual example of how you might import events from an external iCal URL into a CalDAV calendar using `node-ical` and `tsdav`. +Fetch the subscription feed, parse it with [ical.js](https://github.com/kewisch/ical.js), and upload +one resource per UID. Install `ical.js` alongside `tsdav`. The parser and serializer preserve date +values, escaped text, recurrence rules, and recurrence exceptions. Include the feed's timezone +components in each resource. ```ts -import ical from 'node-ical'; +import ICAL from 'ical.js'; import { createCalendarObject, DAVCalendar } from 'tsdav'; -async function importExternalFeed( +export async function importExternalFeed( feedUrl: string, targetCalendar: DAVCalendar, authHeader: string, ) { - // 1. Fetch and parse the feed - const events = await ical.async.fromURL(feedUrl); - - for (const event of Object.values(events)) { - if (event.type !== 'VEVENT') continue; - - // 2. Convert the event back to an iCal string (if needed) or use the raw source - // Note: You should wrap each event in its own VCALENDAR/VEVENT structure for CalDAV - const iCalString = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//tsdav//Import Helper//EN -BEGIN:VEVENT -UID:${event.uid} -SUMMARY:${event.summary} -DTSTART:${event.start.toISOString().replace(/[-:]/g, '').split('.')[0]}Z -DTEND:${event.end.toISOString().replace(/[-:]/g, '').split('.')[0]}Z -DESCRIPTION:${event.description || ''} -END:VEVENT -END:VCALENDAR`; + const response = await fetch(feedUrl); + if (!response.ok) throw new Error(`Feed download failed: ${response.status}`); + const source = new ICAL.Component(ICAL.parse(await response.text())); + const byUid = new Map(); + for (const event of source.getAllSubcomponents('vevent')) { + const uid = event.getFirstPropertyValue('uid'); + if (typeof uid !== 'string' || !uid) throw new Error('Feed event has no UID'); + const group = byUid.get(uid) ?? []; + group.push(event); + byUid.set(uid, group); + } - // 3. Upload to CalDAV - try { - await createCalendarObject({ - calendar: targetCalendar, - filename: `${event.uid}.ics`, - iCalString, - headers: { - authorization: authHeader, - }, - }); - console.log(`Imported event: ${event.summary}`); - } catch (error) { - // In a real application, you'd handle 412 Precondition Failed (If-None-Match) - // if the event already exists, or use updateCalendarObject if you want to sync. - console.error(`Failed to import event ${event.uid}:`, error); + let imported = 0; + for (const [uid, events] of byUid) { + const resource = new ICAL.Component(['vcalendar', [], []]); + resource.addPropertyWithValue('version', '2.0'); + resource.addPropertyWithValue('prodid', '-//tsdav//Feed importer//EN'); + for (const timezone of source.getAllSubcomponents('vtimezone')) { + resource.addSubcomponent(new ICAL.Component(JSON.parse(JSON.stringify(timezone.toJSON())))); + } + for (const event of events) { + resource.addSubcomponent(new ICAL.Component(JSON.parse(JSON.stringify(event.toJSON())))); } + const result = await createCalendarObject({ + calendar: targetCalendar, + filename: `feed-${encodeURIComponent(uid)}.ics`, + iCalString: resource.toString(), + headers: { authorization: authHeader }, + }); + if (!result.ok) { + throw new Error(`Import failed for ${uid}: ${result.status}`); + } + imported += 1; } + return imported; } ``` -## Considerations - -- **Syncing**: To avoid duplicates, you should track which events have already been imported (e.g., by keeping a map of UIDs). -- **Updates**: If an event changes in the source feed, you should use `updateCalendarObject` instead of `createCalendarObject`. -- **Feed Polling**: You will need to set up your own periodic job (e.g., a cron job or a background worker) to poll the iCal feed and trigger the import process. +This is an import, not a feed synchronization policy. `createCalendarObject` uses `If-None-Match: *`; +a duplicate resource returns 412 and the example throws. For repeated polling, keep a UID-to-resource +mapping and fetched etags, use `updateCalendarObject` with the current etag for changes, and decide +explicitly whether a removed feed event should be deleted. A later failure can leave earlier uploads +completed, so persist successful imports individually in that workflow. diff --git a/docs/docs/caldav/syncCalendars.md b/docs/docs/caldav/syncCalendars.md index 48bc7b77..9480ec88 100644 --- a/docs/docs/caldav/syncCalendars.md +++ b/docs/docs/caldav/syncCalendars.md @@ -40,22 +40,25 @@ const { created, updated, deleted } = await syncCalendarsDetailed({ - `fetchOptions` options to pass to underlying fetch function :::info -`objects` inside `oldCalendars` are not needed when using `syncCalendarsDetailed`. +Both functions sync objects in updated calendars. Include previously stored objects to retain +unchanged objects during incremental WebDAV sync. Newly discovered calendars contain metadata only; +fetch their objects separately. ::: ### Return Value `syncCalendars` returns: -array of [DAVCalendar](../types/DAVCalendar.md) with calendar objects. +array of [DAVCalendar](../types/DAVCalendar.md). Updated calendars contain synced objects; unchanged +calendars retain the supplied objects, and newly discovered calendars contain metadata only. `syncCalendarsDetailed` returns: an object of - `created` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. -- `updated` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. -- `deleted` array of [DAVCalendar](../types/DAVCalendar.md) without calendar objects. +- `updated` array of [DAVCalendar](../types/DAVCalendar.md) with synced calendar objects. +- `deleted` array of the previously supplied [DAVCalendar](../types/DAVCalendar.md). ### Behavior @@ -71,4 +74,7 @@ return latest list of calendars with latest list of objects for `updated` calend When using `syncCalendarsDetailed`, -return three list of separate calendars without objects for `created`, `updated`, and `deleted`. +return separate lists for `created`, `updated`, and `deleted`, with the object behavior described above. + +Discovery and object-fetch failures reject before returning changes. Built-in sync queries all calendar +components, including tasks and journals, and preserves the token returned by the sync REPORT. diff --git a/docs/docs/helper.mdx b/docs/docs/helper.mdx index 671e1b7b..efbeadf6 100644 --- a/docs/docs/helper.mdx +++ b/docs/docs/helper.mdx @@ -6,30 +6,33 @@ import { Converter } from '../src/components/Converter.tsx'; # Helper -Helper to convert xml to expected js object to be consumed by tsdav +Inspect normalized DAV responses or serialize compact XML request objects. The normalized response +keys are for reading responses; request keys use the exact XML names and namespace prefixes. +All object inputs use JSON. ### xml -> js - - - - - - - - - - - - - -`} + + + /calendars/personal/event.ics + + + "123" + + + HTTP/1.1 200 OK + + +`} /> ### js -> xml @@ -37,14 +40,14 @@ Helper to convert xml to expected js object to be consumed by tsdav - import { createDAVClient } from 'https://unpkg.com/tsdav/dist/tsdav.mjs'; + import { createDAVClient } from 'https://unpkg.com/tsdav/dist/tsdav.js'; const client = await createDAVClient({ serverUrl: 'https://caldav.icloud.com', diff --git a/docs/docs/smart calendar sync.md b/docs/docs/smart calendar sync.md index 9630f2d7..856312c4 100644 --- a/docs/docs/smart calendar sync.md +++ b/docs/docs/smart calendar sync.md @@ -4,263 +4,95 @@ sidebar_position: 8 # Smart calendar sync -To actually achieve two way syncing of calendar events between cloud provider and your service. +Store each calendar's URL, ctag, sync token, supported reports, and object URLs, etags, and original +calendar data. This example uses Basic authentication with a username and application password. +Keep credentials in your application's protected credential storage. An incremental +sync needs the previous object list to distinguish created and updated objects and retain unchanged +objects. -## preparation - -You need to - -#### create database structures to store the calendar info - -for databases, you need following structures: - -##### App calendar - -your app's calendar object type like: +The following adapter keeps database operations explicit. Implement `CalendarStore` with your +existing database; its transaction must commit all object changes and their tokens together. ```ts -type AppCalendar = { - id: string; - userId: string; - timezone?: string; - name?: string; - description?: string; - email?: string; - createdAt: string; - updatedAt: string; -}; -``` - -this table is used for your app's display, daily use, etc, optional if you do not alredy have a table like this or you do not want one-to-many relations with your app calendar, you can skip creating this table. - -##### Caldav calendar +import { DAVClient, DAVCalendar, DAVObject, DAVCredentials } from 'tsdav'; -you need to store caldav calendar information obtained from `fetchCalendars` - -```typescript -type CaldavCalendar = { - id: string; - userId: string; - timezone: string; - name: string; - source: string; // your caldav provider name - ctag: string; // obtained from remote - syncToken: string; // obtained from remote - url: string; - credentialId: - createAt: string; -}; -``` - -##### credentials - -save caldav calendar credentials in another table, encryption is recommended: - -```ts -type CalendarCredential = { - account: string; - refreshToken?: string; - password?: string; - valid: boolean; - source: // your caldav provider name +interface CalendarTransaction { + putCalendar(calendar: DAVCalendar): Promise; + removeCalendar(url: string): Promise; + putObject(calendarUrl: string, object: DAVObject): Promise; + removeObject(calendarUrl: string, objectUrl: string): Promise; } -``` - -##### caldav calendar objects - -you also need to store caldav calendar objects obtained from `fetchCalendarObjects` - -```ts -export type CalendarObject = { - id: string; - calendarId: string; // foreign key reference the CaldavCalendar if needed - url: string; - etag: string; - start: string; // recommend to have this field for easy filtering/sorting - end: string; // recommend to have this field for easy filtering/sorting - data: string; // actual ics data -}; -``` - -to parse and obtain information from ics data, it's recommended to use a combination of - -https://github.com/natelindev/pretty-jcal and https://github.com/kewisch/ical.js - -for generating new ics data, it's recommended to use https://github.com/nwcell/ics.js/ - -## Actual syncing - -First you need to have user go through authorization process and obtain valid `CalendarCredential`, the method differs for each caldav provider. You need to find and setup it yourself. - -after having obtained the credentials, you can begin the actual sync - -First you need to get all stored calendars for the user - -```ts -const localCalendars = await this.db.getCalendarByUserIdAndSource(userId, source); -``` - -then you need to create caldav client using credentials - -```ts -const client = new DAVClient({ - serverUrl: 'https://caldav.icloud.com', - credentials: { - username: 'YOUR_APPLE_ID', - password: 'YOUR_APP_SPECIFIC_PASSWORD', - }, - authMethod: 'Basic', - defaultAccountType: 'caldav', -}); -``` - -##### Remote to local -you can use `syncCalendarsDetailed` function from the lib - -```ts -const { created, updated, deleted } = await client.syncCalendarsDetailed({ - oldCalendars: localCalendars.map((lc) => ({ - displayName: lc.name, - syncToken: lc.syncToken, - ctag: lc.ctag, - url: lc.url, - })), -}); -``` - -make sure you send the `syncToken` and `ctag`, this way the remote will know your last sync and identify the calendar changes. - -now you have all calendar changes on remote. make actual changes to your database like: - -```ts -await this.db.transaction(async (tx) => { - await Promise.all( - created.map(async (c) => { - // created - const calendarObjects = await client.fetchCalendarObjects({ calendar: c }); - - if (calendarObjects.length > 0) { - await Promise.all( - calendarObjects.map((co) => { - // parse start end time if needed - // const parsedObject = parse(co); - // const { start, end } = parsedObject; - return this.db.createCalendarObject(tx, { - ...co, - // start, - // end, - calendarId: c.id, - }); - }), - ); - } - }), - ); - - // deleted - if (deleted.length > 0) { - await this.db.deleteByUrls( - tx, - filteredDeleted.map((d) => d.url), - ); - } - - // updated - const localCalendarsToBeUpdated = await this.db.getByUrls( - tx, - updated.map((u) => u.url), - ); - - // find out and apply the change on calendar - await Promise.all( - localCalendarsToBeUpdated.map(async (lc) => { - const localObjects = await this.db.getCalendarById(tx, lc.id); - const { - created: createdObjects, - updated: updatedObjects, - deleted: deletedObjects, - } = ( - await client.smartCollectionSyncDetailed({ - collection: { - url: lc.url, - ctag: lc.ctag, - syncToken: lc.syncToken, - objects: localObjects, - objectMultiGet: client.calendarMultiGet, - }, - method: 'webdav', - }) - ).objects; - - // apply changes to local calendar objects - // created objects - if (createdObjects.length > 0) { - await Promise.all( - createdObjects - .filter((co) => co.url.includes('.ics')) - .map((co) => { - // parse start end time if needed - // const parsedObject = parse(co); - // const { start, end } = parsedObject; - - return this.db.createCalendarObject(tx, { - ...co, - // start, - // end, - url: URL.resolve(lc.url, co.url), - calendarId: lc.id, - }); - }), - ); - } - - // deleted objects - if (deletedObjects.length > 0) { - await this.db.deleteCalendarObjectByUrls( - tx, - deletedObjects.map((d) => URL.resolve(lc.url, d.url)), - ); - } - - // updated objects - if (updatedObjects.length > 0) { - await Promise.all( - updatedObjects.map((uo) => { - // parse start end time if needed - // const parsedObject = parse(co); - // const { start, end } = parsedObject; +interface CalendarStore { + readCalendars(): Promise; + transaction(work: (tx: CalendarTransaction) => Promise): Promise; +} - return this.db.updateCalendarObjectByUrl(tx, URL.resolve(lc.url, uo.url), { - etag: uo.etag, - data: uo.data, - start, - end, - }); - }), - ); +export async function synchronizeCalendars( + serverUrl: string, + credentials: DAVCredentials, + store: CalendarStore, +) { + const client = new DAVClient({ + serverUrl, + credentials, + defaultAccountType: 'caldav', + }); + await client.login(); + const local = await store.readCalendars(); + const remote = await client.fetchCalendars(); + const byUrl = new Map(local.map((calendar) => [calendar.url, calendar])); + const remoteUrls = new Set(remote.map((calendar) => calendar.url)); + + const results = await Promise.all(remote.map(async (calendar) => { + const previous = byUrl.get(calendar.url); + return client.smartCollectionSyncDetailed({ + collection: { + ...calendar, + ctag: previous?.ctag, + syncToken: previous?.syncToken, + objects: previous?.objects ?? [], + objectMultiGet: client.calendarMultiGet, + fetchObjects: async (params: Parameters>[0]) => { + if (!params) throw new Error('A collection is required'); + const { collection, ...options } = params; + return client.fetchCalendarObjects({ + ...options, + calendar: collection, + filters: { 'comp-filter': { _attributes: { name: 'VCALENDAR' } } }, + urlFilter: (url) => new URL(url, collection.url).href !== collection.url, + }); + }, + }, + }); + })); + + await store.transaction(async (tx) => { + for (const calendar of local) { + if (!remoteUrls.has(calendar.url)) await tx.removeCalendar(calendar.url); + } + for (const result of results) { + const { objects, objectMultiGet, fetchObjects, ...metadata } = result; + for (const object of [...objects.created, ...objects.updated]) { + await tx.putObject(result.url, { ...object, url: new URL(object.url, result.url).href }); } - }), - ); - - // update the syncToken & ctag for the calendars to be updated - await Promise.all( - filteredUpdated.map((u) => { - const lcu = localCalendarToBeUpdated.find((uo) => uo.url === u.url); - if (!lcu) { - throw new ValidationError(`local calendar with url ${u.url} not found `); + for (const object of objects.deleted) { + await tx.removeObject(result.url, new URL(object.url, result.url).href); } - return this.db.updateCalendarById(tx, lcu.id, { - syncToken: u.syncToken, - ctag: u.ctag, - }); - }), - ); -}); + await tx.putCalendar(metadata); + } + }); +} ``` -##### Local to remote - -when going local to remote, it's rather easy - -just generate the ics data and then use `createCalendarObject` , `updateCalendarObject` and `deleteCalendarObject` directly on remote caldav calendars. Update your locally stored calendar objects in your database after remote operation success. +Method selection uses the discovered reports: WebDAV sync retrieves changed objects through +`objectMultiGet`; basic sync uses ctag and a complete collection query through `fetchObjects`. +The client's multiget methods are bound and can be used as callbacks. Store the returned token, +which represents the successfully fetched changes. If discovery, sync, fetching, or the database +transaction fails, retain the previous tokens and retry from that state. + +For local changes, serialize a complete iCalendar resource and call `createCalendarObject`, +`updateCalendarObject`, or `deleteCalendarObject`. These helpers return a Fetch `Response`; check +`response.ok` before updating local state. A 412 response indicates a precondition conflict and +requires fetching the current remote object before deciding how to reconcile it. Preserve recurrence, +all-day dates, timezones, and exceptions when editing the data; see [feed import](./caldav/import-ical-feed.md). diff --git a/docs/docs/types/DAVResponse.md b/docs/docs/types/DAVResponse.md index fd450abf..b1dc30af 100644 --- a/docs/docs/types/DAVResponse.md +++ b/docs/docs/types/DAVResponse.md @@ -2,6 +2,8 @@ export type DAVResponse = { raw?: any; href?: string; + parseError?: string; + propStats?: DAVPropStat[]; status: number; statusText: string; ok: boolean; @@ -40,10 +42,20 @@ response type of [davRequest](../webdav/davRequest.md) - `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 - `href` [content element URI](https://datatracker.ietf.org/doc/html/rfc2518#section-12.3) -- `status` fetch response status +- `status` resource status, an all-failed property status, or the HTTP response status - `statusText` fetch response statusText -- `ok` fetch response ok +- `ok` whether the resource response has a successful status; false when all returned properties fail - `error` error object from error response - `responsedescription` [information about a status response within a Multi-Status](https://datatracker.ietf.org/doc/html/rfc4918#section-14.25) - `props` response [propstat](https://datatracker.ietf.org/doc/html/rfc4918#section-14.22) props with camel case names. + +`propStats` preserves each property's status, properties, namespace URI map (`namespaces`), and +optional error/description. `props` contains only successful properties. A response whose every +property failed has `ok: false`; mixed results require inspecting the statuses of the properties you +need. XML text stays a string, including numeric-looking display names and opaque tokens. Pure CDATA +uses `{ _cdata: string }`; mixed text and CDATA are joined in document order. When namespaces share a +local name, DAV names retain their usual key and colliding names use `{namespaceURI}localName`. + +`raw` preserves the complete body for unparsed responses. A malformed XML response has `ok: false` +and `parseError`, so callers can distinguish parsing failures from a successful empty result. diff --git a/docs/docs/webdav/account/serviceDiscovery.md b/docs/docs/webdav/account/serviceDiscovery.md index ffbdf894..a7fae16b 100644 --- a/docs/docs/webdav/account/serviceDiscovery.md +++ b/docs/docs/webdav/account/serviceDiscovery.md @@ -29,4 +29,16 @@ root url ### Behavior -use `/.well-known/` request to follow redirects to find redirected url +Requests `/.well-known/caldav` or `/.well-known/carddav` on the server's origin using +`PROPFIND`, then tries `GET` if necessary. Relative `Location` headers resolve against +that discovery request URL. For example, `Location: ../dav/` resolves to `/dav/`, even +when `serverUrl` contains a nested calendar path. + +Path-relative redirects retain the request's origin and port. Absolute and +protocol-relative redirects use their own host and port, including the protocol's +default port when none is specified. + +Discovery controls the method, body, and manual redirect handling. A `body` supplied +in `fetchOptions` is not sent with the `GET` fallback; other transport options and +headers still apply. If neither request redirects, the function returns `serverUrl` +as a normalized URL. diff --git a/docs/docs/webdav/collection/isCollectionDirty.md b/docs/docs/webdav/collection/isCollectionDirty.md index 9631ae58..50434819 100644 --- a/docs/docs/webdav/collection/isCollectionDirty.md +++ b/docs/docs/webdav/collection/isCollectionDirty.md @@ -25,7 +25,7 @@ const { isDirty, newCtag } = await isCollectionDirty({ ### Return Value - `isDirty` a boolean indicate if the collection is dirty -- `newCtag` if collection is dirty, new ctag of the collection +- `newCtag` the remote ctag, or `undefined` when the server does not provide one. An unavailable ctag means `isDirty: true` and basic sync fetches a complete snapshot. ### Behavior diff --git a/docs/docs/webdav/collection/smartCollectionSync.md b/docs/docs/webdav/collection/smartCollectionSync.md index a758c963..f97818c7 100644 --- a/docs/docs/webdav/collection/smartCollectionSync.md +++ b/docs/docs/webdav/collection/smartCollectionSync.md @@ -49,18 +49,21 @@ const { created, updated, deleted } = ( - `fetch` custom fetch implementation :::info -`objects` inside `collection` are not needed when using `smartCollectionSyncDetailed`. +Provide the previously stored `collection.objects` to distinguish created from updated objects. +Without that baseline, changed remote objects are classified as created. Basic sync also needs the +baseline to identify deletions. ::: ### Return Value `smartCollectionSync` returns: -array of latest [DAVObject](../../types/DAVObject.md) +the supplied collection with an `objects` array containing the latest [DAVObject](../../types/DAVObject.md) +and the resulting `syncToken` or `ctag`. `smartCollectionSyncDetailed` returns: -an object of +the supplied collection with the resulting `syncToken` or `ctag` and - `objects` - `created` array of [DAVObject](../../types/DAVObject.md) diff --git a/docs/docs/webdav/davRequest.md b/docs/docs/webdav/davRequest.md index a5401b4a..24d6c757 100644 --- a/docs/docs/webdav/davRequest.md +++ b/docs/docs/webdav/davRequest.md @@ -37,6 +37,7 @@ const [result] = await davRequest({ - `init` **required**, [DAVRequest](davRequest.md) Object - `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. - `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. +- `headersToExclude` case-insensitive header names to remove after merging defaults, request headers, and `fetchOptions.headers` - `fetchOptions` options to pass to underlying fetch function - `fetch` custom fetch implementation to override the runtime's native `fetch` @@ -55,3 +56,7 @@ if request failed, response-> raw will be raw response text returned from server Multistatus responses without resource entries still return one result with the parsed `raw` payload, so callers can read collection metadata such as `raw.multistatus.syncToken`. Status codes are parsed even when the server omits the reason phrase. + +Successful unparsed response bodies are preserved in full. Parsed property failures remain available +in `propStats`; `props` includes only successful properties, and an all-failed propstat response has +`ok: false`. Malformed XML has `ok: false` and a `parseError`. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 80c493b5..dd16a379 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -1,4 +1,4 @@ -/** @type {import('@docusaurus/types').DocusaurusConfig} */ +/** @type {import('@docusaurus/types').Config} */ module.exports = { title: 'tsdav', tagline: 'webdav request made easy', @@ -54,7 +54,7 @@ module.exports = { }, presets: [ [ - '@docusaurus/preset-classic', + require.resolve('@docusaurus/preset-classic'), { docs: { path: 'docs', @@ -63,7 +63,7 @@ module.exports = { lastVersion: 'current', versions: { current: { - label: '2.3.2', + label: require('../package.json').version, }, '1.1.6': { label: '1.1.6', @@ -81,11 +81,11 @@ module.exports = { ], ], plugins: [ - '@cmfcmf/docusaurus-search-local', + require.resolve('@cmfcmf/docusaurus-search-local'), require.resolve('./docusuarusWebpack5Plugin'), - 'docusaurus-markdown-source-plugin', + require.resolve('docusaurus-markdown-source-plugin'), [ - 'docusaurus-plugin-llms', + require.resolve('docusaurus-plugin-llms'), { generateLLMsTxt: true, generateLLMsFullTxt: true, diff --git a/docs/docusuarusWebpack5Plugin.js b/docs/docusuarusWebpack5Plugin.js index 61d69b30..dbfbc74b 100644 --- a/docs/docusuarusWebpack5Plugin.js +++ b/docs/docusuarusWebpack5Plugin.js @@ -6,6 +6,7 @@ module.exports = function (context, options) { configureWebpack(config, isServer, utils) { return { resolve: { + alias: { 'xml-js': require.resolve('xml-js') }, // alias: { // path: require.resolve('path-browserify'), // }, diff --git a/docs/package.json b/docs/package.json index 3da75347..c595e580 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,8 @@ "swizzle": "docusaurus swizzle", "write-heading-ids": "docusaurus write-heading-ids", "write-translations": "docusaurus write-translations", - "test": "node --test tests/*.test.cjs" + "test": "node --test tests/*.test.cjs", + "typecheck": "tsc --noEmit" }, "browserslist": { "production": [ @@ -32,25 +33,21 @@ "@docusaurus/core": "3.10.2", "@docusaurus/preset-classic": "3.10.2", "@mdx-js/react": "3.1.1", - "@svgr/webpack": "8.1.0", "buffer": "6.0.3", - "clsx": "2.1.1", "docusaurus-markdown-source-plugin": "^2.2.5", - "file-loader": "6.2.0", "prism-react-renderer": "2.4.1", "react": "19.2.8", "react-dom": "19.2.8", "stream-browserify": "3.0.0", - "url-loader": "4.1.1", "xml-js": "1.6.11" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/types": "3.10.2", "@tsconfig/docusaurus": "2.0.9", "@types/node": "26.4.1", "@types/react": "19.2.18", - "@types/react-helmet": "6.1.11", "@types/react-router-dom": "5.3.3", "docusaurus-plugin-llms": "^0.6.0", "typescript": "7.0.2" diff --git a/docs/plans/review-fixes.md b/docs/plans/review-fixes.md new file mode 100644 index 00000000..8289191d --- /dev/null +++ b/docs/plans/review-fixes.md @@ -0,0 +1,95 @@ +# Codebase review fixes + +Keep `xml-js`, the existing entry points, and runtime portability across Node.js, +browsers, Bun, Deno, and Workers. Do not run live provider tests. + +## Stage 1: Response and transport correctness + +- Normalize XML after parsing without coercing opaque strings or losing mixed text. +- Preserve namespaces and property statuses while retaining existing property access. +- Reject incomplete or failed collection discovery before calculating deletions. +- Preserve successful raw payloads and apply header exclusions after merging. + +## Stage 2: Synchronization and client behavior + +- Sync every supported calendar component and extensionless resources safely. +- Keep the REPORT sync token, respect query subsets, and validate expansion inputs. +- Replace quadratic URL comparisons with indexes. +- Share client authentication/request defaults; refresh expired OAuth tokens safely. +- Correct class return types, export `makeCollection`, and restrict build cleanup. + +## Stage 3: Documentation and dependencies + +- Repair browser, sync, feed-import, and free/busy examples and document actual contracts. +- Reuse the XML normalizer in the docs converter and replace `eval` with JSON parsing. +- Synchronize version labels and describe provider capabilities accurately. +- Remove unused direct dependencies, patch docs advisories, and keep `xml-js`. +- Add repeatable dependency and executable documentation/consumer checks. + +## Stage 4: Verification + +- Add regressions for the original failing scenarios, including both client APIs. +- Run unit tests, consumer type checks, lint, package build, and packaged entry checks. +- Run docs tests, type checking, production build, and both dependency audits. +- Review the final diff and report remaining verification limits. + +## Stage 5: PR #281 review and follow-up + +- Reproduce the relative discovery redirect bug and validate the PR and required CI. +- Merge the PR and preserve the existing local review fixes while updating `main`. +- Correct redirect authority handling and remove GET fallback bodies; add regressions. +- Verify the combined changes, including both client APIs, consumer examples, and docs. + +## Stage 6: Release readiness + +- Build before testing in the release workflow and include consumer/example checks. +- Inspect a freshly packed candidate from a clean snapshot of the local changes. +- Verify packaged imports and mocked requests across installed supported runtimes. +- Report the next version and outstanding Git/release steps without publishing. + +## Stage 7: Publish 2.3.5 + +- Credit `@bensynapse` and PR #281 in the changelog and GitHub release notes. +- Bump the version, regenerate package artifacts, and verify the final candidate. +- Commit the changes, pass the release PR checks, and merge without bypassing checks. +- Pass CI on the merged commit, publish the GitHub release, and verify npm publication. + +## Agent progress + +- Initial review completed; reproduced sync, parsing, authentication, typing, header, + build-cleanup, and documentation failures. +- Response normalization, final header exclusion, client authentication sharing, and sync safeguards implemented. +- Documentation examples and converter corrected; unused direct dependencies removed and docs advisories patched. +- Stages 1–4 completed; `xml-js` retained in the library and docs. +- Verification passed: 320 unit tests, 44 docs tests, 3 executable documentation tests, + public consumer type checks, lint, root/docs type checks, and package/docs builds. +- Frozen lockfile installs, a standalone docs install/build, packed CJS/ESM consumers, + and native Chrome import/parsing/request checks passed. Both dependency audits and + the dependency-usage checks are clean. +- Generated package output excluded from the patch. Live provider integration tests + were not run; local verification used Node.js 24 and Chrome. Other runtime smoke + checks remain covered by CI. +- PR #281 reviewed and merged as `c035f3e`; its eight regressions failed before the + change and its 306 unit tests plus required GitHub CI passed after the change. +- Stage 5 completed: corrected explicit redirect ports and GET fallback bodies, added + 19 regressions, and documented the discovery behavior. +- Combined verification passed: 347 unit tests, 3 executable documentation tests, + consumer type checks, lint, root type checking, and package/docs builds. Clearing + the stale Docusaurus cache resolved the first incremental docs-build failure. +- PR and post-merge GitHub CI passed all seven jobs. Follow-up fixes remain local + alongside the earlier review changes; generated package output is excluded. +- Stage 6 completed: release workflow now verifies fresh artifacts, lint, public + consumers/examples, and dependency usage before publishing. +- A clean root-only install exposed an example-test dependency on the docs toolchain; + Vitest now uses the root compiler settings. The original failure is resolved. +- Clean-snapshot verification passed: 347 unit tests, 3 executable documentation + tests, consumer type checks, type checking, lint, dependency usage, package build, + and packed-file inspection. The tarball contains 35 files and no test/source files. +- Packed CJS/ESM and mocked request checks passed on Node 18.20.8, 20.18.1, 22.21.1, + and 24.11.1, Bun 1.3.14, Deno 2.9.4, and native Chrome. Both browser bundles also + passed in a sandbox without Node globals. Both dependency audits remain clean. +- Recommend a 2.3.5 patch release. Version bump, committing/pushing the local changes, + and CI on the final commit remain before publishing. No release was published; + live provider verification was not run. +- Stage 7 authorized: preparing the 2.3.5 release with contributor credit, final + artifacts, and CI verification before publication. diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index d081a9a1..34818cdf 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -13,6 +13,9 @@ overrides: sockjs>uuid: 11.1.1 serialize-javascript@<7.0.5: 7.0.5 image-size: npm:image-size-next@2.1.1 + brace-expansion@1: 1.1.21 + brace-expansion@5: 5.0.12 + fast-uri@<3.1.8: 3.1.8 importers: @@ -30,21 +33,12 @@ importers: '@mdx-js/react': specifier: 3.1.1 version: 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@svgr/webpack': - specifier: 8.1.0 - version: 8.1.0(typescript@7.0.2) buffer: specifier: 6.0.3 version: 6.0.3 - clsx: - specifier: 2.1.1 - version: 2.1.1 docusaurus-markdown-source-plugin: specifier: ^2.2.5 version: 2.2.5(@docusaurus/core@3.10.2(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(postcss@8.5.28)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - file-loader: - specifier: 6.2.0 - version: 6.2.0(webpack@5.110.3(postcss@8.5.28)) prism-react-renderer: specifier: 2.4.1 version: 2.4.1(react@19.2.8) @@ -57,9 +51,6 @@ importers: stream-browserify: specifier: 3.0.0 version: 3.0.0 - url-loader: - specifier: 4.1.1 - version: 4.1.1(file-loader@6.2.0(webpack@5.110.3(postcss@8.5.28)))(webpack@5.110.3(postcss@8.5.28)) xml-js: specifier: 1.6.11 version: 1.6.11 @@ -70,6 +61,9 @@ importers: '@docusaurus/theme-classic': specifier: 3.10.2 version: 3.10.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + '@docusaurus/types': + specifier: 3.10.2 + version: 3.10.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.28))(html-minifier-terser@7.2.0)(postcss@8.5.28)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/docusaurus': specifier: 2.0.9 version: 2.0.9 @@ -79,9 +73,6 @@ importers: '@types/react': specifier: 19.2.18 version: 19.2.18 - '@types/react-helmet': - specifier: 6.1.11 - version: 6.1.11 '@types/react-router-dom': specifier: 5.3.3 version: 5.3.3 @@ -1782,9 +1773,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-helmet@6.1.11': - resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} - '@types/react-router-config@5.0.11': resolution: {integrity: sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==} @@ -2208,11 +2196,11 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2932,8 +2920,8 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -8376,10 +8364,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-helmet@6.1.11': - dependencies: - '@types/react': 19.2.18 - '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 @@ -8631,7 +8615,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8832,12 +8816,12 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -9599,7 +9583,7 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-uri@3.1.7: {} + fast-uri@3.1.8: {} fastq@1.20.3: dependencies: @@ -10828,11 +10812,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimatch@9.0.7: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimist@1.2.8: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml index 1d58f34d..4d4f5b4c 100644 --- a/docs/pnpm-workspace.yaml +++ b/docs/pnpm-workspace.yaml @@ -10,3 +10,6 @@ overrides: sockjs>uuid: "11.1.1" serialize-javascript@<7.0.5: "7.0.5" image-size: "npm:image-size-next@2.1.1" + 'brace-expansion@1': '1.1.21' + 'brace-expansion@5': '5.0.12' + 'fast-uri@<3.1.8': '3.1.8' diff --git a/docs/src/components/Converter.module.css b/docs/src/components/Converter.module.css index 6ce03a84..1100b06b 100644 --- a/docs/src/components/Converter.module.css +++ b/docs/src/components/Converter.module.css @@ -1,25 +1,36 @@ .converter { display: flex; flex-direction: column; - justify-content: center; + gap: 0.65rem; width: 100%; - height: 100%; + margin-block: 1.5rem; } .textarea { width: 100%; - min-height: 20rem; - height: 100%; - border: none; - resize: none; - font-size: 1rem; - padding: 10px; - border-radius: 0.2rem; - margin-bottom: 1rem; + min-height: 16rem; + padding: 1rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 0.5rem; + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + resize: vertical; + font-family: var(--ifm-font-family-monospace); + font-size: 0.9rem; + line-height: 1.5; +} + +.textarea:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 3px; } .title { - font-size: 1.5rem; - font-weight: bold; - margin-bottom: 1rem; -} \ No newline at end of file + font-size: 1rem; + font-weight: 600; +} + +.error { + color: var(--ifm-color-danger); + margin: 0; +} diff --git a/docs/src/components/Converter.tsx b/docs/src/components/Converter.tsx index d488987e..2cc0d004 100644 --- a/docs/src/components/Converter.tsx +++ b/docs/src/components/Converter.tsx @@ -1,69 +1,51 @@ -/* eslint-disable no-eval */ -/* eslint-disable no-nested-ternary */ -import React, { useState } from 'react'; - -import ErrorBoundary from '@docusaurus/ErrorBoundary'; - +import React, { useId, useState } from 'react'; import styles from './Converter.module.css'; -import { - DAVNamespaceString, - formatFilters, - formatProps, - safeGuard, - xml2js, - js2xml, -} from './ConverterBase'; +import { convertInput } from './ConverterBase'; type ConverterProps = { - content: any; + content: unknown; variant: 'prop' | 'filter' | 'xml' | 'xml-reverse'; }; -export const Converter = (props: ConverterProps) => { - const { variant, content } = props; - const [input, setInput] = useState( - typeof content === 'string' ? content : JSON.stringify(content, null, 2) +export const Converter = ({ variant, content }: ConverterProps) => { + const id = useId(); + const [input, setInput] = useState( + typeof content === 'string' ? content : JSON.stringify(content, null, 2), ); - + let output = ''; + let error = ''; + try { + output = convertInput(variant, input); + } catch (reason) { + error = reason instanceof Error ? reason.message : 'Conversion failed'; + } return ( - ( -
-

conversion failed, reason: {error.message}.

- -
- )} - > -
-

{variant}:

-