diff --git a/docs/code-mapping.json b/docs/code-mapping.json index 995e47d74..2dd683962 100644 --- a/docs/code-mapping.json +++ b/docs/code-mapping.json @@ -187,6 +187,18 @@ "description": "Time tracking HTTP API endpoints", "source": ["src/api/TimeTrackingController.ts"], "docs": ["docs/HTTP_API.md"] + }, + { + "id": "caldav-sync", + "description": "Two-way CalDAV VTODO task sync", + "source": [ + "src/services/CalDavSyncService.ts", + "src/services/CalDavClient.ts", + "src/services/CalDavSecretStore.ts", + "src/services/caldav/**", + "src/settings/tabs/caldavSection.ts" + ], + "docs": ["docs/features/caldav-sync.md"] } ] } diff --git a/docs/features/caldav-sync.md b/docs/features/caldav-sync.md new file mode 100644 index 000000000..e0ef11187 --- /dev/null +++ b/docs/features/caldav-sync.md @@ -0,0 +1,117 @@ +# CalDAV Sync + +TaskNotes can keep your tasks in sync with a CalDAV task list in both directions. Tasks you create in Obsidian appear on your phone, and tasks you tick off elsewhere are reflected back in your vault. + +This works with any CalDAV server that stores tasks, including Nextcloud, Apple Reminders (iCloud), Radicale and Baikal. + +## How it differs from the calendar integrations + +TaskNotes has two separate ways of talking to calendar servers, and they do different jobs: + +- **[Calendar integration](calendar-integration.md)** (Google, Microsoft) exports tasks *as calendar events* on a time grid, and is one-way. +- **CalDAV sync** treats tasks as tasks. They land in the task list of your phone or desktop client — Reminders, Nextcloud Tasks, and so on — with a due date, a status and a priority, and changes flow both ways. + +You can use both at once; they do not interfere with each other. + +## Setup + +1. Open `Settings -> TaskNotes -> Integrations` and turn on **Enable CalDAV sync**. +2. Select **Add account**. +3. Fill in the **Server URL**, **Username** and **Password**. Any address on your server will do — TaskNotes works its way up to the account root if you paste something more specific. + - For Nextcloud, the server URL usually looks like `https://cloud.example.com/remote.php/dav`. + - For iCloud, use `https://caldav.icloud.com` and an app-specific password generated in your Apple account settings. + - Credentials are only ever sent over `https://`, except to `localhost` for local testing. +4. Select **Discover** to find the task lists this account can reach. TaskNotes only offers lists that can actually hold tasks, so event-only calendars, read-only subscriptions and deleted calendars are filtered out. If more than one list qualifies, pick the one you want from **Selected task list**. +5. Select **Preview** under **First sync** to see what would change. +6. Turn on **Sync this account**. + +Passwords are stored in Obsidian's secret storage, which is encrypted at rest where the operating system supports it. They are never written to the plugin's `data.json`. + +## The first sync + +The first sync is the one moment where a wrong setting is expensive, so nothing is written until you confirm it. The preview reports four numbers: + +- **to upload** — tasks in your vault that the server has never seen. +- **to import** — tasks on the server with no counterpart in your vault. +- **already matching** — tasks that are linked and in agreement. +- **changed on both sides** — tasks that differ, and will be resolved by the rule below. + +If those numbers look wrong — a much larger import than expected, for instance — cancel, correct the task list or the filter, and preview again. + +## What gets synced + +| Task property | CalDAV field | +|---|---| +| Title | `SUMMARY` | +| Due date | `DUE` | +| Scheduled date | `DTSTART` | +| Status | `STATUS` | +| Priority | `PRIORITY` | +| Completed date | `COMPLETED` | +| Tags | `CATEGORIES` | +| Recurrence | `RRULE` | +| Projects (parents) | `RELATED-TO;RELTYPE=PARENT` | +| Blocked by | `RELATED-TO` with the dependency type | +| Reminders | `VALARM` | + +**The note body is not synced.** Anything already in a task's description on the server is left untouched, and your Markdown body stays in Obsidian. The same is true of attachments and any other field your other client sets that TaskNotes does not model — those are preserved exactly as they were. + +### Subtasks and dependencies + +In TaskNotes a subtask is a task whose **Projects** field points at its parent, and that is what gets sent as the standard `RELATED-TO` link. Your task hierarchy shows up as a real hierarchy in Nextcloud Tasks, Apple Reminders and anything else that understands subtasks. + +Blocking relationships travel the same way, keeping their type and any offset, so two vaults syncing through the same list see the same dependencies. + +A link can only be sent once both tasks exist on the server. If a parent is a plain note rather than a task, is archived, or belongs to a different account, the link is simply left out — nothing in your vault is changed, the hierarchy just is not visible on the server. + +### Reminders + +Reminders become alarms on the server, so a reminder set in Obsidian can notify you on your phone. A reminder attached to the due date fires relative to that date, one attached to the scheduled date relative to that. + +TaskNotes only ever rewrites the alarms it created itself. An alarm you add in another app is left exactly as it is. + +### Statuses and priorities + +TaskNotes lets you define your own statuses and priorities, while CalDAV has a fixed set. TaskNotes maps between them automatically: a status marked as completed becomes `COMPLETED`, one marked as skipped becomes `CANCELLED`, and everything else becomes `NEEDS-ACTION`. Priorities are spread across the CalDAV 1–9 scale by their configured weight, and a priority with no weight is sent as no priority at all. + +## Choosing which tasks sync + +Each account can carry a filter, using the same conditions as the FilterBar. A task syncs to the first account whose filter it matches, so a task is never uploaded twice. An account with no filter takes every task. + +This is how you keep separate lists separate: give one account a `#work` filter and another a `#personal` filter, and each syncs to its own task list on the server. + +Archived tasks are never uploaded. + +## Changes made in two places at once + +If you edit a task in Obsidian and someone edits the same task on the server before the next sync, TaskNotes notices — it remembers the version it last saw, and the server tells it when that version is out of date. The more recently changed side wins, and the change that lost is written to the debug log so you can recover it. + +Because this compares a timestamp from your computer against one from the server, it works best when both have a roughly accurate clock. + +## When a task is deleted on the server + +You choose what happens, per account: + +- **Archive the note** (default) — the note is archived and stops syncing. Nothing is lost. +- **Keep the note and stop syncing it** — the note stays exactly as it is and is unlinked from the server. +- **Delete the note** — the note is moved to trash. + +Deleting a task in Obsidian always deletes it on the server. + +## Sync timing + +Local edits are sent within a couple of seconds by default. You can turn that off with **Push changes immediately**, in which case they go out on the next scheduled sync. + +Changes made on the server are picked up on the interval you set per account, 15 minutes by default. Each check starts by asking the server a single question — has anything in this list changed? — and stops there when the answer is no. Task lists often share a calendar with hundreds of ordinary events, and this keeps those out of the way entirely. + +If a change cannot be sent because the server is unreachable, it is queued and retried in the background rather than lost. + +Two commands are available from the command palette: **Sync tasks with CalDAV now**, and **Unlink all tasks from CalDAV**, which detaches every task without deleting anything. Note that the link is also what stops a task syncing twice, so syncing the same list again after unlinking gives you a second copy of every task. + +## Troubleshooting + +**"The server rejected those credentials."** Check the username and password. Many providers require an app-specific password rather than your account password. + +**No task lists found.** The account may only have event calendars. Confirm that a task list exists on the server, and that the server URL points at the DAV endpoint rather than the web interface. + +**Tasks are not syncing.** Confirm that both the global **Enable CalDAV sync** toggle and the per-account **Sync this account** toggle are on, and that a task list has been selected. Turn on debug logging under `Settings -> TaskNotes -> Misc` to see what the sync is doing. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 4c320e310..f32b9ecbe 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,17 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added +- (#811) Added two-way CalDAV sync for tasks. TaskNotes can now keep a vault in sync with a CalDAV task list such as Nextcloud, Apple Reminders, Radicale or Baikal, in both directions. Configure accounts under Settings -> TaskNotes -> Integrations. See [CalDAV Sync](https://tasknotes.dev/features/caldav-sync/). + - Each synced task stores its CalDAV identifier in frontmatter, so disconnecting and reconnecting later picks up where it left off instead of creating duplicates. + - When a task is edited in both places at once, the more recent change wins, and the change that lost is recorded in the debug log. + - Choose what happens locally when a task is deleted on the server: archive the note (default), keep it without syncing, or delete it. + - Each account can be scoped with a filter, so separate task lists can hold separate sets of tasks. + - Subtasks and blocking relationships are carried across as standard CalDAV task links, so a task hierarchy built in TaskNotes shows up as a hierarchy in Nextcloud Tasks or Apple Reminders. + - Reminders become alarms on the server, so a reminder set in Obsidian can notify you on your phone. Alarms added in other apps are left untouched. + - Passwords are kept in Obsidian secret storage rather than in the plugin's data file, and are only sent over HTTPS. + - A change that could not be sent because the server was unreachable is queued and retried rather than lost. + - Added commands to sync with CalDAV on demand and to unlink every task from CalDAV. + - Thanks to @Archetype444 for the request. - (#2147) Added context-menu actions for recording task completion today, on the scheduled date, on the due date, or on a chosen date. The actions can be grouped in a submenu from Appearance settings. See [Completing Tasks](https://tasknotes.dev/features/task-management/#completing-tasks). - Rescheduling a recurring task can reactivate affected completed or skipped instances after confirmation. See [Recurring Tasks](https://tasknotes.dev/features/recurring-tasks/). - Thanks to @renatomen for the contribution. diff --git a/eslint.config.mjs b/eslint.config.mjs index 885df9b20..f34789a48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,8 +8,11 @@ import globals from "globals"; const englishLocaleSentenceCaseOptions = { allowAutoFix: true, brands: [ + "Apple Reminders", + "Baikal", "Bases", "Brazil", + "CalDAV", "CORS", "Discord", "Farsi", @@ -32,11 +35,13 @@ const englishLocaleSentenceCaseOptions = { "Model Context Protocol", "Monday", "N/A", + "Nextcloud", "NLP", "Obsidian", "Outlook Calendar", "Persian", "Pomodoro", + "Radicale", "RRULE", "Saturday", "Slack", @@ -45,7 +50,9 @@ const englishLocaleSentenceCaseOptions = { "Thursday", "Tuesday", "UID", + "URL", "UTC", + "VTODO", "Wednesday", "Yahoo Calendar", "YYMMDD", diff --git a/i18n.manifest.json b/i18n.manifest.json index 801d7efc3..6983b4ebe 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1137,6 +1137,67 @@ "settings.integrations.autoExport.notices.exportSuccess": "bac3306a4c636ea65338c835a9a20f014f5d5906", "settings.integrations.autoExport.notices.exportFailure": "68f10dec3894c0eca2f183297000b6e00b638e94", "settings.integrations.autoExport.notices.serviceUnavailable": "1b69a3770528d48e5851443f5dc568a407c0e69f", + "settings.integrations.caldav.header": "2c1d650e718980f488377ac3543381c9e2fd750e", + "settings.integrations.caldav.description": "25e1ec574445d1f6d96ebb3a5f3bb6fec8a5fe7f", + "settings.integrations.caldav.enable.name": "d4c64688b41ace0bbc732d8df7be33a71aa2ab0a", + "settings.integrations.caldav.enable.description": "8e4629cff5c9781bc2244d6cf6085f5b38a8fe20", + "settings.integrations.caldav.pushOnChange.name": "49c00c64290de59f1a0939b784fb734289be1325", + "settings.integrations.caldav.pushOnChange.description": "d1b3c0d8272bd0f970dfa6a93f526629fe56adec", + "settings.integrations.caldav.addAccount.name": "98b0ed858ae45c510636bd66d9882afa7c99c042", + "settings.integrations.caldav.addAccount.description": "d8f4f5a33c14e2c1acb627f8901d0954ab7949a0", + "settings.integrations.caldav.addAccount.button": "98b0ed858ae45c510636bd66d9882afa7c99c042", + "settings.integrations.caldav.addAccount.defaultName": "d33d781dfbf7247d752f37f7e2f2ffd29148f87c", + "settings.integrations.caldav.account.name.name": "709a23220f2c3d64d1e1d6d18c4d5280f8d82fca", + "settings.integrations.caldav.account.name.description": "1a1398f91ca8d12b0a27d9b79857cc335abfdeef", + "settings.integrations.caldav.account.serverUrl.name": "1d5d1effa999db571869970c0ccf9ad86e9d96a9", + "settings.integrations.caldav.account.serverUrl.description": "316e15f8135317c92c2a15f2340be464fe508c38", + "settings.integrations.caldav.account.username.name": "84c29015de33e5d22422382a372caba5c58f8c01", + "settings.integrations.caldav.account.username.description": "38aba9d60697e681c6265ad15499a96d718eb9e0", + "settings.integrations.caldav.account.password.name": "8be3c943b1609fffbfc51aad666d0a04adf83c9d", + "settings.integrations.caldav.account.password.description": "8671e52bd5937b25bc61bfd3c7f503adef7bef98", + "settings.integrations.caldav.account.password.stored": "64b7152505708c08b08e4a96e6eaa02ea034a173", + "settings.integrations.caldav.account.password.placeholder": "9695086041c462c6598c6dd5e35d3a2c9cbbe5f8", + "settings.integrations.caldav.account.password.clear": "719ea396ad92e01b4757ec2b93bb1e5f270f771d", + "settings.integrations.caldav.account.discover.name": "91c40abdae9e4c8b6af1dad8d8fc60d2351b4d25", + "settings.integrations.caldav.account.discover.description": "676840443d2ba378d31073cfe01100bf19760d80", + "settings.integrations.caldav.account.discover.button": "4827ea22716a74aaf8bbe499fc314bb93f73c65e", + "settings.integrations.caldav.account.collection.name": "c73016fa9b30a26886ab8bfabb06323ce3714447", + "settings.integrations.caldav.account.collection.choose": "bee9475ddafab210a55e00cad718c8406c99ebcb", + "settings.integrations.caldav.account.interval.name": "6d5fdb0573998aae458d0c115eedfc6c77172209", + "settings.integrations.caldav.account.interval.description": "b2e8a4dd84a9b3df31d0ba946636c0ebc0d296f3", + "settings.integrations.caldav.account.deletionPolicy.name": "0a537ed71b7b3d1bd545a1d0c36c3001f7d089d3", + "settings.integrations.caldav.account.deletionPolicy.description": "f6bdf8eff8da651f748a034a7aac92b303596f4d", + "settings.integrations.caldav.account.deletionPolicy.archive": "12fdfb9484c14b5d3252a98829407afe614e41db", + "settings.integrations.caldav.account.deletionPolicy.unlink": "0f71ba0a4610b69519432b0ee93679d16132cdc3", + "settings.integrations.caldav.account.deletionPolicy.delete": "81a60ef22edfc82eb4e139169cd1e3f0a3c61fb5", + "settings.integrations.caldav.account.enable.name": "f025efdef3d9739f01535d4da256f1d8047cf6f3", + "settings.integrations.caldav.account.enable.description": "c5dbda373cd00838dd6176b05e20d9a6132b2091", + "settings.integrations.caldav.account.firstSync.name": "9273e1c1da660e643a5fdbb47b59d986189d6953", + "settings.integrations.caldav.account.firstSync.description": "b91de47f60a3b2a4de1d9e100d0a6e6500aaac71", + "settings.integrations.caldav.account.firstSync.button": "f1fbb2b43dca281d0138f4fcc92543ad143ef0b1", + "settings.integrations.caldav.account.remove.name": "e556b5329b346b99d0e21a78f4409bb3171d8a76", + "settings.integrations.caldav.account.remove.description": "cc97d6383c222534df4e9b6acdaf175746ca5205", + "settings.integrations.caldav.account.remove.button": "e963907dac5cd5c017869b4c96c18021c9bd058b", + "settings.integrations.caldav.firstSync.title": "71b8dc16334debd2e95cb3fffd1f5ede47ad080f", + "settings.integrations.caldav.firstSync.summary": "3bdbc9fe0b25d307886dac1beb7db6f2ee82ee00", + "settings.integrations.caldav.firstSync.confirm": "2b7d938e6787ea92dd55e165343965957112451b", + "settings.integrations.caldav.remove.title": "e556b5329b346b99d0e21a78f4409bb3171d8a76", + "settings.integrations.caldav.remove.message": "0d53619c7d955fbc8057d48652ff850752e4d4a6", + "settings.integrations.caldav.remove.confirm": "e963907dac5cd5c017869b4c96c18021c9bd058b", + "settings.integrations.caldav.notices.missingCredentials": "7bb9ca34b6eef4809db16a78721ec15b2841fb29", + "settings.integrations.caldav.notices.credentialsNotStored": "8f1d7a482345549ef2b894160af55cb222cba1b9", + "settings.integrations.caldav.notices.noCollections": "de824dabbb41a0ef73d04aa13474d8a186fe03f1", + "settings.integrations.caldav.notices.noCollectionSelected": "74c79b855418a79782480996ec726e9e758a6ea4", + "settings.integrations.caldav.notices.discovered": "9d46f14a6d309f232b4a7677a2dc03a560444b65", + "settings.integrations.caldav.notices.authFailed": "d5031df81963839bfa97556077d58d85046e6386", + "settings.integrations.caldav.notices.connectionFailed": "f2b81a5e02bf03f5dfa0b401efa56ebe8603a252", + "settings.integrations.caldav.notices.firstSyncComplete": "5a1bfdfa0d81346803cfa799c340e90dfa4f5200", + "settings.integrations.caldav.notices.reloadRequired": "50dc6ec366c913e5fc0e592161e6c6fdd1e622ed", + "settings.integrations.caldav.notices.syncComplete": "28a45280f986c1c86e7178c6616790a0e3f22526", + "settings.integrations.caldav.notices.unlinkedAll": "fdd79bed5323161db093623ddd4e0781ab62a0a8", + "settings.integrations.caldav.unlinkAll.confirmTitle": "25a592b928743b42ad364473f692b62e5184fe06", + "settings.integrations.caldav.unlinkAll.confirmMessage": "566dbb09df41e754cd2b08c9da9888141dfcf03a", + "settings.integrations.caldav.unlinkAll.confirmText": "0dc2913c6ee9143b2534f7f3a8fe46f8a6421167", "settings.integrations.googleCalendarExport.header": "869c551781d06c3b2556af5856a42342c1d53b96", "settings.integrations.googleCalendarExport.description": "7bf2b738d7caa0d4b7bda1fe3d2cb8229697d053", "settings.integrations.googleCalendarExport.enable.name": "87000ca9a51b4d7f0b4494af98ceb923fec07091", @@ -1406,6 +1467,8 @@ "commands.createOrOpenTask": "17dd392d243d955d9a4fdaf9a4c29066f26c408a", "commands.createOrOpenTaskWithTracking": "eae087fd6478e527140061f67e199dcee752e00c", "commands.rolloverOverdueScheduledTasks": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", + "commands.caldavSyncNow": "de19b0b22272cf11f5fd75cb21d73d46549763ce", + "commands.caldavUnlinkAllTasks": "c07171704f3322341f388b4358c04ee17fb2f0de", "modals.deviceCode.title": "1b248f8b8aea742005866f499359b67ada70b86e", "modals.deviceCode.instructions.intro": "6f588f0b7cb402c539b8814f65a4798e8da218e0", "modals.deviceCode.steps.open": "cf9b77061f7b3126b49d50a6fa68f7ca8c26b7a3", diff --git a/i18n.state.json b/i18n.state.json index 1d6968638..8251cbb85 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -4546,6 +4546,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "87881e234037a34399221d315b63336c4c6ba00c" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "72cc0b08cd0e4c9143f08fc297e75b0d4bae856c" @@ -5622,6 +5683,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "641022fdb5e4fe9391c040c2c9683738ec18ec56" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "4ae489e473a5a1d2bf9aaf0efed4a3aed66c8526" @@ -13454,6 +13517,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "13f5ef17bd1c4cc217b4ef4a3575f655b35c1a3c" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "abb32d18731f96e26b868ea02bddaf56a3a26bd7" @@ -14530,6 +14654,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "dfb197d44003b01e388d18d203804b109ffb52e3" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "b751828a1280ee6986bb981e927b7233e89f770f" @@ -22362,6 +22488,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "c6c4ced2149d44c5df64fb79ab8ee33ca84bc763" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "d231af5d1306ed74e3916500a39af6a46a9ab3d3" @@ -23438,6 +23625,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "2a6f9d87e5ea5c98a846f17d561a5558aee14a55" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "3fd7f1097ffdd1ec563ab5714dcdee521c89ec5f" @@ -31270,6 +31459,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "1ade6968a1e13676c8333fb9230faf97f4c119ab" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "b4f8bcb11e8e304a2442d4156dcc473f17d68133" @@ -32346,6 +32596,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "885709e454f1004cac119f0a31c2f5f5d2e73d21" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "ece297f69ffd13b505e878cc8ccb5ee9c37cae9c" @@ -40178,6 +40430,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "b09dd809fc31e9cd4cc8051938b2e394254dd4a3" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "ce27706d477c5bf029b1b70e16b3bd7af9851b98" @@ -41254,6 +41567,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "a398bc3fee1e815294881cb86084db6efffa1703" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "ec82f0165b75226c3dbdce229bcefd772703dbb3" @@ -49086,6 +49401,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "39915ff77737701ee07b3cebe996f55bf3201c4f" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "0015bccda7fce65912d30b88714f53debc6bcede" @@ -50162,6 +50538,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "854b064c78143187ceda78369040021eeed6f3c2" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "593a2468a8efaddbd38aadc53f01098d23309cd8" @@ -57994,6 +58372,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "423e0e04c04005ef22c476d658f7bdc92b4cbe1a" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "e8e7a11bdda0777951c032893fea52c41c61b2a4" @@ -59070,6 +59509,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "b75667f91c66134ba3fb5a45fcb4c210d8778648" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "e803dccd7dc588f274c09f49489687d8725f9f82" @@ -66902,6 +67343,67 @@ "source": "1b69a3770528d48e5851443f5dc568a407c0e69f", "translation": "6891b9e3e425435bdddbea4882e5bae37e788ece" }, + "settings.integrations.caldav.header": null, + "settings.integrations.caldav.description": null, + "settings.integrations.caldav.enable.name": null, + "settings.integrations.caldav.enable.description": null, + "settings.integrations.caldav.pushOnChange.name": null, + "settings.integrations.caldav.pushOnChange.description": null, + "settings.integrations.caldav.addAccount.name": null, + "settings.integrations.caldav.addAccount.description": null, + "settings.integrations.caldav.addAccount.button": null, + "settings.integrations.caldav.addAccount.defaultName": null, + "settings.integrations.caldav.account.name.name": null, + "settings.integrations.caldav.account.name.description": null, + "settings.integrations.caldav.account.serverUrl.name": null, + "settings.integrations.caldav.account.serverUrl.description": null, + "settings.integrations.caldav.account.username.name": null, + "settings.integrations.caldav.account.username.description": null, + "settings.integrations.caldav.account.password.name": null, + "settings.integrations.caldav.account.password.description": null, + "settings.integrations.caldav.account.password.stored": null, + "settings.integrations.caldav.account.password.placeholder": null, + "settings.integrations.caldav.account.password.clear": null, + "settings.integrations.caldav.account.discover.name": null, + "settings.integrations.caldav.account.discover.description": null, + "settings.integrations.caldav.account.discover.button": null, + "settings.integrations.caldav.account.collection.name": null, + "settings.integrations.caldav.account.collection.choose": null, + "settings.integrations.caldav.account.interval.name": null, + "settings.integrations.caldav.account.interval.description": null, + "settings.integrations.caldav.account.deletionPolicy.name": null, + "settings.integrations.caldav.account.deletionPolicy.description": null, + "settings.integrations.caldav.account.deletionPolicy.archive": null, + "settings.integrations.caldav.account.deletionPolicy.unlink": null, + "settings.integrations.caldav.account.deletionPolicy.delete": null, + "settings.integrations.caldav.account.enable.name": null, + "settings.integrations.caldav.account.enable.description": null, + "settings.integrations.caldav.account.firstSync.name": null, + "settings.integrations.caldav.account.firstSync.description": null, + "settings.integrations.caldav.account.firstSync.button": null, + "settings.integrations.caldav.account.remove.name": null, + "settings.integrations.caldav.account.remove.description": null, + "settings.integrations.caldav.account.remove.button": null, + "settings.integrations.caldav.firstSync.title": null, + "settings.integrations.caldav.firstSync.summary": null, + "settings.integrations.caldav.firstSync.confirm": null, + "settings.integrations.caldav.remove.title": null, + "settings.integrations.caldav.remove.message": null, + "settings.integrations.caldav.remove.confirm": null, + "settings.integrations.caldav.notices.missingCredentials": null, + "settings.integrations.caldav.notices.credentialsNotStored": null, + "settings.integrations.caldav.notices.noCollections": null, + "settings.integrations.caldav.notices.noCollectionSelected": null, + "settings.integrations.caldav.notices.discovered": null, + "settings.integrations.caldav.notices.authFailed": null, + "settings.integrations.caldav.notices.connectionFailed": null, + "settings.integrations.caldav.notices.firstSyncComplete": null, + "settings.integrations.caldav.notices.reloadRequired": null, + "settings.integrations.caldav.notices.syncComplete": null, + "settings.integrations.caldav.notices.unlinkedAll": null, + "settings.integrations.caldav.unlinkAll.confirmTitle": null, + "settings.integrations.caldav.unlinkAll.confirmMessage": null, + "settings.integrations.caldav.unlinkAll.confirmText": null, "settings.integrations.googleCalendarExport.header": { "source": "869c551781d06c3b2556af5856a42342c1d53b96", "translation": "66753339619b68f3da90a7324f055804e1226391" @@ -67978,6 +68480,8 @@ "source": "371dd90ae93e5e2bd3270bc26af3e491de9844b4", "translation": "1b0fee896b2122eaf22be533e1183db56e0ffd88" }, + "commands.caldavSyncNow": null, + "commands.caldavUnlinkAllTasks": null, "modals.deviceCode.title": { "source": "1b248f8b8aea742005866f499359b67ada70b86e", "translation": "93ded0de3e847f73c0023e4206ccd3aa03cdd0bf" diff --git a/mkdocs.yml b/mkdocs.yml index 86d3ceddc..b91ae6bb2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ nav: - Calendar and integrations: - Calendar integration: features/calendar-integration.md - ICS integration: features/ics-integration.md + - CalDAV sync: features/caldav-sync.md - Third-party integrations: features/integrations.md - Calendar setup: calendar-setup.md - Backup, restore, and removal: guides/backup-recovery.md diff --git a/scripts/run-architecture-conformance.mjs b/scripts/run-architecture-conformance.mjs index fc5f4aa40..5d2da0888 100644 --- a/scripts/run-architecture-conformance.mjs +++ b/scripts/run-architecture-conformance.mjs @@ -33,6 +33,9 @@ const VAULT_WRITE_METHODS = new Set([ const pureModulePatterns = [ /^src\/bases\/.*(?:Planning|Conversion|Defaults|Signature|Snapshot|Properties|Export|Value|Values|State)\.ts$/, + // CalDAV VTODO mapping and scoping. caldavXml.ts is deliberately absent: it + // parses with DOMParser, which a pure module may not reach for. + /^src\/services\/caldav\/(?:caldavFingerprint|caldavReconciliation|collectionMembership|icsDateValue|vtodoAlarms|vtodoDocument|vtodoMapping|vtodoRelations)\.ts$/, /^src\/services\/filter-service\/.*\.ts$/, /^src\/services\/task-service\/.*Planning\.ts$/, /^src\/services\/task-service\/taskTitleSanitizer\.ts$/, @@ -73,7 +76,7 @@ const vaultWriteAllowedPatterns = [ const networkAllowedPatterns = [ /^src\/api\/.*\.ts$/, - /^src\/services\/(?:GoogleCalendarService|HTTPAPIService|ICSSubscriptionService|MCPService|MicrosoftCalendarService|OAuthService)\.ts$/, + /^src\/services\/(?:CalDavClient|GoogleCalendarService|HTTPAPIService|ICSSubscriptionService|MCPService|MicrosoftCalendarService|OAuthService)\.ts$/, ]; const pluginImportAllowedPatterns = [ diff --git a/src/bootstrap/pluginBootstrap.ts b/src/bootstrap/pluginBootstrap.ts index 07e1347a4..7f442bdf6 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -510,6 +510,50 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { await plugin.microsoftCalendarService.initialize(); } + // CalDAV VTODO sync deliberately sits outside the mobile + // calendar-integration guard above: it syncs tasks, not calendar + // events, and reaching the phone is the whole point of it. + if (plugin.settings.caldav?.enabled) { + const { CalDavSyncService } = await import("../services/CalDavSyncService"); + plugin.caldavSyncService = new CalDavSyncService(plugin); + await plugin.caldavSyncService.initialize(); + + plugin.registerEvent( + plugin.emitter.on("file-updated", (data: FileUpdatedEventData) => { + if (!plugin.caldavSyncService || !data?.path) return; + plugin.caldavSyncService + .handleTaskFileUpdated(data.path, data.updatedTask) + .catch((error) => { + tasknotesLogger.warn("Failed to handle task update for CalDAV:", { + category: "provider", + operation: "caldav-handle-task-update", + error: error, + }); + }); + }) + ); + + plugin.registerEvent( + plugin.emitter.on("file-deleted", (data: FileDeletedEventData) => { + if (!plugin.caldavSyncService || !data?.path) return; + // The frontmatter is already gone from the vault, so the + // href and ETag have to come from the previous cache entry. + const prevCache = data.prevCache as + | { frontmatter?: Record } + | undefined; + plugin.caldavSyncService + .handleTaskFileDeleted(data.path, prevCache?.frontmatter) + .catch((error) => { + tasknotesLogger.warn("Failed to delete remote CalDAV task:", { + category: "provider", + operation: "caldav-handle-task-delete", + error: error, + }); + }); + }) + ); + } + plugin.taskFileLifecycleReconciliationService = new TaskFileLifecycleReconciliationService(plugin); await plugin.taskFileLifecycleReconciliationService.initialize(); diff --git a/src/bootstrap/pluginRuntime.ts b/src/bootstrap/pluginRuntime.ts index d56b93e9c..6fcbdb10b 100644 --- a/src/bootstrap/pluginRuntime.ts +++ b/src/bootstrap/pluginRuntime.ts @@ -75,6 +75,7 @@ export async function cleanupPluginRuntime(plugin: TaskNotesPlugin): Promise { + // The service is only constructed when the integration is on, so + // its absence is the same situation as it being disabled. + if (!ctx.caldavSyncService?.isEnabled()) { + new Notice( + ctx.i18n.translate("settings.integrations.caldav.notices.reloadRequired") + ); + return; + } + + await ctx.caldavSyncService.syncAllAccounts(); + new Notice(ctx.i18n.translate("settings.integrations.caldav.notices.syncComplete")); + }, + }, + { + id: "caldav-unlink-all-tasks", + nameKey: "commands.caldavUnlinkAllTasks", + callback: async (ctx) => { + if (!ctx.caldavSyncService) { + new Notice( + ctx.i18n.translate("settings.integrations.caldav.notices.reloadRequired") + ); + return; + } + + const confirmed = await showConfirmationModal(ctx.app, { + title: ctx.i18n.translate("settings.integrations.caldav.unlinkAll.confirmTitle"), + message: ctx.i18n.translate( + "settings.integrations.caldav.unlinkAll.confirmMessage" + ), + confirmText: ctx.i18n.translate( + "settings.integrations.caldav.unlinkAll.confirmText" + ), + isDestructive: true, + }); + if (!confirmed) return; + + const count = await ctx.caldavSyncService.unlinkAllTasks(); + new Notice( + ctx.i18n.translate("settings.integrations.caldav.notices.unlinkedAll", { count }) + ); + }, + }, ]; } diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 7be66f416..b2df36055 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1978,6 +1978,115 @@ export const en: TranslationTree = { serviceUnavailable: "Auto export service not available", }, }, + caldav: { + header: "CalDAV task sync", + description: + "Two-way sync between your tasks and a CalDAV task list, such as Nextcloud, Apple Reminders, Radicale or Baikal. This is separate from the calendar integrations above: it syncs tasks as VTODO entries rather than as calendar events.", + enable: { + name: "Enable CalDAV sync", + description: "Sync tasks two ways with one or more CalDAV task lists.", + }, + pushOnChange: { + name: "Push changes immediately", + description: + "Send local edits to the server as they happen. When off, changes are sent on the next scheduled sync instead.", + }, + addAccount: { + name: "Add account", + description: "Configure another CalDAV task list.", + button: "Add account", + defaultName: "New account", + }, + account: { + name: { + name: "Name", + description: "A label for this account.", + }, + serverUrl: { + name: "Server URL", + description: + "Base URL of the CalDAV server. Credentials are only sent over HTTPS, except to localhost.", + }, + username: { + name: "Username", + description: "The account name on the CalDAV server.", + }, + password: { + name: "Password", + description: + "Stored in Obsidian secret storage, never in the plugin's data file. Use an app password where your provider offers one.", + stored: "A password is stored for this account. Type a new one to replace it.", + placeholder: "Enter a password", + clear: "Clear", + }, + discover: { + name: "Task list", + description: "Find the task lists this account can reach.", + button: "Discover", + }, + collection: { + name: "Selected task list", + choose: "Choose which list this account syncs with.", + }, + interval: { + name: "Check for changes every", + description: "How often to look for changes on the server, in minutes.", + }, + deletionPolicy: { + name: "When a task is deleted on the server", + description: "What happens to the local note when its entry disappears from the server.", + archive: "Archive the note", + unlink: "Keep the note and stop syncing it", + delete: "Delete the note", + }, + enable: { + name: "Sync this account", + description: "Turn syncing on once the details above are correct.", + }, + firstSync: { + name: "First sync", + description: + "Compare this task list against your vault and show what would change before anything is written.", + button: "Preview", + }, + remove: { + name: "Remove account", + description: "Stop syncing and forget this account's stored password.", + button: "Remove", + }, + }, + firstSync: { + title: "Review the first sync", + summary: + "{upload} to upload, {import} to import, {link} already matching, {resolve} changed on both sides. Nothing has been written yet.", + confirm: "Sync now", + }, + remove: { + title: "Remove account", + message: + "Remove \"{name}\"? Tasks already in your vault are kept, but they stop syncing and the stored password is deleted.", + confirm: "Remove", + }, + notices: { + missingCredentials: "Enter a username and password for this account first.", + credentialsNotStored: "TaskNotes could not save the password to secret storage.", + noCollections: "No task lists were found for this account.", + noCollectionSelected: "Choose a task list for this account first.", + discovered: "Found {count} task list(s). Using \"{name}\".", + authFailed: "The server rejected those credentials.", + connectionFailed: "TaskNotes could not reach the CalDAV server.", + firstSyncComplete: "First sync finished.", + reloadRequired: "Reload the plugin to start CalDAV syncing.", + syncComplete: "CalDAV sync finished.", + unlinkedAll: "Unlinked {count} task(s) from CalDAV.", + }, + unlinkAll: { + confirmTitle: "Unlink all tasks", + confirmMessage: + "Remove the CalDAV link from every task in this vault? Nothing is deleted, here or on the server. The link is what stops a task syncing twice, so if you sync this vault with the same list again afterwards, you will get a second copy of every task.", + confirmText: "Unlink", + }, + }, googleCalendarExport: { header: "Export tasks to Google Calendar", description: @@ -2436,6 +2545,8 @@ export const en: TranslationTree = { createOrOpenTask: "Create or open task", createOrOpenTaskWithTracking: "Create or open task and start time tracking", rolloverOverdueScheduledTasks: "Postpone overdue scheduled tasks to today", + caldavSyncNow: "Sync tasks with CalDAV now", + caldavUnlinkAllTasks: "Unlink all tasks from CalDAV", }, modals: { deviceCode: { diff --git a/src/main.ts b/src/main.ts index 4a967ff72..0d8afa292 100644 --- a/src/main.ts +++ b/src/main.ts @@ -216,6 +216,9 @@ export default class TaskNotesPlugin extends Plugin { // Task-to-Google Calendar sync service taskCalendarSyncService: TaskCalendarSyncService; + + // Two-way CalDAV VTODO sync (independent of the OAuth calendar integration) + caldavSyncService?: import("./services/CalDavSyncService").CalDavSyncService; taskFileLifecycleReconciliationService?: import("./services/TaskFileLifecycleReconciliationService").TaskFileLifecycleReconciliationService; // mdbase-spec generation service diff --git a/src/services/CalDavClient.ts b/src/services/CalDavClient.ts new file mode 100644 index 000000000..3c54e9d1c --- /dev/null +++ b/src/services/CalDavClient.ts @@ -0,0 +1,623 @@ +/** + * CalDAV protocol client (RFC 4791, RFC 6578). + * + * Every request goes through Obsidian's `requestUrl`, which accepts arbitrary + * methods and headers, exposes response headers (so ETags are readable), and + * bypasses CORS — none of which `fetch` gives us inside a plugin. It also works + * unchanged on mobile, which matters because TaskNotes is not desktop-only. + * + * This file is on the `no-network-outside-provider` allowlist in + * scripts/run-architecture-conformance.mjs as a provider service, alongside the + * Google/Microsoft/ICS ones. + */ + +import { requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian"; + +import { + buildCalendarCollectionsRequest, + buildCalendarHomeSetRequest, + buildCalendarQueryVTodoRequest, + buildCollectionTagRequest, + buildCurrentUserPrincipalRequest, + buildEtagListRequest, + buildMultigetRequest, + buildSyncCollectionRequest, + normalizeEtag, + parseMultistatus, + parseSyncCollection, + selectVTodoCollections, +} from "./caldav/caldavXml"; +import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger"; + +export interface CalDavCredentials { + username: string; + password: string; +} + +export interface CalDavCollectionInfo { + /** Absolute URL of the collection. */ + url: string; + displayName: string; + /** Sync token as of discovery, when the server advertises one. */ + syncToken?: string; +} + +export interface CalDavResource { + /** Absolute URL of the resource. */ + url: string; + etag?: string; + /** Raw iCalendar body, when the response carried one. */ + data?: string; +} + +/** Cheap change signal for a collection, used to skip pointless polls. */ +export interface CalDavCollectionTag { + ctag?: string; + syncToken?: string; +} + +export interface CalDavSyncResult { + syncToken?: string; + changed: CalDavResource[]; + removed: string[]; + /** True when the server does not support sync-collection and we listed instead. */ + usedFallback: boolean; +} + +export type CalDavRequestFn = (params: RequestUrlParam) => Promise; + +export type CalDavErrorKind = + | "auth" + | "not-found" + | "conflict" + | "precondition" + | "server" + | "network" + | "protocol"; + +export class CalDavError extends Error { + constructor( + readonly kind: CalDavErrorKind, + message: string, + readonly status?: number + ) { + super(message); + this.name = "CalDavError"; + } +} + +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); +const MAX_RETRIES = 3; +const INITIAL_BACKOFF_MS = 500; +const MAX_BACKOFF_MS = 8000; +const BACKOFF_MULTIPLIER = 2; +/** Upper bound on ancestor paths probed while hunting for the principal. */ +const MAX_PRINCIPAL_ANCESTORS = 6; + +export interface CalDavClientOptions { + serverUrl: string; + credentials: CalDavCredentials; + /** Injectable for tests; defaults to Obsidian's `requestUrl`. */ + requestFn?: CalDavRequestFn; + logger?: TaskNotesLogger; + /** Overridable so tests do not actually wait out the backoff. */ + sleepFn?: (ms: number) => Promise; +} + +export class CalDavClient { + private readonly serverUrl: string; + private readonly credentials: CalDavCredentials; + private readonly request: CalDavRequestFn; + private readonly logger: TaskNotesLogger; + private readonly sleep: (ms: number) => Promise; + + constructor(options: CalDavClientOptions) { + this.serverUrl = options.serverUrl; + this.credentials = options.credentials; + this.request = options.requestFn ?? requestUrl; + this.logger = options.logger ?? createTaskNotesLogger({ tag: "Services/CalDavClient" }); + this.sleep = + options.sleepFn ?? + ((ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))); + + assertCredentialsAreSafeToSend(options.serverUrl); + } + + /** + * Walks the RFC 4791 discovery chain and returns only the collections that + * can actually hold VTODOs, so an event-only calendar is never offered as a + * task list. + */ + async discoverCollections(): Promise { + const principal = await this.findCurrentUserPrincipal(); + const homeSet = await this.findCalendarHomeSet(principal); + + const response = await this.send({ + method: "PROPFIND", + url: homeSet, + headers: { Depth: "1" }, + body: buildCalendarCollectionsRequest(), + }); + + const { responses } = parseMultistatus(response.text); + return selectVTodoCollections(responses).map((entry) => ({ + url: this.resolve(entry.href), + displayName: entry.displayName?.trim() || lastPathSegment(entry.href), + })); + } + + /** + * Reads the collection's change tokens without listing anything. + * + * One small request that answers "is a poll worth doing at all?". Collections + * routinely hold far more VEVENTs than VTODOs, so skipping the query when + * nothing has moved is the difference between one round trip and a full + * calendar download. + */ + async getCollectionTag(collectionUrl: string): Promise { + const response = await this.send({ + method: "PROPFIND", + url: collectionUrl, + headers: { Depth: "0" }, + body: buildCollectionTagRequest(), + }); + + const { responses } = parseMultistatus(response.text); + const entry = responses.find((item) => item.ctag || item.collectionSyncToken); + return { ctag: entry?.ctag, syncToken: entry?.collectionSyncToken }; + } + + /** Fetches every VTODO in a collection, bodies included. Used for first sync. */ + async fetchAllVTodos(collectionUrl: string): Promise { + const response = await this.send({ + method: "REPORT", + url: collectionUrl, + headers: { Depth: "1" }, + body: buildCalendarQueryVTodoRequest(), + }); + + return parseMultistatus(response.text) + .responses.filter((entry) => entry.calendarData) + .map((entry) => ({ + url: this.resolve(entry.href), + etag: entry.etag, + data: entry.calendarData, + })); + } + + /** + * Incremental change feed. + * + * Prefers RFC 6578 sync-collection, which reports deletions explicitly. When + * the server rejects it (many older Radicale and Baikal builds do), falls + * back to listing ETags — the caller then infers deletions from hrefs that + * were in its index but absent from the listing. + */ + async syncCollection( + collectionUrl: string, + syncToken?: string + ): Promise { + try { + const response = await this.send({ + method: "REPORT", + url: collectionUrl, + headers: { Depth: "1" }, + body: buildSyncCollectionRequest(syncToken), + }); + + const parsed = parseSyncCollection(response.text); + // A server that ignores sync-collection can still answer 207 with an + // empty body; treating that as "nothing changed" would stall the sync + // forever, so fall back when it yields nothing usable. + if (!parsed.syncToken && parsed.changed.length === 0 && parsed.removed.length === 0) { + return this.listCollectionEtags(collectionUrl); + } + + return { + syncToken: parsed.syncToken, + changed: parsed.changed.map((entry) => ({ + url: this.resolve(entry.href), + etag: entry.etag, + })), + removed: parsed.removed.map((href) => this.resolve(href)), + usedFallback: false, + }; + } catch (error) { + if (error instanceof CalDavError && isSyncCollectionUnsupported(error)) { + this.logger.info("Server does not support sync-collection; listing ETags", { + category: "provider", + operation: "sync-collection-fallback", + details: { collectionUrl }, + }); + return this.listCollectionEtags(collectionUrl); + } + throw error; + } + } + + /** Lists hrefs and ETags without bodies. The sync-collection fallback. */ + async listCollectionEtags(collectionUrl: string): Promise { + const response = await this.send({ + method: "PROPFIND", + url: collectionUrl, + headers: { Depth: "1" }, + body: buildEtagListRequest(), + }); + + const collection = normalizeUrl(collectionUrl); + const changed = parseMultistatus(response.text) + .responses.map((entry) => ({ url: this.resolve(entry.href), etag: entry.etag })) + // The collection itself comes back in a Depth:1 listing; it is not a + // resource, and treating it as one would create a phantom task. + .filter((entry) => normalizeUrl(entry.url) !== collection && Boolean(entry.etag)); + + return { changed, removed: [], usedFallback: true }; + } + + /** Fetches the bodies of specific resources in one round trip. */ + async fetchResources( + collectionUrl: string, + urls: readonly string[] + ): Promise { + if (urls.length === 0) return []; + + const response = await this.send({ + method: "REPORT", + url: collectionUrl, + headers: { Depth: "1" }, + body: buildMultigetRequest(urls.map((url) => pathOf(url))), + }); + + return parseMultistatus(response.text) + .responses.filter((entry) => entry.calendarData) + .map((entry) => ({ + url: this.resolve(entry.href), + etag: entry.etag, + data: entry.calendarData, + })); + } + + /** Reads one resource. Returns null when it no longer exists. */ + async getResource(url: string): Promise { + try { + const response = await this.send({ + method: "GET", + url, + headers: { Accept: "text/calendar" }, + }); + return { + url, + etag: normalizeEtag(headerValue(response.headers, "etag")), + data: response.text, + }; + } catch (error) { + if (error instanceof CalDavError && error.kind === "not-found") return null; + throw error; + } + } + + /** + * Writes a resource under optimistic concurrency control. + * + * `ifMatch` carries the ETag from the last sync, so a 412 means the remote + * changed underneath us — that is what makes conflict *detection* possible + * rather than blindly overwriting. `ifNoneMatch: "*"` is used for a first + * push, so an existing resource at the same href is never clobbered. + */ + async putResource( + url: string, + icsBody: string, + options: { ifMatch?: string; ifNoneMatch?: "*" } = {} + ): Promise<{ etag?: string; conflict: boolean }> { + const headers: Record = { + "Content-Type": "text/calendar; charset=utf-8", + }; + if (options.ifMatch) headers["If-Match"] = `"${options.ifMatch}"`; + if (options.ifNoneMatch) headers["If-None-Match"] = options.ifNoneMatch; + + try { + const response = await this.send({ method: "PUT", url, headers, body: icsBody }); + return { + etag: normalizeEtag(headerValue(response.headers, "etag")), + conflict: false, + }; + } catch (error) { + if (error instanceof CalDavError && error.kind === "precondition") { + return { conflict: true }; + } + throw error; + } + } + + /** + * Deletes a resource. Returns false when it was already gone, which is a + * success for our purposes, and reports a conflict when the ETag no longer + * matches. + */ + async deleteResource( + url: string, + options: { ifMatch?: string } = {} + ): Promise<{ deleted: boolean; conflict: boolean }> { + const headers: Record = {}; + if (options.ifMatch) headers["If-Match"] = `"${options.ifMatch}"`; + + try { + await this.send({ method: "DELETE", url, headers }); + return { deleted: true, conflict: false }; + } catch (error) { + if (error instanceof CalDavError && error.kind === "not-found") { + return { deleted: false, conflict: false }; + } + if (error instanceof CalDavError && error.kind === "precondition") { + return { deleted: false, conflict: true }; + } + throw error; + } + } + + // ----------------------------------------------------------------------- + // Discovery steps + // ----------------------------------------------------------------------- + + /** + * Entry points to probe for the principal, nearest first. + * + * Users paste whatever URL they had to hand — often a collection or even the + * files endpoint — and the principal usually lives on an ancestor path, so + * walk up to the origin before falling back to the well-known endpoint. + * That fallback is deliberately last: servers behind a reverse proxy + * frequently redirect it to plain http, and `requestUrl` follows redirects + * with no way to veto a scheme downgrade, which would put the credentials on + * the wire in clear text. + */ + private principalCandidates(): string[] { + const candidates: string[] = []; + const push = (url: string) => { + if (!candidates.includes(url)) candidates.push(url); + }; + + push(this.serverUrl); + + try { + const parsed = new URL(this.serverUrl); + const segments = parsed.pathname.split("/").filter(Boolean); + for (let depth = segments.length - 1; depth >= 0; depth--) { + if (candidates.length > MAX_PRINCIPAL_ANCESTORS) break; + const path = `/${segments.slice(0, depth).join("/")}${depth > 0 ? "/" : ""}`; + push(new URL(path, parsed).toString()); + } + } catch { + // An unparseable URL is caught by assertCredentialsAreSafeToSend in the + // constructor; nothing useful to add to the ladder here. + } + + // Always last, never dropped by the cap above. + push(this.resolve("/.well-known/caldav")); + return candidates; + } + + private async findCurrentUserPrincipal(): Promise { + for (const candidate of this.principalCandidates()) { + try { + const response = await this.send({ + method: "PROPFIND", + url: candidate, + headers: { Depth: "0" }, + body: buildCurrentUserPrincipalRequest(), + }); + const principal = parseMultistatus(response.text).responses.find( + (entry) => entry.currentUserPrincipal + )?.currentUserPrincipal; + if (principal) return this.resolve(principal); + } catch (error) { + // Auth failures are fatal and worth surfacing immediately; anything + // else just means this candidate was not the right entry point. + if (error instanceof CalDavError && error.kind === "auth") throw error; + } + } + + // Some servers expose no principal at all but serve collections directly + // from the configured URL. + return this.serverUrl; + } + + private async findCalendarHomeSet(principalUrl: string): Promise { + try { + const response = await this.send({ + method: "PROPFIND", + url: principalUrl, + headers: { Depth: "0" }, + body: buildCalendarHomeSetRequest(), + }); + const home = parseMultistatus(response.text).responses.find( + (entry) => entry.calendarHomeSet + )?.calendarHomeSet; + if (home) return this.resolve(home); + } catch (error) { + if (error instanceof CalDavError && error.kind === "auth") throw error; + } + return principalUrl; + } + + // ----------------------------------------------------------------------- + // Transport + // ----------------------------------------------------------------------- + + private async send(params: { + method: string; + url: string; + headers?: Record; + body?: string; + }): Promise { + let backoff = INITIAL_BACKOFF_MS; + let lastError: unknown; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + let response: RequestUrlResponse; + try { + response = await this.request({ + url: params.url, + method: params.method, + headers: { + Authorization: basicAuthHeader(this.credentials), + "User-Agent": "TaskNotes/CalDAV", + ...params.headers, + }, + body: params.body, + // Status codes are part of the protocol here — 207, 404 and 412 + // all carry meaning — so they must not be thrown as exceptions. + throw: false, + }); + } catch (error) { + // A genuine transport failure (DNS, TLS, offline). + lastError = error; + if (attempt === MAX_RETRIES) { + throw new CalDavError("network", describeError(error)); + } + await this.sleep(jitter(backoff)); + backoff = Math.min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); + continue; + } + + if (response.status >= 200 && response.status < 300) return response; + + if (RETRYABLE_STATUSES.has(response.status) && attempt < MAX_RETRIES) { + this.logger.debug("Retrying CalDAV request", { + category: "provider", + operation: "retry", + details: { status: response.status, method: params.method, attempt }, + }); + await this.sleep(jitter(backoff)); + backoff = Math.min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); + continue; + } + + throw this.toError(response, params.method, params.url); + } + + throw new CalDavError("network", describeError(lastError)); + } + + private toError( + response: RequestUrlResponse, + method: string, + url: string + ): CalDavError { + const kind = classifyStatus(response.status); + // Never log the URL's credentials or the request body; a 401 body from + // some servers echoes the submitted username. + this.logger.warn("CalDAV request failed", { + category: "provider", + operation: "request", + details: { status: response.status, method, path: pathOf(url), kind }, + }); + return new CalDavError( + kind, + `CalDAV ${method} failed with status ${response.status}`, + response.status + ); + } + + /** Resolves an href or absolute URL against the configured server. */ + private resolve(hrefOrUrl: string): string { + try { + return new URL(hrefOrUrl, this.serverUrl).toString(); + } catch { + return hrefOrUrl; + } + } +} + +function classifyStatus(status: number): CalDavErrorKind { + if (status === 401 || status === 403) return "auth"; + if (status === 404 || status === 410) return "not-found"; + if (status === 409) return "conflict"; + if (status === 412) return "precondition"; + if (status >= 500) return "server"; + return "protocol"; +} + +/** + * A server without sync-collection support answers with 400 (bad request), + * 403 (forbidden report) or 501 (not implemented) rather than a clean signal. + */ +function isSyncCollectionUnsupported(error: CalDavError): boolean { + return ( + error.status === 400 || + error.status === 403 || + error.status === 501 || + error.kind === "protocol" + ); +} + +/** + * Refuses to send credentials in the clear. Loopback is exempt so a local + * Radicale instance can be used for development without a certificate. + */ +export function assertCredentialsAreSafeToSend(serverUrl: string): void { + let parsed: URL; + try { + parsed = new URL(serverUrl); + } catch { + throw new CalDavError("protocol", "CalDAV server URL is not a valid URL"); + } + + if (parsed.protocol === "https:") return; + + const host = parsed.hostname; + const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (parsed.protocol === "http:" && isLoopback) return; + + throw new CalDavError( + "protocol", + "Refusing to send CalDAV credentials over an unencrypted connection. Use https://." + ); +} + +export function basicAuthHeader(credentials: CalDavCredentials): string { + const raw = `${credentials.username}:${credentials.password}`; + // btoa is Latin-1 only, so encode to UTF-8 bytes first — otherwise a + // non-ASCII password throws instead of authenticating. + const bytes = new TextEncoder().encode(raw); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `Basic ${btoa(binary)}`; +} + +function headerValue( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) return undefined; + const wanted = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === wanted) return value; + } + return undefined; +} + +function jitter(backoffMs: number): number { + return Math.round(backoffMs * (1 + Math.random() * 0.3)); +} + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/u, ""); +} + +function pathOf(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +function lastPathSegment(href: string): string { + const segments = href.split("/").filter(Boolean); + return segments[segments.length - 1] ?? href; +} + +function describeError(error: unknown): string { + if (error instanceof Error) return `CalDAV request failed: ${error.message}`; + return "CalDAV request failed"; +} diff --git a/src/services/CalDavSecretStore.ts b/src/services/CalDavSecretStore.ts new file mode 100644 index 000000000..e0b1e5343 --- /dev/null +++ b/src/services/CalDavSecretStore.ts @@ -0,0 +1,162 @@ +/** + * CalDAV credentials, held in Obsidian's SecretStorage. + * + * Follows the same shape as OAuthSecretStore: a versioned envelope with an + * explicit "cleared" tombstone (distinct from "missing"), reads that return a + * status union rather than throwing, and writes that are read back to confirm + * they actually persisted. + * + * Credentials never touch `data.json` — only the non-secret account + * configuration does. + */ + +import type { SecretStorage } from "obsidian"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; + +const tasknotesLogger = createTaskNotesLogger({ tag: "Services/CalDavSecretStore" }); + +export interface CalDavAccountCredentials { + username: string; + /** Password, or an app-specific password for iCloud and similar. */ + password: string; +} + +type CredentialsEnvelope = + | { version: 1; state: "configured"; credentials: CalDavAccountCredentials } + | { version: 1; state: "cleared" }; + +export type CalDavCredentialsState = + | { status: "missing" } + | { status: "configured"; credentials: CalDavAccountCredentials } + | { status: "cleared" } + | { status: "invalid" }; + +type SecretStorageAccess = Pick; + +const SECRET_ID_PREFIX = "tasknotes-caldav-"; +const SECRET_ID_SUFFIX = "-credentials"; +/** Obsidian rejects anything longer than this outright. */ +const SECRET_ID_MAX_LENGTH = 64; +const ACCOUNT_SLUG_MAX_LENGTH = + SECRET_ID_MAX_LENGTH - SECRET_ID_PREFIX.length - SECRET_ID_SUFFIX.length; + +/** + * Obsidian only accepts lowercase alphanumerics and dashes, up to 64 + * characters, and throws on anything else. Account ids are generated with a + * separator and can be edited by hand, so they are folded into that alphabet + * here rather than trusted. + */ +function slugifyAccountId(accountId: string): string { + const slug = accountId + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + if (!slug) return "account"; + if (slug.length <= ACCOUNT_SLUG_MAX_LENGTH) return slug; + + // Truncation alone would let two long ids sharing a prefix collide into one + // another's secret, so the discriminator is derived from the whole id. + const digest = fnv1aHex(accountId); + const head = slug.slice(0, ACCOUNT_SLUG_MAX_LENGTH - digest.length - 1).replace(/-+$/u, ""); + return `${head}-${digest}`; +} + +/** FNV-1a, purely as a short collision discriminator — not a security hash. */ +function fnv1aHex(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +/** + * Secret ids are namespaced per account. The id is restricted to characters + * that are safe in a key, so an account id can never collide with or escape + * into another account's slot. + */ +export function calDavSecretId(accountId: string): string { + return `${SECRET_ID_PREFIX}${slugifyAccountId(accountId)}${SECRET_ID_SUFFIX}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCredentialsEnvelope(raw: string): CalDavCredentialsState { + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || parsed.version !== 1) return { status: "invalid" }; + if (parsed.state === "cleared") return { status: "cleared" }; + if (parsed.state !== "configured" || !isRecord(parsed.credentials)) { + return { status: "invalid" }; + } + + const { username, password } = parsed.credentials; + if (typeof username !== "string" || typeof password !== "string" || !username) { + return { status: "invalid" }; + } + + return { status: "configured", credentials: { username, password } }; + } catch { + return { status: "invalid" }; + } +} + +export class CalDavSecretStore { + constructor(private readonly secretStorage: SecretStorageAccess) {} + + getCredentialsState(accountId: string): CalDavCredentialsState { + const secretId = calDavSecretId(accountId); + const raw = this.secretStorage.getSecret(secretId); + if (raw === null || raw === undefined) return { status: "missing" }; + + const state = parseCredentialsEnvelope(raw); + if (state.status === "invalid") { + // Deliberately logs the account id only — never the stored payload. + tasknotesLogger.warn("Stored CalDAV credentials could not be read", { + category: "configuration", + operation: "read-caldav-credentials", + details: { accountId }, + }); + } + return state; + } + + getCredentials(accountId: string): CalDavAccountCredentials | null { + const state = this.getCredentialsState(accountId); + return state.status === "configured" ? state.credentials : null; + } + + setCredentials(accountId: string, credentials: CalDavAccountCredentials): void { + const username = credentials.username.trim(); + if (!username) { + throw new Error("CalDAV credentials require a username"); + } + + this.writeVerified(calDavSecretId(accountId), { + version: 1, + state: "configured", + // The password is stored verbatim: leading or trailing whitespace can + // be significant, and app-specific passwords are generated, not typed. + credentials: { username, password: credentials.password }, + }); + } + + clearCredentials(accountId: string): void { + this.writeVerified(calDavSecretId(accountId), { version: 1, state: "cleared" }); + } + + hasCredentials(accountId: string): boolean { + return this.getCredentialsState(accountId).status === "configured"; + } + + private writeVerified(secretId: string, value: CredentialsEnvelope): void { + const serialized = JSON.stringify(value); + this.secretStorage.setSecret(secretId, serialized); + if (this.secretStorage.getSecret(secretId) !== serialized) { + throw new Error(`Obsidian SecretStorage did not persist ${secretId}`); + } + } +} diff --git a/src/services/CalDavSyncService.ts b/src/services/CalDavSyncService.ts new file mode 100644 index 000000000..e89cc6149 --- /dev/null +++ b/src/services/CalDavSyncService.ts @@ -0,0 +1,1346 @@ +/** + * Two-way CalDAV VTODO sync orchestration. + * + * Holds the moving parts that the pure modules under ./caldav/ cannot: vault + * writes, timers, persisted state and the plugin's task services. The decisions + * themselves — what a VTODO looks like, who wins a conflict, what to upload — + * live in those pure modules and are tested there. + * + * Loop prevention is structural, copied from the Google integration: writing + * `caldav_etag` back into frontmatter re-fires `file-updated`, and there is no + * event-suppression flag anywhere in the plugin, so a content fingerprint that + * excludes every `caldav_*` key is what stops the cycle. See caldavFingerprint.ts. + */ + +import { TFile } from "obsidian"; + +import type TaskNotesPlugin from "../main"; +import type { TaskDependency, TaskInfo } from "../types"; +import type { CalDavAccountSettings } from "../types/settings"; +import { publishUserNotice } from "../core/userNotices"; +import { processVaultFrontMatter } from "../core/VaultMutationService"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { CalDavClient, CalDavError } from "./CalDavClient"; +import { CalDavSecretStore } from "./CalDavSecretStore"; +import { + CALDAV_FRONTMATTER_KEYS, + getCalDavRelevantFingerprint, +} from "./caldav/caldavFingerprint"; +import { + planFirstSync, + planIncrementalSync, + planRemoteDeletion, + resolveConflict, + summarizeFirstSyncPlan, + type FirstSyncPlan, + type LocalTaskSnapshot, + type RemoteTodoSnapshot, +} from "./caldav/caldavReconciliation"; +import { + taskBelongsToCollection, + type CalDavCollectionScope, +} from "./caldav/collectionMembership"; +import { + applyTaskToVTodo, + readVTodoIntoTaskPatch, + readVTodoRevision, + readVTodoUid, + type VTodoMappingContext, +} from "./caldav/vtodoMapping"; +import { + createVTodoDocument, + parseVTodoDocument, + serializeVTodoDocument, + type VTodoDocument, +} from "./caldav/vtodoDocument"; +import { applyReminders, readReminders } from "./caldav/vtodoAlarms"; +import { applyRelations, readRelations, type VTodoRelations } from "./caldav/vtodoRelations"; +import { generateLink, parseLinkToPath } from "../utils/linkUtils"; +import { formatDependencyLink, resolveDependencyEntry } from "../utils/dependencyUtils"; +import type { FilterPredicateEvaluationContext } from "./filter-service/filterPredicateEvaluation"; + +const DATA_KEY_FINGERPRINTS = "caldavTaskFingerprints"; +const DATA_KEY_COLLECTION_STATE = "caldavCollectionState"; +const DATA_KEY_RESOURCE_INDEX = "caldavResourceIndex"; +const DATA_KEY_SYNC_QUEUE = "caldavSyncQueue"; + +/** How often the retry queue is drained. */ +const RETRY_QUEUE_INTERVAL_MS = 60_000; +/** + * Attempts before a queued push is abandoned. The Google queue retries forever; + * that turns a permanently rejected task into an endless request loop, so this + * one gives up and says so. + */ +const MAX_PUSH_ATTEMPTS = 5; + +/** A push that failed and is waiting to be retried. */ +interface PendingCalDavPush { + taskPath: string; + accountId: string; + requestedAt: number; + attempts: number; + lastAttemptAt?: number; + lastError?: string; +} + +interface CalDavCollectionState { + syncToken?: string; + /** Last seen collection ctag; equal means nothing changed server-side. */ + ctag?: string; + lastSyncedAt?: string; +} + +interface CalDavResourceIndexEntry { + accountId: string; + uid: string; + path: string; + href: string; +} + +export class CalDavSyncService { + private readonly logger = createTaskNotesLogger({ tag: "Services/CalDavSync" }); + private readonly secretStore: CalDavSecretStore; + + private pollTimers = new Map(); + private pushTimers = new Map(); + /** Paths currently being written by an inbound sync; guards reentrancy. */ + private handlingPaths = new Set(); + /** Tasks whose relations pointed at a target with no UID yet. */ + private pendingRelationPaths = new Set(); + private relationFlushTimers = new Map(); + /** Set while replaying deferred relations, to keep the retry to one pass. */ + private flushingRelations = false; + /** Imported tasks whose relations must wait until every sibling exists. */ + private pendingInboundRelations: { path: string; doc: VTodoDocument }[] = []; + private inFlightAccounts = new Set(); + private fingerprints: Record | null = null; + private destroyed = false; + private retryTimer: number | null = null; + /** Serialises queue writes so concurrent failures cannot lose each other. */ + private queueWrite: Promise = Promise.resolve(); + + constructor(private readonly plugin: TaskNotesPlugin) { + this.secretStore = new CalDavSecretStore(plugin.app.secretStorage); + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + async initialize(): Promise { + if (!this.isEnabled()) return; + for (const account of this.enabledAccounts()) { + this.startPollTimer(account.id); + } + this.scheduleRetryDrain(); + } + + destroy(): void { + this.destroyed = true; + for (const timer of this.pollTimers.values()) window.clearTimeout(timer); + for (const timer of this.pushTimers.values()) window.clearTimeout(timer); + if (this.retryTimer !== null) window.clearTimeout(this.retryTimer); + this.retryTimer = null; + for (const timer of this.relationFlushTimers.values()) window.clearTimeout(timer); + this.relationFlushTimers.clear(); + this.pollTimers.clear(); + this.pushTimers.clear(); + } + + isEnabled(): boolean { + return this.plugin.settings.caldav?.enabled === true; + } + + private enabledAccounts(): CalDavAccountSettings[] { + return (this.plugin.settings.caldav?.accounts ?? []).filter( + (account) => account.enabled && account.collectionUrl + ); + } + + private getAccount(accountId: string): CalDavAccountSettings | undefined { + return this.plugin.settings.caldav?.accounts.find( + (account) => account.id === accountId + ); + } + + // ----------------------------------------------------------------------- + // Event hooks (wired in pluginBootstrap) + // ----------------------------------------------------------------------- + + /** + * Reacts to any change to a task file, whether TaskNotes or an external tool + * made it. The fingerprint comparison is what distinguishes a real edit from + * our own sync-metadata write. + */ + async handleTaskFileUpdated(path: string, updatedTask?: TaskInfo): Promise { + if (!this.isEnabled() || this.destroyed) return; + if (this.handlingPaths.has(path)) return; // our own inbound write + + const task = updatedTask ?? (await this.plugin.cacheManager.getTaskInfo(path)); + if (!task) return; + + const fingerprints = await this.getFingerprints(); + const fingerprint = getCalDavRelevantFingerprint(task); + if (fingerprints[path] === fingerprint) return; // nothing sync-relevant changed + + const account = await this.resolveAccountForTask(task); + if (!account) { + // Out of scope for every collection: remember the fingerprint so we do + // not re-evaluate it on every keystroke. + await this.recordFingerprint(path, fingerprint); + return; + } + + if (!this.plugin.settings.caldav.pushOnChange) { + await this.recordFingerprint(path, fingerprint); + return; + } + + this.schedulePush(account, path); + } + + /** Deletes the remote VTODO when its task file is removed from the vault. */ + async handleTaskFileDeleted( + path: string, + previousFrontmatter?: Record + ): Promise { + if (!this.isEnabled() || this.destroyed) return; + + const accountId = asString(previousFrontmatter?.[CALDAV_FRONTMATTER_KEYS.account]); + const href = asString(previousFrontmatter?.[CALDAV_FRONTMATTER_KEYS.href]); + const etag = asString(previousFrontmatter?.[CALDAV_FRONTMATTER_KEYS.etag]); + if (!accountId || !href) return; + + const account = this.getAccount(accountId); + if (!account?.enabled) return; + + try { + const client = this.createClient(account); + await client.deleteResource(href, etag ? { ifMatch: etag } : {}); + await this.forgetTask(path); + } catch (error) { + this.logError("Failed to delete remote task", error, { + operation: "delete-remote", + }); + } + } + + private schedulePush(account: CalDavAccountSettings, path: string): void { + const existing = this.pushTimers.get(path); + if (existing !== undefined) window.clearTimeout(existing); + + const delay = this.plugin.settings.caldav.pushDebounceMs ?? 1500; + const timer = window.setTimeout(() => { + this.pushTimers.delete(path); + void this.pushTask(account.id, path).catch((error: unknown) => { + this.logError("Failed to push task", error, { operation: "push" }); + // Without this the edit is simply lost until the task is touched + // again: a transient network failure would silently desync a task. + void this.enqueueRetry(account.id, path, error); + }); + }, delay); + this.pushTimers.set(path, timer); + } + + // ----------------------------------------------------------------------- + // Push (local -> remote) + // ----------------------------------------------------------------------- + + async pushTask(accountId: string, path: string): Promise { + const account = this.getAccount(accountId); + if (!account?.enabled || this.destroyed) return; + + const task = await this.plugin.cacheManager.getTaskInfo(path); + const file = this.getFile(path); + if (!task || !file) return; + + const client = this.createClient(account); + const snapshot = await this.snapshotTask(task, file); + const uid = snapshot.uid ?? generateUid(); + const href = snapshot.href ?? joinUrl(account.collectionUrl, `${uid}.ics`); + + // Fetch the current resource first so properties we do not model — + // VALARM, X- properties, DESCRIPTION written on a phone — survive. + const existing = snapshot.href ? await client.getResource(href) : null; + const doc = + (existing?.data ? parseVTodoDocument(existing.data) : null) ?? + createVTodoDocument(); + + applyTaskToVTodo(doc, task, this.mappingContext(account), { uid }); + const relations = this.resolveOutboundRelations(task); + applyRelations(doc, relations.relations); + applyReminders(doc, task.reminders ?? []); + const body = serializeVTodoDocument(doc); + + const result = await client.putResource( + href, + body, + snapshot.etag ? { ifMatch: snapshot.etag } : { ifNoneMatch: "*" } + ); + + if (result.conflict) { + await this.resolveConflictAt(account, path, href, task); + return; + } + + await this.stampSyncMetadata(path, { + uid, + href, + etag: result.etag, + accountId: account.id, + }); + await this.indexResource({ accountId: account.id, uid, path, href }); + + // A parent pushed moments earlier only gets its UID once its own write + // lands, so revisit the link rather than leaving the hierarchy missing + // on the server until the next poll. + if (relations.unresolved && !this.flushingRelations) { + this.pendingRelationPaths.add(path); + this.scheduleRelationFlush(account.id); + } + } + + /** + * Runs after a 412. The ETag mismatch has already established that both + * sides changed; this only decides who wins and applies it. + */ + private async resolveConflictAt( + account: CalDavAccountSettings, + path: string, + href: string, + task: TaskInfo + ): Promise { + const client = this.createClient(account); + const current = await client.getResource(href); + + if (!current?.data) { + // Vanished between the PUT and the GET: treat as a remote deletion. + await this.applyRemoteDeletion(account, path); + return; + } + + const remoteDoc = parseVTodoDocument(current.data); + if (!remoteDoc) { + this.logError("Remote resource is not a VTODO", undefined, { + operation: "resolve-conflict", + }); + return; + } + + const file = this.getFile(path); + if (!file) return; + + const localChangedAt = await this.localChangedAtMs(task, file); + const winner = resolveConflict(localChangedAt, readVTodoRevision(remoteDoc)); + + this.logger.info("Resolved CalDAV conflict", { + category: "provider", + operation: "resolve-conflict", + details: { winner, path }, + }); + + if (winner === "local") { + applyTaskToVTodo(remoteDoc, task, this.mappingContext(account), { + uid: readVTodoUid(remoteDoc) ?? generateUid(), + }); + const retry = await client.putResource(href, serializeVTodoDocument(remoteDoc), { + ifMatch: current.etag, + }); + if (!retry.conflict) { + await this.stampSyncMetadata(path, { + uid: readVTodoUid(remoteDoc) ?? "", + href, + etag: retry.etag, + accountId: account.id, + }); + } + return; + } + + await this.applyRemotePatch(account, path, { + uid: readVTodoUid(remoteDoc) ?? "", + url: href, + etag: current.etag, + revisionMs: readVTodoRevision(remoteDoc), + data: current.data, + }); + } + + // ----------------------------------------------------------------------- + // Pull (remote -> local) + // ----------------------------------------------------------------------- + + /** One polling pass for a single account. */ + async syncAccount(accountId: string, options: { force?: boolean } = {}): Promise { + const force = options.force ?? false; + const account = this.getAccount(accountId); + if (!account?.enabled || this.destroyed) return; + if (this.inFlightAccounts.has(accountId)) return; + + this.inFlightAccounts.add(accountId); + try { + const client = this.createClient(account); + const state = await this.getCollectionState(accountId); + + // Collections routinely mix VTODOs with far more VEVENTs, so ask for + // the change token first and skip everything else when it has not + // moved. Walking the resource list instead would drag down every + // event body just to discover none of them are tasks. + const tag = await client.getCollectionTag(account.collectionUrl); + const currentTag = tag.ctag ?? tag.syncToken; + if (currentTag && state.ctag === currentTag && !force) { + return; + } + + // A VTODO-filtered calendar-query returns only tasks, and returns all + // of them — completeness is what makes deletion detection safe. + const remotes = (await client.fetchAllVTodos(account.collectionUrl)) + .map((resource) => this.toRemoteSnapshot(resource.url, resource.etag, resource.data)) + .filter((snapshot): snapshot is RemoteSnapshotWithData => snapshot !== null); + + const locals = await this.snapshotAccountTasks(account); + const plan = planIncrementalSync(locals, remotes, { + remotesAreComplete: true, + }); + + for (const remote of plan.toPull) { + const withData = remotes.find((entry) => entry.uid === remote.uid); + if (withData) await this.applyRemotePatch(account, undefined, withData); + } + + for (const conflict of plan.conflicts) { + if (conflict.winner === "local") { + await this.pushTask(accountId, conflict.local.path); + } else { + const withData = remotes.find((entry) => entry.uid === conflict.remote.uid); + if (withData) await this.applyRemotePatch(account, conflict.local.path, withData); + } + } + + for (const local of plan.remoteDeleted) { + await this.applyRemoteDeletion(account, local.path); + } + + for (const local of plan.toPush) { + await this.pushTask(accountId, local.path); + } + + await this.flushRelations(account); + + await this.setCollectionState(accountId, { + syncToken: tag.syncToken ?? state.syncToken, + ctag: currentTag, + lastSyncedAt: new Date().toISOString(), + }); + } catch (error) { + this.reportSyncError(account, error); + } finally { + this.inFlightAccounts.delete(accountId); + } + } + + /** + * Writes a remote VTODO into the vault, either patching the matching task or + * creating one. `knownPath` short-circuits the index lookup when the caller + * already knows which task this is. + */ + private async applyRemotePatch( + account: CalDavAccountSettings, + knownPath: string | undefined, + remote: RemoteSnapshotWithData + ): Promise { + const doc = parseVTodoDocument(remote.data); + if (!doc) return; + + const uid = readVTodoUid(doc) ?? remote.uid; + const patch = readVTodoIntoTaskPatch(doc, this.mappingContext(account)); + const path = knownPath ?? (await this.findPathForUid(account.id, uid)); + + if (path) { + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (!task) return; + + this.handlingPaths.add(path); + try { + await this.plugin.taskService.updateTask(task, { + ...(patch.title !== undefined && { title: patch.title }), + ...(patch.status !== undefined && { status: patch.status }), + ...(patch.priority !== undefined && { priority: patch.priority }), + due: patch.due ?? undefined, + scheduled: patch.scheduled ?? undefined, + completedDate: patch.completedDate ?? undefined, + ...(patch.tags !== undefined && { tags: patch.tags }), + recurrence: patch.recurrence ?? undefined, + }); + await this.stampSyncMetadata(path, { + uid, + href: remote.url, + etag: remote.etag, + accountId: account.id, + }); + } finally { + this.handlingPaths.delete(path); + } + await this.indexResource({ accountId: account.id, uid, path, href: remote.url }); + await this.applyInboundRelations(account, path, doc); + return; + } + + // New on the server: create through the normal creation choke point so + // folder rules, templates and defaults all apply. + const created = await this.plugin.taskService.createTask({ + title: patch.title ?? "Untitled task", + ...(patch.status !== undefined && { status: patch.status }), + ...(patch.priority !== undefined && { priority: patch.priority }), + // Empty string rather than an omitted key: leaving a date out lets the + // vault's creation defaults invent one (scheduled defaults to today), + // and the next push would write that invented date onto the user's + // remote task. An empty value skips the default and is normalised + // away again by the creation service. + due: patch.due ?? "", + scheduled: patch.scheduled ?? "", + completedDate: patch.completedDate ?? "", + recurrence: patch.recurrence ?? "", + ...(patch.tags?.length ? { tags: patch.tags } : {}), + creationContext: "import", + // completeTaskData drops unrecognised fields, so the CalDAV keys have + // to travel as custom frontmatter. + customFrontmatter: { + [CALDAV_FRONTMATTER_KEYS.uid]: uid, + [CALDAV_FRONTMATTER_KEYS.href]: remote.url, + ...(remote.etag ? { [CALDAV_FRONTMATTER_KEYS.etag]: remote.etag } : {}), + [CALDAV_FRONTMATTER_KEYS.account]: account.id, + [CALDAV_FRONTMATTER_KEYS.syncedAt]: new Date().toISOString(), + }, + }); + + await this.recordFingerprint( + created.taskInfo.path, + getCalDavRelevantFingerprint(created.taskInfo) + ); + await this.indexResource({ + accountId: account.id, + uid, + path: created.taskInfo.path, + href: remote.url, + }); + // Deferred: a parent imported later in this same run has no path yet. + this.pendingInboundRelations.push({ path: created.taskInfo.path, doc }); + } + + /** + * Syncs every enabled account now, ignoring the change gate. + * + * Forced because the point of asking is usually to check a suspicion that + * the tokens are lying. + */ + async syncAllAccounts(): Promise { + for (const account of this.enabledAccounts()) { + await this.syncAccount(account.id, { force: true }); + } + } + + /** + * Detaches every task from CalDAV, leaving the notes and the server alone. + * + * Only the local link is removed; nothing is deleted on either side. Note + * that the link is also what makes re-syncing idempotent, so syncing the + * same list again after this will duplicate every task — the confirmation + * text says so, and this is why the command asks before running. + * + * The set of tasks to clear is taken from the notes as well as the index, + * because the notes are the authority and an index can be stale. + */ + async unlinkAllTasks(): Promise { + const index = await this.getResourceIndex(); + const paths = new Set(index.map((entry) => entry.path)); + + for (const task of await this.plugin.cacheManager.getAllTasks()) { + if (this.readFrontmatterAt(task.path)?.[CALDAV_FRONTMATTER_KEYS.uid]) { + paths.add(task.path); + } + } + + let unlinked = 0; + for (const path of paths) { + try { + await this.clearSyncMetadata(path); + + // Keep the fingerprint rather than forgetting it. Dropping it would + // make the task look freshly edited, and push-on-change would + // immediately re-upload it under a new UID — unlinking would undo + // itself and leave a duplicate behind. + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) await this.recordFingerprint(path, getCalDavRelevantFingerprint(task)); + + unlinked++; + } catch (error) { + this.logError("Failed to unlink a task from CalDAV", error, { + operation: "caldav-unlink-all", + path, + }); + } + } + + await this.writeData(DATA_KEY_RESOURCE_INDEX, []); + await this.writeData(DATA_KEY_COLLECTION_STATE, {}); + await this.writeData(DATA_KEY_SYNC_QUEUE, []); + return unlinked; + } + + // ----------------------------------------------------------------------- + // Retry queue + // ----------------------------------------------------------------------- + + /** + * Records a failed push so it is retried later. + * + * Queue writes are serialised through a promise chain because several pushes + * can fail in the same tick, and a plain read-modify-write would let the last + * one overwrite the others. + */ + private async enqueueRetry(accountId: string, path: string, error: unknown): Promise { + await this.mutateQueue((queue) => { + const existing = queue.find( + (entry) => entry.taskPath === path && entry.accountId === accountId + ); + if (existing) { + existing.lastError = describeError(error); + return queue; + } + queue.push({ + taskPath: path, + accountId, + requestedAt: Date.now(), + attempts: 0, + lastError: describeError(error), + }); + return queue; + }); + } + + private scheduleRetryDrain(): void { + if (this.destroyed || this.retryTimer !== null) return; + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + void this.drainRetryQueue().finally(() => this.scheduleRetryDrain()); + }, RETRY_QUEUE_INTERVAL_MS); + } + + /** Retries every queued push once, dropping entries that keep failing. */ + async drainRetryQueue(): Promise { + if (this.destroyed || !this.isEnabled()) return; + + const queue = await this.getQueue(); + if (queue.length === 0) return; + + const remaining: PendingCalDavPush[] = []; + for (const entry of queue) { + const account = this.getAccount(entry.accountId); + if (!account?.enabled) { + // Hold rather than drop: the account may simply be switched off + // for now, and the edit is still worth sending when it returns. + remaining.push(entry); + continue; + } + + try { + await this.pushTask(entry.accountId, entry.taskPath); + } catch (error) { + const attempts = entry.attempts + 1; + if (attempts >= MAX_PUSH_ATTEMPTS) { + this.logError("Giving up on a queued CalDAV push", error, { + operation: "caldav-retry-exhausted", + path: entry.taskPath, + attempts, + }); + continue; + } + remaining.push({ + ...entry, + attempts, + lastAttemptAt: Date.now(), + lastError: describeError(error), + }); + } + } + + await this.mutateQueue((current) => + // Anything enqueued while this drain was running is not in `queue`, so + // keep it rather than overwriting with the snapshot we started from. + [ + ...remaining, + ...current.filter( + (entry) => + !queue.some( + (seen) => + seen.taskPath === entry.taskPath && seen.accountId === entry.accountId + ) + ), + ] + ); + } + + private async getQueue(): Promise { + const data = await this.plugin.loadData(); + return (data?.[DATA_KEY_SYNC_QUEUE] as PendingCalDavPush[] | undefined) ?? []; + } + + private async mutateQueue( + mutation: (queue: PendingCalDavPush[]) => PendingCalDavPush[] + ): Promise { + const next = this.queueWrite.catch(() => undefined).then(async () => { + const queue = await this.getQueue(); + await this.writeData(DATA_KEY_SYNC_QUEUE, mutation(queue)); + }); + this.queueWrite = next; + await next; + } + + // ----------------------------------------------------------------------- + // Relations + // ----------------------------------------------------------------------- + + /** + * Turns a task's vault-link relations into UID relations. + * + * TaskNotes addresses relations by path while CalDAV addresses them by UID, + * so a link can only be expressed once its target has been synced. A target + * without a UID is dropped rather than guessed at, and reported back so the + * caller can retry after the rest of the run has assigned UIDs. + */ + private resolveOutboundRelations(task: TaskInfo): { + relations: VTodoRelations; + unresolved: boolean; + } { + const parents: string[] = []; + const dependencies: TaskDependency[] = []; + let unresolved = false; + + for (const project of task.projects ?? []) { + const uid = this.uidForLink(project, task.path); + if (uid) parents.push(uid); + else unresolved = true; + } + + for (const dependency of task.blockedBy ?? []) { + const resolution = resolveDependencyEntry(this.plugin.app, task.path, dependency); + const uid = resolution?.path ? this.uidForPath(resolution.path) : undefined; + if (uid) dependencies.push({ ...dependency, uid }); + else unresolved = true; + } + + if (unresolved) { + // Expected whenever a parent is a plain note, archived, or filtered + // into another account — not a failure, so not a warning. + this.logger.debug("Some relations have no CalDAV counterpart yet", { + category: "provider", + operation: "caldav-resolve-relations", + details: { path: task.path }, + }); + } + + return { relations: { parents, dependencies }, unresolved }; + } + + /** + * Writes a remote VTODO's relations and reminders back onto a task. + * + * Both are non-destructive: a relation whose target is not in this vault, or + * an alarm list a foreign client stripped, must not erase what the vault + * already holds. Only resolved values are written. + */ + private async applyInboundRelations( + account: CalDavAccountSettings, + path: string, + doc: VTodoDocument + ): Promise { + const { parents, dependencies } = readRelations(doc); + const reminders = readReminders(doc); + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (!task) return; + + const projects: string[] = []; + for (const uid of parents) { + const parentPath = await this.findPathForUid(account.id, uid); + const file = parentPath ? this.getFile(parentPath) : null; + if (file) projects.push(generateLink(this.plugin.app, file, path)); + } + + const blockedBy: TaskDependency[] = []; + for (const dependency of dependencies) { + const targetPath = await this.findPathForUid(account.id, dependency.uid); + if (!targetPath) continue; + blockedBy.push({ + ...dependency, + uid: formatDependencyLink(this.plugin.app, path, targetPath), + }); + } + + const updates: Partial = {}; + if (parents.length > 0 && projects.length > 0) updates.projects = projects; + if (dependencies.length > 0 && blockedBy.length > 0) updates.blockedBy = blockedBy; + if (reminders.length > 0) updates.reminders = reminders; + if (Object.keys(updates).length === 0) return; + + this.handlingPaths.add(path); + try { + await this.plugin.taskService.updateTask(task, updates); + } finally { + this.handlingPaths.delete(path); + } + } + + /** Re-pushes tasks whose relations could not be addressed on the first pass. */ + /** + * Applies the relations of freshly imported tasks. + * + * Deferred to the end of a run because a relation can only be written once + * both ends exist in the vault, and imports arrive in server order. + */ + private async flushInboundRelations(account: CalDavAccountSettings): Promise { + const pending = this.pendingInboundRelations; + this.pendingInboundRelations = []; + + for (const entry of pending) { + try { + await this.applyInboundRelations(account, entry.path, entry.doc); + } catch (error) { + this.logError("Failed to apply imported relations", error, { + operation: "caldav-flush-inbound-relations", + path: entry.path, + }); + } + } + } + + private async flushRelations(account: CalDavAccountSettings): Promise { + await this.flushInboundRelations(account); + await this.flushPendingRelations(account.id); + } + + /** + * Queues the deferred relation pass shortly after a push. + * + * Without this a new subtask shows no parent on the server until the next + * poll, which can be a quarter of an hour away. + */ + private scheduleRelationFlush(accountId: string): void { + if (this.destroyed || this.relationFlushTimers.has(accountId)) return; + + const delay = (this.plugin.settings.caldav.pushDebounceMs ?? 1500) * 2; + const timer = window.setTimeout(() => { + this.relationFlushTimers.delete(accountId); + void this.flushPendingRelations(accountId).catch((error: unknown) => { + this.logError("Failed to replay deferred relations", error, { + operation: "caldav-flush-relations", + }); + }); + }, delay); + this.relationFlushTimers.set(accountId, timer); + } + + /** Re-pushes tasks whose relations could not be addressed on the first pass. */ + private async flushPendingRelations(accountId: string): Promise { + const paths = [...this.pendingRelationPaths]; + this.pendingRelationPaths.clear(); + if (paths.length === 0) return; + + // Exactly one extra pass: a parent that is still unaddressable is a plain + // note or lives in another account, and retrying would never change that. + this.flushingRelations = true; + try { + for (const path of paths) { + try { + await this.pushTask(accountId, path); + } catch (error) { + this.logError("Failed to push deferred relations", error, { + operation: "caldav-flush-relations", + path, + }); + } + } + } finally { + this.flushingRelations = false; + } + } + + private uidForLink(link: string, sourcePath: string): string | undefined { + const linkPath = parseLinkToPath(link); + if (!linkPath) return undefined; + const file = this.plugin.app.metadataCache.getFirstLinkpathDest(linkPath, sourcePath); + return file ? this.uidForPath(file.path) : undefined; + } + + private uidForPath(path: string): string | undefined { + const uid = this.readFrontmatterAt(path)?.[CALDAV_FRONTMATTER_KEYS.uid]; + return typeof uid === "string" && uid ? uid : undefined; + } + + /** Applies the configured policy when a VTODO disappears from the server. */ + private async applyRemoteDeletion( + account: CalDavAccountSettings, + path: string + ): Promise { + const outcome = planRemoteDeletion(account.remoteDeletionPolicy); + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (!task) return; + + this.handlingPaths.add(path); + try { + if (outcome.action === "delete") { + await this.plugin.taskService.deleteTask(task); + await this.forgetTask(path); + return; + } + + if (outcome.action === "archive" && !task.archived) { + await this.plugin.taskService.toggleArchive(task); + } + if (outcome.stripSyncMetadata) { + await this.clearSyncMetadata(path); + } + await this.forgetTask(path); + } finally { + this.handlingPaths.delete(path); + } + } + + // ----------------------------------------------------------------------- + // First sync + // ----------------------------------------------------------------------- + + /** Computes the first-sync plan without writing anything — the dry run. */ + async previewFirstSync(accountId: string): Promise { + const account = this.getAccount(accountId); + if (!account) throw new Error(`Unknown CalDAV account ${accountId}`); + + const client = this.createClient(account); + const resources = await client.fetchAllVTodos(account.collectionUrl); + const remotes = resources + .map((resource) => this.toRemoteSnapshot(resource.url, resource.etag, resource.data)) + .filter((snapshot): snapshot is RemoteSnapshotWithData => snapshot !== null); + + const locals = await this.snapshotAccountTasks(account); + return planFirstSync(locals, remotes); + } + + /** Applies a plan the user has confirmed. */ + async applyFirstSync(accountId: string, plan: FirstSyncPlan): Promise { + const account = this.getAccount(accountId); + if (!account) return; + + for (const local of plan.toUpload) { + await this.pushTask(accountId, local.path); + } + + for (const remote of plan.toImport) { + const withData = remote as RemoteSnapshotWithData; + if (withData.data) await this.applyRemotePatch(account, undefined, withData); + } + + for (const pair of plan.toLink) { + await this.stampSyncMetadata(pair.local.path, { + uid: pair.remote.uid, + href: pair.remote.url, + etag: pair.remote.etag, + accountId: account.id, + }); + await this.indexResource({ + accountId: account.id, + uid: pair.remote.uid, + path: pair.local.path, + href: pair.remote.url, + }); + } + + for (const pair of plan.toResolve) { + if (pair.winner === "local") { + await this.pushTask(accountId, pair.local.path); + } else { + const withData = pair.remote as RemoteSnapshotWithData; + if (withData.data) { + await this.applyRemotePatch(account, pair.local.path, withData); + } + } + } + + // Only now does every task on both sides have both a path and a UID, so + // this is the first point at which relations can be written at all. + await this.flushRelations(account); + + const summary = summarizeFirstSyncPlan(plan); + this.logger.info("Completed first CalDAV sync", { + category: "provider", + operation: "first-sync", + details: { accountId, ...summary }, + }); + } + + // ----------------------------------------------------------------------- + // Snapshots and scope + // ----------------------------------------------------------------------- + + private async snapshotAccountTasks( + account: CalDavAccountSettings + ): Promise { + const all = await this.plugin.cacheManager.getAllTasks(); + const scope = this.scopeFor(account); + const context = this.filterContext(); + const snapshots: LocalTaskSnapshot[] = []; + + for (const task of all) { + const file = this.getFile(task.path); + if (!file) continue; + + const frontmatter = this.readFrontmatter(file); + const ownedByAccount = + asString(frontmatter?.[CALDAV_FRONTMATTER_KEYS.account]) === account.id; + + // A task already linked to this account stays in scope even if it no + // longer matches the filter, so it can be unlinked deliberately rather + // than silently stranded on the server. + if (!ownedByAccount && !taskBelongsToCollection(task, scope, context)) continue; + + snapshots.push(await this.snapshotTask(task, file)); + } + + return snapshots; + } + + private async snapshotTask(task: TaskInfo, file: TFile): Promise { + const frontmatter = this.readFrontmatter(file); + const fingerprints = await this.getFingerprints(); + + return { + path: task.path, + uid: asString(frontmatter?.[CALDAV_FRONTMATTER_KEYS.uid]), + href: asString(frontmatter?.[CALDAV_FRONTMATTER_KEYS.href]), + etag: asString(frontmatter?.[CALDAV_FRONTMATTER_KEYS.etag]), + changedAtMs: await this.localChangedAtMs(task, file), + syncedFingerprint: fingerprints[task.path], + fingerprint: getCalDavRelevantFingerprint(task), + }; + } + + /** + * `dateModified` is optional and user-renameable, so the file's mtime is the + * fallback for deciding which side of a conflict is newer. + */ + private async localChangedAtMs(task: TaskInfo, file: TFile): Promise { + if (task.dateModified) { + const parsed = Date.parse(task.dateModified); + if (!Number.isNaN(parsed)) return parsed; + } + return file.stat?.mtime ?? null; + } + + private async resolveAccountForTask( + task: TaskInfo + ): Promise { + const owner = asString( + this.readFrontmatterAt(task.path)?.[CALDAV_FRONTMATTER_KEYS.account] + ); + if (owner) { + const account = this.getAccount(owner); + if (account?.enabled) return account; + } + + const context = this.filterContext(); + return this.enabledAccounts().find((account) => + taskBelongsToCollection(task, this.scopeFor(account), context) + ); + } + + private scopeFor(account: CalDavAccountSettings): CalDavCollectionScope { + return { accountId: account.id, filter: account.filter }; + } + + private filterContext(): FilterPredicateEvaluationContext { + const statusManager = this.plugin.statusManager; + return { + app: this.plugin.app, + userFields: this.plugin.settings.userFields, + getUserFieldRawValue: (task, fieldKey) => + (task as unknown as Record)[fieldKey], + getCompletedStatuses: () => statusManager.getCompletedStatuses(), + isCompletedStatus: (status: string) => statusManager.isCompletedStatus(status), + }; + } + + private mappingContext(account: CalDavAccountSettings): VTodoMappingContext { + return { + statuses: this.plugin.settings.customStatuses, + priorities: this.plugin.settings.customPriorities, + statusOverrides: account.statusOverrides, + }; + } + + // ----------------------------------------------------------------------- + // Frontmatter + // ----------------------------------------------------------------------- + + private async stampSyncMetadata( + path: string, + metadata: { uid: string; href: string; etag?: string; accountId: string } + ): Promise { + const file = this.getFile(path); + if (!file) return; + + // processVaultFrontMatter already serializes per file; wrapping it in + // withVaultFileMutation would deadlock on the same file's queue. + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { + frontmatter[CALDAV_FRONTMATTER_KEYS.uid] = metadata.uid; + frontmatter[CALDAV_FRONTMATTER_KEYS.href] = metadata.href; + frontmatter[CALDAV_FRONTMATTER_KEYS.account] = metadata.accountId; + frontmatter[CALDAV_FRONTMATTER_KEYS.syncedAt] = new Date().toISOString(); + if (metadata.etag) { + frontmatter[CALDAV_FRONTMATTER_KEYS.etag] = metadata.etag; + } else { + delete frontmatter[CALDAV_FRONTMATTER_KEYS.etag]; + } + }); + + // Record the fingerprint straight after, so the file-updated event this + // write triggers is recognised as a no-op. + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) await this.recordFingerprint(path, getCalDavRelevantFingerprint(task)); + } + + private async clearSyncMetadata(path: string): Promise { + const file = this.getFile(path); + if (!file) return; + + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { + for (const key of Object.values(CALDAV_FRONTMATTER_KEYS)) { + delete frontmatter[key]; + } + }); + } + + private readFrontmatter(file: TFile): Record | undefined { + return this.plugin.app.metadataCache.getFileCache(file)?.frontmatter; + } + + private readFrontmatterAt(path: string): Record | undefined { + const file = this.getFile(path); + return file ? this.readFrontmatter(file) : undefined; + } + + // Return type is inferred rather than annotated: the review-types stub + // declares TFile as a value, so a written `TFile | null` reads as an error type. + private getFile(path: string) { + const file = this.plugin.app.vault.getAbstractFileByPath(path); + return file instanceof TFile ? file : null; + } + + // ----------------------------------------------------------------------- + // Persisted state + // ----------------------------------------------------------------------- + + private async getFingerprints(): Promise> { + if (this.fingerprints) return this.fingerprints; + const data = await this.plugin.loadData(); + this.fingerprints = (data?.[DATA_KEY_FINGERPRINTS] as Record) ?? {}; + return this.fingerprints; + } + + private async recordFingerprint(path: string, fingerprint: string): Promise { + const fingerprints = await this.getFingerprints(); + if (fingerprints[path] === fingerprint) return; + + fingerprints[path] = fingerprint; + await this.writeData(DATA_KEY_FINGERPRINTS, fingerprints); + } + + private async forgetTask(path: string): Promise { + const fingerprints = await this.getFingerprints(); + delete fingerprints[path]; + await this.writeData(DATA_KEY_FINGERPRINTS, fingerprints); + + const index = await this.getResourceIndex(); + const next = index.filter((entry) => entry.path !== path); + if (next.length !== index.length) { + await this.writeData(DATA_KEY_RESOURCE_INDEX, next); + } + } + + private async getResourceIndex(): Promise { + const data = await this.plugin.loadData(); + return (data?.[DATA_KEY_RESOURCE_INDEX] as CalDavResourceIndexEntry[]) ?? []; + } + + private async indexResource(entry: CalDavResourceIndexEntry): Promise { + const index = await this.getResourceIndex(); + const filtered = index.filter( + (existing) => + !(existing.accountId === entry.accountId && existing.uid === entry.uid) && + existing.path !== entry.path + ); + filtered.push(entry); + await this.writeData(DATA_KEY_RESOURCE_INDEX, filtered); + } + + private async findPathForUid( + accountId: string, + uid: string + ): Promise { + const index = await this.getResourceIndex(); + const entry = index.find( + (candidate) => candidate.accountId === accountId && candidate.uid === uid + ); + if (entry && this.getFile(entry.path)) return entry.path; + + // The index can go stale when a task is renamed outside the plugin; fall + // back to a scan rather than creating a duplicate. + const all = await this.plugin.cacheManager.getAllTasks(); + for (const task of all) { + const frontmatter = this.readFrontmatterAt(task.path); + if (asString(frontmatter?.[CALDAV_FRONTMATTER_KEYS.uid]) === uid) { + return task.path; + } + } + return undefined; + } + + private async getCollectionState(accountId: string): Promise { + const data = await this.plugin.loadData(); + const all = (data?.[DATA_KEY_COLLECTION_STATE] as + | Record + | undefined) ?? {}; + return all[accountId] ?? {}; + } + + private async setCollectionState( + accountId: string, + state: CalDavCollectionState + ): Promise { + const data = await this.plugin.loadData(); + const all = (data?.[DATA_KEY_COLLECTION_STATE] as + | Record + | undefined) ?? {}; + all[accountId] = state; + await this.writeData(DATA_KEY_COLLECTION_STATE, all); + } + + private async writeData(key: string, value: unknown): Promise { + // A null return means data.json exists but could not be read. Writing + // anyway would persist a document built from nothing and wipe every + // setting in the vault, so the only safe move is to skip this write. + const data = await this.plugin.loadPluginDataForSafeWrite(`caldav-${key}`); + if (!data) return; + data[key] = value; + await this.plugin.saveData(data); + } + + // ----------------------------------------------------------------------- + // Timers and plumbing + // ----------------------------------------------------------------------- + + private startPollTimer(accountId: string): void { + const account = this.getAccount(accountId); + if (!account?.enabled || this.destroyed) return; + + const intervalMs = Math.max(1, account.syncIntervalMinutes) * 60 * 1000; + const timer = window.setTimeout(() => { + void this.syncAccount(accountId).finally(() => { + if (!this.destroyed) this.startPollTimer(accountId); + }); + }, intervalMs); + + this.pollTimers.set(accountId, timer); + } + + private createClient(account: CalDavAccountSettings): CalDavClient { + const credentials = this.secretStore.getCredentials(account.id); + if (!credentials) { + throw new CalDavError( + "auth", + `No stored credentials for CalDAV account ${account.name || account.id}` + ); + } + return new CalDavClient({ + serverUrl: account.serverUrl || account.collectionUrl, + credentials, + logger: this.logger, + }); + } + + private toRemoteSnapshot( + url: string, + etag: string | undefined, + data: string | undefined + ): RemoteSnapshotWithData | null { + if (!data) return null; + const doc = parseVTodoDocument(data); + if (!doc) return null; + const uid = readVTodoUid(doc); + if (!uid) return null; + + return { uid, url, etag, revisionMs: readVTodoRevision(doc), data }; + } + + private reportSyncError(account: CalDavAccountSettings, error: unknown): void { + const label = account.name || account.id; + if (error instanceof CalDavError && error.kind === "auth") { + publishUserNotice( + this.plugin.emitter, + `TaskNotes could not sign in to the CalDAV account "${label}". Check its username and password.` + ); + } + this.logError("CalDAV sync failed", error, { + operation: "sync-account", + accountId: account.id, + }); + } + + private logError( + message: string, + error: unknown, + context: { operation: string; [key: string]: unknown } + ): void { + this.logger.error(message, { + category: "provider", + operation: context.operation, + details: context, + error, + }); + } +} + +interface RemoteSnapshotWithData extends RemoteTodoSnapshot { + data: string; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * A short, loggable description of a failure. + * + * Deliberately the message only: CalDAV errors can carry request context, and + * none of it belongs in a file that syncs around with the vault. + */ +function describeError(error: unknown): string { + if (error instanceof CalDavError) return `${error.kind}: ${error.message}`; + if (error instanceof Error) return error.message; + return "Unknown error"; +} + +function joinUrl(base: string, segment: string): string { + return `${base.replace(/\/+$/u, "")}/${segment}`; +} + +/** + * UIDs must be globally unique and stable for the life of the task. A random + * v4-shaped id avoids leaking the vault path, which would otherwise be visible + * to everyone the collection is shared with. + */ +function generateUid(): string { + const random = window.crypto?.randomUUID?.(); + if (random) return `${random}@tasknotes`; + + const fallback = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return `${fallback}@tasknotes`; +} diff --git a/src/services/caldav/caldavFingerprint.ts b/src/services/caldav/caldavFingerprint.ts new file mode 100644 index 000000000..51b35c2ba --- /dev/null +++ b/src/services/caldav/caldavFingerprint.ts @@ -0,0 +1,135 @@ +/** + * Content fingerprints for CalDAV sync. + * + * These are what break the write-back loop. Stamping `caldav_etag` into a + * task's frontmatter re-fires the same `file-updated` event that triggers a + * sync, and there is no event-suppression flag anywhere in the plugin. The + * Google integration solves this structurally — its fingerprint covers only + * user-visible content, so a sync-metadata write produces an identical + * fingerprint and the handler returns early (see + * `getCalendarRelevantFingerprint` in TaskCalendarSyncService.ts:517). + * + * The same rule applies here: every `caldav_*` key is excluded by construction. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { TaskInfo } from "../../types"; + +/** + * Frontmatter keys owned by the CalDAV integration. + * + * Fixed names rather than `FieldMapper` entries: `DEFAULT_FIELD_MAPPING` and + * the frontmatter mappers ship from the external `@tasknotes/model` package, + * which this repo cannot extend, so these are read and written directly. + */ +export const CALDAV_FRONTMATTER_KEYS = { + uid: "caldav_uid", + href: "caldav_href", + etag: "caldav_etag", + syncedAt: "caldav_synced_at", + account: "caldav_account", +} as const; + +export const CALDAV_FRONTMATTER_KEY_LIST: readonly string[] = Object.values( + CALDAV_FRONTMATTER_KEYS +); + +/** The task fields a VTODO can carry — and therefore the ones worth syncing on. */ +export interface CalDavFingerprintState { + title?: string; + status?: string; + priority?: string; + due?: string; + scheduled?: string; + completedDate?: string; + recurrence?: string; + archived?: boolean; + tags?: string[]; + /** Parent links, as RELATED-TO;RELTYPE=PARENT. */ + projects?: string[]; + /** Dependencies, as RELATED-TO with an RFC 9253 temporal reltype. */ + blockedBy?: string[]; + /** Reminders, as VALARM. */ + reminders?: string[]; +} + +/** + * A stable JSON fingerprint of the sync-relevant content of a task. + * + * Deliberately excluded: every `caldav_*` key, `dateModified`, the note body, + * time entries, and anything else a VTODO cannot represent — so that writing + * sync metadata, or tracking time, never triggers a redundant remote write. + * + * `blocking` and `hasSubtasks` are excluded too: both are derived from other + * tasks' frontmatter, so including them would make an edit to one task look + * like an edit to all its neighbours. + */ +export function getCalDavRelevantFingerprint(task: TaskInfo): string { + const state: CalDavFingerprintState = { + title: task.title, + status: task.status, + priority: task.priority, + due: task.due, + scheduled: task.scheduled, + completedDate: task.completedDate, + recurrence: task.recurrence, + archived: task.archived, + // Sorted so that a reordered tag list is not mistaken for an edit. + tags: task.tags ? [...task.tags].sort() : undefined, + projects: task.projects ? [...task.projects].sort() : undefined, + // Flattened rather than kept as objects so the fingerprint stays a stable + // string regardless of key order, and so an added GAP still registers. + blockedBy: task.blockedBy + ? task.blockedBy + .map((dependency) => + [dependency.uid, dependency.reltype, dependency.gap ?? ""].join("|") + ) + .sort() + : undefined, + reminders: task.reminders + ? task.reminders + .map((reminder) => + [ + reminder.id, + reminder.type, + reminder.relatedTo ?? "", + reminder.offset ?? "", + reminder.absoluteTime ?? "", + reminder.description ?? "", + ].join("|") + ) + .sort() + : undefined, + }; + return JSON.stringify(state); +} + +/** + * Rehydrates the previous state of a task from a stored fingerprint, so an + * edit made while Obsidian was closed can be diffed at startup. Mirrors + * `getTaskStateFromFingerprint` in the Google sync service. + */ +export function parseCalDavFingerprint( + fingerprint: string | undefined +): CalDavFingerprintState | null { + if (!fingerprint) return null; + try { + const parsed: unknown = JSON.parse(fingerprint); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed; + } catch { + // A corrupt fingerprint is treated as "no previous state", which makes the + // task look new and re-syncs it — safe, if slightly wasteful. + return null; + } +} + +/** True when the sync-relevant content of a task differs from a stored fingerprint. */ +export function hasCalDavRelevantChange( + task: TaskInfo, + previousFingerprint: string | undefined +): boolean { + if (!previousFingerprint) return true; + return getCalDavRelevantFingerprint(task) !== previousFingerprint; +} diff --git a/src/services/caldav/caldavReconciliation.ts b/src/services/caldav/caldavReconciliation.ts new file mode 100644 index 000000000..da289e22b --- /dev/null +++ b/src/services/caldav/caldavReconciliation.ts @@ -0,0 +1,281 @@ +/** + * Conflict resolution and sync planning. + * + * The two mechanisms that decide what actually happens to a user's data live + * here, deliberately separated from all I/O so they can be tested exhaustively: + * + * - `resolveConflict` — ETag mismatch has already told us both sides changed; + * this decides which one wins. + * - `planFirstSync` / `planIncrementalSync` — what to upload, import, link or + * resolve. `planFirstSync` is also what the dry-run preview renders. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { CalDavRemoteDeletionPolicy } from "../../types/settings"; + +export interface LocalTaskSnapshot { + path: string; + /** `caldav_uid`, absent until the task has been pushed once. */ + uid?: string; + href?: string; + etag?: string; + /** Epoch ms of the last local edit (`dateModified`, or file mtime). */ + changedAtMs: number | null; + /** Fingerprint recorded at the last successful sync. */ + syncedFingerprint?: string; + /** Fingerprint of the task as it is now. */ + fingerprint: string; +} + +export interface RemoteTodoSnapshot { + uid: string; + url: string; + etag?: string; + /** Epoch ms from LAST-MODIFIED or DTSTAMP. */ + revisionMs: number | null; +} + +export type ConflictWinner = "local" | "remote"; + +export interface LinkedPair { + local: LocalTaskSnapshot; + remote: RemoteTodoSnapshot; +} + +export interface ConflictPair extends LinkedPair { + winner: ConflictWinner; +} + +export interface FirstSyncPlan { + /** Local tasks with no counterpart on the server. */ + toUpload: LocalTaskSnapshot[]; + /** Server VTODOs with no local task. */ + toImport: RemoteTodoSnapshot[]; + /** Matched by UID and already in agreement — just record href and ETag. */ + toLink: LinkedPair[]; + /** Matched by UID but differing; `winner` says which side is authoritative. */ + toResolve: ConflictPair[]; +} + +export interface IncrementalSyncPlan { + /** Local edits to push. */ + toPush: LocalTaskSnapshot[]; + /** Remote changes to pull into tasks. */ + toPull: RemoteTodoSnapshot[]; + /** Both sides changed since the last sync. */ + conflicts: ConflictPair[]; + /** Tasks whose VTODO disappeared from the server. */ + remoteDeleted: LocalTaskSnapshot[]; +} + +/** + * Decides which side wins once a conflict has been *detected* (by an ETag + * mismatch — a timestamp alone can never tell you a conflict happened). + * + * Newest revision wins. Where a timestamp is missing the other side is + * preferred, since a known edit time is better evidence than none. An exact tie + * goes to the remote: every device sees the same server revision, so that + * choice converges, whereas local clocks do not agree with each other. + */ +export function resolveConflict( + localChangedAtMs: number | null, + remoteRevisionMs: number | null +): ConflictWinner { + if (localChangedAtMs === null && remoteRevisionMs === null) return "remote"; + if (localChangedAtMs === null) return "remote"; + if (remoteRevisionMs === null) return "local"; + return localChangedAtMs > remoteRevisionMs ? "local" : "remote"; +} + +/** True when the task has local edits that have not reached the server. */ +export function hasUnsyncedLocalChange(local: LocalTaskSnapshot): boolean { + return local.syncedFingerprint !== local.fingerprint; +} + +/** + * Reconciles a whole collection against the vault, keyed by UID. + * + * Matching is UID-only. Guessing at title or date similarity would silently + * merge two unrelated tasks, and the cost of getting it wrong (a task + * overwritten by another) is far higher than the cost of a duplicate. + */ +export function planFirstSync( + locals: readonly LocalTaskSnapshot[], + remotes: readonly RemoteTodoSnapshot[] +): FirstSyncPlan { + const remotesByUid = new Map(remotes.map((remote) => [remote.uid, remote])); + const matchedUids = new Set(); + + const plan: FirstSyncPlan = { toUpload: [], toImport: [], toLink: [], toResolve: [] }; + + for (const local of locals) { + const remote = local.uid ? remotesByUid.get(local.uid) : undefined; + + if (!remote) { + plan.toUpload.push(local); + continue; + } + + matchedUids.add(remote.uid); + + // Already linked and unchanged since that link was made: nothing to do + // beyond refreshing href and ETag. + const linkedAndClean = + local.etag !== undefined && + local.etag === remote.etag && + !hasUnsyncedLocalChange(local); + + if (linkedAndClean) { + plan.toLink.push({ local, remote }); + continue; + } + + plan.toResolve.push({ + local, + remote, + winner: resolveConflict(local.changedAtMs, remote.revisionMs), + }); + } + + for (const remote of remotes) { + if (!matchedUids.has(remote.uid)) plan.toImport.push(remote); + } + + return plan; +} + +/** + * Plans one polling pass. + * + * `changedRemotes` are the resources the server reported as new or modified; + * `removedUrls` are the ones it reported as gone. When the server has no + * sync-collection support the caller supplies a full listing instead and sets + * `remotesAreComplete`, which is what makes deletion detection possible — a + * task whose href is absent from a complete listing has been deleted. + */ +export function planIncrementalSync( + locals: readonly LocalTaskSnapshot[], + changedRemotes: readonly RemoteTodoSnapshot[], + options: { + removedUrls?: readonly string[]; + remotesAreComplete?: boolean; + } = {} +): IncrementalSyncPlan { + const plan: IncrementalSyncPlan = { + toPush: [], + toPull: [], + conflicts: [], + remoteDeleted: [], + }; + + const remotesByUid = new Map(changedRemotes.map((remote) => [remote.uid, remote])); + const removed = new Set(options.removedUrls ?? []); + const presentUrls = new Set(changedRemotes.map((remote) => normalizeUrl(remote.url))); + + for (const local of locals) { + const localChanged = hasUnsyncedLocalChange(local); + + // Never pushed: a straightforward upload. + if (!local.uid) { + if (localChanged) plan.toPush.push(local); + continue; + } + + const isRemoteDeleted = + (local.href && removed.has(local.href)) || + (options.remotesAreComplete === true && + local.href !== undefined && + !presentUrls.has(normalizeUrl(local.href))); + + if (isRemoteDeleted) { + plan.remoteDeleted.push(local); + continue; + } + + const remote = remotesByUid.get(local.uid); + if (!remote) { + // The server did not report this one as changed, so only a local edit + // could be outstanding. + if (localChanged) plan.toPush.push(local); + continue; + } + + // The server reported a change. If the ETag still matches what we stored, + // the "change" is our own write echoing back. + const remoteChanged = local.etag === undefined || local.etag !== remote.etag; + + if (remoteChanged && localChanged) { + plan.conflicts.push({ + local, + remote, + winner: resolveConflict(local.changedAtMs, remote.revisionMs), + }); + continue; + } + if (remoteChanged) { + plan.toPull.push(remote); + continue; + } + if (localChanged) plan.toPush.push(local); + } + + // Remote resources with no local task at all are new arrivals to import. + const knownUids = new Set(locals.map((local) => local.uid).filter(Boolean)); + for (const remote of changedRemotes) { + if (!knownUids.has(remote.uid)) plan.toPull.push(remote); + } + + return plan; +} + +export interface RemoteDeletionOutcome { + action: "archive" | "delete" | "unlink"; + /** Whether the CalDAV frontmatter keys should be stripped from the note. */ + stripSyncMetadata: boolean; +} + +/** + * Maps the configured policy onto what actually happens to the note. + * + * Archiving and unlinking both strip the sync metadata, so the task is not + * re-uploaded on the next pass and resurrected on the server. + */ +export function planRemoteDeletion( + policy: CalDavRemoteDeletionPolicy +): RemoteDeletionOutcome { + switch (policy) { + case "delete": + return { action: "delete", stripSyncMetadata: false }; + case "unlink": + return { action: "unlink", stripSyncMetadata: true }; + case "archive": + default: + return { action: "archive", stripSyncMetadata: true }; + } +} + +/** Human-readable counts for the first-sync preview. */ +export function summarizeFirstSyncPlan(plan: FirstSyncPlan): { + upload: number; + import: number; + link: number; + resolve: number; + total: number; +} { + const upload = plan.toUpload.length; + const importCount = plan.toImport.length; + const link = plan.toLink.length; + const resolve = plan.toResolve.length; + return { + upload, + import: importCount, + link, + resolve, + total: upload + importCount + link + resolve, + }; +} + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/u, ""); +} diff --git a/src/services/caldav/caldavXml.ts b/src/services/caldav/caldavXml.ts new file mode 100644 index 000000000..0df33214b --- /dev/null +++ b/src/services/caldav/caldavXml.ts @@ -0,0 +1,333 @@ +/** + * CalDAV request bodies and `207 Multistatus` parsing (RFC 4791, RFC 6578). + * + * Element lookup is by local name rather than prefix. Servers disagree wildly + * about prefixes — Nextcloud emits `d:`/`cal:`, Radicale `D:`/`C:`, iCloud + * something else again — and the namespace URIs are what actually matter. + * + * Not registered as a pure module: parsing uses `DOMParser`, which is available + * in every environment Obsidian runs in (desktop, mobile webview, jsdom under + * jest) but is still a DOM global. + */ + +const NS_DAV = "DAV:"; +const NS_CALDAV = "urn:ietf:params:xml:ns:caldav"; + +export interface DavResponse { + href: string; + /** Response-level status, present on sync-collection removals (404). */ + status?: number; + etag?: string; + calendarData?: string; + displayName?: string; + /** Local names inside ``, e.g. `collection`, `calendar`. */ + resourceTypes: string[]; + /** Local names inside ``, e.g. `VTODO`. */ + supportedComponents: string[]; + currentUserPrincipal?: string; + calendarHomeSet?: string; + /** CalendarServer `getctag`, bumped whenever anything in the collection changes. */ + ctag?: string; + /** Sync token read from ``, as opposed to the multistatus root. */ + collectionSyncToken?: string; +} + +export interface MultistatusResult { + responses: DavResponse[]; + /** Collection-level sync token, returned by a sync-collection REPORT. */ + syncToken?: string; +} + +export interface SyncCollectionChanges { + syncToken?: string; + changed: Array<{ href: string; etag?: string }>; + removed: string[]; +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +const XML_HEADER = ''; + +/** Step 1 of discovery: who am I? Used with `PROPFIND Depth: 0`. */ +export function buildCurrentUserPrincipalRequest(): string { + return `${XML_HEADER} + + +`; +} + +/** Step 2: where does this principal keep its calendars? `PROPFIND Depth: 0`. */ +export function buildCalendarHomeSetRequest(): string { + return `${XML_HEADER} + + +`; +} + +/** Step 3: enumerate collections in the home set. `PROPFIND Depth: 1`. */ +export function buildCalendarCollectionsRequest(): string { + return `${XML_HEADER} + + + + + + + +`; +} + +/** Lists hrefs and ETags of every VTODO in a collection. `PROPFIND Depth: 1`. */ +/** + * Cheap "has anything changed?" probe for a collection. `PROPFIND Depth: 0`. + * + * Asks for both tokens because servers vary: Nextcloud/SabreDAV answer with + * each, older Radicale only with `getctag`. + */ +export function buildCollectionTagRequest(): string { + return `${XML_HEADER} + + +`; +} + +export function buildEtagListRequest(): string { + return `${XML_HEADER} + + +`; +} + +/** + * Fetches every VTODO in a collection, bodies included. `REPORT Depth: 1`. + * Used for the first sync and as the fallback when sync-collection is absent. + */ +export function buildCalendarQueryVTodoRequest(): string { + return `${XML_HEADER} + + + + + + + + + + +`; +} + +/** + * Incremental change feed (RFC 6578). An empty token asks for a full listing + * plus a token to poll with next time. + */ +export function buildSyncCollectionRequest(syncToken?: string): string { + return `${XML_HEADER} + + ${escapeXml(syncToken ?? "")} + 1 + + + + +`; +} + +/** Fetches the bodies of specific hrefs. `REPORT Depth: 1`. */ +export function buildMultigetRequest(hrefs: readonly string[]): string { + const items = hrefs.map((href) => ` ${escapeXml(href)}`).join("\n"); + return `${XML_HEADER} + + + + + +${items} +`; +} + +export function escapeXml(value: string): string { + return value + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """) + .replace(/'/gu, "'"); +} + +// --------------------------------------------------------------------------- +// Responses +// --------------------------------------------------------------------------- + +export function parseMultistatus(xml: string): MultistatusResult { + const doc = new DOMParser().parseFromString(xml, "application/xml"); + + // A parser error yields a element rather than throwing. + if (doc.getElementsByTagName("parsererror").length > 0) { + return { responses: [] }; + } + + const root = doc.documentElement; + if (!root || localName(root) !== "multistatus") { + return { responses: [] }; + } + + const responses = childrenByLocalName(root, "response").map(parseResponse); + const syncToken = directChildText(root, "sync-token"); + + return syncToken ? { responses, syncToken } : { responses }; +} + +/** + * Interprets a sync-collection result. + * + * Removals arrive as a `` carrying a 404 status instead of a + * propstat, which is how a deletion on the server is distinguished from a + * resource we simply have not seen. + */ +export function parseSyncCollection(xml: string): SyncCollectionChanges { + const { responses, syncToken } = parseMultistatus(xml); + + const changed: Array<{ href: string; etag?: string }> = []; + const removed: string[] = []; + + for (const response of responses) { + if (!response.href) continue; + if (response.status === 404 || response.status === 410) { + removed.push(response.href); + continue; + } + changed.push({ href: response.href, etag: response.etag }); + } + + return syncToken ? { syncToken, changed, removed } : { changed, removed }; +} + +/** Filters a collections PROPFIND down to those that can hold VTODOs. */ +export function selectVTodoCollections(responses: readonly DavResponse[]): DavResponse[] { + return responses.filter((response) => { + if (!response.resourceTypes.includes("calendar")) return false; + // An absent component set means "everything is supported" per RFC 4791. + if (response.supportedComponents.length === 0) return true; + return response.supportedComponents.includes("VTODO"); + }); +} + +function parseResponse(element: Element): DavResponse { + const response: DavResponse = { + href: decodeHref(directChildText(element, "href") ?? ""), + resourceTypes: [], + supportedComponents: [], + }; + + const responseStatus = parseStatusCode(directChildText(element, "status")); + if (responseStatus !== undefined) response.status = responseStatus; + + for (const propstat of childrenByLocalName(element, "propstat")) { + const propstatStatus = parseStatusCode(directChildText(propstat, "status")); + // Skip 404 propstats: they list properties the server does not have, and + // reading them would overwrite good values from the 200 propstat. + if (propstatStatus !== undefined && propstatStatus >= 400) continue; + + for (const prop of childrenByLocalName(propstat, "prop")) { + readProp(prop, response); + } + } + + return response; +} + +function readProp(prop: Element, response: DavResponse): void { + for (const child of elementChildren(prop)) { + switch (localName(child)) { + case "getetag": + response.etag = normalizeEtag(child.textContent ?? undefined); + break; + case "calendar-data": + response.calendarData = child.textContent ?? undefined; + break; + case "displayname": + response.displayName = child.textContent ?? undefined; + break; + case "getctag": + response.ctag = child.textContent?.trim() || undefined; + break; + case "sync-token": + response.collectionSyncToken = child.textContent?.trim() || undefined; + break; + case "resourcetype": + response.resourceTypes = elementChildren(child).map(localName); + break; + case "supported-calendar-component-set": + response.supportedComponents = elementChildren(child) + .map((comp) => comp.getAttribute("name")?.toUpperCase()) + .filter((name): name is string => Boolean(name)); + break; + case "current-user-principal": + response.currentUserPrincipal = decodeHref( + directChildText(child, "href") ?? "" + ); + break; + case "calendar-home-set": + response.calendarHomeSet = decodeHref(directChildText(child, "href") ?? ""); + break; + default: + break; + } + } +} + +/** + * ETags are quoted, and a weak validator carries a `W/` prefix. Both are + * stripped so a stored ETag compares equal to the one echoed back on a PUT. + */ +export function normalizeEtag(etag: string | undefined): string | undefined { + if (!etag) return undefined; + const trimmed = etag.trim().replace(/^W\//iu, ""); + const unquoted = trimmed.replace(/^"|"$/gu, ""); + return unquoted || undefined; +} + +function parseStatusCode(status: string | undefined): number | undefined { + if (!status) return undefined; + const match = /\s(\d{3})\s?/u.exec(status); + return match ? Number.parseInt(match[1], 10) : undefined; +} + +/** + * Hrefs come percent-encoded. Decoding keeps stored hrefs comparable with the + * ones the server returns later, but a malformed sequence must not throw. + */ +function decodeHref(href: string): string { + const trimmed = href.trim(); + try { + return decodeURIComponent(trimmed); + } catch { + return trimmed; + } +} + +/** Local name without the namespace prefix, whatever prefix the server chose. */ +function localName(element: Element): string { + return element.localName || element.nodeName.replace(/^.*:/u, ""); +} + +function elementChildren(element: Element): Element[] { + return Array.from(element.children ?? []); +} + +function childrenByLocalName(element: Element, name: string): Element[] { + return elementChildren(element).filter( + (child) => localName(child).toLowerCase() === name.toLowerCase() + ); +} + +function directChildText(element: Element, name: string): string | undefined { + const match = childrenByLocalName(element, name)[0]; + return match?.textContent ?? undefined; +} + +/** Exposed for tests and callers that need the namespace URIs. */ +export const CALDAV_NAMESPACES = { dav: NS_DAV, caldav: NS_CALDAV } as const; diff --git a/src/services/caldav/collectionMembership.ts b/src/services/caldav/collectionMembership.ts new file mode 100644 index 000000000..bcd47f010 --- /dev/null +++ b/src/services/caldav/collectionMembership.ts @@ -0,0 +1,62 @@ +/** + * Decides which CalDAV collection a task belongs to. + * + * Membership reuses the plugin's existing filter engine rather than inventing a + * second query language: each configured collection carries a `FilterGroup`, + * evaluated with `evaluateFilterNode` from the filter-service pure layer. That + * means a collection can be scoped by tag, folder, project, status or any other + * property the FilterBar already exposes. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { FilterGroup, TaskInfo } from "../../types"; +import { + evaluateFilterNode, + type FilterPredicateEvaluationContext, +} from "../filter-service/filterPredicateEvaluation"; + +export interface CalDavCollectionScope { + /** Stable id of the configured account/collection. */ + accountId: string; + /** + * Filter deciding membership. An undefined filter, or a group with no + * children, means "every task" — the single-collection case. + */ + filter?: FilterGroup; +} + +export function taskBelongsToCollection( + task: TaskInfo, + scope: CalDavCollectionScope, + context: FilterPredicateEvaluationContext +): boolean { + // Archived tasks are never pushed; archiving is how a remote deletion is + // reflected locally, so re-uploading them would resurrect deleted VTODOs. + if (task.archived) return false; + + if (!scope.filter || scope.filter.children.length === 0) return true; + + try { + return evaluateFilterNode(scope.filter, task, context); + } catch { + // A malformed saved filter must not take the whole sync down; excluding + // the task is the conservative reading, since it avoids uploading things + // the user meant to scope out. + return false; + } +} + +/** + * Resolves the single collection that owns a task. + * + * Scopes are evaluated in configured order and the first match wins, so a task + * matching two collections is uploaded once rather than duplicated across both. + */ +export function resolveCollectionForTask( + task: TaskInfo, + scopes: readonly CalDavCollectionScope[], + context: FilterPredicateEvaluationContext +): CalDavCollectionScope | undefined { + return scopes.find((scope) => taskBelongsToCollection(task, scope, context)); +} diff --git a/src/services/caldav/icsDateValue.ts b/src/services/caldav/icsDateValue.ts new file mode 100644 index 000000000..0b4f0c817 --- /dev/null +++ b/src/services/caldav/icsDateValue.ts @@ -0,0 +1,256 @@ +/** + * iCalendar (RFC 5545) date and date-time values, in the three forms a CalDAV + * server actually sends them: + * + * DUE;VALUE=DATE:20250901 date-only + * DUE:20250901T120000Z UTC instant + * DUE;TZID=Europe/Berlin:20250901T120000 wall time in a named zone + * + * TaskNotes stores dates as `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm[:ss]` local wall + * time (see `hasTimeComponent` / `getDatePart` in src/utils/dateUtils.ts), so + * this module is the translation boundary between the two. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. Zone + * resolution for TZID values is injected rather than imported, because the + * repo's TZID table (src/utils/icsTimezoneFallback.ts) pulls in ical.js. + */ + +export interface IcsDateValue { + /** VALUE=DATE — a calendar day with no time component. */ + dateOnly: boolean; + /** + * `YYYY-MM-DD` when `dateOnly`, otherwise `YYYY-MM-DDTHH:mm:ss`. + * For `utc` values this is the UTC wall time; for `tzid` values it is the + * wall time in that zone; with neither it is a floating local time. + */ + value: string; + /** TZID parameter, when the property carried one. */ + tzid?: string; + /** The raw form ended with `Z`. */ + utc: boolean; +} + +/** Converts a wall time in a named zone to a UTC ISO instant. */ +export type ZoneToUtc = (wallTime: string, tzid: string) => string | null; + +const ICS_DATE = /^(\d{4})(\d{2})(\d{2})$/u; +const ICS_DATE_TIME = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/u; +const TASK_DATE = /^(\d{4})-(\d{2})-(\d{2})$/u; +const TASK_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/u; + +/** + * Parses a raw iCalendar date/date-time value plus its property parameters. + * Returns null for anything malformed — callers treat that as "property absent" + * rather than failing the whole sync over one bad line. + */ +export function parseIcsDateValue( + raw: string, + params: Record = {} +): IcsDateValue | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const tzid = params.TZID?.trim() || undefined; + + const dateMatch = ICS_DATE.exec(trimmed); + if (dateMatch) { + const [, year, month, day] = dateMatch; + if (!isRealDate(+year, +month, +day)) return null; + return { dateOnly: true, value: `${year}-${month}-${day}`, utc: false }; + } + + const dateTimeMatch = ICS_DATE_TIME.exec(trimmed); + if (dateTimeMatch) { + const [, year, month, day, hour, minute, second, zulu] = dateTimeMatch; + if (!isRealDate(+year, +month, +day)) return null; + if (+hour > 23 || +minute > 59 || +second > 60) return null; + // A VALUE=DATE parameter on a value that carries a time is contradictory; + // the value itself wins, since that is what the server will round-trip. + return { + dateOnly: false, + value: `${year}-${month}-${day}T${hour}:${minute}:${second}`, + tzid: zulu ? undefined : tzid, + utc: Boolean(zulu), + }; + } + + return null; +} + +/** + * Serialises back to the raw value plus the parameters that must accompany it. + */ +export function formatIcsDateValue(value: IcsDateValue): { + value: string; + params: Record; +} { + if (value.dateOnly) { + return { value: value.value.replace(/-/gu, ""), params: { VALUE: "DATE" } }; + } + + const compact = value.value.replace(/[-:]/gu, ""); + if (value.utc) { + return { value: `${compact}Z`, params: {} }; + } + return { value: compact, params: value.tzid ? { TZID: value.tzid } : {} }; +} + +/** + * Converts to the string TaskNotes stores in frontmatter. + * + * UTC and zoned values are resolved to local wall time so that a task due at + * 12:00 in Berlin reads as the viewer's own clock time, matching how the rest + * of the plugin displays dates. + */ +export function icsDateValueToTaskDate( + value: IcsDateValue, + zoneToUtc?: ZoneToUtc +): string | null { + if (value.dateOnly) return value.value; + + if (value.utc) return utcWallTimeToLocal(value.value); + + if (value.tzid) { + const asUtc = zoneToUtc?.(value.value, value.tzid); + // Without a resolver, a zoned time is treated as floating rather than + // silently shifted by the wrong offset. + if (!asUtc) return trimSeconds(value.value); + const normalised = asUtc.replace(/\.\d+Z?$/u, "").replace(/Z$/u, ""); + return utcWallTimeToLocal(normalised); + } + + return trimSeconds(value.value); +} + +/** + * Converts a TaskNotes frontmatter date into an iCalendar value. + * + * Date-only stays date-only. A local wall time is emitted as a UTC instant, + * which every CalDAV server understands and which avoids shipping a VTIMEZONE + * component we would then have to keep correct. + */ +export function taskDateToIcsDateValue(taskDate: string): IcsDateValue | null { + const trimmed = taskDate?.trim(); + if (!trimmed) return null; + + const dateMatch = TASK_DATE.exec(trimmed); + if (dateMatch) { + const [, year, month, day] = dateMatch; + if (!isRealDate(+year, +month, +day)) return null; + return { dateOnly: true, value: trimmed, utc: false }; + } + + const match = TASK_DATE_TIME.exec(trimmed); + if (!match) return null; + + const [, year, month, day, hour, minute, second] = match; + if (!isRealDate(+year, +month, +day)) return null; + + const local = new Date( + +year, + +month - 1, + +day, + +hour, + +minute, + second ? +second : 0, + 0 + ); + if (Number.isNaN(local.getTime())) return null; + + return { + dateOnly: false, + value: [ + local.getUTCFullYear(), + "-", + pad(local.getUTCMonth() + 1), + "-", + pad(local.getUTCDate()), + "T", + pad(local.getUTCHours()), + ":", + pad(local.getUTCMinutes()), + ":", + pad(local.getUTCSeconds()), + ].join(""), + utc: true, + }; +} + +/** Renders an ISO timestamp as a UTC iCalendar date-time (for DTSTAMP etc.). */ +export function isoToIcsUtcStamp(iso: string): string | null { + const parsed = Date.parse(iso); + if (Number.isNaN(parsed)) return null; + const date = new Date(parsed); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + "T", + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + "Z", + ].join(""); +} + +/** + * Reads a UTC iCalendar stamp (DTSTAMP / LAST-MODIFIED) as epoch milliseconds, + * for the conflict tiebreak. Returns null when absent or malformed so callers + * can fall back rather than comparing against NaN. + */ +export function icsStampToEpochMs(raw: string | undefined): number | null { + if (!raw) return null; + const parsed = parseIcsDateValue(raw); + if (!parsed || parsed.dateOnly) return null; + + const match = TASK_DATE_TIME.exec(parsed.value); + if (!match) return null; + const [, year, month, day, hour, minute, second] = match; + + const ms = Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0); + return Number.isNaN(ms) ? null : ms; +} + +function utcWallTimeToLocal(utcWallTime: string): string | null { + const match = TASK_DATE_TIME.exec(utcWallTime); + if (!match) return null; + const [, year, month, day, hour, minute, second] = match; + + const instant = new Date( + Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0) + ); + if (Number.isNaN(instant.getTime())) return null; + + return [ + instant.getFullYear(), + "-", + pad(instant.getMonth() + 1), + "-", + pad(instant.getDate()), + "T", + pad(instant.getHours()), + ":", + pad(instant.getMinutes()), + ].join(""); +} + +function trimSeconds(wallTime: string): string { + const match = TASK_DATE_TIME.exec(wallTime); + if (!match) return wallTime; + const [, year, month, day, hour, minute] = match; + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +function isRealDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + const probe = new Date(Date.UTC(year, month - 1, day)); + return ( + probe.getUTCFullYear() === year && + probe.getUTCMonth() === month - 1 && + probe.getUTCDate() === day + ); +} + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} diff --git a/src/services/caldav/vtodoAlarms.ts b/src/services/caldav/vtodoAlarms.ts new file mode 100644 index 000000000..f69b1517c --- /dev/null +++ b/src/services/caldav/vtodoAlarms.ts @@ -0,0 +1,153 @@ +/** + * TaskNotes reminders as iCalendar `VALARM` components (RFC 5545 §3.6.6). + * + * The guiding constraint is that TaskNotes is not the only client writing to a + * VTODO. An alarm set in Nextcloud Tasks or Apple Reminders must survive every + * push, so ownership is explicit: TaskNotes stamps the alarms it writes with + * `X-TASKNOTES-REMINDER` and rewrites only those. An untagged VALARM is never + * matched, never rewritten and never dropped. + * + * Round-tripping the reminder id in that stamp also keeps reminder identity + * stable, so an edit updates an alarm instead of replacing it with a new one. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { Reminder } from "../../types"; +import { isoToIcsUtcStamp } from "./icsDateValue"; +import { + escapeText, + getComponents, + parseContentLine, + replaceComponents, + serializeContentLine, + unescapeText, + type VTodoDocument, +} from "./vtodoDocument"; + +/** Marks a VALARM as written by TaskNotes, and carries the reminder id back. */ +export const REMINDER_STAMP = "X-TASKNOTES-REMINDER"; + +const DEFAULT_DESCRIPTION = "Reminder"; + +/** True for alarms TaskNotes wrote, and only those. */ +export function ownsAlarm(lines: readonly string[]): boolean { + return lines.some((line) => parseContentLine(line)?.name === REMINDER_STAMP); +} + +/** + * Builds the VALARM lines for one reminder. + * + * A relative reminder anchors to the task's due date via `RELATED=END` or its + * scheduled date via `RELATED=START`, which is how DUE and DTSTART are already + * mapped. An absolute one carries a UTC timestamp instead. + */ +export function reminderToAlarmLines(reminder: Reminder): string[] | null { + const trigger = triggerFor(reminder); + if (!trigger) return null; + + const description = reminder.description?.trim() || DEFAULT_DESCRIPTION; + return [ + "BEGIN:VALARM", + "ACTION:DISPLAY", + serializeContentLine({ + name: "DESCRIPTION", + params: {}, + value: escapeText(description), + }), + serializeContentLine(trigger), + serializeContentLine({ + name: REMINDER_STAMP, + params: {}, + value: escapeText(reminder.id), + }), + "END:VALARM", + ]; +} + +function triggerFor( + reminder: Reminder +): { name: string; params: Record; value: string } | null { + if (reminder.type === "absolute") { + const stamp = reminder.absoluteTime ? isoToIcsUtcStamp(reminder.absoluteTime) : null; + if (!stamp) return null; + return { name: "TRIGGER", params: { VALUE: "DATE-TIME" }, value: stamp }; + } + + const offset = reminder.offset?.trim(); + if (!offset) return null; + // Anything but "scheduled" anchors to the due date, matching the default the + // reminder UI applies when no anchor was chosen. + const related = reminder.relatedTo === "scheduled" ? "START" : "END"; + return { name: "TRIGGER", params: { RELATED: related }, value: offset }; +} + +/** Reads back the reminders TaskNotes wrote, ignoring foreign alarms. */ +export function readReminders(doc: VTodoDocument): Reminder[] { + const reminders: Reminder[] = []; + + for (const lines of getComponents(doc, "VALARM")) { + if (!ownsAlarm(lines)) continue; + + const properties = lines + .map((line) => parseContentLine(line)) + .filter((property): property is NonNullable => property !== null); + + const id = properties.find((property) => property.name === REMINDER_STAMP)?.value; + const trigger = properties.find((property) => property.name === "TRIGGER"); + if (!id || !trigger) continue; + + const description = properties.find((property) => property.name === "DESCRIPTION")?.value; + const reminder = toReminder(unescapeText(id), trigger); + if (!reminder) continue; + if (description) reminder.description = unescapeText(description); + + reminders.push(reminder); + } + + return reminders; +} + +function toReminder( + id: string, + trigger: { params: Record; value: string } +): Reminder | null { + const value = trigger.value.trim(); + if (!value) return null; + + // A DATE-TIME trigger is absolute; a duration is relative. Servers may omit + // VALUE=DATE-TIME, so the value's own shape is the reliable signal. + if (/^\d{8}T\d{6}Z?$/u.test(value)) { + const iso = icsStampToIso(value); + return iso ? { id, type: "absolute", absoluteTime: iso } : null; + } + + if (!/^[+-]?P/u.test(value)) return null; + return { + id, + type: "relative", + relatedTo: trigger.params.RELATED?.toUpperCase() === "START" ? "scheduled" : "due", + offset: value, + }; +} + +function icsStampToIso(stamp: string): string | null { + const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/u.exec(stamp); + if (!match) return null; + const [, year, month, day, hour, minute, second, zulu] = match; + return `${year}-${month}-${day}T${hour}:${minute}:${second}${zulu ? "Z" : ""}`; +} + +/** + * Writes the task's reminders, leaving alarms TaskNotes did not create alone. + * + * Passing an empty list removes the tagged alarms, which is what an edit that + * clears every reminder should do. + */ +export function applyReminders(doc: VTodoDocument, reminders: readonly Reminder[]): void { + const replacements = reminders + .map((reminder) => reminderToAlarmLines(reminder)) + .filter((lines): lines is string[] => lines !== null); + + replaceComponents(doc, "VALARM", ownsAlarm, replacements); +} diff --git a/src/services/caldav/vtodoDocument.ts b/src/services/caldav/vtodoDocument.ts new file mode 100644 index 000000000..92bcfb7fa --- /dev/null +++ b/src/services/caldav/vtodoDocument.ts @@ -0,0 +1,487 @@ +/** + * A VTODO resource, modelled as an ordered list of content lines rather than a + * decoded object graph. + * + * The point is preservation. A VTODO written by Nextcloud Tasks or Apple + * Reminders carries properties TaskNotes does not model — VALARM blocks, + * ATTACH, X-APPLE-SORT-ORDER, RELATED-TO chains. Re-serialising from a decoded + * struct would silently drop all of them, so instead we keep every line we did + * not deliberately change, and patch only the handful we own. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +export interface IcsProperty { + /** Uppercased property name, e.g. `SUMMARY`. */ + name: string; + /** Parameters, keyed by uppercased name. */ + params: Record; + /** Raw (still escaped) value text. */ + value: string; +} + +/** A nested component such as VALARM, kept verbatim. */ +interface NestedComponent { + kind: "component"; + lines: string[]; +} + +interface PropertyEntry { + kind: "property"; + property: IcsProperty; +} + +type VTodoEntry = PropertyEntry | NestedComponent; + +export interface VTodoDocument { + /** Everything before `BEGIN:VTODO`, verbatim. */ + prologue: string[]; + entries: VTodoEntry[]; + /** Everything after `END:VTODO`, verbatim. */ + epilogue: string[]; +} + +const MAX_LINE_OCTETS = 75; + +/** + * Parses an iCalendar object and isolates its first VTODO. + * Returns null when the payload contains no VTODO (e.g. the server handed back + * a VEVENT, or an error page). + */ +export function parseVTodoDocument(icsText: string): VTodoDocument | null { + const lines = unfoldLines(icsText); + + const start = lines.findIndex((line) => line.trim().toUpperCase() === "BEGIN:VTODO"); + if (start === -1) return null; + + const entries: VTodoEntry[] = []; + let depth = 0; + let nested: string[] | null = null; + let index = start + 1; + + for (; index < lines.length; index++) { + const line = lines[index]; + const upper = line.trim().toUpperCase(); + + if (upper === "END:VTODO" && depth === 0) break; + + if (upper.startsWith("BEGIN:")) { + depth++; + nested = nested ?? []; + } + + if (nested) { + nested.push(line); + if (upper.startsWith("END:")) { + depth--; + if (depth === 0) { + entries.push({ kind: "component", lines: nested }); + nested = null; + } + } + continue; + } + + const property = parseContentLine(line); + if (property) entries.push({ kind: "property", property }); + } + + // An unterminated VTODO means a truncated or malformed response; refusing it + // is safer than syncing against half an object. + if (index >= lines.length) return null; + + return { + prologue: lines.slice(0, start), + entries, + epilogue: lines.slice(index + 1), + }; +} + +/** Builds a minimal VCALENDAR wrapper around a brand-new VTODO. */ +export function createVTodoDocument(): VTodoDocument { + return { + prologue: [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//TaskNotes//CalDAV Sync//EN", + "CALSCALE:GREGORIAN", + ], + entries: [], + epilogue: ["END:VCALENDAR"], + }; +} + +export function serializeVTodoDocument(doc: VTodoDocument): string { + const lines: string[] = [ + ...doc.prologue, + "BEGIN:VTODO", + ...doc.entries.flatMap((entry) => + entry.kind === "component" + ? entry.lines + : [serializeContentLine(entry.property)] + ), + "END:VTODO", + ...doc.epilogue, + ]; + + return `${lines.map(foldLine).join("\r\n")}\r\n`; +} + +export function getProperty(doc: VTodoDocument, name: string): IcsProperty | undefined { + const wanted = name.toUpperCase(); + for (const entry of doc.entries) { + if (entry.kind === "property" && entry.property.name === wanted) { + return entry.property; + } + } + return undefined; +} + +export function getProperties(doc: VTodoDocument, name: string): IcsProperty[] { + const wanted = name.toUpperCase(); + return doc.entries + .filter( + (entry): entry is PropertyEntry => + entry.kind === "property" && entry.property.name === wanted + ) + .map((entry) => entry.property); +} + +/** + * Sets a property, replacing the first occurrence in place so property order — + * which some clients rely on for display — survives an edit. Any duplicate + * occurrences are removed. + */ +export function setProperty( + doc: VTodoDocument, + name: string, + value: string, + params: Record = {} +): void { + const wanted = name.toUpperCase(); + const property: IcsProperty = { name: wanted, params, value }; + + let replaced = false; + const next: VTodoEntry[] = []; + for (const entry of doc.entries) { + if (entry.kind === "property" && entry.property.name === wanted) { + if (!replaced) { + next.push({ kind: "property", property }); + replaced = true; + } + continue; + } + next.push(entry); + } + if (!replaced) next.push({ kind: "property", property }); + + doc.entries = next; +} + +/** + * Replaces the occurrences of a repeatable property that we own, leaving the + * rest untouched. + * + * `RELATED-TO` is the motivating case: TaskNotes owns the parent and dependency + * links but must not disturb a `RELTYPE=SIBLING` line some other client wrote. + * The replacements are placed where the first owned occurrence sat, so ordering + * stays stable across a round trip. + */ +export function replaceProperties( + doc: VTodoDocument, + name: string, + owns: (property: IcsProperty) => boolean, + replacements: readonly Omit[] +): void { + const wanted = name.toUpperCase(); + const fresh: VTodoEntry[] = replacements.map((replacement) => ({ + kind: "property", + property: { name: wanted, params: replacement.params, value: replacement.value }, + })); + + let inserted = false; + const next: VTodoEntry[] = []; + for (const entry of doc.entries) { + if (entry.kind === "property" && entry.property.name === wanted && owns(entry.property)) { + if (!inserted) { + next.push(...fresh); + inserted = true; + } + continue; + } + next.push(entry); + } + if (!inserted) next.push(...fresh); + + doc.entries = next; +} + +/** The raw lines of every nested component with the given name, e.g. VALARM. */ +export function getComponents(doc: VTodoDocument, name: string): string[][] { + const begin = `BEGIN:${name.toUpperCase()}`; + return doc.entries + .filter((entry): entry is NestedComponent => entry.kind === "component") + .filter((entry) => entry.lines[0]?.trim().toUpperCase() === begin) + .map((entry) => [...entry.lines]); +} + +/** + * Replaces the nested components we own, keeping every other one verbatim. + * + * Alarms are the case that matters: TaskNotes writes a VALARM per reminder, but + * an alarm added on someone's phone has to survive the next push, so ownership + * is decided per component rather than by wiping the list. + */ +export function replaceComponents( + doc: VTodoDocument, + name: string, + owns: (lines: readonly string[]) => boolean, + replacements: readonly (readonly string[])[] +): void { + const begin = `BEGIN:${name.toUpperCase()}`; + const isOwned = (entry: VTodoEntry): boolean => + entry.kind === "component" && + entry.lines[0]?.trim().toUpperCase() === begin && + owns(entry.lines); + + const fresh: VTodoEntry[] = replacements.map((lines) => ({ + kind: "component", + lines: [...lines], + })); + + let inserted = false; + const next: VTodoEntry[] = []; + for (const entry of doc.entries) { + if (isOwned(entry)) { + if (!inserted) { + next.push(...fresh); + inserted = true; + } + continue; + } + next.push(entry); + } + if (!inserted) next.push(...fresh); + + doc.entries = next; +} + +export function removeProperty(doc: VTodoDocument, name: string): void { + const wanted = name.toUpperCase(); + doc.entries = doc.entries.filter( + (entry) => entry.kind === "component" || entry.property.name !== wanted + ); +} + +/** Reads a TEXT-typed property with iCalendar escaping removed. */ +export function getTextProperty(doc: VTodoDocument, name: string): string | undefined { + const property = getProperty(doc, name); + return property ? unescapeText(property.value) : undefined; +} + +/** Writes a TEXT-typed property, applying iCalendar escaping. */ +export function setTextProperty( + doc: VTodoDocument, + name: string, + value: string, + params: Record = {} +): void { + setProperty(doc, name, escapeText(value), params); +} + +/** Reads a comma-separated TEXT list such as CATEGORIES. */ +export function getTextListProperty(doc: VTodoDocument, name: string): string[] { + const property = getProperty(doc, name); + if (!property) return []; + + return splitUnescapedCommas(property.value) + .map((part) => unescapeText(part).trim()) + .filter((part) => part.length > 0); +} + +export function setTextListProperty( + doc: VTodoDocument, + name: string, + values: string[] +): void { + if (values.length === 0) { + removeProperty(doc, name); + return; + } + setProperty(doc, name, values.map(escapeText).join(",")); +} + +export function escapeText(text: string): string { + return text + .replace(/\\/gu, "\\\\") + .replace(/;/gu, "\\;") + .replace(/,/gu, "\\,") + .replace(/\r\n|\n|\r/gu, "\\n"); +} + +export function unescapeText(text: string): string { + let out = ""; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char !== "\\") { + out += char; + continue; + } + const next = text[++i]; + if (next === undefined) break; + if (next === "n" || next === "N") out += "\n"; + else out += next; + } + return out; +} + +/** + * Reverses RFC 5545 line folding. Continuation lines begin with a single space + * or tab, which is removed on join. + */ +export function unfoldLines(icsText: string): string[] { + const raw = icsText.split(/\r\n|\n|\r/u); + const unfolded: string[] = []; + + for (const line of raw) { + if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) { + unfolded[unfolded.length - 1] += line.slice(1); + continue; + } + unfolded.push(line); + } + + // A trailing newline produces a final empty entry that is not a content line. + while (unfolded.length > 0 && unfolded[unfolded.length - 1].trim() === "") { + unfolded.pop(); + } + + return unfolded; +} + +export function parseContentLine(line: string): IcsProperty | null { + if (!line.trim()) return null; + + // Walk to the value separator, ignoring any ':' inside a quoted parameter. + let colon = -1; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') quoted = !quoted; + else if (char === ":" && !quoted) { + colon = i; + break; + } + } + if (colon === -1) return null; + + const head = line.slice(0, colon); + const value = line.slice(colon + 1); + + const segments = splitUnquoted(head, ";"); + const name = segments.shift()?.trim().toUpperCase(); + if (!name) return null; + + const params: Record = {}; + for (const segment of segments) { + const equals = segment.indexOf("="); + if (equals === -1) continue; + const key = segment.slice(0, equals).trim().toUpperCase(); + const paramValue = segment.slice(equals + 1).trim().replace(/^"|"$/gu, ""); + if (key) params[key] = paramValue; + } + + return { name, params, value }; +} + +export function serializeContentLine(property: IcsProperty): string { + const params = Object.entries(property.params) + .map(([key, value]) => `;${key}=${needsQuoting(value) ? `"${value}"` : value}`) + .join(""); + return `${property.name}${params}:${property.value}`; +} + +/** Folds to 75 octets per RFC 5545, counting UTF-8 bytes rather than characters. */ +function foldLine(line: string): string { + if (octetLength(line) <= MAX_LINE_OCTETS) return line; + + const parts: string[] = []; + let current = ""; + let currentOctets = 0; + // The continuation space costs an octet, so subsequent chunks get one less. + let limit = MAX_LINE_OCTETS; + + for (const char of line) { + const size = octetLength(char); + if (currentOctets + size > limit) { + parts.push(current); + current = ""; + currentOctets = 0; + limit = MAX_LINE_OCTETS - 1; + } + current += char; + currentOctets += size; + } + if (current) parts.push(current); + + return parts.map((part, index) => (index === 0 ? part : ` ${part}`)).join("\r\n"); +} + +function octetLength(text: string): number { + let octets = 0; + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x7f) octets += 1; + else if (code <= 0x7ff) octets += 2; + else if (code <= 0xffff) octets += 3; + else octets += 4; + } + return octets; +} + +function needsQuoting(value: string): boolean { + return /[;:,]/u.test(value); +} + +function splitUnquoted(text: string, separator: string): string[] { + const parts: string[] = []; + let current = ""; + let quoted = false; + + for (const char of text) { + if (char === '"') { + quoted = !quoted; + current += char; + continue; + } + if (char === separator && !quoted) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} + +function splitUnescapedCommas(text: string): string[] { + const parts: string[] = []; + let current = ""; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === "\\") { + current += char + (text[++i] ?? ""); + continue; + } + if (char === ",") { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} diff --git a/src/services/caldav/vtodoMapping.ts b/src/services/caldav/vtodoMapping.ts new file mode 100644 index 000000000..967052ac7 --- /dev/null +++ b/src/services/caldav/vtodoMapping.ts @@ -0,0 +1,389 @@ +/** + * TaskInfo <-> VTODO field mapping. + * + * Statuses and priorities in TaskNotes are user-defined strings, while VTODO + * has four fixed STATUS values and a 1-9 PRIORITY scale, so both directions go + * through the user's configured `StatusConfig` / `PriorityConfig` lists rather + * than any hard-coded vocabulary. + * + * Fields deliberately NOT mapped, and preserved verbatim instead (see + * vtodoDocument.ts): DESCRIPTION (the note body is not synced), VALARM, + * RELATED-TO, ATTACH and every X- property. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { PriorityConfig, StatusConfig, TaskInfo } from "../../types"; +import { + formatIcsDateValue, + icsDateValueToTaskDate, + icsStampToEpochMs, + isoToIcsUtcStamp, + parseIcsDateValue, + taskDateToIcsDateValue, + type ZoneToUtc, +} from "./icsDateValue"; +import { + getProperty, + getTextListProperty, + getTextProperty, + removeProperty, + setProperty, + setTextListProperty, + setTextProperty, + type VTodoDocument, +} from "./vtodoDocument"; + +export const VTODO_STATUSES = [ + "NEEDS-ACTION", + "IN-PROCESS", + "COMPLETED", + "CANCELLED", +] as const; + +export type VTodoStatus = (typeof VTODO_STATUSES)[number]; + +export interface VTodoMappingContext { + statuses: StatusConfig[]; + priorities: PriorityConfig[]; + /** Per-status overrides of the auto-derived VTODO status. */ + statusOverrides?: Record; + /** Resolves a TZID wall time to UTC; see icsDateValue.ts. */ + zoneToUtc?: ZoneToUtc; +} + +/** The subset of a task that a remote VTODO can dictate. */ +export interface VTodoTaskPatch { + title?: string; + status?: string; + priority?: string; + due?: string | null; + scheduled?: string | null; + completedDate?: string | null; + tags?: string[]; + recurrence?: string | null; +} + +export function isVTodoStatus(value: string): value is VTodoStatus { + return (VTODO_STATUSES as readonly string[]).includes(value); +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +/** + * Derives a VTODO status from the flags `StatusConfig` already carries, unless + * the user has pinned an explicit override for this status value. + */ +export function taskStatusToVTodo( + statusValue: string, + context: VTodoMappingContext +): VTodoStatus { + const override = context.statusOverrides?.[statusValue]; + if (override && isVTodoStatus(override)) return override; + + const config = findStatus(context.statuses, statusValue); + if (config?.isCompleted) return "COMPLETED"; + if (config?.isSkipped) return "CANCELLED"; + return "NEEDS-ACTION"; +} + +/** + * Picks the TaskNotes status that best represents a remote VTODO status. + * + * An explicit override wins, so a user who mapped "in-progress" to IN-PROCESS + * gets that same status back rather than a generic open one. + */ +export function vTodoStatusToTaskStatus( + vtodoStatus: string, + context: VTodoMappingContext +): string | undefined { + const normalized = vtodoStatus?.trim().toUpperCase(); + if (!normalized) return undefined; + + const override = Object.entries(context.statusOverrides ?? {}).find( + ([, mapped]) => mapped === normalized + )?.[0]; + if (override && findStatus(context.statuses, override)) return override; + + const ordered = [...context.statuses].sort((a, b) => a.order - b.order); + const open = ordered.filter((status) => !status.isCompleted && !status.isSkipped); + + switch (normalized) { + case "COMPLETED": + return ordered.find((status) => status.isCompleted)?.value; + case "CANCELLED": + return ( + ordered.find((status) => status.isSkipped)?.value ?? + ordered.find((status) => status.isCompleted)?.value + ); + case "IN-PROCESS": + // Without an override there is no way to distinguish in-progress from + // not-started, so prefer a second open status when one exists. + return (open[1] ?? open[0] ?? ordered[0])?.value; + case "NEEDS-ACTION": + return (open[0] ?? ordered[0])?.value; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Priority +// --------------------------------------------------------------------------- + +/** + * Spreads the user's priorities across the 1-9 VTODO scale by weight, highest + * weight to the lowest (most urgent) number. Deterministic in both directions, + * so a value that leaves TaskNotes comes back as the same priority. + */ +export function taskPriorityToVTodo( + priorityValue: string, + context: VTodoMappingContext +): number | undefined { + const scale = buildPriorityScale(context.priorities); + return scale.get(priorityValue); +} + +export function vTodoPriorityToTaskPriority( + priority: number | undefined, + context: VTodoMappingContext +): string | undefined { + // 0 means "undefined" in RFC 5545. + if (priority === undefined || priority <= 0 || priority > 9) return undefined; + + const scale = buildPriorityScale(context.priorities); + let best: { value: string; distance: number } | undefined; + for (const [value, mapped] of scale) { + const distance = Math.abs(mapped - priority); + if (!best || distance < best.distance) best = { value, distance }; + } + return best?.value; +} + +function buildPriorityScale(priorities: PriorityConfig[]): Map { + const scale = new Map(); + // A zero-or-negative weight is the "none" priority; RFC 5545 spells that as + // an absent PRIORITY rather than as 9, which would read as "lowest". + const ordered = priorities + .filter((priority) => priority.weight > 0) + .sort((a, b) => b.weight - a.weight); + if (ordered.length === 0) return scale; + + if (ordered.length === 1) { + scale.set(ordered[0].value, 5); + return scale; + } + + ordered.forEach((priority, index) => { + const mapped = Math.round(1 + (index * 8) / (ordered.length - 1)); + scale.set(priority.value, mapped); + }); + return scale; +} + +// --------------------------------------------------------------------------- +// Recurrence +// --------------------------------------------------------------------------- + +/** + * TaskNotes stores recurrence as an RRULE with an embedded DTSTART + * ("DTSTART:20240115;FREQ=WEEKLY"), whereas iCalendar carries DTSTART as its + * own property. These two helpers move between the forms. + */ +export function splitRecurrence(recurrence: string): { + dtstart?: string; + rule: string; +} { + const match = /DTSTART:(\d{8})(T\d{6}Z?)?;?/u.exec(recurrence); + if (!match) return { rule: recurrence.replace(/^RRULE:/u, "").trim() }; + + const rule = recurrence.replace(match[0], "").replace(/^RRULE:/u, "").trim(); + return { dtstart: `${match[1]}${match[2] ?? ""}`, rule }; +} + +export function joinRecurrence(dtstartCompact: string | undefined, rule: string): string { + const cleaned = rule.replace(/^RRULE:/u, "").trim(); + if (!cleaned) return ""; + return dtstartCompact ? `DTSTART:${dtstartCompact};${cleaned}` : cleaned; +} + +// --------------------------------------------------------------------------- +// Task -> VTODO +// --------------------------------------------------------------------------- + +/** + * Patches the fields TaskNotes owns onto an existing VTODO, leaving every other + * line — including VALARM blocks and X- properties — untouched. + */ +export function applyTaskToVTodo( + doc: VTodoDocument, + task: TaskInfo, + context: VTodoMappingContext, + options: { uid: string; now?: string } +): void { + const now = options.now ?? new Date().toISOString(); + + setTextProperty(doc, "UID", options.uid); + setTextProperty(doc, "SUMMARY", task.title ?? ""); + + writeDate(doc, "DUE", task.due); + + const recurrence = task.recurrence ? splitRecurrence(task.recurrence) : undefined; + // DTSTART doubles as the recurrence anchor, so a recurring task falls back to + // the rule's own anchor when it has no scheduled date of its own. + const scheduled = task.scheduled + ? taskDateToIcsDateValue(task.scheduled) + : recurrence?.dtstart + ? parseIcsDateValue(recurrence.dtstart) + : null; + + if (scheduled) { + const { value, params } = formatIcsDateValue(scheduled); + setProperty(doc, "DTSTART", value, params); + } else { + removeProperty(doc, "DTSTART"); + } + + if (recurrence?.rule) { + setProperty(doc, "RRULE", recurrence.rule); + } else { + removeProperty(doc, "RRULE"); + } + + const status = taskStatusToVTodo(task.status, context); + setProperty(doc, "STATUS", status); + + if (status === "COMPLETED") { + const completed = task.completedDate + ? taskDateToIcsDateValue(task.completedDate) + : null; + // COMPLETED must be a UTC date-time per RFC 5545, so a date-only + // completion is anchored at midnight rather than emitted as a DATE. + const stamp = completed + ? completed.dateOnly + ? `${completed.value.replace(/-/gu, "")}T000000Z` + : formatIcsDateValue(completed).value + : isoToIcsUtcStamp(now); + if (stamp) setProperty(doc, "COMPLETED", stamp); + setProperty(doc, "PERCENT-COMPLETE", "100"); + } else { + removeProperty(doc, "COMPLETED"); + removeProperty(doc, "PERCENT-COMPLETE"); + } + + const priority = taskPriorityToVTodo(task.priority, context); + if (priority === undefined) removeProperty(doc, "PRIORITY"); + else setProperty(doc, "PRIORITY", String(priority)); + + setTextListProperty(doc, "CATEGORIES", task.tags ?? []); + + const stamp = isoToIcsUtcStamp(now); + if (stamp) { + setProperty(doc, "DTSTAMP", stamp); + setProperty(doc, "LAST-MODIFIED", stamp); + } + bumpSequence(doc); +} + +function writeDate(doc: VTodoDocument, name: string, taskDate: string | undefined): void { + const parsed = taskDate ? taskDateToIcsDateValue(taskDate) : null; + if (!parsed) { + removeProperty(doc, name); + return; + } + const { value, params } = formatIcsDateValue(parsed); + setProperty(doc, name, value, params); +} + +function bumpSequence(doc: VTodoDocument): void { + const current = Number.parseInt(getProperty(doc, "SEQUENCE")?.value ?? "0", 10); + setProperty(doc, "SEQUENCE", String(Number.isFinite(current) ? current + 1 : 1)); +} + +// --------------------------------------------------------------------------- +// VTODO -> Task +// --------------------------------------------------------------------------- + +export function readVTodoUid(doc: VTodoDocument): string | undefined { + return getTextProperty(doc, "UID")?.trim() || undefined; +} + +/** + * Epoch milliseconds of the remote's last revision, for the conflict tiebreak. + * + * LAST-MODIFIED is preferred where present; RFC 5545 gives DTSTAMP the same + * meaning for an object held in a calendar store (one with no METHOD property), + * which is exactly the CalDAV case. + */ +export function readVTodoRevision(doc: VTodoDocument): number | null { + return ( + icsStampToEpochMs(getProperty(doc, "LAST-MODIFIED")?.value) ?? + icsStampToEpochMs(getProperty(doc, "DTSTAMP")?.value) + ); +} + +export function readVTodoIntoTaskPatch( + doc: VTodoDocument, + context: VTodoMappingContext +): VTodoTaskPatch { + const patch: VTodoTaskPatch = {}; + + const summary = getTextProperty(doc, "SUMMARY"); + if (summary !== undefined) patch.title = summary; + + patch.due = readDate(doc, "DUE", context) ?? null; + + const dtstart = readDate(doc, "DTSTART", context); + patch.scheduled = dtstart ?? null; + + const statusProperty = getProperty(doc, "STATUS")?.value; + const status = statusProperty + ? vTodoStatusToTaskStatus(statusProperty, context) + : undefined; + if (status) patch.status = status; + + const completed = readDate(doc, "COMPLETED", context); + patch.completedDate = completed ? completed.slice(0, 10) : null; + + const priorityRaw = getProperty(doc, "PRIORITY")?.value; + const priority = priorityRaw + ? vTodoPriorityToTaskPriority(Number.parseInt(priorityRaw, 10), context) + : undefined; + if (priority) patch.priority = priority; + + patch.tags = getTextListProperty(doc, "CATEGORIES"); + + const rrule = getProperty(doc, "RRULE")?.value?.trim(); + if (rrule) { + const anchor = getProperty(doc, "DTSTART"); + const anchorValue = anchor + ? parseIcsDateValue(anchor.value, anchor.params) + : null; + const compact = anchorValue ? formatIcsDateValue(anchorValue).value : undefined; + patch.recurrence = joinRecurrence(compact, rrule); + } else { + patch.recurrence = null; + } + + return patch; +} + +function readDate( + doc: VTodoDocument, + name: string, + context: VTodoMappingContext +): string | undefined { + const property = getProperty(doc, name); + if (!property) return undefined; + + const parsed = parseIcsDateValue(property.value, property.params); + if (!parsed) return undefined; + + return icsDateValueToTaskDate(parsed, context.zoneToUtc) ?? undefined; +} + +function findStatus(statuses: StatusConfig[], value: string): StatusConfig | undefined { + return statuses.find((status) => status.value === value); +} diff --git a/src/services/caldav/vtodoRelations.ts b/src/services/caldav/vtodoRelations.ts new file mode 100644 index 000000000..527ca659a --- /dev/null +++ b/src/services/caldav/vtodoRelations.ts @@ -0,0 +1,123 @@ +/** + * Task relationships as iCalendar `RELATED-TO` (RFC 5545 §3.8.4.5, RFC 9253). + * + * TaskNotes has no parent field: a subtask is a task whose `projects` array + * links to its parent, so `projects` *is* the parent relation and, being + * many-valued, maps directly onto repeated `RELATED-TO;RELTYPE=PARENT` lines. + * Dependencies map just as directly — `TaskDependency` is already shaped like + * RFC 9253's temporal relation, carrying a reltype and an optional `GAP`. + * + * Relation targets are VTODO UIDs, not vault paths. Resolving between the two + * needs Obsidian's metadata cache, so it stays in CalDavSyncService; this + * module only ever speaks UIDs. + * + * Pure: no Obsidian runtime, no network, no DOM or timer globals. + */ + +import type { TaskDependency, TaskDependencyRelType } from "../../types"; +import { + getProperties, + replaceProperties, + unescapeText, + type IcsProperty, + type VTodoDocument, +} from "./vtodoDocument"; + +const RELATED_TO = "RELATED-TO"; +const PARENT = "PARENT"; + +/** RFC 9253 temporal reltypes, which are exactly TaskNotes' dependency kinds. */ +const DEPENDENCY_RELTYPES: readonly TaskDependencyRelType[] = [ + "FINISHTOSTART", + "FINISHTOFINISH", + "STARTTOSTART", + "STARTTOFINISH", +]; + +export interface VTodoRelations { + /** UIDs of the parent tasks this task belongs to. */ + parents: string[]; + /** Dependencies, with UIDs in place of vault links. */ + dependencies: TaskDependency[]; +} + +/** + * The reltype of a RELATED-TO line. Absent means PARENT: RFC 5545 defines it as + * the default, and Nextcloud Tasks and Apple Reminders both rely on that when + * writing subtasks. + */ +function reltypeOf(property: IcsProperty): string { + return (property.params.RELTYPE ?? PARENT).toUpperCase(); +} + +function isDependencyRelType(value: string): value is TaskDependencyRelType { + return (DEPENDENCY_RELTYPES as readonly string[]).includes(value); +} + +/** True for the relations TaskNotes maps, and only those. */ +export function ownsRelation(property: IcsProperty): boolean { + const reltype = reltypeOf(property); + return reltype === PARENT || isDependencyRelType(reltype); +} + +/** Reads the relations TaskNotes models, ignoring any others (e.g. SIBLING). */ +export function readRelations(doc: VTodoDocument): VTodoRelations { + const parents: string[] = []; + const dependencies: TaskDependency[] = []; + + for (const property of getProperties(doc, RELATED_TO)) { + const uid = unescapeText(property.value).trim(); + if (!uid) continue; + + const reltype = reltypeOf(property); + if (reltype === PARENT) { + if (!parents.includes(uid)) parents.push(uid); + continue; + } + if (!isDependencyRelType(reltype)) continue; + + const gap = property.params.GAP?.trim(); + dependencies.push(gap ? { uid, reltype, gap } : { uid, reltype }); + } + + return { parents, dependencies }; +} + +/** + * Writes the relations TaskNotes owns, preserving every other RELATED-TO line. + * + * Callers pass only relations whose target has a known UID; a parent that is + * not itself synced simply has no address on the server, and dropping the line + * is the honest representation of that. + */ +export function applyRelations(doc: VTodoDocument, relations: VTodoRelations): void { + const replacements: Omit[] = []; + + for (const uid of dedupe(relations.parents)) { + replacements.push({ params: { RELTYPE: PARENT }, value: uid }); + } + + const seenDependencies = new Set(); + for (const dependency of relations.dependencies) { + const uid = dependency.uid.trim(); + if (!uid) continue; + const key = `${dependency.reltype} ${uid}`; + if (seenDependencies.has(key)) continue; + seenDependencies.add(key); + + const params: Record = { RELTYPE: dependency.reltype }; + if (dependency.gap) params.GAP = dependency.gap; + replacements.push({ params, value: uid }); + } + + replaceProperties(doc, RELATED_TO, ownsRelation, replacements); +} + +function dedupe(values: readonly string[]): string[] { + const seen: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (trimmed && !seen.includes(trimmed)) seen.push(trimmed); + } + return seen; +} diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 9886b3488..02cf9756f 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -7,6 +7,8 @@ import { ProjectAutosuggestSettings, NLPTriggersConfig, GoogleCalendarExportSettings, + CalDavSettings, + CalDavAccountSettings, } from "../types/settings"; import { DEFAULT_FIELD_MAPPING } from "../core/defaultFieldMapping"; export { DEFAULT_FIELD_MAPPING } from "../core/defaultFieldMapping"; @@ -206,6 +208,27 @@ export const DEFAULT_GOOGLE_CALENDAR_EXPORT: GoogleCalendarExportSettings = { defaultReminderMinutes: null, // No reminder override by default (user opts in) }; +export const DEFAULT_CALDAV_SETTINGS: CalDavSettings = { + enabled: false, // Opt-in; an account must be configured first + accounts: [], + pushOnChange: true, // Local edits reach the server in seconds + pushDebounceMs: 1500, // One write per burst of edits, not per keystroke +}; + +/** Sensible starting point for a newly added CalDAV account. */ +export const DEFAULT_CALDAV_ACCOUNT: Omit = { + name: "", + enabled: false, // Stays off until credentials and a collection are chosen + serverUrl: "", + collectionUrl: "", + username: "", + syncIntervalMinutes: 15, + taskFolder: "", + statusOverrides: {}, + remoteDeletionPolicy: "archive", // Never destroy notes without being asked + initialSyncCompleted: false, +}; + export const DEFAULT_PROJECT_AUTOSUGGEST: ProjectAutosuggestSettings = { enableFuzzy: false, rows: ["{title|n(Title)}", "{aliases|n(Aliases)}", "{file.path|n(Path)}"], @@ -415,6 +438,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { microsoftCalendarSyncTokens: {}, // Google Calendar task export settings googleCalendarExport: DEFAULT_GOOGLE_CALENDAR_EXPORT, + // Two-way CalDAV VTODO sync + caldav: DEFAULT_CALDAV_SETTINGS, // Debug logging enableDebugLogging: false, }; diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index 9fbf67f17..0b54eb10f 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -1,5 +1,5 @@ import { normalizePath } from "obsidian"; -import { DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS } from "./defaults"; +import { DEFAULT_CALDAV_ACCOUNT, DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS } from "./defaults"; import { hasMissingMigratedSettings } from "./settingsMigration"; import type { TaskCreationDefaults, TaskNotesSettings } from "../types/settings"; import { initializeFieldConfig } from "../utils/fieldConfigDefaults"; @@ -227,6 +227,16 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se ...(loadedData?.nlpTriggers || {}), triggers: loadedData?.nlpTriggers?.triggers || DEFAULT_SETTINGS.nlpTriggers.triggers, }, + caldav: { + ...DEFAULT_SETTINGS.caldav, + ...(loadedData?.caldav || {}), + // Accounts written by an older build may lack fields added since, so + // each one is merged over the account defaults rather than trusted whole. + accounts: (loadedData?.caldav?.accounts || []).map((account) => ({ + ...DEFAULT_CALDAV_ACCOUNT, + ...account, + })), + }, modalFieldsConfig: initializeFieldConfig( loadedData?.modalFieldsConfig, loadedData?.userFields diff --git a/src/settings/tabs/caldavSection.ts b/src/settings/tabs/caldavSection.ts new file mode 100644 index 000000000..cc6e9c08a --- /dev/null +++ b/src/settings/tabs/caldavSection.ts @@ -0,0 +1,449 @@ +/** + * CalDAV account configuration UI. + * + * Kept out of integrationsTab.ts, which is already very large. Rendering is + * re-entrant: any change that alters which controls apply (adding an account, + * discovering collections) re-renders the whole section. + */ + +import { Setting } from "obsidian"; + +import type TaskNotesPlugin from "../../main"; +import type { CalDavAccountSettings } from "../../types/settings"; +import type { TranslationKey } from "../../i18n"; +import { + CalDavClient, + CalDavError, + type CalDavCollectionInfo, +} from "../../services/CalDavClient"; +import { CalDavSecretStore } from "../../services/CalDavSecretStore"; +import { summarizeFirstSyncPlan } from "../../services/caldav/caldavReconciliation"; +import { DEFAULT_CALDAV_ACCOUNT } from "../defaults"; +import { showConfirmationModal } from "../../modals/ConfirmationModal"; +import { + configureButtonSetting, + configureDropdownSetting, + configureNumberSetting, + configureTextSetting, + configureToggleSetting, + createSettingGroup, +} from "../components/settingHelpers"; +import { showNotice } from "../../ui/notifications"; +import { createTaskNotesLogger } from "../../utils/tasknotesLogger"; + +const tasknotesLogger = createTaskNotesLogger({ tag: "Settings/CalDavSection" }); + +type Translate = (key: TranslationKey, params?: Record) => string; + +/** + * Collections found by the last discovery, per account. + * + * Kept outside the render function because the section re-renders on every + * change, and re-running discovery each time would hammer the server. + */ +const discoveredCollections = new Map(); + +export function renderCalDavSection( + container: HTMLElement, + plugin: TaskNotesPlugin, + save: () => void, + translate: Translate, + rerender: () => void +): void { + const secretStore = new CalDavSecretStore(plugin.app.secretStorage); + + createSettingGroup( + container, + { + heading: translate("settings.integrations.caldav.header"), + description: translate("settings.integrations.caldav.description"), + }, + (group) => { + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: translate("settings.integrations.caldav.enable.name"), + desc: translate("settings.integrations.caldav.enable.description"), + getValue: () => plugin.settings.caldav.enabled, + setValue: async (value: boolean) => { + plugin.settings.caldav.enabled = value; + save(); + // The service and its event listeners are wired up at + // startup, so this only takes effect after a reload. + showNotice( + translate("settings.integrations.caldav.notices.reloadRequired") + ); + rerender(); + }, + }) + ); + + if (!plugin.settings.caldav.enabled) return; + + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: translate("settings.integrations.caldav.pushOnChange.name"), + desc: translate("settings.integrations.caldav.pushOnChange.description"), + getValue: () => plugin.settings.caldav.pushOnChange, + setValue: async (value: boolean) => { + plugin.settings.caldav.pushOnChange = value; + save(); + }, + }) + ); + + for (const account of plugin.settings.caldav.accounts) { + renderAccount(group, account); + } + + group.addSetting( + (setting) => + void configureButtonSetting(setting, { + name: translate("settings.integrations.caldav.addAccount.name"), + desc: translate("settings.integrations.caldav.addAccount.description"), + buttonText: translate("settings.integrations.caldav.addAccount.button"), + onClick: async () => { + plugin.settings.caldav.accounts.push({ + ...DEFAULT_CALDAV_ACCOUNT, + id: `caldav-${Date.now().toString(36)}`, + name: translate("settings.integrations.caldav.addAccount.defaultName"), + }); + save(); + rerender(); + }, + }) + ); + } + ); + + function renderAccount( + group: { addSetting(configure: (setting: Setting) => void): unknown }, + account: CalDavAccountSettings + ): void { + group.addSetting((setting) => { + setting.setName(account.name || account.id); + setting.setHeading(); + }); + + group.addSetting( + (setting) => + void configureTextSetting(setting, { + name: translate("settings.integrations.caldav.account.name.name"), + desc: translate("settings.integrations.caldav.account.name.description"), + getValue: () => account.name, + setValue: async (value: string) => { + account.name = value; + save(); + }, + debounceMs: 500, + }) + ); + + group.addSetting( + (setting) => + void configureTextSetting(setting, { + name: translate("settings.integrations.caldav.account.serverUrl.name"), + desc: translate("settings.integrations.caldav.account.serverUrl.description"), + placeholder: "https://cloud.example.com/remote.php/dav", + getValue: () => account.serverUrl, + setValue: async (value: string) => { + account.serverUrl = value.trim(); + save(); + }, + debounceMs: 500, + }) + ); + + group.addSetting( + (setting) => + void configureTextSetting(setting, { + name: translate("settings.integrations.caldav.account.username.name"), + desc: translate("settings.integrations.caldav.account.username.description"), + getValue: () => account.username, + setValue: async (value: string) => { + account.username = value.trim(); + save(); + }, + debounceMs: 500, + }) + ); + + // The password is write-only from here: it lives in Obsidian's + // SecretStorage and is never read back into the settings UI. + group.addSetting((setting) => { + setting.setName(translate("settings.integrations.caldav.account.password.name")); + setting.setDesc( + secretStore.hasCredentials(account.id) + ? translate("settings.integrations.caldav.account.password.stored") + : translate("settings.integrations.caldav.account.password.description") + ); + setting.addText((text) => { + text.inputEl.type = "password"; + text.setPlaceholder( + translate("settings.integrations.caldav.account.password.placeholder") + ); + text.onChange((value) => { + if (!value) return; + try { + secretStore.setCredentials(account.id, { + username: account.username, + password: value, + }); + } catch (error) { + tasknotesLogger.error("Could not store CalDAV credentials", { + category: "configuration", + operation: "store-caldav-credentials", + error, + }); + showNotice( + translate("settings.integrations.caldav.notices.credentialsNotStored") + ); + } + }); + }); + setting.addButton((button) => { + button + .setButtonText(translate("settings.integrations.caldav.account.password.clear")) + .onClick(() => { + secretStore.clearCredentials(account.id); + rerender(); + }); + }); + }); + + group.addSetting( + (setting) => + void configureButtonSetting(setting, { + name: translate("settings.integrations.caldav.account.discover.name"), + desc: translate("settings.integrations.caldav.account.discover.description"), + buttonText: translate("settings.integrations.caldav.account.discover.button"), + onClick: () => discoverCollections(account), + }) + ); + + const discovered = discoveredCollections.get(account.id); + if (discovered && discovered.length > 1) { + // Several task lists can share a display name, so the URL is the only + // thing that reliably tells them apart. + group.addSetting( + (setting) => + void configureDropdownSetting(setting, { + name: translate("settings.integrations.caldav.account.collection.name"), + desc: translate("settings.integrations.caldav.account.collection.choose"), + options: discovered.map((collection) => ({ + value: collection.url, + label: `${collection.displayName} (${collection.url})`, + })), + getValue: () => account.collectionUrl, + setValue: async (value: string) => { + account.collectionUrl = value; + save(); + }, + }) + ); + } else if (account.collectionUrl) { + group.addSetting((setting) => { + setting.setName( + translate("settings.integrations.caldav.account.collection.name") + ); + setting.setDesc(account.collectionUrl); + }); + } + + group.addSetting( + (setting) => + void configureNumberSetting(setting, { + name: translate("settings.integrations.caldav.account.interval.name"), + desc: translate("settings.integrations.caldav.account.interval.description"), + getValue: () => account.syncIntervalMinutes, + setValue: async (value: number) => { + account.syncIntervalMinutes = Math.max(1, Math.min(1440, value)); + save(); + }, + min: 1, + max: 1440, + }) + ); + + group.addSetting( + (setting) => + void configureDropdownSetting(setting, { + name: translate("settings.integrations.caldav.account.deletionPolicy.name"), + desc: translate( + "settings.integrations.caldav.account.deletionPolicy.description" + ), + options: [ + { + value: "archive", + label: translate( + "settings.integrations.caldav.account.deletionPolicy.archive" + ), + }, + { + value: "unlink", + label: translate( + "settings.integrations.caldav.account.deletionPolicy.unlink" + ), + }, + { + value: "delete", + label: translate( + "settings.integrations.caldav.account.deletionPolicy.delete" + ), + }, + ], + getValue: () => account.remoteDeletionPolicy, + setValue: async (value: string) => { + account.remoteDeletionPolicy = + value as CalDavAccountSettings["remoteDeletionPolicy"]; + save(); + }, + }) + ); + + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: translate("settings.integrations.caldav.account.enable.name"), + desc: translate("settings.integrations.caldav.account.enable.description"), + getValue: () => account.enabled, + setValue: async (value: boolean) => { + account.enabled = value; + save(); + }, + }) + ); + + group.addSetting( + (setting) => + void configureButtonSetting(setting, { + name: translate("settings.integrations.caldav.account.firstSync.name"), + desc: translate("settings.integrations.caldav.account.firstSync.description"), + buttonText: translate("settings.integrations.caldav.account.firstSync.button"), + onClick: () => runFirstSyncPreview(account), + }) + ); + + group.addSetting( + (setting) => + void configureButtonSetting(setting, { + name: translate("settings.integrations.caldav.account.remove.name"), + desc: translate("settings.integrations.caldav.account.remove.description"), + buttonText: translate("settings.integrations.caldav.account.remove.button"), + onClick: () => removeAccount(account), + }) + ); + } + + async function discoverCollections(account: CalDavAccountSettings): Promise { + const credentials = secretStore.getCredentials(account.id); + if (!credentials) { + showNotice(translate("settings.integrations.caldav.notices.missingCredentials")); + return; + } + + try { + const client = new CalDavClient({ + serverUrl: account.serverUrl || account.collectionUrl, + credentials, + }); + const collections = await client.discoverCollections(); + + if (collections.length === 0) { + showNotice(translate("settings.integrations.caldav.notices.noCollections")); + return; + } + + discoveredCollections.set(account.id, collections); + + // Adopt the first result so a single-list account needs no further + // input; when there are several, the dropdown lets the user correct it + // before anything is written. + if (!collections.some((collection) => collection.url === account.collectionUrl)) { + account.collectionUrl = collections[0].url; + } + if (!account.name) account.name = collections[0].displayName; + save(); + showNotice( + translate("settings.integrations.caldav.notices.discovered", { + count: collections.length, + name: collections[0].displayName, + }) + ); + rerender(); + } catch (error) { + reportError(error); + } + } + + async function runFirstSyncPreview(account: CalDavAccountSettings): Promise { + if (!plugin.caldavSyncService) { + showNotice(translate("settings.integrations.caldav.notices.reloadRequired")); + return; + } + if (!account.collectionUrl) { + showNotice(translate("settings.integrations.caldav.notices.noCollectionSelected")); + return; + } + + try { + const plan = await plugin.caldavSyncService.previewFirstSync(account.id); + const summary = summarizeFirstSyncPlan(plan); + + // The first sync is the one destructive moment: a mis-scoped filter or + // a wrong collection is cheap to catch here and expensive afterwards. + const confirmed = await showConfirmationModal(plugin.app, { + title: translate("settings.integrations.caldav.firstSync.title"), + message: translate("settings.integrations.caldav.firstSync.summary", { + upload: summary.upload, + import: summary.import, + link: summary.link, + resolve: summary.resolve, + }), + confirmText: translate("settings.integrations.caldav.firstSync.confirm"), + }); + if (!confirmed) return; + + await plugin.caldavSyncService.applyFirstSync(account.id, plan); + account.initialSyncCompleted = true; + save(); + showNotice(translate("settings.integrations.caldav.notices.firstSyncComplete")); + } catch (error) { + reportError(error); + } + } + + async function removeAccount(account: CalDavAccountSettings): Promise { + const confirmed = await showConfirmationModal(plugin.app, { + title: translate("settings.integrations.caldav.remove.title"), + message: translate("settings.integrations.caldav.remove.message", { + name: account.name || account.id, + }), + confirmText: translate("settings.integrations.caldav.remove.confirm"), + isDestructive: true, + }); + if (!confirmed) return; + + secretStore.clearCredentials(account.id); + plugin.settings.caldav.accounts = plugin.settings.caldav.accounts.filter( + (candidate) => candidate.id !== account.id + ); + save(); + rerender(); + } + + function reportError(error: unknown): void { + tasknotesLogger.error("CalDAV settings action failed", { + category: "provider", + operation: "caldav-settings-action", + error, + }); + + if (error instanceof CalDavError && error.kind === "auth") { + showNotice(translate("settings.integrations.caldav.notices.authFailed")); + return; + } + showNotice(translate("settings.integrations.caldav.notices.connectionFailed")); + } +} diff --git a/src/settings/tabs/integrationsTab.ts b/src/settings/tabs/integrationsTab.ts index 7d9083238..2e16d227b 100644 --- a/src/settings/tabs/integrationsTab.ts +++ b/src/settings/tabs/integrationsTab.ts @@ -33,6 +33,7 @@ import { type CardSection, } from "../components/CardComponent"; import { createTaskNotesLogger } from "../../utils/tasknotesLogger"; +import { renderCalDavSection } from "./caldavSection"; const tasknotesLogger = createTaskNotesLogger({ tag: "Settings/Tabs/IntegrationsTab" }); @@ -1443,6 +1444,11 @@ export function renderIntegrationsTab( } ); + // CalDAV two-way task sync + renderCalDavSection(container, plugin, save, translate, () => + renderIntegrationsTab(container, plugin, save) + ); + // ICS Subscriptions List - buttons first, then cards createSettingGroup( container, diff --git a/src/types/settings.ts b/src/types/settings.ts index 72926c30c..c9638d72c 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,4 +1,11 @@ -import { FieldMapping, StatusConfig, PriorityConfig, SavedView, WebhookConfig } from "../types"; +import { + FieldMapping, + FilterGroup, + StatusConfig, + PriorityConfig, + SavedView, + WebhookConfig, +} from "../types"; import type { FileFilterConfig } from "../suggest/FileSuggestHelper"; export interface UserFieldMapping { @@ -257,6 +264,8 @@ export interface TaskNotesSettings { microsoftCalendarSyncTokens: Record; // Maps calendar ID to delta link // Google Calendar task export settings googleCalendarExport: GoogleCalendarExportSettings; + // Two-way CalDAV VTODO sync + caldav: CalDavSettings; // Debug logging enableDebugLogging: boolean; } @@ -344,6 +353,54 @@ export interface GoogleCalendarExportSettings { defaultReminderMinutes: number | number[] | null; // Popup reminder(s) X minutes before event (null = no reminder) } +/** + * What happens locally when a task's VTODO disappears from the server. + * + * Defaults to `archive`: nothing is destroyed, but the task leaves the active + * list. Deleting notes is not reversible from inside Obsidian, so it is opt-in. + */ +export type CalDavRemoteDeletionPolicy = "archive" | "delete" | "unlink"; + +/** VTODO STATUS values, for the per-status override table. */ +export type CalDavVTodoStatus = "NEEDS-ACTION" | "IN-PROCESS" | "COMPLETED" | "CANCELLED"; + +/** + * One CalDAV collection synced as a task list. + * + * Credentials are deliberately absent: only the username is stored here, and + * the password lives in Obsidian's SecretStorage via CalDavSecretStore. + */ +export interface CalDavAccountSettings { + id: string; // Stable id, also namespaces the stored secret + name: string; // User-facing label + enabled: boolean; + serverUrl: string; // Base URL used for discovery + collectionUrl: string; // The chosen VTODO collection + username: string; // Non-secret half of the credentials + syncIntervalMinutes: number; // Poll interval for inbound changes + /** Membership rule; undefined or empty means every task. */ + filter?: FilterGroup; + taskFolder: string; // Where tasks created from remote VTODOs are written + /** Overrides the status mapping auto-derived from StatusConfig flags. */ + statusOverrides: Record; + remoteDeletionPolicy: CalDavRemoteDeletionPolicy; + /** Set once the user has confirmed the first-sync preview for this account. */ + initialSyncCompleted: boolean; +} + +/** + * Two-way CalDAV VTODO sync. Independent of the OAuth calendar integration, + * which syncs tasks as calendar events rather than as task-list entries. + */ +export interface CalDavSettings { + enabled: boolean; // Master switch + accounts: CalDavAccountSettings[]; + /** Push local edits as they happen rather than waiting for the poll. */ + pushOnChange: boolean; + /** Debounce before an edit is pushed, so a burst of keystrokes is one write. */ + pushDebounceMs: number; +} + export type TimeblockAttachmentSearchOrder = | "name-asc" | "name-desc" diff --git a/tests/services/CalDavSyncService.test.ts b/tests/services/CalDavSyncService.test.ts new file mode 100644 index 000000000..e3aa451d7 --- /dev/null +++ b/tests/services/CalDavSyncService.test.ts @@ -0,0 +1,591 @@ +import { TFile } from "obsidian"; + +import { CalDavSyncService } from "../../src/services/CalDavSyncService"; +import { CALDAV_FRONTMATTER_KEYS } from "../../src/services/caldav/caldavFingerprint"; +import { DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "../../src/settings/defaults"; +import { parseVTodoDocument, getTextProperty } from "../../src/services/caldav/vtodoDocument"; +import type { TaskInfo } from "../../src/types"; + +/** + * Builds a plugin stand-in with an in-memory vault of one task file, plus a + * fake CalDAV client so no network is involved. + */ +function makeHarness( + options: { + frontmatter?: Record; + task?: Partial; + remoteDeletionPolicy?: "archive" | "delete" | "unlink"; + } = {} +) { + const path = "Tasks/buy-groceries.md"; + const frontmatter: Record = { ...(options.frontmatter ?? {}) }; + + const task: TaskInfo = { + title: "Buy groceries", + status: "open", + priority: "normal", + path, + archived: false, + dateModified: "2025-09-01T12:00:00.000Z", + ...options.task, + }; + + const file = Object.assign(Object.create(TFile.prototype) as TFile, { + path, + basename: "buy-groceries", + extension: "md", + stat: { mtime: Date.parse("2025-09-01T12:00:00.000Z"), ctime: 0, size: 0 }, + }); + + const pluginData: Record = {}; + + const client = { + getResource: jest.fn().mockResolvedValue(null), + putResource: jest.fn().mockResolvedValue({ etag: "etag-1", conflict: false }), + deleteResource: jest.fn().mockResolvedValue({ deleted: true, conflict: false }), + fetchAllVTodos: jest.fn().mockResolvedValue([]), + fetchResources: jest.fn().mockResolvedValue([]), + syncCollection: jest + .fn() + .mockResolvedValue({ changed: [], removed: [], usedFallback: false }), + getCollectionTag: jest.fn().mockResolvedValue({ ctag: "ctag-1", syncToken: "token-1" }), + }; + + const taskService = { + updateTask: jest.fn().mockResolvedValue(task), + createTask: jest.fn().mockResolvedValue({ file, taskInfo: task }), + toggleArchive: jest.fn().mockResolvedValue({ ...task, archived: true }), + deleteTask: jest.fn().mockResolvedValue(undefined), + }; + + const plugin = { + settings: { + caldav: { + enabled: true, + pushOnChange: true, + pushDebounceMs: 100, + accounts: [ + { + id: "work", + name: "Work", + enabled: true, + serverUrl: "https://cloud.example.com", + collectionUrl: "https://cloud.example.com/cal/tasks/", + username: "fabian", + syncIntervalMinutes: 15, + taskFolder: "", + statusOverrides: {}, + remoteDeletionPolicy: options.remoteDeletionPolicy ?? "archive", + initialSyncCompleted: true, + }, + ], + }, + customStatuses: DEFAULT_STATUSES, + customPriorities: DEFAULT_PRIORITIES, + userFields: [], + }, + app: { + secretStorage: { + getSecret: jest.fn(() => + JSON.stringify({ + version: 1, + state: "configured", + credentials: { username: "fabian", password: "pw" }, + }) + ), + setSecret: jest.fn(), + }, + vault: { + getAbstractFileByPath: jest.fn((candidate: string) => + candidate === path ? file : null + ), + }, + metadataCache: { + getFileCache: jest.fn(() => ({ frontmatter })), + }, + fileManager: { + processFrontMatter: jest.fn( + async (_file: TFile, update: (fm: Record) => void) => { + update(frontmatter); + } + ), + }, + }, + cacheManager: { + getTaskInfo: jest.fn(async (candidate: string) => + candidate === path ? task : null + ), + getAllTasks: jest.fn(async () => [task]), + }, + taskService, + statusManager: { + getCompletedStatuses: () => ["done"], + isCompletedStatus: (status: string) => status === "done", + }, + emitter: { trigger: jest.fn() }, + // The real implementations re-parse data.json on every read, so hand out + // a copy: returning the live object would let saveData's clear-and-assign + // wipe the very document it was given. + loadData: jest.fn(async () => ({ ...pluginData })), + loadPluginDataForSafeWrite: jest.fn(async () => ({ ...pluginData })), + saveData: jest.fn(async (data: Record) => { + for (const key of Object.keys(pluginData)) delete pluginData[key]; + Object.assign(pluginData, data); + }), + }; + + const service = new CalDavSyncService(plugin as never); + // Isolate from the network the same way the Google sync tests do. + (service as unknown as { createClient: () => unknown }).createClient = () => client; + + return { service, plugin, client, taskService, frontmatter, task, path, pluginData }; +} + +describe("CalDavSyncService push", () => { + it("uploads a new task and stamps the sync metadata", async () => { + const { service, client, frontmatter, path } = makeHarness(); + + await service.pushTask("work", path); + + expect(client.putResource).toHaveBeenCalledTimes(1); + const [url, body, options] = client.putResource.mock.calls[0]; + expect(url).toMatch(/^https:\/\/cloud\.example\.com\/cal\/tasks\/.+\.ics$/u); + // A first push must not clobber an existing resource at that href. + expect(options).toEqual({ ifNoneMatch: "*" }); + + const doc = parseVTodoDocument(body as string)!; + expect(getTextProperty(doc, "SUMMARY")).toBe("Buy groceries"); + + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.etag]).toBe("etag-1"); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.account]).toBe("work"); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.uid]).toBeDefined(); + }); + + it("does not put the vault path in the UID", async () => { + // The collection may be shared, so the UID must not leak local structure. + const { service, frontmatter, path } = makeHarness(); + await service.pushTask("work", path); + expect(String(frontmatter[CALDAV_FRONTMATTER_KEYS.uid])).not.toContain("Tasks"); + }); + + it("sends If-Match once the task has a stored ETag", async () => { + const { service, client, path } = makeHarness({ + frontmatter: { + [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1", + [CALDAV_FRONTMATTER_KEYS.href]: "https://cloud.example.com/cal/tasks/uid-1.ics", + [CALDAV_FRONTMATTER_KEYS.etag]: "etag-0", + [CALDAV_FRONTMATTER_KEYS.account]: "work", + }, + }); + client.getResource.mockResolvedValue({ + url: "https://cloud.example.com/cal/tasks/uid-1.ics", + etag: "etag-0", + data: [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:uid-1", + "SUMMARY:Old", + "X-PHONE-ONLY:keep-me", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"), + }); + + await service.pushTask("work", path); + + expect(client.putResource.mock.calls[0][2]).toEqual({ ifMatch: "etag-0" }); + }); + + it("preserves remote properties it does not model", async () => { + const { service, client, path } = makeHarness({ + frontmatter: { + [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1", + [CALDAV_FRONTMATTER_KEYS.href]: "https://cloud.example.com/cal/tasks/uid-1.ics", + [CALDAV_FRONTMATTER_KEYS.etag]: "etag-0", + }, + }); + client.getResource.mockResolvedValue({ + url: "https://cloud.example.com/cal/tasks/uid-1.ics", + etag: "etag-0", + data: [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:uid-1", + "SUMMARY:Old", + "DESCRIPTION:Written on the phone", + "X-PHONE-ONLY:keep-me", + "BEGIN:VALARM", + "TRIGGER:-PT15M", + "END:VALARM", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"), + }); + + await service.pushTask("work", path); + + const body = client.putResource.mock.calls[0][1] as string; + expect(body).toContain("X-PHONE-ONLY:keep-me"); + expect(body).toContain("DESCRIPTION:Written on the phone"); + expect(body).toContain("BEGIN:VALARM"); + expect(body).toContain("SUMMARY:Buy groceries"); + }); +}); + +describe("CalDavSyncService loop prevention", () => { + it("ignores a change whose sync-relevant content is unchanged", async () => { + const { service, plugin, path, task } = makeHarness(); + + // First pass records the fingerprint. + await service.pushTask("work", path); + const putCallsAfterPush = 1; + + // Now simulate the file-updated event that our own metadata write fires. + await service.handleTaskFileUpdated(path, task); + + // No push was scheduled, so nothing further was written. + expect(plugin.saveData).toHaveBeenCalled(); + expect( + (service as unknown as { pushTimers: Map }).pushTimers.size + ).toBe(0); + expect(putCallsAfterPush).toBe(1); + }); + + it("ignores a file it is currently writing itself", async () => { + const { service, path, task } = makeHarness(); + (service as unknown as { handlingPaths: Set }).handlingPaths.add(path); + + await service.handleTaskFileUpdated(path, task); + + expect( + (service as unknown as { pushTimers: Map }).pushTimers.size + ).toBe(0); + }); + + it("schedules a push for a genuine content edit", async () => { + const { service, path, task } = makeHarness(); + + await service.handleTaskFileUpdated(path, { ...task, title: "Something new" }); + + expect( + (service as unknown as { pushTimers: Map }).pushTimers.size + ).toBe(1); + service.destroy(); + }); +}); + +describe("CalDavSyncService remote deletion", () => { + const linkedFrontmatter = { + [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1", + [CALDAV_FRONTMATTER_KEYS.href]: "https://cloud.example.com/cal/tasks/uid-1.ics", + [CALDAV_FRONTMATTER_KEYS.etag]: "etag-0", + [CALDAV_FRONTMATTER_KEYS.account]: "work", + }; + + async function runDeletionPass(policy: "archive" | "delete" | "unlink") { + const harness = makeHarness({ + frontmatter: { ...linkedFrontmatter }, + remoteDeletionPolicy: policy, + }); + // A VTODO-filtered query returns the whole task list, so a linked task + // missing from it is a remote deletion. + harness.client.fetchAllVTodos.mockResolvedValue([]); + + await harness.service.syncAccount("work"); + return harness; + } + + it("archives the note by default and strips its sync metadata", async () => { + const { taskService, frontmatter } = await runDeletionPass("archive"); + + expect(taskService.toggleArchive).toHaveBeenCalledTimes(1); + expect(taskService.deleteTask).not.toHaveBeenCalled(); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.uid]).toBeUndefined(); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.etag]).toBeUndefined(); + }); + + it("deletes the note only when explicitly configured", async () => { + const { taskService } = await runDeletionPass("delete"); + + expect(taskService.deleteTask).toHaveBeenCalledTimes(1); + expect(taskService.toggleArchive).not.toHaveBeenCalled(); + }); + + it("unlinks without archiving or deleting", async () => { + const { taskService, frontmatter } = await runDeletionPass("unlink"); + + expect(taskService.deleteTask).not.toHaveBeenCalled(); + expect(taskService.toggleArchive).not.toHaveBeenCalled(); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.uid]).toBeUndefined(); + }); +}); + +describe("CalDavSyncService local deletion", () => { + it("deletes the remote VTODO when the task file is removed", async () => { + const { service, client } = makeHarness(); + + await service.handleTaskFileDeleted("Tasks/buy-groceries.md", { + [CALDAV_FRONTMATTER_KEYS.account]: "work", + [CALDAV_FRONTMATTER_KEYS.href]: "https://cloud.example.com/cal/tasks/uid-1.ics", + [CALDAV_FRONTMATTER_KEYS.etag]: "etag-0", + }); + + expect(client.deleteResource).toHaveBeenCalledWith( + "https://cloud.example.com/cal/tasks/uid-1.ics", + { ifMatch: "etag-0" } + ); + }); + + it("does nothing for a task that was never synced", async () => { + const { service, client } = makeHarness(); + await service.handleTaskFileDeleted("Tasks/other.md", {}); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); +}); + +describe("CalDavSyncService first sync", () => { + it("previews without writing anything", async () => { + const { service, client, taskService, plugin } = makeHarness(); + client.fetchAllVTodos.mockResolvedValue([ + { + url: "https://cloud.example.com/cal/tasks/remote.ics", + etag: "e-remote", + data: [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:remote-uid", + "SUMMARY:From the server", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"), + }, + ]); + + const plan = await service.previewFirstSync("work"); + + expect(plan.toImport).toHaveLength(1); + expect(plan.toUpload).toHaveLength(1); // the local task has no UID yet + expect(client.putResource).not.toHaveBeenCalled(); + expect(taskService.createTask).not.toHaveBeenCalled(); + expect(plugin.saveData).not.toHaveBeenCalled(); + }); + + it("creates imported tasks through the normal creation path", async () => { + const { service, client, taskService } = makeHarness(); + client.fetchAllVTodos.mockResolvedValue([ + { + url: "https://cloud.example.com/cal/tasks/remote.ics", + etag: "e-remote", + data: [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:remote-uid", + "SUMMARY:From the server", + "DUE;VALUE=DATE:20250910", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"), + }, + ]); + + const plan = await service.previewFirstSync("work"); + await service.applyFirstSync("work", { ...plan, toUpload: [] }); + + expect(taskService.createTask).toHaveBeenCalledTimes(1); + const created = taskService.createTask.mock.calls[0][0]; + expect(created.title).toBe("From the server"); + expect(created.due).toBe("2025-09-10"); + // completeTaskData drops unknown fields, so these must ride along as + // custom frontmatter or the link is lost. + expect(created.customFrontmatter[CALDAV_FRONTMATTER_KEYS.uid]).toBe("remote-uid"); + expect(created.customFrontmatter[CALDAV_FRONTMATTER_KEYS.account]).toBe("work"); + }); +}); + +describe("CalDavSyncService import fidelity", () => { + it("does not invent dates the remote task never had", async () => { + // The vault's creation defaults schedule new tasks for today. Letting + // that apply to an import would write a date back onto the user's remote + // task on the next push. + const { service, client, taskService } = makeHarness(); + client.fetchAllVTodos.mockResolvedValue([ + { + url: "https://cloud.example.com/cal/tasks/remote-1.ics", + etag: "etag-r", + data: [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:remote-1", + "SUMMARY:No dates here", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"), + }, + ]); + + await service.syncAccount("work", { force: true }); + + const created = taskService.createTask.mock.calls.at(-1)?.[0] as Record; + expect(created.title).toBe("No dates here"); + expect(created.due).toBe(""); + expect(created.scheduled).toBe(""); + }); +}); + +describe("CalDavSyncService polling", () => { + it("skips the whole poll when the collection tag has not moved", async () => { + const { service, client } = makeHarness(); + await service.syncAccount("work"); + client.fetchAllVTodos.mockClear(); + + // Second pass sees the same ctag: nothing should be downloaded. + await service.syncAccount("work"); + + expect(client.getCollectionTag).toHaveBeenCalledTimes(2); + expect(client.fetchAllVTodos).not.toHaveBeenCalled(); + }); + + it("polls again once the tag changes", async () => { + const { service, client } = makeHarness(); + await service.syncAccount("work"); + + client.getCollectionTag.mockResolvedValue({ ctag: "ctag-2", syncToken: "token-2" }); + client.fetchAllVTodos.mockClear(); + await service.syncAccount("work"); + + expect(client.fetchAllVTodos).toHaveBeenCalledTimes(1); + }); + + it("never fans out over the collection's non-task resources", async () => { + // Task lists commonly share a collection with far more VEVENTs; pulling + // every resource body to find the todos is the thing to avoid. + const { service, client } = makeHarness(); + await service.syncAccount("work", { force: true }); + + expect(client.fetchResources).not.toHaveBeenCalled(); + expect(client.syncCollection).not.toHaveBeenCalled(); + }); +}); + +describe("CalDavSyncService persistence safety", () => { + it("does not write sync state when data.json could not be read", async () => { + const { service, plugin, client } = makeHarness(); + // null is the "exists but unreadable" signal; writing anyway would + // persist a document built from nothing and wipe every setting. + plugin.loadPluginDataForSafeWrite.mockResolvedValue(null); + + await service.syncAccount("work", { force: true }); + + expect(client.fetchAllVTodos).toHaveBeenCalled(); + expect(plugin.saveData).not.toHaveBeenCalled(); + }); +}); + +describe("CalDavSyncService retry queue", () => { + async function failPush() { + const harness = makeHarness(); + harness.client.putResource.mockRejectedValue(new Error("network down")); + harness.client.getResource.mockResolvedValue(null); + await expect(harness.service.pushTask("work", harness.path)).rejects.toThrow(); + return harness; + } + + it("queues a push that failed so the edit is not lost", async () => { + const harness = await failPush(); + await ( + harness.service as unknown as { + enqueueRetry: (a: string, p: string, e: unknown) => Promise; + } + ).enqueueRetry("work", harness.path, new Error("network down")); + + const queue = harness.pluginData.caldavSyncQueue as { taskPath: string }[]; + expect(queue).toHaveLength(1); + expect(queue[0].taskPath).toBe(harness.path); + }); + + it("clears the entry once the retry succeeds", async () => { + const harness = await failPush(); + await ( + harness.service as unknown as { + enqueueRetry: (a: string, p: string, e: unknown) => Promise; + } + ).enqueueRetry("work", harness.path, new Error("network down")); + + harness.client.putResource.mockResolvedValue({ etag: "etag-2", conflict: false }); + await harness.service.drainRetryQueue(); + + expect(harness.pluginData.caldavSyncQueue).toEqual([]); + }); + + it("gives up after repeated failures rather than retrying forever", async () => { + const harness = await failPush(); + await ( + harness.service as unknown as { + enqueueRetry: (a: string, p: string, e: unknown) => Promise; + } + ).enqueueRetry("work", harness.path, new Error("network down")); + + for (let attempt = 0; attempt < 6; attempt++) { + await harness.service.drainRetryQueue(); + } + + expect(harness.pluginData.caldavSyncQueue).toEqual([]); + }); +}); + +describe("CalDavSyncService unlink", () => { + it("strips the sync metadata but leaves the note's own fields alone", async () => { + const { service, frontmatter, taskService } = makeHarness({ + frontmatter: { + [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1", + [CALDAV_FRONTMATTER_KEYS.href]: "https://cloud.example.com/cal/tasks/uid-1.ics", + [CALDAV_FRONTMATTER_KEYS.etag]: "etag-0", + [CALDAV_FRONTMATTER_KEYS.account]: "work", + projects: ["[[Renovation]]"], + }, + }); + + await service.unlinkAllTasks(); + + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.uid]).toBeUndefined(); + expect(frontmatter[CALDAV_FRONTMATTER_KEYS.href]).toBeUndefined(); + expect(frontmatter.projects).toEqual(["[[Renovation]]"]); + expect(taskService.deleteTask).not.toHaveBeenCalled(); + }); + + it("does not delete anything on the server", async () => { + const { service, client } = makeHarness({ + frontmatter: { [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1" }, + }); + await service.unlinkAllTasks(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it("leaves the fingerprint in place so the task is not instantly re-pushed", async () => { + // Forgetting it would make every unlinked task look freshly edited, and + // push-on-change would re-upload it under a new UID. + const { service, plugin, path } = makeHarness({ + frontmatter: { [CALDAV_FRONTMATTER_KEYS.uid]: "uid-1" }, + }); + + await service.unlinkAllTasks(); + const fingerprints = plugin.loadData.mock.calls.length + ? ((await plugin.loadData()) as Record>) + : {}; + + expect(fingerprints.caldavTaskFingerprints?.[path]).toBeDefined(); + }); +}); + +describe("CalDavSyncService gating", () => { + it("does nothing when the integration is disabled", async () => { + const { service, plugin, path, task } = makeHarness(); + plugin.settings.caldav.enabled = false; + + await service.handleTaskFileUpdated(path, task); + + expect( + (service as unknown as { pushTimers: Map }).pushTimers.size + ).toBe(0); + }); +}); diff --git a/tests/unit/caldav/CalDavClient.test.ts b/tests/unit/caldav/CalDavClient.test.ts new file mode 100644 index 000000000..27d41367a --- /dev/null +++ b/tests/unit/caldav/CalDavClient.test.ts @@ -0,0 +1,461 @@ +import type { RequestUrlParam, RequestUrlResponse } from "obsidian"; +import { + assertCredentialsAreSafeToSend, + basicAuthHeader, + CalDavClient, + CalDavError, + type CalDavRequestFn, +} from "../../../src/services/CalDavClient"; + +const credentials = { username: "fabian", password: "app-password" }; + +function response( + status: number, + body = "", + headers: Record = {} +): RequestUrlResponse { + return { + status, + headers, + text: body, + json: undefined, + arrayBuffer: new ArrayBuffer(0), + } as unknown as RequestUrlResponse; +} + +/** Records every request and replies from a queue of canned responses. */ +function recorder(replies: Array<(params: RequestUrlParam) => RequestUrlResponse>) { + const calls: RequestUrlParam[] = []; + let index = 0; + const requestFn: CalDavRequestFn = async (params) => { + calls.push(params); + const reply = replies[Math.min(index, replies.length - 1)]; + index++; + return reply(params); + }; + return { calls, requestFn }; +} + +function makeClient(requestFn: CalDavRequestFn, serverUrl = "https://cloud.example.com") { + return new CalDavClient({ + serverUrl, + credentials, + requestFn, + sleepFn: async () => undefined, // never actually wait out the backoff + }); +} + +const MULTISTATUS_ETAGS = ` + /cal/tasks/ + + HTTP/1.1 200 OK + /cal/tasks/1.ics + "e1" + HTTP/1.1 200 OK +`; + +describe("credential safety", () => { + it("accepts https", () => { + expect(() => assertCredentialsAreSafeToSend("https://cloud.example.com")).not.toThrow(); + }); + + it("refuses plain http to a remote host", () => { + expect(() => assertCredentialsAreSafeToSend("http://cloud.example.com")).toThrow( + /unencrypted/iu + ); + }); + + it("allows http on loopback so a local Radicale can be used", () => { + for (const url of ["http://localhost:5232", "http://127.0.0.1:5232"]) { + expect(() => assertCredentialsAreSafeToSend(url)).not.toThrow(); + } + }); + + it("rejects a malformed URL", () => { + expect(() => assertCredentialsAreSafeToSend("not a url")).toThrow(CalDavError); + }); + + it("is enforced by the constructor", () => { + expect( + () => + new CalDavClient({ + serverUrl: "http://cloud.example.com", + credentials, + requestFn: async () => response(200), + }) + ).toThrow(/unencrypted/iu); + }); +}); + +describe("basicAuthHeader", () => { + it("encodes username and password", () => { + expect(basicAuthHeader({ username: "user", password: "pass" })).toBe( + `Basic ${btoa("user:pass")}` + ); + }); + + it("handles a non-ASCII password without throwing", () => { + // btoa is Latin-1 only, so this would throw without the UTF-8 step. + expect(() => + basicAuthHeader({ username: "fabian", password: "pässwörd–ü" }) + ).not.toThrow(); + }); +}); + +describe("authentication header", () => { + it("is sent on every request", async () => { + const { calls, requestFn } = recorder([() => response(207, MULTISTATUS_ETAGS)]); + await makeClient(requestFn).listCollectionEtags("https://cloud.example.com/cal/tasks/"); + + expect(calls[0].headers?.Authorization).toBe(basicAuthHeader(credentials)); + }); +}); + +describe("listCollectionEtags", () => { + it("returns resources and excludes the collection itself", async () => { + const { requestFn } = recorder([() => response(207, MULTISTATUS_ETAGS)]); + const result = await makeClient(requestFn).listCollectionEtags( + "https://cloud.example.com/cal/tasks/" + ); + + expect(result.usedFallback).toBe(true); + expect(result.changed).toEqual([ + { url: "https://cloud.example.com/cal/tasks/1.ics", etag: "e1" }, + ]); + }); + + it("sends PROPFIND with Depth 1", async () => { + const { calls, requestFn } = recorder([() => response(207, MULTISTATUS_ETAGS)]); + await makeClient(requestFn).listCollectionEtags("https://cloud.example.com/cal/tasks/"); + + expect(calls[0].method).toBe("PROPFIND"); + expect(calls[0].headers?.Depth).toBe("1"); + }); +}); + +describe("syncCollection", () => { + const SYNC_RESPONSE = ` + /cal/tasks/1.ics + "e2" + HTTP/1.1 200 OK + /cal/tasks/gone.ics + HTTP/1.1 404 Not Found + token-2 + `; + + it("reports changes, removals and the next token", async () => { + const { calls, requestFn } = recorder([() => response(207, SYNC_RESPONSE)]); + const result = await makeClient(requestFn).syncCollection( + "https://cloud.example.com/cal/tasks/", + "token-1" + ); + + expect(calls[0].method).toBe("REPORT"); + expect(calls[0].body).toContain("token-1"); + expect(result.usedFallback).toBe(false); + expect(result.syncToken).toBe("token-2"); + expect(result.changed).toEqual([ + { url: "https://cloud.example.com/cal/tasks/1.ics", etag: "e2" }, + ]); + expect(result.removed).toEqual(["https://cloud.example.com/cal/tasks/gone.ics"]); + }); + + it("falls back to an ETag listing when the server rejects the REPORT", async () => { + const { calls, requestFn } = recorder([ + () => response(400, "unsupported report"), + () => response(207, MULTISTATUS_ETAGS), + ]); + const result = await makeClient(requestFn).syncCollection( + "https://cloud.example.com/cal/tasks/" + ); + + expect(calls[0].method).toBe("REPORT"); + expect(calls[1].method).toBe("PROPFIND"); + expect(result.usedFallback).toBe(true); + expect(result.changed).toHaveLength(1); + }); + + it("falls back when the server answers 207 with nothing usable", async () => { + // Stalling forever on an empty delta would be worse than one extra listing. + const { requestFn } = recorder([ + () => response(207, ``), + () => response(207, MULTISTATUS_ETAGS), + ]); + const result = await makeClient(requestFn).syncCollection( + "https://cloud.example.com/cal/tasks/" + ); + expect(result.usedFallback).toBe(true); + }); +}); + +describe("putResource", () => { + it("sends If-Match with the stored ETag and returns the new one", async () => { + const { calls, requestFn } = recorder([() => response(204, "", { etag: '"e3"' })]); + const result = await makeClient(requestFn).putResource( + "https://cloud.example.com/cal/tasks/1.ics", + "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", + { ifMatch: "e2" } + ); + + expect(calls[0].method).toBe("PUT"); + expect(calls[0].headers?.["If-Match"]).toBe('"e2"'); + expect(calls[0].headers?.["Content-Type"]).toContain("text/calendar"); + expect(result).toEqual({ etag: "e3", conflict: false }); + }); + + it("reports a 412 as a conflict rather than throwing", async () => { + // This is the detection half of the conflict rule. + const { requestFn } = recorder([() => response(412)]); + const result = await makeClient(requestFn).putResource( + "https://cloud.example.com/cal/tasks/1.ics", + "body", + { ifMatch: "stale" } + ); + expect(result.conflict).toBe(true); + expect(result.etag).toBeUndefined(); + }); + + it("uses If-None-Match on a first push so an existing resource is not clobbered", async () => { + const { calls, requestFn } = recorder([() => response(201, "", { ETag: "W/\"new\"" })]); + const result = await makeClient(requestFn).putResource( + "https://cloud.example.com/cal/tasks/new.ics", + "body", + { ifNoneMatch: "*" } + ); + + expect(calls[0].headers?.["If-None-Match"]).toBe("*"); + expect(calls[0].headers?.["If-Match"]).toBeUndefined(); + // Weak validator and quotes are stripped so it compares equal later. + expect(result.etag).toBe("new"); + }); + + it("reads the ETag header case-insensitively", async () => { + const { requestFn } = recorder([() => response(204, "", { ETAG: '"shouty"' })]); + const result = await makeClient(requestFn).putResource( + "https://cloud.example.com/cal/tasks/1.ics", + "body" + ); + expect(result.etag).toBe("shouty"); + }); +}); + +describe("deleteResource", () => { + it("deletes with If-Match", async () => { + const { calls, requestFn } = recorder([() => response(204)]); + const result = await makeClient(requestFn).deleteResource( + "https://cloud.example.com/cal/tasks/1.ics", + { ifMatch: "e1" } + ); + + expect(calls[0].method).toBe("DELETE"); + expect(calls[0].headers?.["If-Match"]).toBe('"e1"'); + expect(result).toEqual({ deleted: true, conflict: false }); + }); + + it("treats an already-missing resource as a non-error", async () => { + const { requestFn } = recorder([() => response(404)]); + expect( + await makeClient(requestFn).deleteResource("https://cloud.example.com/cal/tasks/1.ics") + ).toEqual({ deleted: false, conflict: false }); + }); + + it("reports a 412 as a conflict", async () => { + const { requestFn } = recorder([() => response(412)]); + expect( + await makeClient(requestFn).deleteResource( + "https://cloud.example.com/cal/tasks/1.ics", + { ifMatch: "stale" } + ) + ).toEqual({ deleted: false, conflict: true }); + }); +}); + +describe("getResource", () => { + it("returns the body and ETag", async () => { + const { requestFn } = recorder([ + () => response(200, "BEGIN:VCALENDAR", { etag: '"e9"' }), + ]); + const result = await makeClient(requestFn).getResource( + "https://cloud.example.com/cal/tasks/1.ics" + ); + expect(result).toMatchObject({ etag: "e9", data: "BEGIN:VCALENDAR" }); + }); + + it("returns null when the resource is gone", async () => { + const { requestFn } = recorder([() => response(404)]); + expect( + await makeClient(requestFn).getResource("https://cloud.example.com/cal/tasks/1.ics") + ).toBeNull(); + }); +}); + +describe("error handling", () => { + it("classifies 401 as an auth error", async () => { + const { requestFn } = recorder([() => response(401)]); + await expect( + makeClient(requestFn).getResource("https://cloud.example.com/cal/tasks/1.ics") + ).rejects.toMatchObject({ kind: "auth", status: 401 }); + }); + + it("classifies 500 as a server error after exhausting retries", async () => { + const { calls, requestFn } = recorder([() => response(500)]); + await expect( + makeClient(requestFn).getResource("https://cloud.example.com/cal/tasks/1.ics") + ).rejects.toMatchObject({ kind: "server" }); + expect(calls).toHaveLength(4); // initial attempt plus three retries + }); + + it("retries a 429 and succeeds", async () => { + let attempts = 0; + const requestFn: CalDavRequestFn = async () => { + attempts++; + return attempts < 3 ? response(429) : response(200, "ok", { etag: '"e"' }); + }; + const result = await makeClient(requestFn).getResource( + "https://cloud.example.com/cal/tasks/1.ics" + ); + expect(attempts).toBe(3); + expect(result?.data).toBe("ok"); + }); + + it("does not retry a 404", async () => { + const { calls, requestFn } = recorder([() => response(404)]); + await makeClient(requestFn).getResource("https://cloud.example.com/cal/tasks/1.ics"); + expect(calls).toHaveLength(1); + }); + + it("wraps a transport failure as a network error", async () => { + const requestFn: CalDavRequestFn = async () => { + throw new Error("ENOTFOUND"); + }; + await expect( + makeClient(requestFn).getResource("https://cloud.example.com/cal/tasks/1.ics") + ).rejects.toMatchObject({ kind: "network" }); + }); + + it("does not leak credentials in the error message", async () => { + const { requestFn } = recorder([() => response(401, "user fabian rejected")]); + const error = await makeClient(requestFn) + .getResource("https://cloud.example.com/cal/tasks/1.ics") + .catch((caught: CalDavError) => caught); + + expect((error as CalDavError).message).not.toContain(credentials.password); + }); +}); + +describe("discoverCollections", () => { + const PRINCIPAL = `/ + /principals/fabian/ + HTTP/1.1 200 OK + `; + + // What Nextcloud actually answers for a path that has no principal: a 207 + // whose propstat is 404, not an HTTP error. + const NO_PRINCIPAL = `/ + + HTTP/1.1 404 Not Found + `; + + const HOME_SET = ` + /principals/fabian/ + /calendars/fabian/ + HTTP/1.1 200 OK`; + + const COLLECTIONS = ` + /calendars/fabian/tasks/ + Tasks + + + HTTP/1.1 200 OK + /calendars/fabian/events/ + Events + + + HTTP/1.1 200 OK + `; + + it("walks the discovery chain and keeps only VTODO collections", async () => { + const { calls, requestFn } = recorder([ + () => response(207, PRINCIPAL), + () => response(207, HOME_SET), + () => response(207, COLLECTIONS), + ]); + const collections = await makeClient(requestFn).discoverCollections(); + + // The configured URL is tried first: it is the one address the user + // actually vouched for, and it keeps the scheme they chose. + expect(calls[0].url).toBe("https://cloud.example.com"); + expect(collections).toEqual([ + { + url: "https://cloud.example.com/calendars/fabian/tasks/", + displayName: "Tasks", + }, + ]); + }); + + it("climbs to ancestor paths when the configured URL has no principal", async () => { + // Pasting the files endpoint or a collection URL is common, and the + // principal usually lives further up the tree. + const { calls, requestFn } = recorder([ + () => response(207, NO_PRINCIPAL), + () => response(207, NO_PRINCIPAL), + () => response(207, PRINCIPAL), + () => response(207, HOME_SET), + () => response(207, COLLECTIONS), + ]); + await makeClient( + requestFn, + "https://cloud.example.com/remote.php/dav/files/fabian" + ).discoverCollections(); + + expect(calls.slice(0, 3).map((call) => call.url)).toEqual([ + "https://cloud.example.com/remote.php/dav/files/fabian", + "https://cloud.example.com/remote.php/dav/files/", + "https://cloud.example.com/remote.php/dav/", + ]); + }); + + it("tries well-known only after every ancestor path", async () => { + // requestUrl follows redirects with no way to veto a downgrade, and + // well-known commonly redirects to plain http behind a reverse proxy, + // so it must be the last place credentials are sent, never the first. + const { calls, requestFn } = recorder([ + () => response(207, NO_PRINCIPAL), + () => response(207, NO_PRINCIPAL), + () => response(207, NO_PRINCIPAL), + () => response(207, PRINCIPAL), + () => response(207, HOME_SET), + () => response(207, COLLECTIONS), + ]); + await makeClient(requestFn, "https://cloud.example.com/dav/cal/").discoverCollections(); + + const probed = calls.map((call) => call.url); + expect(probed.slice(0, 3)).toEqual([ + "https://cloud.example.com/dav/cal/", + "https://cloud.example.com/dav/", + "https://cloud.example.com/", + ]); + expect(probed[3]).toBe("https://cloud.example.com/.well-known/caldav"); + }); + + it("surfaces an auth failure immediately instead of trying other entry points", async () => { + const { calls, requestFn } = recorder([() => response(401)]); + await expect(makeClient(requestFn).discoverCollections()).rejects.toMatchObject({ + kind: "auth", + }); + expect(calls).toHaveLength(1); + }); + + it("falls back to well-known when no ancestor serves a principal", async () => { + const { calls, requestFn } = recorder([ + () => response(404), + () => response(207, PRINCIPAL), + () => response(207, HOME_SET), + () => response(207, COLLECTIONS), + ]); + const collections = await makeClient(requestFn).discoverCollections(); + + expect(calls[1].url).toBe("https://cloud.example.com/.well-known/caldav"); + expect(collections).toHaveLength(1); + }); +}); diff --git a/tests/unit/caldav/CalDavSecretStore.test.ts b/tests/unit/caldav/CalDavSecretStore.test.ts new file mode 100644 index 000000000..85bf2669c --- /dev/null +++ b/tests/unit/caldav/CalDavSecretStore.test.ts @@ -0,0 +1,158 @@ +import { + calDavSecretId, + CalDavSecretStore, +} from "../../../src/services/CalDavSecretStore"; + +/** Stand-in for Obsidian's synchronous SecretStorage. */ +function makeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getSecret: jest.fn((id: string) => values.get(id) ?? null), + setSecret: jest.fn((id: string, value: string) => { + values.set(id, value); + }), + }; +} + +describe("calDavSecretId", () => { + it("namespaces per account", () => { + expect(calDavSecretId("work")).toBe("tasknotes-caldav-work-credentials"); + }); + + it("sanitises characters that could escape the key namespace", () => { + expect(calDavSecretId("../../other")).toBe("tasknotes-caldav-other-credentials"); + expect(calDavSecretId("a b/c")).toBe("tasknotes-caldav-a-b-c-credentials"); + }); + + // Obsidian throws on anything outside this alphabet, which is what made + // storing a password fail for every generated account id. + it("emits only lowercase letters, digits and dashes", () => { + const ids = ["caldav_mtidlkvy", "Work Account", "ACCOUNT", "a..b__c", "-lead-"]; + for (const id of ids) { + expect(calDavSecretId(id)).toMatch(/^[a-z0-9-]+$/u); + } + }); + + it("never exceeds Obsidian's 64 character limit", () => { + expect(calDavSecretId("x".repeat(500)).length).toBeLessThanOrEqual(64); + }); + + it("keeps long ids that share a prefix in separate slots", () => { + const prefix = "account-".repeat(12); + expect(calDavSecretId(`${prefix}one`)).not.toBe(calDavSecretId(`${prefix}two`)); + }); + + it("falls back to a usable id when nothing survives sanitisation", () => { + expect(calDavSecretId("///")).toBe("tasknotes-caldav-account-credentials"); + }); +}); + +describe("CalDavSecretStore", () => { + it("reports missing credentials for an unknown account", () => { + const store = new CalDavSecretStore(makeStorage()); + expect(store.getCredentialsState("work")).toEqual({ status: "missing" }); + expect(store.getCredentials("work")).toBeNull(); + expect(store.hasCredentials("work")).toBe(false); + }); + + it("round-trips credentials", () => { + const store = new CalDavSecretStore(makeStorage()); + store.setCredentials("work", { username: "fabian", password: "app-pass" }); + + expect(store.getCredentials("work")).toEqual({ + username: "fabian", + password: "app-pass", + }); + expect(store.hasCredentials("work")).toBe(true); + }); + + it("keeps accounts isolated", () => { + const store = new CalDavSecretStore(makeStorage()); + store.setCredentials("work", { username: "w", password: "1" }); + store.setCredentials("home", { username: "h", password: "2" }); + + expect(store.getCredentials("work")?.username).toBe("w"); + expect(store.getCredentials("home")?.username).toBe("h"); + }); + + it("trims the username but preserves the password verbatim", () => { + // App-specific passwords are generated; whitespace in them can be real. + const store = new CalDavSecretStore(makeStorage()); + store.setCredentials("work", { username: " fabian ", password: " secret " }); + + expect(store.getCredentials("work")).toEqual({ + username: "fabian", + password: " secret ", + }); + }); + + it("refuses to store an empty username", () => { + const store = new CalDavSecretStore(makeStorage()); + expect(() => store.setCredentials("work", { username: " ", password: "x" })).toThrow( + /username/iu + ); + }); + + it("distinguishes cleared from missing", () => { + const store = new CalDavSecretStore(makeStorage()); + store.setCredentials("work", { username: "fabian", password: "p" }); + store.clearCredentials("work"); + + expect(store.getCredentialsState("work")).toEqual({ status: "cleared" }); + expect(store.getCredentials("work")).toBeNull(); + }); + + it("reports invalid rather than throwing on a corrupt envelope", () => { + const storage = makeStorage({ + "tasknotes-caldav-work-credentials": "{not json", + }); + expect(new CalDavSecretStore(storage).getCredentialsState("work")).toEqual({ + status: "invalid", + }); + }); + + it("reports invalid for an unknown envelope version", () => { + const storage = makeStorage({ + "tasknotes-caldav-work-credentials": JSON.stringify({ version: 2, state: "configured" }), + }); + expect(new CalDavSecretStore(storage).getCredentialsState("work")).toEqual({ + status: "invalid", + }); + }); + + it("reports invalid when the payload is missing a password", () => { + const storage = makeStorage({ + "tasknotes-caldav-work-credentials": JSON.stringify({ + version: 1, + state: "configured", + credentials: { username: "fabian" }, + }), + }); + expect(new CalDavSecretStore(storage).getCredentialsState("work")).toEqual({ + status: "invalid", + }); + }); + + it("throws when SecretStorage silently fails to persist", () => { + const storage = makeStorage(); + storage.setSecret.mockImplementation(() => { + /* drops the write */ + }); + expect(() => + new CalDavSecretStore(storage).setCredentials("work", { + username: "fabian", + password: "p", + }) + ).toThrow(/did not persist/iu); + }); + + it("never writes credentials anywhere but the namespaced secret id", () => { + const storage = makeStorage(); + new CalDavSecretStore(storage).setCredentials("work", { + username: "fabian", + password: "p", + }); + expect([...storage.values.keys()]).toEqual(["tasknotes-caldav-work-credentials"]); + }); +}); diff --git a/tests/unit/caldav/caldavReconciliation.test.ts b/tests/unit/caldav/caldavReconciliation.test.ts new file mode 100644 index 000000000..2a0bd0954 --- /dev/null +++ b/tests/unit/caldav/caldavReconciliation.test.ts @@ -0,0 +1,272 @@ +import { + hasUnsyncedLocalChange, + planFirstSync, + planIncrementalSync, + planRemoteDeletion, + resolveConflict, + summarizeFirstSyncPlan, + type LocalTaskSnapshot, + type RemoteTodoSnapshot, +} from "../../../src/services/caldav/caldavReconciliation"; + +function local(overrides: Partial = {}): LocalTaskSnapshot { + return { + path: "Tasks/a.md", + changedAtMs: 1000, + fingerprint: "fp", + syncedFingerprint: "fp", + ...overrides, + }; +} + +function remote(overrides: Partial = {}): RemoteTodoSnapshot { + return { + uid: "uid-1", + url: "https://s/cal/1.ics", + etag: "e1", + revisionMs: 1000, + ...overrides, + }; +} + +describe("resolveConflict", () => { + it("gives the win to the newer side", () => { + expect(resolveConflict(2000, 1000)).toBe("local"); + expect(resolveConflict(1000, 2000)).toBe("remote"); + }); + + it("breaks an exact tie deterministically in favour of the remote", () => { + // Every device sees the same server revision, so this converges; local + // clocks do not agree with each other. + expect(resolveConflict(1000, 1000)).toBe("remote"); + }); + + it("prefers the side whose edit time is actually known", () => { + expect(resolveConflict(null, 1000)).toBe("remote"); + expect(resolveConflict(1000, null)).toBe("local"); + }); + + it("falls back to the remote when neither side has a timestamp", () => { + expect(resolveConflict(null, null)).toBe("remote"); + }); +}); + +describe("hasUnsyncedLocalChange", () => { + it("is false when the fingerprint matches the last sync", () => { + expect(hasUnsyncedLocalChange(local())).toBe(false); + }); + + it("is true after a content edit", () => { + expect(hasUnsyncedLocalChange(local({ fingerprint: "changed" }))).toBe(true); + }); + + it("is true for a task that has never been synced", () => { + expect(hasUnsyncedLocalChange(local({ syncedFingerprint: undefined }))).toBe(true); + }); +}); + +describe("planFirstSync", () => { + it("uploads a local task the server has never seen", () => { + const plan = planFirstSync([local({ uid: undefined })], []); + expect(plan.toUpload).toHaveLength(1); + expect(plan.toImport).toHaveLength(0); + }); + + it("imports a VTODO with no local counterpart", () => { + const plan = planFirstSync([], [remote()]); + expect(plan.toImport).toEqual([remote()]); + }); + + it("links a matched pair that is already in agreement", () => { + const plan = planFirstSync([local({ uid: "uid-1", etag: "e1" })], [remote()]); + expect(plan.toLink).toHaveLength(1); + expect(plan.toResolve).toHaveLength(0); + expect(plan.toUpload).toHaveLength(0); + }); + + it("resolves a matched pair whose ETags differ", () => { + const plan = planFirstSync( + [local({ uid: "uid-1", etag: "old", changedAtMs: 5000 })], + [remote({ etag: "new", revisionMs: 1000 })] + ); + expect(plan.toResolve).toHaveLength(1); + expect(plan.toResolve[0].winner).toBe("local"); + }); + + it("resolves a matched pair with unsynced local edits", () => { + const plan = planFirstSync( + [local({ uid: "uid-1", etag: "e1", fingerprint: "edited", changedAtMs: 500 })], + [remote({ revisionMs: 9000 })] + ); + expect(plan.toResolve[0].winner).toBe("remote"); + }); + + it("reconnecting after a disconnect is a no-op", () => { + // The whole point of storing the UID: nothing is duplicated or moved. + const locals = [ + local({ path: "Tasks/a.md", uid: "uid-1", etag: "e1" }), + local({ path: "Tasks/b.md", uid: "uid-2", etag: "e2" }), + ]; + const remotes = [ + remote({ uid: "uid-1", etag: "e1" }), + remote({ uid: "uid-2", etag: "e2", url: "https://s/cal/2.ics" }), + ]; + + const plan = planFirstSync(locals, remotes); + expect(plan.toLink).toHaveLength(2); + expect(plan.toUpload).toHaveLength(0); + expect(plan.toImport).toHaveLength(0); + expect(plan.toResolve).toHaveLength(0); + }); + + it("never matches on title or dates, only UID", () => { + // Two tasks that look identical but carry no shared UID stay separate. + const plan = planFirstSync([local({ uid: undefined })], [remote({ uid: "other" })]); + expect(plan.toUpload).toHaveLength(1); + expect(plan.toImport).toHaveLength(1); + expect(plan.toLink).toHaveLength(0); + }); + + it("uploads a task whose UID is not on the server", () => { + const plan = planFirstSync([local({ uid: "orphan" })], [remote({ uid: "uid-1" })]); + expect(plan.toUpload).toHaveLength(1); + expect(plan.toImport).toHaveLength(1); + }); + + it("summarises for the preview", () => { + const plan = planFirstSync( + [local({ uid: undefined }), local({ path: "b", uid: "uid-1", etag: "e1" })], + [remote(), remote({ uid: "uid-9", url: "https://s/cal/9.ics" })] + ); + expect(summarizeFirstSyncPlan(plan)).toEqual({ + upload: 1, + import: 1, + link: 1, + resolve: 0, + total: 3, + }); + }); +}); + +describe("planIncrementalSync", () => { + it("pushes a local edit the server did not report", () => { + const plan = planIncrementalSync( + [local({ uid: "uid-1", etag: "e1", fingerprint: "edited" })], + [] + ); + expect(plan.toPush).toHaveLength(1); + expect(plan.conflicts).toHaveLength(0); + }); + + it("does nothing for a task with no changes on either side", () => { + const plan = planIncrementalSync([local({ uid: "uid-1", etag: "e1" })], []); + expect(plan.toPush).toHaveLength(0); + expect(plan.toPull).toHaveLength(0); + }); + + it("pulls a remote change when the local side is clean", () => { + const plan = planIncrementalSync( + [local({ uid: "uid-1", etag: "old" })], + [remote({ etag: "new" })] + ); + expect(plan.toPull).toHaveLength(1); + expect(plan.conflicts).toHaveLength(0); + }); + + it("ignores our own write echoing back", () => { + // The server reports the resource as changed, but the ETag is the one we + // already stored, so there is nothing new to pull. + const plan = planIncrementalSync( + [local({ uid: "uid-1", etag: "e1" })], + [remote({ etag: "e1" })] + ); + expect(plan.toPull).toHaveLength(0); + expect(plan.toPush).toHaveLength(0); + }); + + it("reports a conflict when both sides changed", () => { + const plan = planIncrementalSync( + [local({ uid: "uid-1", etag: "old", fingerprint: "edited", changedAtMs: 9000 })], + [remote({ etag: "new", revisionMs: 1000 })] + ); + expect(plan.conflicts).toHaveLength(1); + expect(plan.conflicts[0].winner).toBe("local"); + expect(plan.toPush).toHaveLength(0); + expect(plan.toPull).toHaveLength(0); + }); + + it("imports a remote resource with no local task", () => { + const plan = planIncrementalSync([], [remote({ uid: "brand-new" })]); + expect(plan.toPull).toHaveLength(1); + }); + + it("detects an explicitly reported remote deletion", () => { + const plan = planIncrementalSync( + [local({ uid: "uid-1", href: "https://s/cal/1.ics", etag: "e1" })], + [], + { removedUrls: ["https://s/cal/1.ics"] } + ); + expect(plan.remoteDeleted).toHaveLength(1); + expect(plan.toPush).toHaveLength(0); + }); + + it("infers a deletion from a complete listing", () => { + // The sync-collection fallback path: absent from a full listing means gone. + const plan = planIncrementalSync( + [local({ uid: "uid-1", href: "https://s/cal/1.ics", etag: "e1" })], + [remote({ uid: "uid-2", url: "https://s/cal/2.ics" })], + { remotesAreComplete: true } + ); + expect(plan.remoteDeleted).toHaveLength(1); + }); + + it("does NOT infer deletions from an incomplete delta", () => { + // Without remotesAreComplete, an unreported task is simply unchanged — + // treating it as deleted would wipe the vault on every poll. + const plan = planIncrementalSync( + [local({ uid: "uid-1", href: "https://s/cal/1.ics", etag: "e1" })], + [] + ); + expect(plan.remoteDeleted).toHaveLength(0); + }); + + it("ignores a trailing slash when comparing hrefs", () => { + const plan = planIncrementalSync( + [local({ uid: "uid-1", href: "https://s/cal/1.ics/", etag: "e1" })], + [remote({ uid: "uid-1", url: "https://s/cal/1.ics", etag: "e1" })], + { remotesAreComplete: true } + ); + expect(plan.remoteDeleted).toHaveLength(0); + }); + + it("pushes a never-synced task", () => { + const plan = planIncrementalSync( + [local({ uid: undefined, syncedFingerprint: undefined })], + [] + ); + expect(plan.toPush).toHaveLength(1); + }); +}); + +describe("planRemoteDeletion", () => { + it("archives and unlinks by default", () => { + expect(planRemoteDeletion("archive")).toEqual({ + action: "archive", + stripSyncMetadata: true, + }); + }); + + it("deletes when explicitly configured", () => { + expect(planRemoteDeletion("delete")).toEqual({ + action: "delete", + stripSyncMetadata: false, + }); + }); + + it("strips metadata when unlinking so the task is not re-uploaded", () => { + expect(planRemoteDeletion("unlink")).toEqual({ + action: "unlink", + stripSyncMetadata: true, + }); + }); +}); diff --git a/tests/unit/caldav/caldavScoping.test.ts b/tests/unit/caldav/caldavScoping.test.ts new file mode 100644 index 000000000..abcc785b6 --- /dev/null +++ b/tests/unit/caldav/caldavScoping.test.ts @@ -0,0 +1,211 @@ +import type { FilterGroup, TaskInfo } from "../../../src/types"; +import { + CALDAV_FRONTMATTER_KEYS, + CALDAV_FRONTMATTER_KEY_LIST, + getCalDavRelevantFingerprint, + hasCalDavRelevantChange, + parseCalDavFingerprint, +} from "../../../src/services/caldav/caldavFingerprint"; +import { + resolveCollectionForTask, + taskBelongsToCollection, + type CalDavCollectionScope, +} from "../../../src/services/caldav/collectionMembership"; +import type { FilterPredicateEvaluationContext } from "../../../src/services/filter-service/filterPredicateEvaluation"; + +function makeTask(overrides: Partial = {}): TaskInfo { + return { + title: "Buy groceries", + status: "open", + priority: "normal", + path: "Tasks/buy-groceries.md", + archived: false, + ...overrides, + }; +} + +const filterContext: FilterPredicateEvaluationContext = { + getUserFieldRawValue: () => undefined, + getCompletedStatuses: () => ["done"], + isCompletedStatus: (status: string) => status === "done", +}; + +function tagFilter(tag: string): FilterGroup { + return { + type: "group", + id: "root", + conjunction: "and", + children: [ + { + type: "condition", + id: "c1", + property: "tags", + operator: "contains", + value: tag, + }, + ], + }; +} + +describe("getCalDavRelevantFingerprint", () => { + it("is stable for an unchanged task", () => { + const task = makeTask(); + expect(getCalDavRelevantFingerprint(task)).toBe(getCalDavRelevantFingerprint(task)); + }); + + it("changes when user-visible content changes", () => { + const before = getCalDavRelevantFingerprint(makeTask()); + expect(getCalDavRelevantFingerprint(makeTask({ title: "Something else" }))).not.toBe( + before + ); + expect(getCalDavRelevantFingerprint(makeTask({ status: "done" }))).not.toBe(before); + expect(getCalDavRelevantFingerprint(makeTask({ due: "2025-09-03" }))).not.toBe(before); + }); + + it("does NOT change when only sync metadata is written", () => { + // This is the property that breaks the write-back loop: stamping an ETag + // into frontmatter must not look like a content edit. + const before = getCalDavRelevantFingerprint(makeTask()); + const withMetadata = makeTask() as TaskInfo & Record; + for (const key of CALDAV_FRONTMATTER_KEY_LIST) { + withMetadata[key] = "written-by-sync"; + } + expect(getCalDavRelevantFingerprint(withMetadata)).toBe(before); + }); + + it("does not change when only dateModified or time tracking changes", () => { + const before = getCalDavRelevantFingerprint(makeTask()); + expect( + getCalDavRelevantFingerprint( + makeTask({ dateModified: "2025-09-01T12:00:00Z", totalTrackedTime: 42 }) + ) + ).toBe(before); + }); + + it("ignores tag reordering", () => { + expect(getCalDavRelevantFingerprint(makeTask({ tags: ["a", "b"] }))).toBe( + getCalDavRelevantFingerprint(makeTask({ tags: ["b", "a"] })) + ); + }); + + it("exposes the frontmatter keys the integration owns", () => { + expect(CALDAV_FRONTMATTER_KEYS.uid).toBe("caldav_uid"); + expect(CALDAV_FRONTMATTER_KEY_LIST).toHaveLength(5); + expect(CALDAV_FRONTMATTER_KEY_LIST.every((key) => key.startsWith("caldav_"))).toBe(true); + }); +}); + +describe("parseCalDavFingerprint", () => { + it("round-trips a fingerprint back into a previous state", () => { + const fingerprint = getCalDavRelevantFingerprint( + makeTask({ title: "Old", status: "open" }) + ); + expect(parseCalDavFingerprint(fingerprint)).toMatchObject({ + title: "Old", + status: "open", + }); + }); + + it("treats missing or corrupt fingerprints as no previous state", () => { + expect(parseCalDavFingerprint(undefined)).toBeNull(); + expect(parseCalDavFingerprint("{not json")).toBeNull(); + expect(parseCalDavFingerprint("[1,2,3]")).toBeNull(); + }); +}); + +describe("hasCalDavRelevantChange", () => { + it("reports a change against no stored fingerprint", () => { + expect(hasCalDavRelevantChange(makeTask(), undefined)).toBe(true); + }); + + it("reports no change for a metadata-only write", () => { + const task = makeTask(); + expect(hasCalDavRelevantChange(task, getCalDavRelevantFingerprint(task))).toBe(false); + }); + + it("reports a change for a real edit", () => { + const fingerprint = getCalDavRelevantFingerprint(makeTask()); + expect(hasCalDavRelevantChange(makeTask({ status: "done" }), fingerprint)).toBe(true); + }); +}); + +describe("taskBelongsToCollection", () => { + it("matches every task when the scope has no filter", () => { + const scope: CalDavCollectionScope = { accountId: "a" }; + expect(taskBelongsToCollection(makeTask(), scope, filterContext)).toBe(true); + }); + + it("matches every task when the filter group is empty", () => { + const scope: CalDavCollectionScope = { + accountId: "a", + filter: { type: "group", id: "root", conjunction: "and", children: [] }, + }; + expect(taskBelongsToCollection(makeTask(), scope, filterContext)).toBe(true); + }); + + it("applies the configured filter", () => { + const scope: CalDavCollectionScope = { accountId: "work", filter: tagFilter("work") }; + expect( + taskBelongsToCollection(makeTask({ tags: ["work"] }), scope, filterContext) + ).toBe(true); + expect( + taskBelongsToCollection(makeTask({ tags: ["personal"] }), scope, filterContext) + ).toBe(false); + }); + + it("never includes an archived task", () => { + // Archiving is how a remote deletion is reflected locally; re-uploading + // archived tasks would resurrect VTODOs the user deleted on the server. + const scope: CalDavCollectionScope = { accountId: "a" }; + expect( + taskBelongsToCollection(makeTask({ archived: true }), scope, filterContext) + ).toBe(false); + }); + + it("excludes the task rather than throwing when a filter is malformed", () => { + const scope: CalDavCollectionScope = { + accountId: "a", + filter: { type: "group", id: "root", conjunction: "and", children: [null as never] }, + }; + expect(() => taskBelongsToCollection(makeTask(), scope, filterContext)).not.toThrow(); + }); +}); + +describe("resolveCollectionForTask", () => { + const scopes: CalDavCollectionScope[] = [ + { accountId: "work", filter: tagFilter("work") }, + { accountId: "personal", filter: tagFilter("personal") }, + { accountId: "catch-all" }, + ]; + + it("returns the first matching collection", () => { + expect( + resolveCollectionForTask(makeTask({ tags: ["personal"] }), scopes, filterContext) + ?.accountId + ).toBe("personal"); + }); + + it("assigns a task to exactly one collection when several match", () => { + // Order decides, so a task is uploaded once rather than duplicated. + expect( + resolveCollectionForTask( + makeTask({ tags: ["work", "personal"] }), + scopes, + filterContext + )?.accountId + ).toBe("work"); + }); + + it("falls through to an unfiltered collection", () => { + expect( + resolveCollectionForTask(makeTask({ tags: ["other"] }), scopes, filterContext) + ?.accountId + ).toBe("catch-all"); + }); + + it("returns undefined when nothing matches", () => { + expect( + resolveCollectionForTask(makeTask({ tags: ["other"] }), scopes.slice(0, 2), filterContext) + ).toBeUndefined(); + }); +}); diff --git a/tests/unit/caldav/caldavXml.test.ts b/tests/unit/caldav/caldavXml.test.ts new file mode 100644 index 000000000..077af90d2 --- /dev/null +++ b/tests/unit/caldav/caldavXml.test.ts @@ -0,0 +1,225 @@ +import { + buildCalendarCollectionsRequest, + buildCalendarQueryVTodoRequest, + buildCurrentUserPrincipalRequest, + buildMultigetRequest, + buildSyncCollectionRequest, + escapeXml, + normalizeEtag, + parseMultistatus, + parseSyncCollection, + selectVTodoCollections, +} from "../../../src/services/caldav/caldavXml"; + +describe("request bodies", () => { + it("asks for the current user principal", () => { + const body = buildCurrentUserPrincipalRequest(); + expect(body).toContain(""); + expect(body).toContain('xmlns:d="DAV:"'); + }); + + it("asks for the properties needed to pick a VTODO collection", () => { + const body = buildCalendarCollectionsRequest(); + expect(body).toContain(""); + expect(body).toContain(""); + expect(body).toContain(""); + }); + + it("filters a calendar-query down to VTODO", () => { + const body = buildCalendarQueryVTodoRequest(); + expect(body).toContain(''); + expect(body).toContain(''); + }); + + it("sends an empty sync-token on the first pass", () => { + expect(buildSyncCollectionRequest()).toContain(""); + expect(buildSyncCollectionRequest("http://x/token/42")).toContain( + "http://x/token/42" + ); + }); + + it("escapes XML metacharacters in a sync token", () => { + expect(buildSyncCollectionRequest('a&b { + const body = buildMultigetRequest(["/c/1.ics", "/c/2.ics"]); + expect(body).toContain("/c/1.ics"); + expect(body).toContain("/c/2.ics"); + }); + + it("escapes an ampersand in an href", () => { + expect(buildMultigetRequest(["/c/a&b.ics"])).toContain("/c/a&b.ics"); + }); + + it("escapes the five XML metacharacters", () => { + expect(escapeXml(`&<>"'`)).toBe("&<>"'"); + }); +}); + +describe("parseMultistatus", () => { + const NEXTCLOUD_PREFIXES = ` + + + /remote.php/dav/calendars/fabian/tasks/1.ics + + + "etag-one" + BEGIN:VCALENDAR +BEGIN:VTODO +UID:task-1 +END:VTODO +END:VCALENDAR + + HTTP/1.1 200 OK + + +`; + + // Radicale uses different prefixes and uppercase D:, which must parse the same. + const RADICALE_PREFIXES = ` + + + /fabian/tasks/1.ics + + "etag-one" + HTTP/1.1 200 OK + + +`; + + it("parses hrefs, ETags and calendar data", () => { + const { responses } = parseMultistatus(NEXTCLOUD_PREFIXES); + expect(responses).toHaveLength(1); + expect(responses[0].href).toBe("/remote.php/dav/calendars/fabian/tasks/1.ics"); + expect(responses[0].etag).toBe("etag-one"); + expect(responses[0].calendarData).toContain("UID:task-1"); + }); + + it("is indifferent to namespace prefix and case", () => { + const { responses } = parseMultistatus(RADICALE_PREFIXES); + expect(responses[0].href).toBe("/fabian/tasks/1.ics"); + expect(responses[0].etag).toBe("etag-one"); + }); + + it("percent-decodes hrefs so they compare equal later", () => { + const xml = ` + /cal/My%20Tasks/a%20b.ics`; + expect(parseMultistatus(xml).responses[0].href).toBe("/cal/My Tasks/a b.ics"); + }); + + it("keeps a malformed percent sequence rather than throwing", () => { + const xml = ` + /cal/100%.ics`; + expect(parseMultistatus(xml).responses[0].href).toBe("/cal/100%.ics"); + }); + + it("ignores properties reported as 404 in their own propstat", () => { + const xml = ` + /c/1.ics + "good" + HTTP/1.1 200 OK + + HTTP/1.1 404 Not Found + `; + const response = parseMultistatus(xml).responses[0]; + expect(response.etag).toBe("good"); + expect(response.displayName).toBeUndefined(); + }); + + it("returns no responses for malformed XML or a non-multistatus root", () => { + expect(parseMultistatus("401").responses).toEqual([]); + expect(parseMultistatus("").responses).toEqual([]); + }); + + it("reads discovery properties", () => { + const xml = ` + / + /principals/fabian/ + /calendars/fabian/ + HTTP/1.1 200 OK + `; + const response = parseMultistatus(xml).responses[0]; + expect(response.currentUserPrincipal).toBe("/principals/fabian/"); + expect(response.calendarHomeSet).toBe("/calendars/fabian/"); + }); +}); + +describe("normalizeEtag", () => { + it("strips quotes and weak validators so stored ETags compare equal", () => { + expect(normalizeEtag('"abc"')).toBe("abc"); + expect(normalizeEtag('W/"abc"')).toBe("abc"); + expect(normalizeEtag("abc")).toBe("abc"); + }); + + it("returns undefined for absent or empty ETags", () => { + expect(normalizeEtag(undefined)).toBeUndefined(); + expect(normalizeEtag('""')).toBeUndefined(); + expect(normalizeEtag(" ")).toBeUndefined(); + }); +}); + +describe("selectVTodoCollections", () => { + const base = { href: "/c/", resourceTypes: ["collection", "calendar"] }; + + it("keeps a collection that advertises VTODO", () => { + expect( + selectVTodoCollections([{ ...base, supportedComponents: ["VEVENT", "VTODO"] }]) + ).toHaveLength(1); + }); + + it("drops an event-only calendar", () => { + expect( + selectVTodoCollections([{ ...base, supportedComponents: ["VEVENT"] }]) + ).toHaveLength(0); + }); + + it("keeps a calendar that advertises nothing, per RFC 4791", () => { + expect(selectVTodoCollections([{ ...base, supportedComponents: [] }])).toHaveLength(1); + }); + + it("drops a plain collection that is not a calendar", () => { + expect( + selectVTodoCollections([ + { href: "/c/", resourceTypes: ["collection"], supportedComponents: ["VTODO"] }, + ]) + ).toHaveLength(0); + }); +}); + +describe("parseSyncCollection", () => { + it("separates changed resources from removed ones", () => { + const xml = ` + + /c/changed.ics + "new" + HTTP/1.1 200 OK + + + /c/gone.ics + HTTP/1.1 404 Not Found + + http://server/token/99 + `; + + const result = parseSyncCollection(xml); + expect(result.changed).toEqual([{ href: "/c/changed.ics", etag: "new" }]); + expect(result.removed).toEqual(["/c/gone.ics"]); + expect(result.syncToken).toBe("http://server/token/99"); + }); + + it("treats 410 Gone as a removal", () => { + const xml = ` + /c/gone.icsHTTP/1.1 410 Gone + `; + expect(parseSyncCollection(xml).removed).toEqual(["/c/gone.ics"]); + }); + + it("returns empty change sets for an unusable response", () => { + const result = parseSyncCollection("500"); + expect(result.changed).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.syncToken).toBeUndefined(); + }); +}); diff --git a/tests/unit/caldav/icsDateValue.test.ts b/tests/unit/caldav/icsDateValue.test.ts new file mode 100644 index 000000000..512eb26b7 --- /dev/null +++ b/tests/unit/caldav/icsDateValue.test.ts @@ -0,0 +1,214 @@ +import { + formatIcsDateValue, + icsDateValueToTaskDate, + icsStampToEpochMs, + isoToIcsUtcStamp, + parseIcsDateValue, + taskDateToIcsDateValue, +} from "../../../src/services/caldav/icsDateValue"; + +// jest.config.js pins TZ=UTC, so local wall time and UTC wall time coincide +// here. Conversions that depend on a real offset are exercised through the +// injected zone resolver instead. + +describe("parseIcsDateValue", () => { + it("parses a date-only value", () => { + expect(parseIcsDateValue("20250901", { VALUE: "DATE" })).toEqual({ + dateOnly: true, + value: "2025-09-01", + utc: false, + }); + }); + + it("parses a UTC date-time", () => { + expect(parseIcsDateValue("20250901T120000Z")).toEqual({ + dateOnly: false, + value: "2025-09-01T12:00:00", + tzid: undefined, + utc: true, + }); + }); + + it("parses a zoned date-time and keeps the TZID", () => { + expect(parseIcsDateValue("20250901T120000", { TZID: "Europe/Berlin" })).toEqual({ + dateOnly: false, + value: "2025-09-01T12:00:00", + tzid: "Europe/Berlin", + utc: false, + }); + }); + + it("ignores a TZID on a value that is already UTC", () => { + const parsed = parseIcsDateValue("20250901T120000Z", { TZID: "Europe/Berlin" }); + expect(parsed?.tzid).toBeUndefined(); + expect(parsed?.utc).toBe(true); + }); + + it("returns null for malformed and impossible values", () => { + expect(parseIcsDateValue("")).toBeNull(); + expect(parseIcsDateValue("not-a-date")).toBeNull(); + expect(parseIcsDateValue("20250230")).toBeNull(); // 30 February + expect(parseIcsDateValue("20250901T250000Z")).toBeNull(); // hour 25 + }); + + it("accepts a leap second and a leap day", () => { + expect(parseIcsDateValue("20240229")).not.toBeNull(); + expect(parseIcsDateValue("20250630T235960Z")).not.toBeNull(); + }); +}); + +describe("formatIcsDateValue", () => { + it("round-trips a date-only value with its VALUE=DATE parameter", () => { + const formatted = formatIcsDateValue({ + dateOnly: true, + value: "2025-09-01", + utc: false, + }); + expect(formatted).toEqual({ value: "20250901", params: { VALUE: "DATE" } }); + }); + + it("emits a Z suffix for UTC and no parameters", () => { + expect( + formatIcsDateValue({ dateOnly: false, value: "2025-09-01T12:00:00", utc: true }) + ).toEqual({ value: "20250901T120000Z", params: {} }); + }); + + it("emits a TZID parameter for a zoned value", () => { + expect( + formatIcsDateValue({ + dateOnly: false, + value: "2025-09-01T12:00:00", + tzid: "Europe/Berlin", + utc: false, + }) + ).toEqual({ value: "20250901T120000", params: { TZID: "Europe/Berlin" } }); + }); + + it("survives a parse/format round trip", () => { + for (const raw of ["20250901", "20250901T120000Z"]) { + const parsed = parseIcsDateValue(raw); + expect(parsed).not.toBeNull(); + expect(formatIcsDateValue(parsed!).value).toBe(raw); + } + }); +}); + +describe("icsDateValueToTaskDate", () => { + it("passes a date-only value through unchanged", () => { + expect( + icsDateValueToTaskDate({ dateOnly: true, value: "2025-09-01", utc: false }) + ).toBe("2025-09-01"); + }); + + it("renders a UTC instant as local wall time without seconds", () => { + expect( + icsDateValueToTaskDate({ + dateOnly: false, + value: "2025-09-01T12:00:00", + utc: true, + }) + ).toBe("2025-09-01T12:00"); + }); + + it("uses the injected resolver to shift a zoned time", () => { + // Berlin is UTC+2 in September, so 12:00 local is 10:00 UTC. + const resolver = jest.fn().mockReturnValue("2025-09-01T10:00:00Z"); + const result = icsDateValueToTaskDate( + { dateOnly: false, value: "2025-09-01T12:00:00", tzid: "Europe/Berlin", utc: false }, + resolver + ); + expect(resolver).toHaveBeenCalledWith("2025-09-01T12:00:00", "Europe/Berlin"); + expect(result).toBe("2025-09-01T10:00"); + }); + + it("treats a zoned time as floating when no resolver is supplied", () => { + // Better a wall time that reads correctly than one silently shifted by + // the wrong offset. + expect( + icsDateValueToTaskDate({ + dateOnly: false, + value: "2025-09-01T12:00:00", + tzid: "Europe/Berlin", + utc: false, + }) + ).toBe("2025-09-01T12:00"); + }); + + it("falls back to floating when the resolver cannot resolve the zone", () => { + expect( + icsDateValueToTaskDate( + { + dateOnly: false, + value: "2025-09-01T12:00:00", + tzid: "Mars/Olympus_Mons", + utc: false, + }, + () => null + ) + ).toBe("2025-09-01T12:00"); + }); +}); + +describe("taskDateToIcsDateValue", () => { + it("keeps a date-only task date date-only", () => { + expect(taskDateToIcsDateValue("2025-09-01")).toEqual({ + dateOnly: true, + value: "2025-09-01", + utc: false, + }); + }); + + it("converts a local wall time to a UTC instant", () => { + expect(taskDateToIcsDateValue("2025-09-01T12:00")).toEqual({ + dateOnly: false, + value: "2025-09-01T12:00:00", + utc: true, + }); + }); + + it("accepts an explicit seconds component", () => { + expect(taskDateToIcsDateValue("2025-09-01T12:00:30")?.value).toBe( + "2025-09-01T12:00:30" + ); + }); + + it("returns null for empty and malformed input", () => { + expect(taskDateToIcsDateValue("")).toBeNull(); + expect(taskDateToIcsDateValue("tomorrow")).toBeNull(); + expect(taskDateToIcsDateValue("2025-02-30")).toBeNull(); + }); + + it("round-trips through the ICS form", () => { + for (const taskDate of ["2025-09-01", "2025-09-01T12:00"]) { + const ics = taskDateToIcsDateValue(taskDate); + expect(ics).not.toBeNull(); + expect(icsDateValueToTaskDate(ics!)).toBe(taskDate); + } + }); +}); + +describe("stamps", () => { + it("renders an ISO timestamp as a UTC ICS stamp", () => { + expect(isoToIcsUtcStamp("2025-09-01T12:00:00.000Z")).toBe("20250901T120000Z"); + }); + + it("returns null for an unparseable ISO timestamp", () => { + expect(isoToIcsUtcStamp("whenever")).toBeNull(); + }); + + it("reads a stamp back as epoch milliseconds", () => { + expect(icsStampToEpochMs("20250901T120000Z")).toBe(Date.UTC(2025, 8, 1, 12, 0, 0)); + }); + + it("returns null rather than NaN for missing or unusable stamps", () => { + expect(icsStampToEpochMs(undefined)).toBeNull(); + expect(icsStampToEpochMs("garbage")).toBeNull(); + expect(icsStampToEpochMs("20250901")).toBeNull(); // date-only cannot order edits + }); + + it("orders two stamps so the newer one wins the conflict tiebreak", () => { + const older = icsStampToEpochMs("20250901T120000Z")!; + const newer = icsStampToEpochMs("20250901T120500Z")!; + expect(newer).toBeGreaterThan(older); + }); +}); diff --git a/tests/unit/caldav/vtodoAlarms.test.ts b/tests/unit/caldav/vtodoAlarms.test.ts new file mode 100644 index 000000000..4cc00ee9e --- /dev/null +++ b/tests/unit/caldav/vtodoAlarms.test.ts @@ -0,0 +1,172 @@ +import { + applyReminders, + ownsAlarm, + readReminders, + REMINDER_STAMP, +} from "../../../src/services/caldav/vtodoAlarms"; +import { + getComponents, + parseVTodoDocument, + serializeVTodoDocument, + type VTodoDocument, +} from "../../../src/services/caldav/vtodoDocument"; +import type { Reminder } from "../../../src/types"; + +function docWith(...lines: string[]): VTodoDocument { + const doc = parseVTodoDocument( + ["BEGIN:VCALENDAR", "BEGIN:VTODO", "UID:self", ...lines, "END:VTODO", "END:VCALENDAR"].join( + "\r\n" + ) + ); + if (!doc) throw new Error("fixture did not parse"); + return doc; +} + +const FOREIGN_ALARM = [ + "BEGIN:VALARM", + "ACTION:DISPLAY", + "DESCRIPTION:Set on a phone", + "TRIGGER;RELATED=START:-PT30M", + "END:VALARM", +]; + +describe("applyReminders", () => { + it("writes a relative reminder anchored to the due date", () => { + const doc = docWith(); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "due", offset: "-PT15M" }, + ]); + + const output = serializeVTodoDocument(doc); + expect(output).toContain("BEGIN:VALARM"); + expect(output).toContain("TRIGGER;RELATED=END:-PT15M"); + expect(output).toContain(`${REMINDER_STAMP}:rem_1`); + }); + + it("anchors a scheduled reminder to DTSTART instead", () => { + const doc = docWith(); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "scheduled", offset: "-P1D" }, + ]); + expect(serializeVTodoDocument(doc)).toContain("TRIGGER;RELATED=START:-P1D"); + }); + + it("writes an absolute reminder as a UTC timestamp", () => { + const doc = docWith(); + applyReminders(doc, [ + { id: "rem_1", type: "absolute", absoluteTime: "2026-10-26T09:00:00Z" }, + ]); + expect(serializeVTodoDocument(doc)).toContain("TRIGGER;VALUE=DATE-TIME:20261026T090000Z"); + }); + + it("leaves an alarm TaskNotes did not write completely alone", () => { + // The decision that makes this integration safe to run alongside a phone: + // a foreign alarm is never matched, so it is never rewritten or dropped. + const doc = docWith(...FOREIGN_ALARM); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "due", offset: "-PT15M" }, + ]); + + const output = serializeVTodoDocument(doc); + expect(output).toContain("DESCRIPTION:Set on a phone"); + expect(output).toContain("TRIGGER;RELATED=START:-PT30M"); + expect(getComponents(doc, "VALARM")).toHaveLength(2); + }); + + it("replaces only its own alarm on a second write", () => { + const doc = docWith(...FOREIGN_ALARM); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "due", offset: "-PT15M" }, + ]); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "due", offset: "-PT45M" }, + ]); + + const output = serializeVTodoDocument(doc); + expect(getComponents(doc, "VALARM")).toHaveLength(2); + expect(output).toContain("TRIGGER;RELATED=END:-PT45M"); + expect(output).not.toContain("-PT15M"); + expect(output).toContain("TRIGGER;RELATED=START:-PT30M"); + }); + + it("removes its own alarms when every reminder is cleared", () => { + const doc = docWith(...FOREIGN_ALARM); + applyReminders(doc, [ + { id: "rem_1", type: "relative", relatedTo: "due", offset: "-PT15M" }, + ]); + applyReminders(doc, []); + + expect(getComponents(doc, "VALARM")).toHaveLength(1); + expect(serializeVTodoDocument(doc)).toContain("DESCRIPTION:Set on a phone"); + }); + + it("skips a reminder with nothing to trigger on", () => { + const doc = docWith(); + applyReminders(doc, [{ id: "rem_1", type: "relative" } as Reminder]); + expect(getComponents(doc, "VALARM")).toHaveLength(0); + }); +}); + +describe("readReminders", () => { + it("round-trips a relative reminder", () => { + const doc = docWith(); + const reminders: Reminder[] = [ + { + id: "rem_1", + type: "relative", + relatedTo: "due", + offset: "-PT15M", + description: "Call Max", + }, + ]; + applyReminders(doc, reminders); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc)); + expect(readReminders(reparsed!)).toEqual(reminders); + }); + + it("round-trips an absolute reminder", () => { + const doc = docWith(); + applyReminders(doc, [ + { id: "rem_1", type: "absolute", absoluteTime: "2026-10-26T09:00:00Z" }, + ]); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc)); + expect(readReminders(reparsed!)).toEqual([ + { + id: "rem_1", + type: "absolute", + absoluteTime: "2026-10-26T09:00:00Z", + description: "Reminder", + }, + ]); + }); + + it("ignores alarms without the TaskNotes stamp", () => { + const doc = docWith(...FOREIGN_ALARM); + expect(readReminders(doc)).toEqual([]); + }); + + it("preserves a description containing escaped characters", () => { + const doc = docWith(); + applyReminders(doc, [ + { + id: "rem_1", + type: "relative", + relatedTo: "due", + offset: "-PT5M", + description: "Call Max, then; go", + }, + ]); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc)); + expect(readReminders(reparsed!)[0].description).toBe("Call Max, then; go"); + }); +}); + +describe("ownsAlarm", () => { + it("recognises only stamped alarms", () => { + expect(ownsAlarm(FOREIGN_ALARM)).toBe(false); + expect(ownsAlarm([...FOREIGN_ALARM, `${REMINDER_STAMP}:rem_1`])).toBe(true); + }); +}); diff --git a/tests/unit/caldav/vtodoDocument.test.ts b/tests/unit/caldav/vtodoDocument.test.ts new file mode 100644 index 000000000..0643b9a66 --- /dev/null +++ b/tests/unit/caldav/vtodoDocument.test.ts @@ -0,0 +1,263 @@ +import { + createVTodoDocument, + escapeText, + getProperties, + getProperty, + getTextListProperty, + getTextProperty, + parseContentLine, + parseVTodoDocument, + removeProperty, + serializeVTodoDocument, + setProperty, + setTextListProperty, + setTextProperty, + unescapeText, + unfoldLines, +} from "../../../src/services/caldav/vtodoDocument"; + +/** A VTODO shaped like something Nextcloud Tasks would actually store. */ +const NEXTCLOUD_VTODO = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Nextcloud Tasks//EN", + "BEGIN:VTODO", + "UID:abc-123-def", + "DTSTAMP:20250901T120000Z", + "SUMMARY:Buy groceries", + "DUE;VALUE=DATE:20250903", + "STATUS:NEEDS-ACTION", + "PRIORITY:5", + "CATEGORIES:errands,shopping", + "X-APPLE-SORT-ORDER:12345", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:-PT15M", + "DESCRIPTION:Reminder", + "END:VALARM", + "END:VTODO", + "END:VCALENDAR", +].join("\r\n"); + +describe("parseVTodoDocument", () => { + it("parses properties and isolates the VTODO", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + expect(doc).not.toBeNull(); + expect(getTextProperty(doc, "UID")).toBe("abc-123-def"); + expect(getTextProperty(doc, "SUMMARY")).toBe("Buy groceries"); + expect(getProperty(doc, "DUE")?.params).toEqual({ VALUE: "DATE" }); + expect(getProperty(doc, "DUE")?.value).toBe("20250903"); + }); + + it("is case-insensitive on property lookup", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + expect(getTextProperty(doc, "uid")).toBe("abc-123-def"); + }); + + it("returns null when the payload has no VTODO", () => { + const vevent = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:x", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + expect(parseVTodoDocument(vevent)).toBeNull(); + expect(parseVTodoDocument("404")).toBeNull(); + }); + + it("refuses a truncated VTODO rather than syncing half an object", () => { + const truncated = ["BEGIN:VCALENDAR", "BEGIN:VTODO", "UID:x", "SUMMARY:cut off"].join( + "\r\n" + ); + expect(parseVTodoDocument(truncated)).toBeNull(); + }); +}); + +describe("property preservation", () => { + it("round-trips an untouched document without losing anything", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + const out = serializeVTodoDocument(doc); + + expect(out).toContain("X-APPLE-SORT-ORDER:12345"); + expect(out).toContain("BEGIN:VALARM"); + expect(out).toContain("TRIGGER:-PT15M"); + expect(out).toContain("END:VALARM"); + expect(out).toContain("PRODID:-//Nextcloud Tasks//EN"); + expect(out).toContain("END:VCALENDAR"); + }); + + it("keeps unknown properties and VALARM blocks when a known field changes", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + setTextProperty(doc, "SUMMARY", "Buy groceries and milk"); + const out = serializeVTodoDocument(doc); + + expect(out).toContain("SUMMARY:Buy groceries and milk"); + expect(out).not.toContain("SUMMARY:Buy groceries\r\n"); + // The parts we do not model must survive untouched. + expect(out).toContain("X-APPLE-SORT-ORDER:12345"); + expect(out).toContain("BEGIN:VALARM"); + expect(out).toContain("TRIGGER:-PT15M"); + }); + + it("replaces a property in place rather than appending", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + setTextProperty(doc, "SUMMARY", "Changed"); + const lines = serializeVTodoDocument(doc).split("\r\n"); + + expect(lines.indexOf("SUMMARY:Changed")).toBeLessThan( + lines.indexOf("X-APPLE-SORT-ORDER:12345") + ); + expect(getProperties(doc, "SUMMARY")).toHaveLength(1); + }); + + it("collapses duplicate occurrences on set", () => { + const doc = parseVTodoDocument( + [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:x", + "STATUS:NEEDS-ACTION", + "STATUS:COMPLETED", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n") + )!; + expect(getProperties(doc, "STATUS")).toHaveLength(2); + + setProperty(doc, "STATUS", "IN-PROCESS"); + expect(getProperties(doc, "STATUS")).toHaveLength(1); + expect(getProperty(doc, "STATUS")?.value).toBe("IN-PROCESS"); + }); + + it("removes a property without disturbing its neighbours", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + removeProperty(doc, "DUE"); + const out = serializeVTodoDocument(doc); + + expect(out).not.toContain("DUE"); + expect(out).toContain("SUMMARY:Buy groceries"); + expect(out).toContain("BEGIN:VALARM"); + }); +}); + +describe("escaping", () => { + it("escapes and unescapes the RFC 5545 special characters", () => { + const raw = 'Call Bob; buy milk, bread\nand "cheese" \\ here'; + expect(unescapeText(escapeText(raw))).toBe(raw); + }); + + it("round-trips a summary containing separators through a document", () => { + const doc = createVTodoDocument(); + const summary = "Pay invoice; net 30, urgent\nsecond line"; + setTextProperty(doc, "SUMMARY", summary); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc))!; + expect(getTextProperty(reparsed, "SUMMARY")).toBe(summary); + }); + + it("splits CATEGORIES on unescaped commas only", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + expect(getTextListProperty(doc, "CATEGORIES")).toEqual(["errands", "shopping"]); + }); + + it("round-trips a tag that itself contains a comma", () => { + const doc = createVTodoDocument(); + setTextListProperty(doc, "CATEGORIES", ["home", "shopping, urgent"]); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc))!; + expect(getTextListProperty(reparsed, "CATEGORIES")).toEqual([ + "home", + "shopping, urgent", + ]); + }); + + it("drops the list property entirely when set to empty", () => { + const doc = parseVTodoDocument(NEXTCLOUD_VTODO)!; + setTextListProperty(doc, "CATEGORIES", []); + expect(serializeVTodoDocument(doc)).not.toContain("CATEGORIES"); + }); +}); + +describe("content line parsing", () => { + it("parses parameters", () => { + expect(parseContentLine("DUE;VALUE=DATE:20250903")).toEqual({ + name: "DUE", + params: { VALUE: "DATE" }, + value: "20250903", + }); + }); + + it("does not split on a colon inside a quoted parameter", () => { + const parsed = parseContentLine('ATTENDEE;CN="Bob: The Builder":mailto:bob@example.com'); + expect(parsed?.name).toBe("ATTENDEE"); + expect(parsed?.params.CN).toBe("Bob: The Builder"); + expect(parsed?.value).toBe("mailto:bob@example.com"); + }); + + it("keeps a URL value intact", () => { + expect(parseContentLine("URL:https://example.com/a:b")?.value).toBe( + "https://example.com/a:b" + ); + }); + + it("rejects a line with no value separator", () => { + expect(parseContentLine("GARBAGE")).toBeNull(); + expect(parseContentLine("")).toBeNull(); + }); +}); + +describe("line folding", () => { + it("unfolds continuation lines", () => { + // Exactly one leading space or tab is consumed by the unfold. + expect(unfoldLines("SUMMARY:Hello\r\n world")).toEqual(["SUMMARY:Helloworld"]); + expect(unfoldLines("SUMMARY:Hello\r\n world")).toEqual(["SUMMARY:Hello world"]); + expect(unfoldLines("SUMMARY:Hello\r\n\tworld")).toEqual(["SUMMARY:Helloworld"]); + }); + + it("folds long lines to 75 octets and survives a round trip", () => { + const doc = createVTodoDocument(); + const long = `Long summary ${"x".repeat(200)}`; + setTextProperty(doc, "SUMMARY", long); + + const serialized = serializeVTodoDocument(doc); + for (const line of serialized.split("\r\n")) { + expect(Buffer.byteLength(line, "utf8")).toBeLessThanOrEqual(75); + } + + const reparsed = parseVTodoDocument(serialized)!; + expect(getTextProperty(reparsed, "SUMMARY")).toBe(long); + }); + + it("folds on octet boundaries so multi-byte characters are not split", () => { + const doc = createVTodoDocument(); + const emoji = "🎉".repeat(60); + setTextProperty(doc, "SUMMARY", emoji); + + const serialized = serializeVTodoDocument(doc); + for (const line of serialized.split("\r\n")) { + expect(Buffer.byteLength(line, "utf8")).toBeLessThanOrEqual(75); + } + expect(serialized).not.toContain("�"); + + const reparsed = parseVTodoDocument(serialized)!; + expect(getTextProperty(reparsed, "SUMMARY")).toBe(emoji); + }); +}); + +describe("createVTodoDocument", () => { + it("produces a parseable minimal VCALENDAR", () => { + const doc = createVTodoDocument(); + setTextProperty(doc, "UID", "new-uid"); + setTextProperty(doc, "SUMMARY", "Fresh task"); + + const serialized = serializeVTodoDocument(doc); + expect(serialized).toContain("BEGIN:VCALENDAR"); + expect(serialized).toContain("BEGIN:VTODO"); + expect(serialized).toContain("END:VTODO"); + expect(serialized.endsWith("\r\n")).toBe(true); + + const reparsed = parseVTodoDocument(serialized)!; + expect(getTextProperty(reparsed, "UID")).toBe("new-uid"); + }); +}); diff --git a/tests/unit/caldav/vtodoMapping.test.ts b/tests/unit/caldav/vtodoMapping.test.ts new file mode 100644 index 000000000..5315feb64 --- /dev/null +++ b/tests/unit/caldav/vtodoMapping.test.ts @@ -0,0 +1,368 @@ +import { DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "../../../src/settings/defaults"; +import type { TaskInfo } from "../../../src/types"; +import { + applyTaskToVTodo, + joinRecurrence, + readVTodoIntoTaskPatch, + readVTodoRevision, + readVTodoUid, + splitRecurrence, + taskPriorityToVTodo, + taskStatusToVTodo, + vTodoPriorityToTaskPriority, + vTodoStatusToTaskStatus, + type VTodoMappingContext, +} from "../../../src/services/caldav/vtodoMapping"; +import { + createVTodoDocument, + getProperty, + getTextProperty, + parseVTodoDocument, + serializeVTodoDocument, +} from "../../../src/services/caldav/vtodoDocument"; + +const context: VTodoMappingContext = { + statuses: DEFAULT_STATUSES, + priorities: DEFAULT_PRIORITIES, +}; + +function makeTask(overrides: Partial = {}): TaskInfo { + return { + title: "Buy groceries", + status: "open", + priority: "normal", + path: "Tasks/buy-groceries.md", + archived: false, + ...overrides, + }; +} + +describe("status mapping", () => { + it("derives COMPLETED from the isCompleted flag", () => { + expect(taskStatusToVTodo("done", context)).toBe("COMPLETED"); + }); + + it("maps every other default status to NEEDS-ACTION", () => { + expect(taskStatusToVTodo("open", context)).toBe("NEEDS-ACTION"); + expect(taskStatusToVTodo("in-progress", context)).toBe("NEEDS-ACTION"); + expect(taskStatusToVTodo("none", context)).toBe("NEEDS-ACTION"); + }); + + it("maps an isSkipped status to CANCELLED", () => { + const withSkipped: VTodoMappingContext = { + ...context, + statuses: [ + ...DEFAULT_STATUSES, + { + id: "cancelled", + value: "cancelled", + label: "Cancelled", + color: "#999", + isCompleted: false, + isSkipped: true, + order: 4, + autoArchive: false, + autoArchiveDelay: 5, + }, + ], + }; + expect(taskStatusToVTodo("cancelled", withSkipped)).toBe("CANCELLED"); + expect(vTodoStatusToTaskStatus("CANCELLED", withSkipped)).toBe("cancelled"); + }); + + it("honours an explicit override in both directions", () => { + const overridden: VTodoMappingContext = { + ...context, + statusOverrides: { "in-progress": "IN-PROCESS" }, + }; + expect(taskStatusToVTodo("in-progress", overridden)).toBe("IN-PROCESS"); + expect(vTodoStatusToTaskStatus("IN-PROCESS", overridden)).toBe("in-progress"); + }); + + it("maps inbound statuses to sensible defaults with no override", () => { + expect(vTodoStatusToTaskStatus("COMPLETED", context)).toBe("done"); + expect(vTodoStatusToTaskStatus("NEEDS-ACTION", context)).toBe("none"); + }); + + it("ignores an unknown inbound status rather than guessing", () => { + expect(vTodoStatusToTaskStatus("NONSENSE", context)).toBeUndefined(); + expect(vTodoStatusToTaskStatus("", context)).toBeUndefined(); + }); + + it("falls back to CANCELLED -> a completed status when nothing is skipped", () => { + // The default configuration has no isSkipped status. + expect(vTodoStatusToTaskStatus("CANCELLED", context)).toBe("done"); + }); +}); + +describe("priority mapping", () => { + it("spreads priorities across the 1-9 scale, most urgent lowest", () => { + expect(taskPriorityToVTodo("high", context)).toBe(1); + expect(taskPriorityToVTodo("normal", context)).toBe(5); + expect(taskPriorityToVTodo("low", context)).toBe(9); + }); + + it("treats the zero-weight priority as no PRIORITY at all", () => { + expect(taskPriorityToVTodo("none", context)).toBeUndefined(); + }); + + it("round-trips every weighted priority", () => { + for (const value of ["high", "normal", "low"]) { + const mapped = taskPriorityToVTodo(value, context)!; + expect(vTodoPriorityToTaskPriority(mapped, context)).toBe(value); + } + }); + + it("snaps an intermediate remote priority to the nearest configured one", () => { + expect(vTodoPriorityToTaskPriority(2, context)).toBe("high"); + expect(vTodoPriorityToTaskPriority(4, context)).toBe("normal"); + expect(vTodoPriorityToTaskPriority(8, context)).toBe("low"); + }); + + it("treats 0 and out-of-range values as unset", () => { + expect(vTodoPriorityToTaskPriority(0, context)).toBeUndefined(); + expect(vTodoPriorityToTaskPriority(undefined, context)).toBeUndefined(); + expect(vTodoPriorityToTaskPriority(42, context)).toBeUndefined(); + }); + + it("handles a single configured priority without dividing by zero", () => { + const single: VTodoMappingContext = { + ...context, + priorities: [{ id: "p", value: "p", label: "P", color: "#000", weight: 1 }], + }; + expect(taskPriorityToVTodo("p", single)).toBe(5); + expect(vTodoPriorityToTaskPriority(5, single)).toBe("p"); + }); +}); + +describe("recurrence", () => { + it("splits an embedded DTSTART out of the TaskNotes form", () => { + expect(splitRecurrence("DTSTART:20240115;FREQ=WEEKLY;BYDAY=MO,TU")).toEqual({ + dtstart: "20240115", + rule: "FREQ=WEEKLY;BYDAY=MO,TU", + }); + }); + + it("handles a rule with no DTSTART", () => { + expect(splitRecurrence("FREQ=DAILY")).toEqual({ rule: "FREQ=DAILY" }); + }); + + it("strips an RRULE: prefix", () => { + expect(splitRecurrence("RRULE:FREQ=DAILY").rule).toBe("FREQ=DAILY"); + }); + + it("rejoins into the TaskNotes form", () => { + expect(joinRecurrence("20240115", "FREQ=WEEKLY")).toBe( + "DTSTART:20240115;FREQ=WEEKLY" + ); + expect(joinRecurrence(undefined, "FREQ=WEEKLY")).toBe("FREQ=WEEKLY"); + }); + + it("round-trips through a VTODO", () => { + const doc = createVTodoDocument(); + const task = makeTask({ + recurrence: "DTSTART:20240115;FREQ=WEEKLY;BYDAY=MO", + scheduled: undefined, + }); + applyTaskToVTodo(doc, task, context, { uid: "u1" }); + + expect(getProperty(doc, "RRULE")?.value).toBe("FREQ=WEEKLY;BYDAY=MO"); + expect(getProperty(doc, "DTSTART")?.value).toBe("20240115"); + + const patch = readVTodoIntoTaskPatch(doc, context); + expect(patch.recurrence).toBe("DTSTART:20240115;FREQ=WEEKLY;BYDAY=MO"); + }); +}); + +describe("applyTaskToVTodo", () => { + it("writes the fields TaskNotes owns", () => { + const doc = createVTodoDocument(); + const task = makeTask({ + title: "Buy groceries", + due: "2025-09-03", + priority: "high", + tags: ["errands", "shopping"], + }); + applyTaskToVTodo(doc, task, context, { uid: "uid-1", now: "2025-09-01T12:00:00Z" }); + + expect(getTextProperty(doc, "UID")).toBe("uid-1"); + expect(getTextProperty(doc, "SUMMARY")).toBe("Buy groceries"); + expect(getProperty(doc, "DUE")).toMatchObject({ + value: "20250903", + params: { VALUE: "DATE" }, + }); + expect(getProperty(doc, "STATUS")?.value).toBe("NEEDS-ACTION"); + expect(getProperty(doc, "PRIORITY")?.value).toBe("1"); + expect(getProperty(doc, "DTSTAMP")?.value).toBe("20250901T120000Z"); + expect(getProperty(doc, "CATEGORIES")?.value).toBe("errands,shopping"); + }); + + it("writes COMPLETED and PERCENT-COMPLETE for a done task", () => { + const doc = createVTodoDocument(); + const task = makeTask({ status: "done", completedDate: "2025-09-02" }); + applyTaskToVTodo(doc, task, context, { uid: "uid-1", now: "2025-09-02T09:00:00Z" }); + + expect(getProperty(doc, "STATUS")?.value).toBe("COMPLETED"); + expect(getProperty(doc, "PERCENT-COMPLETE")?.value).toBe("100"); + // RFC 5545 requires COMPLETED to be a UTC date-time. + expect(getProperty(doc, "COMPLETED")?.value).toBe("20250902T000000Z"); + }); + + it("clears COMPLETED when a task is reopened", () => { + const doc = createVTodoDocument(); + applyTaskToVTodo(doc, makeTask({ status: "done", completedDate: "2025-09-02" }), context, { + uid: "uid-1", + }); + expect(getProperty(doc, "COMPLETED")).toBeDefined(); + + applyTaskToVTodo(doc, makeTask({ status: "open" }), context, { uid: "uid-1" }); + expect(getProperty(doc, "COMPLETED")).toBeUndefined(); + expect(getProperty(doc, "PERCENT-COMPLETE")).toBeUndefined(); + expect(getProperty(doc, "STATUS")?.value).toBe("NEEDS-ACTION"); + }); + + it("removes DUE when a task's due date is cleared", () => { + const doc = createVTodoDocument(); + applyTaskToVTodo(doc, makeTask({ due: "2025-09-03" }), context, { uid: "u" }); + expect(getProperty(doc, "DUE")).toBeDefined(); + + applyTaskToVTodo(doc, makeTask({ due: undefined }), context, { uid: "u" }); + expect(getProperty(doc, "DUE")).toBeUndefined(); + }); + + it("bumps SEQUENCE on each write", () => { + const doc = createVTodoDocument(); + applyTaskToVTodo(doc, makeTask(), context, { uid: "u" }); + expect(getProperty(doc, "SEQUENCE")?.value).toBe("1"); + applyTaskToVTodo(doc, makeTask(), context, { uid: "u" }); + expect(getProperty(doc, "SEQUENCE")?.value).toBe("2"); + }); + + it("leaves properties it does not own untouched", () => { + const remote = [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:remote-uid", + "SUMMARY:Old title", + "DESCRIPTION:A long note body written on the phone", + "X-APPLE-SORT-ORDER:987", + "RELATED-TO;RELTYPE=PARENT:parent-uid", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:-PT15M", + "END:VALARM", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n"); + + const doc = parseVTodoDocument(remote)!; + applyTaskToVTodo(doc, makeTask({ title: "New title" }), context, { + uid: "remote-uid", + }); + const out = serializeVTodoDocument(doc); + + expect(out).toContain("SUMMARY:New title"); + expect(out).toContain("DESCRIPTION:A long note body written on the phone"); + expect(out).toContain("X-APPLE-SORT-ORDER:987"); + expect(out).toContain("RELATED-TO;RELTYPE=PARENT:parent-uid"); + expect(out).toContain("BEGIN:VALARM"); + expect(out).toContain("TRIGGER:-PT15M"); + }); +}); + +describe("readVTodoIntoTaskPatch", () => { + it("reads a server-authored VTODO", () => { + const doc = parseVTodoDocument( + [ + "BEGIN:VCALENDAR", + "BEGIN:VTODO", + "UID:abc", + "SUMMARY:Call the dentist", + "DUE;VALUE=DATE:20250910", + "STATUS:NEEDS-ACTION", + "PRIORITY:1", + "CATEGORIES:health,calls", + "END:VTODO", + "END:VCALENDAR", + ].join("\r\n") + )!; + + expect(readVTodoIntoTaskPatch(doc, context)).toMatchObject({ + title: "Call the dentist", + due: "2025-09-10", + status: "none", + priority: "high", + tags: ["health", "calls"], + }); + }); + + it("signals cleared fields with null rather than omitting them", () => { + const doc = parseVTodoDocument( + ["BEGIN:VCALENDAR", "BEGIN:VTODO", "UID:abc", "END:VTODO", "END:VCALENDAR"].join( + "\r\n" + ) + )!; + const patch = readVTodoIntoTaskPatch(doc, context); + + expect(patch.due).toBeNull(); + expect(patch.scheduled).toBeNull(); + expect(patch.completedDate).toBeNull(); + expect(patch.recurrence).toBeNull(); + }); + + it("survives a full task -> VTODO -> task round trip", () => { + const doc = createVTodoDocument(); + const task = makeTask({ + title: "Round trip", + status: "done", + priority: "high", + due: "2025-09-03", + scheduled: "2025-09-01", + completedDate: "2025-09-02", + tags: ["a", "b"], + }); + applyTaskToVTodo(doc, task, context, { uid: "u" }); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc))!; + expect(readVTodoIntoTaskPatch(reparsed, context)).toMatchObject({ + title: "Round trip", + status: "done", + priority: "high", + due: "2025-09-03", + scheduled: "2025-09-01", + completedDate: "2025-09-02", + tags: ["a", "b"], + }); + }); + + it("reads the UID", () => { + const doc = createVTodoDocument(); + applyTaskToVTodo(doc, makeTask(), context, { uid: "the-uid" }); + expect(readVTodoUid(doc)).toBe("the-uid"); + }); +}); + +describe("readVTodoRevision", () => { + function docWith(lines: string[]) { + return parseVTodoDocument( + ["BEGIN:VCALENDAR", "BEGIN:VTODO", "UID:x", ...lines, "END:VTODO", "END:VCALENDAR"].join( + "\r\n" + ) + )!; + } + + it("prefers LAST-MODIFIED over DTSTAMP", () => { + const doc = docWith(["DTSTAMP:20250901T120000Z", "LAST-MODIFIED:20250901T130000Z"]); + expect(readVTodoRevision(doc)).toBe(Date.UTC(2025, 8, 1, 13, 0, 0)); + }); + + it("falls back to DTSTAMP, which RFC 5545 defines as the last revision for stored objects", () => { + expect(readVTodoRevision(docWith(["DTSTAMP:20250901T120000Z"]))).toBe( + Date.UTC(2025, 8, 1, 12, 0, 0) + ); + }); + + it("returns null when neither is present, so the caller can fall back", () => { + expect(readVTodoRevision(docWith([]))).toBeNull(); + }); +}); diff --git a/tests/unit/caldav/vtodoRelations.test.ts b/tests/unit/caldav/vtodoRelations.test.ts new file mode 100644 index 000000000..6eb3315a7 --- /dev/null +++ b/tests/unit/caldav/vtodoRelations.test.ts @@ -0,0 +1,148 @@ +import { + applyRelations, + ownsRelation, + readRelations, +} from "../../../src/services/caldav/vtodoRelations"; +import { + parseVTodoDocument, + serializeVTodoDocument, + type VTodoDocument, +} from "../../../src/services/caldav/vtodoDocument"; + +function docWith(...lines: string[]): VTodoDocument { + const doc = parseVTodoDocument( + ["BEGIN:VCALENDAR", "BEGIN:VTODO", "UID:self", ...lines, "END:VTODO", "END:VCALENDAR"].join( + "\r\n" + ) + ); + if (!doc) throw new Error("fixture did not parse"); + return doc; +} + +describe("readRelations", () => { + it("reads an explicit parent", () => { + const doc = docWith("RELATED-TO;RELTYPE=PARENT:parent-uid"); + expect(readRelations(doc).parents).toEqual(["parent-uid"]); + }); + + it("treats a missing RELTYPE as PARENT", () => { + // RFC 5545 makes PARENT the default, and Nextcloud Tasks relies on it + // when it writes a subtask. + const doc = docWith("RELATED-TO:parent-uid"); + expect(readRelations(doc).parents).toEqual(["parent-uid"]); + }); + + it("reads several parents, since a task can sit under many projects", () => { + const doc = docWith("RELATED-TO:one", "RELATED-TO;RELTYPE=PARENT:two"); + expect(readRelations(doc).parents).toEqual(["one", "two"]); + }); + + it("reads a dependency with its reltype and gap", () => { + const doc = docWith("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=PT2H:blocker-uid"); + expect(readRelations(doc).dependencies).toEqual([ + { uid: "blocker-uid", reltype: "FINISHTOSTART", gap: "PT2H" }, + ]); + }); + + it("omits gap when the server did not send one", () => { + const doc = docWith("RELATED-TO;RELTYPE=STARTTOSTART:blocker-uid"); + expect(readRelations(doc).dependencies).toEqual([ + { uid: "blocker-uid", reltype: "STARTTOSTART" }, + ]); + }); + + it("ignores reltypes TaskNotes does not model", () => { + const doc = docWith("RELATED-TO;RELTYPE=SIBLING:other-uid"); + expect(readRelations(doc)).toEqual({ parents: [], dependencies: [] }); + }); + + it("is case-insensitive about the reltype", () => { + const doc = docWith("RELATED-TO;RELTYPE=parent:parent-uid"); + expect(readRelations(doc).parents).toEqual(["parent-uid"]); + }); + + it("skips an empty value rather than inventing a relation", () => { + const doc = docWith("RELATED-TO;RELTYPE=PARENT:"); + expect(readRelations(doc).parents).toEqual([]); + }); + + it("does not report the same parent twice", () => { + const doc = docWith("RELATED-TO:dup", "RELATED-TO;RELTYPE=PARENT:dup"); + expect(readRelations(doc).parents).toEqual(["dup"]); + }); +}); + +describe("applyRelations", () => { + it("writes parents and dependencies", () => { + const doc = docWith(); + applyRelations(doc, { + parents: ["parent-uid"], + dependencies: [{ uid: "blocker-uid", reltype: "FINISHTOSTART", gap: "PT2H" }], + }); + + const output = serializeVTodoDocument(doc); + expect(output).toContain("RELATED-TO;RELTYPE=PARENT:parent-uid"); + expect(output).toContain("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=PT2H:blocker-uid"); + }); + + it("preserves reltypes it does not own", () => { + // The whole point of owning relations per line: another client's SIBLING + // link has to survive a push from TaskNotes. + const doc = docWith( + "RELATED-TO;RELTYPE=SIBLING:sibling-uid", + "RELATED-TO;RELTYPE=PARENT:old-parent" + ); + applyRelations(doc, { parents: ["new-parent"], dependencies: [] }); + + const output = serializeVTodoDocument(doc); + expect(output).toContain("RELATED-TO;RELTYPE=SIBLING:sibling-uid"); + expect(output).toContain("RELATED-TO;RELTYPE=PARENT:new-parent"); + expect(output).not.toContain("old-parent"); + }); + + it("clears owned relations when the task no longer has any", () => { + const doc = docWith("RELATED-TO;RELTYPE=PARENT:old-parent"); + applyRelations(doc, { parents: [], dependencies: [] }); + expect(serializeVTodoDocument(doc)).not.toContain("RELATED-TO"); + }); + + it("round-trips through a serialize and re-parse", () => { + const doc = docWith(); + const relations = { + parents: ["a-uid", "b-uid"], + dependencies: [{ uid: "c-uid", reltype: "FINISHTOFINISH" as const, gap: "P1D" }], + }; + applyRelations(doc, relations); + + const reparsed = parseVTodoDocument(serializeVTodoDocument(doc)); + expect(reparsed).not.toBeNull(); + expect(readRelations(reparsed!)).toEqual(relations); + }); + + it("does not duplicate a parent listed twice", () => { + const doc = docWith(); + applyRelations(doc, { parents: ["same", "same"], dependencies: [] }); + expect(serializeVTodoDocument(doc).match(/RELATED-TO/gu)).toHaveLength(1); + }); + + it("leaves other properties untouched", () => { + const doc = docWith("SUMMARY:Call Max", "X-APPLE-SORT-ORDER:12"); + applyRelations(doc, { parents: ["parent-uid"], dependencies: [] }); + + const output = serializeVTodoDocument(doc); + expect(output).toContain("SUMMARY:Call Max"); + expect(output).toContain("X-APPLE-SORT-ORDER:12"); + }); +}); + +describe("ownsRelation", () => { + it("claims parents and dependencies but nothing else", () => { + expect(ownsRelation({ name: "RELATED-TO", params: {}, value: "x" })).toBe(true); + expect( + ownsRelation({ name: "RELATED-TO", params: { RELTYPE: "FINISHTOSTART" }, value: "x" }) + ).toBe(true); + expect(ownsRelation({ name: "RELATED-TO", params: { RELTYPE: "SIBLING" }, value: "x" })).toBe( + false + ); + }); +}); diff --git a/tests/unit/issues/issue-811-caldav-two-way-sync.test.ts b/tests/unit/issues/issue-811-caldav-two-way-sync.test.ts deleted file mode 100644 index b431ee23d..000000000 --- a/tests/unit/issues/issue-811-caldav-two-way-sync.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Issue #811 - CalDAV Two-Way Sync Support Feature Request - * - * This test file documents the expected behavior for CalDAV two-way sync. - * The key distinction from issue #1209 (CalDAV integration) is the bidirectional - * nature: not just reading calendar events, but syncing TaskNotes tasks TO a - * CalDAV server (similar to how TaskCalendarSyncService works for Google Calendar). - * - * Feature Request: https://github.com/tasknotes/tasknotes/issues/811 - * - * User's use case: - * - Sync with CalDAV servers (Nextcloud, iCloud, other CalDAV-compatible services) - * - Keep tasks and deadlines unified across devices and apps - * - Integrate Obsidian notes with existing calendar setups - * - Avoid duplicating effort by re-entering tasks in multiple places - * - * Expected functionality: - * - Task-to-CalDAV sync: Create calendar events from tasks on CalDAV server - * - CalDAV-to-task sync: Create/update tasks from CalDAV events - * - Conflict resolution for bidirectional changes - * - Support for various CalDAV providers (Nextcloud, iCloud, Radicale, etc.) - */ - -import { EventEmitter } from "events"; -import { ICSEvent, TaskInfo } from "../../../src/types"; - -// Mock Obsidian dependencies -jest.mock("obsidian", () => ({ - Notice: jest.fn(), - requestUrl: jest.fn(), - TFile: jest.fn(), -})); - -/** - * Mock CalDAV Two-Way Sync Service - * This represents what a real CalDAVTwoWaySyncService would look like, - * analogous to TaskCalendarSyncService for Google Calendar - */ -class MockCalDAVTwoWaySyncService extends EventEmitter { - private serverUrl: string; - private calendarId: string | null = null; - private enabled = false; - - // Track synced items: task path -> CalDAV event UID - private syncedTasks: Map = new Map(); - // Track CalDAV events that have been imported as tasks - private importedEvents: Map = new Map(); - - constructor(config: { serverUrl: string }) { - super(); - this.serverUrl = config.serverUrl; - } - - setTargetCalendar(calendarId: string): void { - this.calendarId = calendarId; - } - - setEnabled(enabled: boolean): void { - this.enabled = enabled; - } - - isEnabled(): boolean { - return this.enabled && this.calendarId !== null; - } - - /** - * Sync a task to CalDAV - creates or updates a calendar event - */ - async syncTaskToCalDAV(task: TaskInfo): Promise { - if (!this.isEnabled()) return null; - if (!task.due && !task.scheduled) return null; - - const eventUid = `tasknotes-${task.path.replace(/[^a-z0-9]/gi, "-")}`; - this.syncedTasks.set(task.path, eventUid); - return eventUid; - } - - /** - * Remove a task's calendar event from CalDAV - */ - async removeTaskFromCalDAV(taskPath: string): Promise { - this.syncedTasks.delete(taskPath); - } - - /** - * Import a CalDAV event as a task - */ - async importEventAsTask(event: ICSEvent): Promise { - if (!this.isEnabled()) return null; - - const taskPath = `tasks/${event.title.replace(/[^a-z0-9]/gi, "-")}.md`; - this.importedEvents.set(event.id, taskPath); - return taskPath; - } - - /** - * Get the CalDAV event UID for a synced task - */ - getEventUidForTask(taskPath: string): string | undefined { - return this.syncedTasks.get(taskPath); - } - - /** - * Get the task path for an imported CalDAV event - */ - getTaskPathForEvent(eventId: string): string | undefined { - return this.importedEvents.get(eventId); - } - - /** - * Perform a full bidirectional sync - */ - async performFullSync(): Promise<{ - tasksCreated: number; - eventsCreated: number; - conflicts: number; - }> { - return { tasksCreated: 0, eventsCreated: 0, conflicts: 0 }; - } - - destroy(): void { - this.syncedTasks.clear(); - this.importedEvents.clear(); - } -} - -describe("Issue #811 - CalDAV Two-Way Sync Support", () => { - let syncService: MockCalDAVTwoWaySyncService; - - beforeEach(() => { - syncService = new MockCalDAVTwoWaySyncService({ - serverUrl: "https://nextcloud.example.com/remote.php/dav/", - }); - }); - - afterEach(() => { - syncService.destroy(); - }); - - describe("Task-to-CalDAV Sync (Outbound)", () => { - it.skip("reproduces issue #811 - should sync task with due date to CalDAV", async () => { - // Feature: When a task has a due date, create a corresponding calendar event on CalDAV - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Submit report", - path: "tasks/submit-report.md", - status: "open", - priority: "medium", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - const eventUid = await syncService.syncTaskToCalDAV(task); - - expect(eventUid).toBeDefined(); - expect(syncService.getEventUidForTask(task.path)).toBe(eventUid); - }); - - it.skip("reproduces issue #811 - should sync task with scheduled date to CalDAV", async () => { - // Feature: Tasks with scheduled dates should also sync as calendar events - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Team meeting prep", - path: "tasks/team-meeting-prep.md", - status: "open", - priority: "high", - scheduled: "2025-01-15T09:00", - projects: [], - contexts: [], - tags: [], - }; - - const eventUid = await syncService.syncTaskToCalDAV(task); - - expect(eventUid).toBeDefined(); - }); - - it.skip("reproduces issue #811 - should update CalDAV event when task changes", async () => { - // Feature: Task updates should propagate to CalDAV - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Original title", - path: "tasks/my-task.md", - status: "open", - priority: "medium", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - await syncService.syncTaskToCalDAV(task); - const eventUid = syncService.getEventUidForTask(task.path); - - // Update the task - task.title = "Updated title"; - task.due = "2025-01-25"; - - await syncService.syncTaskToCalDAV(task); - - // Should maintain the same event UID (update, not create new) - expect(syncService.getEventUidForTask(task.path)).toBe(eventUid); - }); - - it.skip("reproduces issue #811 - should delete CalDAV event when task is deleted", async () => { - // Feature: Removing a task should remove its calendar event - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Temporary task", - path: "tasks/temporary-task.md", - status: "open", - priority: "low", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - await syncService.syncTaskToCalDAV(task); - expect(syncService.getEventUidForTask(task.path)).toBeDefined(); - - await syncService.removeTaskFromCalDAV(task.path); - expect(syncService.getEventUidForTask(task.path)).toBeUndefined(); - }); - - it.skip("reproduces issue #811 - should mark CalDAV event completed when task is completed", async () => { - // Feature: Completing a task should update the CalDAV event status - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Complete me", - path: "tasks/complete-me.md", - status: "open", - priority: "medium", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - await syncService.syncTaskToCalDAV(task); - - // Mark task as completed - task.status = "completed"; - task.completedDate = "2025-01-18"; - - await syncService.syncTaskToCalDAV(task); - - // Event should be updated to reflect completion - expect(syncService.getEventUidForTask(task.path)).toBeDefined(); - }); - }); - - describe("CalDAV-to-Task Sync (Inbound)", () => { - it.skip("reproduces issue #811 - should create task from CalDAV event", async () => { - // Feature: Import CalDAV events as TaskNotes tasks - // This keeps tasks unified across devices and apps - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const caldavEvent: ICSEvent = { - id: "caldav-personal-event123", - subscriptionId: "caldav-personal", - title: "Project deadline", - start: "2025-01-25", - allDay: true, - description: "Final submission for Q1 project", - }; - - const taskPath = await syncService.importEventAsTask(caldavEvent); - - expect(taskPath).toBeDefined(); - expect(syncService.getTaskPathForEvent(caldavEvent.id)).toBe( - taskPath - ); - }); - - it.skip("reproduces issue #811 - should update task when CalDAV event changes", async () => { - // Feature: Changes made to events in external calendar apps should - // sync back to TaskNotes (avoids re-entering tasks in multiple places) - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const caldavEvent: ICSEvent = { - id: "caldav-personal-event456", - subscriptionId: "caldav-personal", - title: "Meeting", - start: "2025-01-20T10:00:00", - end: "2025-01-20T11:00:00", - allDay: false, - }; - - await syncService.importEventAsTask(caldavEvent); - const taskPath = syncService.getTaskPathForEvent(caldavEvent.id); - - // Event is updated externally - caldavEvent.title = "Important Meeting"; - caldavEvent.start = "2025-01-21T10:00:00"; - - await syncService.importEventAsTask(caldavEvent); - - // Should update the same task, not create a new one - expect(syncService.getTaskPathForEvent(caldavEvent.id)).toBe( - taskPath - ); - }); - - it.skip("reproduces issue #811 - should handle timed events from CalDAV", async () => { - // Feature: Support importing events with specific times - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const timedEvent: ICSEvent = { - id: "caldav-personal-timed1", - subscriptionId: "caldav-personal", - title: "Doctor appointment", - start: "2025-01-22T14:30:00", - end: "2025-01-22T15:00:00", - allDay: false, - location: "Medical Center", - }; - - const taskPath = await syncService.importEventAsTask(timedEvent); - - expect(taskPath).toBeDefined(); - // Task should preserve the time component - }); - }); - - describe("Bidirectional Sync and Conflict Resolution", () => { - it.skip("reproduces issue #811 - should detect and handle sync conflicts", async () => { - // Feature: When both task and event are modified, handle conflict - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - // Scenario: Task synced to CalDAV, then both are modified - const result = await syncService.performFullSync(); - - // Should track conflicts for user resolution - expect(result).toHaveProperty("conflicts"); - }); - - it.skip("reproduces issue #811 - should perform full bidirectional sync", async () => { - // Feature: Sync all tasks to CalDAV and import all CalDAV events as tasks - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const result = await syncService.performFullSync(); - - expect(result).toHaveProperty("tasksCreated"); - expect(result).toHaveProperty("eventsCreated"); - }); - - it.skip("reproduces issue #811 - should not duplicate items during sync", async () => { - // Feature: Syncing should maintain 1:1 mapping between tasks and events - // This addresses the user's concern about "duplicating effort" - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - const task: TaskInfo = { - title: "Unique task", - path: "tasks/unique-task.md", - status: "open", - priority: "medium", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - // Sync multiple times - await syncService.syncTaskToCalDAV(task); - await syncService.syncTaskToCalDAV(task); - await syncService.syncTaskToCalDAV(task); - - // Should still have only one event UID - const eventUid = syncService.getEventUidForTask(task.path); - expect(eventUid).toBeDefined(); - }); - }); - - describe("CalDAV Provider Support", () => { - it.skip("reproduces issue #811 - should support Nextcloud CalDAV server", async () => { - // Feature: Connect to Nextcloud calendar - const nextcloudService = new MockCalDAVTwoWaySyncService({ - serverUrl: "https://nextcloud.example.com/remote.php/dav/", - }); - - nextcloudService.setTargetCalendar("personal"); - nextcloudService.setEnabled(true); - - expect(nextcloudService.isEnabled()).toBe(true); - nextcloudService.destroy(); - }); - - it.skip("reproduces issue #811 - should support iCloud CalDAV server", async () => { - // Feature: Connect to iCloud calendar (CalDAV-compatible) - const icloudService = new MockCalDAVTwoWaySyncService({ - serverUrl: "https://caldav.icloud.com/", - }); - - icloudService.setTargetCalendar("calendar-id"); - icloudService.setEnabled(true); - - expect(icloudService.isEnabled()).toBe(true); - icloudService.destroy(); - }); - - it.skip("reproduces issue #811 - should support other CalDAV-compatible services", async () => { - // Feature: Generic CalDAV support for any compliant server - // Examples: Radicale, Baikal, ownCloud, Fastmail, etc. - const genericService = new MockCalDAVTwoWaySyncService({ - serverUrl: "https://calendar.example.com/caldav/", - }); - - genericService.setTargetCalendar("default"); - genericService.setEnabled(true); - - expect(genericService.isEnabled()).toBe(true); - genericService.destroy(); - }); - }); - - describe("Cross-Device Sync Scenarios", () => { - it.skip("reproduces issue #811 - should keep tasks unified across devices", async () => { - // Feature: User's main use case - tasks stay in sync across devices/apps - // Task created in Obsidian -> appears in phone calendar app - // Event created in calendar app -> appears as task in Obsidian - syncService.setTargetCalendar("personal"); - syncService.setEnabled(true); - - // Create task in TaskNotes - const task: TaskInfo = { - title: "Call client", - path: "tasks/call-client.md", - status: "open", - priority: "high", - due: "2025-01-20T14:00", - projects: [], - contexts: [], - tags: [], - }; - - // Sync to CalDAV (will appear on phone, tablet, other devices) - const eventUid = await syncService.syncTaskToCalDAV(task); - expect(eventUid).toBeDefined(); - - // Event created externally (from phone calendar) - const externalEvent: ICSEvent = { - id: "caldav-personal-external1", - subscriptionId: "caldav-personal", - title: "Dentist appointment", - start: "2025-01-22T09:00:00", - end: "2025-01-22T10:00:00", - allDay: false, - }; - - // Import as task in TaskNotes - const taskPath = - await syncService.importEventAsTask(externalEvent); - expect(taskPath).toBeDefined(); - }); - - it.skip("reproduces issue #811 - should integrate with existing calendar setups", async () => { - // Feature: Work with user's existing CalDAV calendar infrastructure - // No need to change their current workflow, just enhance it - syncService.setTargetCalendar("work-calendar"); - syncService.setEnabled(true); - - // Existing calendar events should be importable - const existingEvent: ICSEvent = { - id: "caldav-work-existing1", - subscriptionId: "caldav-work-calendar", - title: "Existing team meeting", - start: "2025-01-21T15:00:00", - end: "2025-01-21T16:00:00", - allDay: false, - description: "Weekly sync", - }; - - const taskPath = - await syncService.importEventAsTask(existingEvent); - expect(taskPath).toBeDefined(); - }); - }); - - describe("Service Configuration", () => { - it.skip("reproduces issue #811 - should require target calendar to be enabled", async () => { - // Feature: Must select a calendar before sync works - syncService.setEnabled(true); - // No calendar selected - - expect(syncService.isEnabled()).toBe(false); - - syncService.setTargetCalendar("personal"); - expect(syncService.isEnabled()).toBe(true); - }); - - it.skip("reproduces issue #811 - should not sync when disabled", async () => { - // Feature: Sync can be toggled on/off - syncService.setTargetCalendar("personal"); - syncService.setEnabled(false); - - const task: TaskInfo = { - title: "Should not sync", - path: "tasks/no-sync.md", - status: "open", - priority: "low", - due: "2025-01-20", - projects: [], - contexts: [], - tags: [], - }; - - const eventUid = await syncService.syncTaskToCalDAV(task); - expect(eventUid).toBeNull(); - }); - }); -});