diff --git a/README.md b/README.md index 5747009b3..2c5e38cf8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ A task management plugin where each task is a separate Markdown note, and all views are powered by [Obsidian Bases](https://help.obsidian.md/bases). +> [!NOTE] +> **TaskNotes is also available in Beta on the web and iPhone.** [Open TaskNotes on the web](https://app.tasknotes.dev/) or [download it from the App Store](https://apps.apple.com/app/tasknotes/id6797168033). + ## Overview diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 6920db99e..343577d4e 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -211,6 +211,8 @@ Update task with partial payload. Configured TaskNotes user fields can be updated either by their frontmatter property key or via `customProperties`. +Sending an empty array for `contexts` or `blockedBy` clears the corresponding frontmatter field. + ```bash curl -X PUT "http://localhost:8080/api/tasks/TaskNotes%2FTasks%2FReview%20docs.md" \ -H "Content-Type: application/json" \ diff --git a/docs/assets/feature-completion-date-actions-flat.png b/docs/assets/feature-completion-date-actions-flat.png new file mode 100644 index 000000000..9f5ab15b0 Binary files /dev/null and b/docs/assets/feature-completion-date-actions-flat.png differ diff --git a/docs/assets/feature-completion-date-actions.png b/docs/assets/feature-completion-date-actions.png new file mode 100644 index 000000000..d200e2594 Binary files /dev/null and b/docs/assets/feature-completion-date-actions.png differ diff --git a/docs/assets/settings-complete-skip-submenu.png b/docs/assets/settings-complete-skip-submenu.png new file mode 100644 index 000000000..37ffa0263 Binary files /dev/null and b/docs/assets/settings-complete-skip-submenu.png differ diff --git a/docs/code-mapping.json b/docs/code-mapping.json index d681fa74d..995e47d74 100644 --- a/docs/code-mapping.json +++ b/docs/code-mapping.json @@ -28,13 +28,15 @@ }, { "id": "task-management", - "description": "Task CRUD, filtering, status, priority, and auto-archiving", + "description": "Task CRUD, filtering, status, priority, completion menu, and auto-archiving", "source": [ "src/services/TaskService.ts", "src/services/FilterService.ts", "src/services/StatusManager.ts", "src/services/PriorityManager.ts", - "src/services/AutoArchiveService.ts" + "src/services/AutoArchiveService.ts", + "src/components/TaskContextMenu.ts", + "src/ui/completionDateResolver.ts" ], "docs": ["docs/features/task-management.md"] }, diff --git a/docs/features/recurring-tasks.md b/docs/features/recurring-tasks.md index 63316c84a..3812e9815 100644 --- a/docs/features/recurring-tasks.md +++ b/docs/features/recurring-tasks.md @@ -209,6 +209,8 @@ Materialized occurrence notes keep `occurrence_date` as their identity. If you d Each occurrence can be completed or skipped independently (task cards, calendar menus, task edit modal completion calendar). +When you complete an occurrence from the context menu, you can choose which date it is recorded against — today, the occurrence's scheduled date, its due date, or a date you pick (see [Completing Tasks](task-management.md#completing-tasks)). Completion-anchored recurrences resolve *on schedule* to the newly scheduled date, an explicit re-anchor. + Completed instances are stored in: ```yaml @@ -225,6 +227,10 @@ When completion changes, `scheduled` updates to the next uncompleted instance. I This means completion history and next-action planning stay synchronized automatically, without manually advancing recurring tasks. +### Rescheduling clears later instances + +When you change a recurring task's scheduled date (from the context menu or the scheduled-date field), TaskNotes restarts the timeline from the new date: it clears every completed and skipped instance dated **on or after** that date, while keeping older instances as history. This reactivates occurrences you had completed or skipped off-schedule so they can be acted on again. Because clearing is destructive, TaskNotes asks you to confirm first — listing the dates that will be cleared — and cancelling leaves both the schedule and the recorded instances untouched. + ## Flexible Scheduling TaskNotes intentionally allows off-pattern scheduling so recurring tasks can absorb real-world disruptions without rewriting the entire recurrence rule. diff --git a/docs/features/task-management.md b/docs/features/task-management.md index 7ce977d09..eb59500ef 100644 --- a/docs/features/task-management.md +++ b/docs/features/task-management.md @@ -178,6 +178,25 @@ These settings let you align task files with existing vault conventions (for exa For configuration details, see [Task Defaults](../settings/task-defaults.md). For template variables, see [Template Variables Reference](template-variables.md). +## Completing Tasks + +You can record a task's completion — and choose the date it is recorded against — directly from the task context menu, without opening the task. Four actions are offered: + +- **Completed today** — records today's date. +- **Completed on schedule** — records the task's scheduled date (for a recurring task, the scheduled date of the occurrence you clicked). +- **Completed on due date** — records the due date. +- **Completed on (pick date)** — opens a date picker and records the date you choose. + +An action is disabled when the date it needs is not set — for example *Completed on schedule* on a task with no scheduled date — rather than silently falling back to another date. Recurring tasks also get a **Skip instance** action alongside these. + +By default the actions are grouped under a single **Mark complete or skip** entry (**Mark complete** for non-recurring tasks). To show them directly in the menu instead — set off by dividers — turn off **Settings → Appearance & UI → Task cards → Group complete and skip actions in a submenu**. + +![Completion actions grouped under a submenu (default)](../assets/feature-completion-date-actions.png) + +With the submenu grouping turned off, the same actions appear directly in the menu: + +![Completion actions shown flat in the menu](../assets/feature-completion-date-actions-flat.png) + ## Recurring Tasks TaskNotes recurring tasks use RFC 5545 RRule syntax with `DTSTART`, separate pattern definition from next occurrence scheduling, and support independent instance completion. When an individual recurrence needs its own checklist, time entries, or notes, you can create a materialized occurrence note from the task or calendar context menu. diff --git a/docs/releases.md b/docs/releases.md index 16c6457af..7d6fa88f0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,6 +6,8 @@ Welcome to the TaskNotes release notes. Here you can find detailed information a ### Version 4.x (Current) +- [4.12.5](releases/4.12.5.md) +- [4.12.4](releases/4.12.4.md) - [4.12.3](releases/4.12.3.md) - [4.12.2](releases/4.12.2.md) - [4.12.1](releases/4.12.1.md) diff --git a/docs/releases/4.12.4.md b/docs/releases/4.12.4.md new file mode 100644 index 000000000..b6af5404d --- /dev/null +++ b/docs/releases/4.12.4.md @@ -0,0 +1,63 @@ +# TaskNotes 4.12.4 + +> [!info] TaskNotes v5 beta +> +> [TaskNotes v5](https://github.com/callumalpass/tasknotes/releases/tag/5.0.0-beta.0) is available as an opt-in beta alongside TaskNotes 4.12. TaskNotes 4.12 remains the recommended stable release. +> +> The major change in v5 is support for portable TaskNotes collections. These collections can be used in both Obsidian and the [TaskNotes app](https://app.tasknotes.dev/), which is also available in beta, so the same tasks can be managed through either interface. +> +> TaskNotes collections are built on [mdbase](https://mdbase.dev/), a Markdown-native collection format. [mdbase Connect](https://mdbase.dev/connect/) provides the permissioned bridge that makes those collections available to the TaskNotes app. Your tasks remain ordinary Markdown files in your vault; mdbase adds a shared structure that compatible tools can understand. +> +> TaskNotes v5, mdbase Connect, and the TaskNotes app are all under active development. Please use a backed-up vault, expect some rough edges, and [report any problems you encounter](https://github.com/callumalpass/tasknotes/issues). + +## Changed + +- (#2187) Improved TaskNotes startup performance by allowing Bases registration to finish asynchronously. Thanks to @tgrosinger for the contribution. + +## Fixed + +- (#2232) Opening an occurrence note from a completion-anchored recurring task now + uses the task's scheduled occurrence date instead of defaulting to today. Thanks + to @chmac for reporting this. +- (#2247) Creating a new inline task now removes nested wikilink and Markdown-link + markup from the inserted task link's display text. Thanks to @bgk0018 for + reporting this. +- (#2231) Fixed Agenda views with `showOverdueOnToday` showing the same task twice + when both its scheduled and due dates were overdue. Thanks to @takashit16833 for + reporting this. +- (#2206) The time entry editor now opens much faster for tasks with many tracked + sessions by delaying the heavier description editor setup until a description + field is focused. Thanks to @Sapere-Kyle for reporting this. +- (#2239) Fixed Task List Bases embedded in mobile notes leaving a large blank gap + after the final task card. Thanks to @same774 for reporting this and @prethrive + for sharing a workaround. +- (#2238) The Relationships widget now stays below long Live Preview Dataview + tables and no longer appears twice after switching to Reading mode. Thanks to + @Romek1769 for reporting this. +- (#2250) Hardened Google Calendar task metadata updates: duplicate Google + Calendar fields are now repaired atomically without replacing concurrent task + edits, and synchronization finishing after a disconnect no longer writes stale + event metadata. Thanks to @3zra47 for reporting the corrupted task data that + prompted this review. +- (#2229) Google Calendar OAuth authorization now opens in the system browser + instead of Obsidian's Web Viewer, avoiding Google's 401 malformed-request + page in the in-app browser. Thanks to @prethrive for reporting this and + confirming the workaround. +- Skipping a recurring task instance from Task List or Kanban now records the + task's occurrence date instead of the view's current date. Thanks to + @renatomen for the contribution. +- (#2130) Tasks added by external sync while Obsidian is closed are now exported + to Google Calendar after TaskNotes starts. Thanks to @seththepeacock for + reporting this and @raphaelfaouakhiri for the contribution. +- (#2246) Fixed materialized recurring occurrences hiding occurrence-template filename suffixes in TaskNotes views. Thanks to @raphaelfaouakhiri for reporting and fixing this issue. +- (#2221) Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. +- (#2191) Completing or skipping a recurring occurrence that was moved earlier + than its original date no longer schedules that original date again. Thanks to + @martin-forge for the contribution. +- (#2193) Google Calendar event descriptions written with formatting now read as + plain text in event details, copied text, and generated notes, instead of + showing raw HTML tags. Paragraph breaks, list structure, and link addresses are + kept. Thanks to @martin-forge for the contribution. +- (#2192) Agenda and list cards for Google and Microsoft calendar events now + show the name of the calendar the event belongs to, instead of the generic + "Calendar" label. Thanks to @martin-forge for the contribution. diff --git a/docs/releases/4.12.5.md b/docs/releases/4.12.5.md new file mode 100644 index 000000000..d9bdbc317 --- /dev/null +++ b/docs/releases/4.12.5.md @@ -0,0 +1,7 @@ +# TaskNotes 4.12.5 + +## Fixed + +- (#2255) Notes with an in-note task card no longer jump while scrolling in reading mode. The card is now restored only after scrolling settles instead of being re-injected every frame against Obsidian's virtualised reading view. Thanks to @logicelf for the detailed diagnosis. +- (#2256) Kanban swimlane order now follows the view's sort configuration again: with a formula or property sort applied, the swimlane holding the top-sorted task floats to the top of the board as it did before the swimlane ordering option was introduced. Thanks to @tsweezy for reporting this. +- (#2222) The TaskNotes settings tab bar now wraps to a second line instead of clipping the Integrations tab beside the documentation link on narrow settings panes. Thanks to @nextstitch and @YongcaiHuang for reporting this. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..4c320e310 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Added + +- (#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/docs/settings/appearance.md b/docs/settings/appearance.md index 13bd2a03f..668f8a22c 100644 --- a/docs/settings/appearance.md +++ b/docs/settings/appearance.md @@ -9,6 +9,10 @@ These settings control the visual appearance of the plugin, including the calend Use **Default visible properties** to decide what metadata appears on task cards without opening each task. This is the primary control for card density. +Use **Group complete and skip actions in a submenu** to control how the completion actions are laid out in the task context menu. When on (the default), the *Completed today / on schedule / on due date / on (pick date)* actions — and *Skip instance* for recurring tasks — are nested under a single **Mark complete or skip** entry. Turn it off to show them directly in the menu, set off by dividers. + +![Group complete and skip actions in a submenu](../assets/settings-complete-skip-submenu.png) + Checklist progress is available as a visible property in task cards. In Bases view `order` arrays, the corresponding source property is `file.tasks` (shown as `tasks` in Bases property pickers once present in the view `order` list). Nested task cards use CSS variables for their indentation. Add a CSS snippet if diff --git a/i18n.manifest.json b/i18n.manifest.json index e28e799f5..801d7efc3 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -816,6 +816,8 @@ "settings.appearance.taskCards.description": "4554f6f0816fa0c30fb2d11d11b7a4e0e9a610de", "settings.appearance.taskCards.defaultVisibleProperties.name": "0aa5efd8c0508868b5a726a4f3890fc3efb71d26", "settings.appearance.taskCards.defaultVisibleProperties.description": "1673317e8fda5fc944e0458b5e51563c048e2dcd", + "settings.appearance.taskCards.completionSubmenu.name": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "settings.appearance.taskCards.completionSubmenu.description": "edc8f49c252f25483a01c4675be3e296ba2623c9", "settings.appearance.taskCards.propertyGroups.coreProperties": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "settings.appearance.taskCards.propertyGroups.organization": "9da72239ddeee345a6b025c468d9817c49cae014", "settings.appearance.taskCards.propertyGroups.customProperties": "07daf13a314add9a92c0f8e5564d81ce72e4937c", @@ -1846,6 +1848,22 @@ "contextMenus.task.markIncomplete": "8d0d3b84c6c7d795e205c345054f9335ae222c21", "contextMenus.task.skipInstance": "ebb18d68bb33bb3188ca558e22656fa8f4cb7e95", "contextMenus.task.unskipInstance": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", + "contextMenus.task.completion.submenu": "54a51295b3ea49320049e2c7a225326711e0a571", + "contextMenus.task.completion.submenuCompleteOnly": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "contextMenus.task.completion.completeToday": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "contextMenus.task.completion.completeAsScheduled": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "contextMenus.task.completion.completeOnDue": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "contextMenus.task.completion.completeOnPicked": "9c2baea91e78091a5c390492c920218cc2a66146", + "contextMenus.task.completion.markIncomplete": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "contextMenus.task.completion.noScheduledDate": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "contextMenus.task.completion.noDueDate": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "contextMenus.task.completion.noPickedDate": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "contextMenus.task.completion.noCompletedStatus": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "contextMenus.task.completion.pickDateTitle": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "contextMenus.task.completion.completeFailure": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "contextMenus.task.completion.clearInstancesConfirmTitle": "a819ff5087a10bfd7d0300249ce038f90e818702", + "contextMenus.task.completion.clearInstancesConfirmMessage": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "contextMenus.task.completion.clearInstancesConfirmButton": "3bdcd7189cfa14996da1fdf37761df828a182d7e", "contextMenus.task.quickReminders.atTime": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "contextMenus.task.quickReminders.fiveMinutes": "c777f20872b577e3164c1b6d3f4f50d70faa8e93", "contextMenus.task.quickReminders.fifteenMinutes": "4bba5a72574fef683d773d45198acbf9a56d8961", diff --git a/i18n.state.json b/i18n.state.json index 7ebb09e0d..1d6968638 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -3268,6 +3268,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "6dcf0eab268427f03a4e65a6caa6da86bf7b388b" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8b854e53ad33addbdd700ee4851887dbc68b254c" @@ -7388,6 +7390,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dc50f18bbaa3249131904a53adc71400e7ebcd31" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "13edcc9c4ebe474df17518c264115a00ddf30433" @@ -12158,6 +12176,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "7544b9bce2f306bf0f132f20496495986a8c0232" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "a3fde73e81c95a92d5645faa11a31f43498671bf" @@ -16278,6 +16298,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "968673d261b5844d3efa0738be3c13af2077c0d9" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "2838cecba7368421d90e0f07c0d0f1e28830ffa4" @@ -21048,6 +21084,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4e2b33fa3b0ae5c5bb3ad726108d1eb4b4da505f" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "45c3c60132d0d7392905160e46d902e841ae8053" @@ -25168,6 +25206,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "dddefd2443584c9cf6331b468a73c21e6fe8556e" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "c6fa1b8853d66e5ede8d309bb9f6d82dc4f10657" @@ -29938,6 +29992,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "de48cc0c5751bfd7eb1c2a3f6043c0dcd7382581" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "5547ff8f91ea1468c73df95fe41ba7ae215a21a1" @@ -34058,6 +34114,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "7cd7d70058059bb1602bce7e2c25a6edaf8ef029" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7fdead3a6d8aaccda120699da7e45e369edc108c" @@ -38828,6 +38900,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "8c6ff390fde8a9506470b23ec2db414c267a6909" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "cc315ebd9ef6c843c3663a2a97dba8eda9edb868" @@ -42948,6 +43022,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "eff53181b011d57fdf7dfb4617f7807573a53d23" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "ee19ba1d573cbe4fb6cbbab4cb59e86260dd132a" @@ -47718,6 +47808,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4a1cd9917a4b43ae471d4c2a299469db22cd0210" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8a083e47d10d9cb24fbe3039f3247ffb08f7f97c" @@ -51838,6 +51930,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5c886f6f62aeb8f203759a44e10a075079e1555e" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "a2936a5b99f3ba9aef0ef806a85097d30db91392" @@ -56608,6 +56716,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "046d0996624d117cbbafac2278b6c7a1bf2d99c3" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "273a4585152f46d25617f5b68785c0aaa5ad0743" @@ -60728,6 +60838,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "4b21cae430620be61f61dc2db0e6dc1ed867b28d" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "0a461c2b22896eac59c259415fdbbe96b207b9bc" @@ -65498,6 +65624,8 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "b0935ca50aeb4136e0ed0229514b3e25e05bb98f" }, + "settings.appearance.taskCards.completionSubmenu.name": null, + "settings.appearance.taskCards.completionSubmenu.description": null, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "6503db48028c919fb82aa2f1b6beb676980bc93e" @@ -69618,6 +69746,22 @@ "source": "f58b99fdb842bddf12fa964f933eb1d9bd54843e", "translation": "5d08dfcf44803b1604301a2e36d0eb317fa81ca2" }, + "contextMenus.task.completion.submenu": null, + "contextMenus.task.completion.submenuCompleteOnly": null, + "contextMenus.task.completion.completeToday": null, + "contextMenus.task.completion.completeAsScheduled": null, + "contextMenus.task.completion.completeOnDue": null, + "contextMenus.task.completion.completeOnPicked": null, + "contextMenus.task.completion.markIncomplete": null, + "contextMenus.task.completion.noScheduledDate": null, + "contextMenus.task.completion.noDueDate": null, + "contextMenus.task.completion.noPickedDate": null, + "contextMenus.task.completion.noCompletedStatus": null, + "contextMenus.task.completion.pickDateTitle": null, + "contextMenus.task.completion.completeFailure": null, + "contextMenus.task.completion.clearInstancesConfirmTitle": null, + "contextMenus.task.completion.clearInstancesConfirmMessage": null, + "contextMenus.task.completion.clearInstancesConfirmButton": null, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7aaeb02feb12bd8334344d70a17e16b367174054" diff --git a/jest.config.js b/jest.config.js index f7d445922..1f5a6c6a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,10 @@ +// Pin the test timezone to UTC so local runs match CI (GitHub Actions runs in +// UTC). Set here — before Jest spawns its worker processes — so each worker +// inherits TZ and reads it at startup, before any Date is constructed (V8 caches +// the zone on first use). Overridable with an explicit `TZ=... jest` for +// timezone-specific testing. +process.env.TZ = process.env.TZ || 'UTC'; + module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', diff --git a/jest.integration.config.js b/jest.integration.config.js index b14fbcf5c..c23a9cc22 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -1,3 +1,8 @@ +// Pin the test timezone to UTC so local runs match CI (GitHub Actions runs in +// UTC). See jest.config.js for the rationale (set before workers spawn so each +// inherits TZ before any Date is constructed). Overridable via `TZ=... jest`. +process.env.TZ = process.env.TZ || 'UTC'; + module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', diff --git a/manifest.json b/manifest.json index cebda5606..64a49b676 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "tasknotes", "name": "TaskNotes", - "version": "4.12.3", + "version": "4.12.5", "minAppVersion": "1.12.2", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "author": "Callum Alpass", diff --git a/package-lock.json b/package-lock.json index 5266aa914..9ce12c4eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.5", "license": "MIT", "dependencies": { "@codemirror/view": "^6.38.6", diff --git a/package.json b/package.json index 01a4d997d..e22fea865 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.5", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "main": "main.js", "scripts": { diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index ed1868e73..9ca0bf0b6 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -29,6 +29,7 @@ import { handleTimeEntryCreation, handleDateTitleClick, getTargetDateForEvent, + getOccurrenceDateForEvent, calculateTaskCreationValues, generateTaskTooltip, applyRecurringTaskStyling, @@ -88,6 +89,7 @@ import { } from "./calendarEventMount"; import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../utils/calendarTime"; import { filterEmptyProjects, sanitizeForCssClass } from "../utils/helpers"; +import { processVaultFrontMatter } from "../services/VaultMutationService"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/CalendarView" }); @@ -2190,7 +2192,7 @@ export class CalendarView extends BasesViewBase { } // Update frontmatter - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const plan = planPropertyEventDrop({ frontmatter, startProperty: startProp, @@ -2323,7 +2325,8 @@ export class CalendarView extends BasesViewBase { const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); - await this.plugin.app.fileManager.processFrontMatter( + await processVaultFrontMatter( + this.plugin.app, spanFile, (frontmatter) => { if (plan.scheduled) frontmatter[scheduledField] = plan.scheduled; @@ -2426,7 +2429,7 @@ export class CalendarView extends BasesViewBase { } // Update frontmatter - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { for (const [property, value] of Object.entries(plan.updates)) { frontmatter[property] = value; } @@ -2763,6 +2766,7 @@ export class CalendarView extends BasesViewBase { task: taskInfo, plugin: this.plugin, targetDate: targetDate, + occurrenceDate: getOccurrenceDateForEvent(taskInfo, arg), promoteOccurrenceControls: Boolean( taskInfo.recurrence || (taskInfo.recurrence_parent && taskInfo.occurrence_date) diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 9256bcc15..3581fe337 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -80,6 +80,7 @@ import { shouldRenderKanbanColumn, } from "./kanbanGrouping"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { processVaultFrontMatter } from "../services/VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/KanbanView" }); @@ -3744,7 +3745,7 @@ export class KanbanView extends BasesViewBase { } // Single atomic write: groupBy + swimlane + sort_order - await this.plugin.app.fileManager.processFrontMatter(file, (fm) => { + await processVaultFrontMatter(this.plugin.app, file, (fm) => { applyKanbanTaskDropFrontmatterPlan(fm, dropPlan, { coerceGroupValue: (frontmatterKey, groupKey) => this.coerceGroupKeyForFrontmatter(frontmatterKey, groupKey), diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index a7de5bac3..aa66a4a34 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -65,6 +65,7 @@ import { moveItemsRelativeToTarget, } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { processVaultFrontMatter } from "../services/VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -1415,7 +1416,7 @@ export class TaskListView extends BasesViewBase { }); // Single atomic write: group property + sort_order + derivative fields - await this.plugin.app.fileManager.processFrontMatter(file, (fm) => { + await processVaultFrontMatter(this.plugin.app, file, (fm) => { applyTaskListDropFrontmatterMutation({ frontmatter: fm, plan: groupDropPlan, diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 4986c207d..0a25be2c2 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -466,6 +466,28 @@ export async function handleRecurringTaskDrop( } } +/** + * Return the recurrence instance addressed by a calendar event. Rendered dates + * from due events or time entries must not become occurrence identity. + */ +export function getOccurrenceDateForEvent( + taskInfo: TaskInfo, + eventArg: unknown +): Date | undefined { + if (!taskInfo.recurrence) { + return undefined; + } + + const eventContainer = eventArg as CalendarEventArgLike; + const event = eventContainer.event || eventContainer; + const instanceDate = event.extendedProps?.instanceDate; + if (typeof instanceDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(instanceDate)) { + return undefined; + } + + return parseDateToUTC(instanceDate); +} + /** * Get target date for calendar event context menu * Uses the same UTC-anchored logic as AdvancedCalendarView @@ -1606,9 +1628,11 @@ export async function generateCalendarEvents( includeScheduled: boolean, allowScheduledToDueSpan: boolean, includeDue = showDue, - hasGeneratedScheduledLayer = false + hasGeneratedScheduledLayer = false, + hasGeneratedScheduledLayerOnToday = false ): void => { let showedSpan = false; + let showedScheduledOverdueOnToday = false; if (allowScheduledToDueSpan && showScheduledToDueSpan && task.scheduled && task.due) { const spanEvents = createScheduledToDueSpanEvents( task, @@ -1646,6 +1670,7 @@ export async function generateCalendarEvents( : null; if (overdueEvent) { events.push(overdueEvent); + showedScheduledOverdueOnToday = true; } } } @@ -1662,7 +1687,11 @@ export async function generateCalendarEvents( if (dueEvent) { events.push(addMaterializedOccurrenceMetadata(dueEvent, task)); } - } else if (todayDate) { + } else if ( + todayDate && + !((showedScheduledOverdueOnToday || hasGeneratedScheduledLayerOnToday) && + !hasTimeComponent(task.due)) + ) { const dueEvent = createDueEvent(task, plugin); const overdueEvent = dueEvent ? createOverdueOnTodayEvent( @@ -1689,6 +1718,7 @@ export async function generateCalendarEvents( let includeStandaloneDue = showDue; let allowScheduledToDueSpan = true; let hasGeneratedScheduledLayer = false; + let hasGeneratedScheduledLayerOnToday = false; if ( (showRecurring || @@ -1719,6 +1749,12 @@ export async function generateCalendarEvents( events.push(...recurringEvents); if (showRecurring) { hasGeneratedScheduledLayer = recurringEvents.length > 0; + hasGeneratedScheduledLayerOnToday = Boolean( + todayDate && + recurringEvents.some( + (event) => getDatePart(event.start) === todayDate + ) + ); includeStandaloneScheduled = false; allowScheduledToDueSpan = false; if ( @@ -1738,7 +1774,8 @@ export async function generateCalendarEvents( includeStandaloneScheduled, allowScheduledToDueSpan, includeStandaloneDue, - hasGeneratedScheduledLayer + hasGeneratedScheduledLayer, + hasGeneratedScheduledLayerOnToday ); } else { // Handle non-recurring tasks with date range filtering diff --git a/src/bases/kanbanGrouping.ts b/src/bases/kanbanGrouping.ts index c6f8f1114..323495214 100644 --- a/src/bases/kanbanGrouping.ts +++ b/src/bases/kanbanGrouping.ts @@ -845,7 +845,12 @@ export function applyDefaultKanbanSwimLaneOrder(options: { }); } - return orderedKeys.sort(); + // No configured or priority/status default applies. Preserve the incoming + // key order instead of sorting alphabetically: `actualKeys` derives from the + // Bases-sorted task sequence, so this keeps global sort-driven swimlane row + // ordering (#2256) — e.g. a formula sort like `daysUntilDue` floats the + // swimlane holding the nearest due date to the top. + return orderedKeys; } export function applyKanbanSwimLaneOrder(options: { diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 987d0062c..de7a56e13 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -278,24 +278,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise window.setTimeout(r, 200)); - if (await attemptRegistration()) { - return true; - } - } - - logger.warn("Failed to register views after multiple attempts", { - category: "configuration", - operation: "register-views", - }); - return false; + return attemptRegistration(); } /** diff --git a/src/bases/sortOrderUtils.ts b/src/bases/sortOrderUtils.ts index aa12fd247..5a607f0e4 100644 --- a/src/bases/sortOrderUtils.ts +++ b/src/bases/sortOrderUtils.ts @@ -7,6 +7,7 @@ import { TFile } from "obsidian"; import type TaskNotesPlugin from "../main"; import type { TaskInfo } from "../types"; import { stringifyUnknown } from "../utils/stringUtils"; +import { processVaultFrontMatter } from "../services/VaultMutationService"; export interface SortOrderScopeFilter { property: string; @@ -557,7 +558,7 @@ async function writeSortOrder( if (!(file instanceof TFile)) return; const sortOrderField = plugin.settings.fieldMapping.sortOrder; - await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(plugin.app, file, (frontmatter) => { frontmatter[sortOrderField] = sortOrder; }); } diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b3..358a67369 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1,8 +1,13 @@ -import { Menu, Notice, Platform, TFile, type MenuItem, type TAbstractFile } from "obsidian"; +import { Menu, Notice, Platform, TFile, setTooltip, type MenuItem, type TAbstractFile } from "obsidian"; import type { OccurrenceMaterializationMode, OccurrenceNextTrigger } from "@tasknotes/model"; import TaskNotesPlugin from "../main"; import { TaskDependency, TaskInfo } from "../types"; -import { formatDateForStorage } from "../utils/dateUtils"; +import { formatDateForStorage, getDatePart, parseDateToUTC } from "../utils/dateUtils"; +import { + resolveCompletionDate, + type CompletionDateContext, + type CompletionMode, +} from "../ui/completionDateResolver"; import { ReminderModal } from "../modals/ReminderModal"; import { addTaskToProject, @@ -10,6 +15,7 @@ import { buildSubtaskCreationPrePopulatedValues, } from "../services/taskRelationshipActions"; import { renameVaultFile } from "../services/VaultMutationService"; +import { getRecurringTaskActionDate } from "../services/task-service/taskRecurringPlanning"; import { showConfirmationModal } from "../modals/ConfirmationModal"; import { DateContextMenu } from "./DateContextMenu"; import { DateTimePickerModal } from "../modals/DateTimePickerModal"; @@ -142,6 +148,8 @@ export interface TaskContextMenuOptions { task: TaskInfo; plugin: TaskNotesPlugin; targetDate: Date; + /** The clicked occurrence (Calendar); omit to let the service's anchor-aware default resolve the date. */ + occurrenceDate?: Date; onUpdate?: () => void; promoteOccurrenceControls?: boolean; } @@ -270,8 +278,11 @@ export class TaskContextMenu { this.addCustomDateFieldMenuItems(task, plugin); - if (task.recurrence) { - this.addRecurringInstanceMenuItems(task, plugin); + this.addCompleteOrSkipSection(task, plugin); + + // Occurrence note stays at the top level (it is not a complete/skip action). + if (task.recurrence && !this.options.promoteOccurrenceControls) { + this.addOccurrenceNoteMenuItem(task, plugin); } if (!hasPromotedOccurrenceControls && task.recurrence_parent && task.occurrence_date) { @@ -856,41 +867,230 @@ export class TaskContextMenu { }); } - private addRecurringInstanceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { - const dateStr = formatDateForStorage(this.options.targetDate); - const isCompletedForDate = task.complete_instances?.includes(dateStr) || false; + /** Render the completion and, for recurring tasks, skip actions. */ + private addCompleteOrSkipSection(task: TaskInfo, plugin: TaskNotesPlugin): void { + const isRecurring = !!task.recurrence; + + // Default to the submenu when the setting is unset (backward compatible). + if (plugin.settings.completionMenuAsSubmenu === false) { + this.menu.addSeparator(); + this.addCompletionMenuItems(task, plugin, this.menu); + if (isRecurring) { + this.addSkipMenuItem(task, plugin, this.menu); + } + this.menu.addSeparator(); + return; + } this.menu.addItem((item) => { item.setTitle( - isCompletedForDate - ? this.t("contextMenus.task.markIncomplete") - : this.t("contextMenus.task.markComplete") + this.t( + isRecurring + ? "contextMenus.task.completion.submenu" + : "contextMenus.task.completion.submenuCompleteOnly" + ) ); - item.setIcon(isCompletedForDate ? "x" : "check"); - item.onClick(async () => { - try { - await plugin.toggleRecurringTaskComplete(task, this.options.targetDate); - this.options.onUpdate?.(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - tasknotesLogger.error("Error toggling recurring task completion:", { - category: "persistence", - operation: "toggling-recurring-task-completion", - details: { taskPath: task.path }, - error: errorMessage, - }); - new Notice( - this.t("contextMenus.task.notices.toggleCompletionFailure", { - message: errorMessage, - }) - ); + item.setIcon("check-check"); + const submenu = getSubmenu(item); + this.addCompletionMenuItems(task, plugin, submenu); + if (isRecurring) { + this.addSkipMenuItem(task, plugin, submenu); + } + }); + } + + /** + * Render the four completion actions. Un-complete differs by type: recurring + * toggles each action's own date; non-recurring collapses to one "Mark incomplete". + */ + private addCompletionMenuItems(task: TaskInfo, plugin: TaskNotesPlugin, menu: Menu): void { + const isRecurring = !!task.recurrence; + + if (!isRecurring && plugin.statusManager.isCompletedStatus(task.status)) { + menu.addItem((item) => { + item.setTitle(this.t("contextMenus.task.completion.markIncomplete")); + item.setIcon("x"); + item.onClick(async () => { + await this.uncompleteNonRecurringTask(task, plugin); + }); + }); + return; + } + + this.addConcreteCompletionAction( + task, + plugin, + "today", + "contextMenus.task.completion.completeToday", + menu + ); + this.addConcreteCompletionAction( + task, + plugin, + "asScheduled", + "contextMenus.task.completion.completeAsScheduled", + menu + ); + this.addConcreteCompletionAction( + task, + plugin, + "onDue", + "contextMenus.task.completion.completeOnDue", + menu + ); + this.addPickCompletionDateAction(task, plugin, menu); + } + + private addConcreteCompletionAction( + task: TaskInfo, + plugin: TaskNotesPlugin, + mode: CompletionMode, + baseLabelKey: string, + menu: Menu + ): void { + const isRecurring = !!task.recurrence; + const ctx: CompletionDateContext = { occurrenceDate: this.options.occurrenceDate }; + const resolution = resolveCompletionDate(task, mode, ctx); + + menu.addItem((item) => { + if (!resolution.available) { + // Shown disabled (not hidden) with a reason tooltip. + item.setTitle(this.t(baseLabelKey)); + item.setIcon("check"); + item.setDisabled(true); + const el = getMenuItemElement(item); + if (el) { + setTooltip(el, this.t(resolution.reasonKey)); } + return; + } + + const resolvedDate = resolution.date; + const alreadyComplete = + isRecurring && + (task.complete_instances?.includes(formatDateForStorage(resolvedDate)) ?? false); + item.setTitle( + alreadyComplete + ? this.t("contextMenus.task.completion.markIncomplete") + : this.t(baseLabelKey) + ); + item.setIcon(alreadyComplete ? "x" : "check"); + item.onClick(async () => { + await this.dispatchCompletion(task, plugin, resolvedDate); }); }); + } - const isSkippedForDate = task.skipped_instances?.includes(dateStr) || false; + private addPickCompletionDateAction( + task: TaskInfo, + plugin: TaskNotesPlugin, + menu: Menu + ): void { + menu.addItem((item) => { + item.setTitle(this.t("contextMenus.task.completion.completeOnPicked")); + item.setIcon("calendar-plus"); + item.onClick(() => { + this.pickCompletionDate(task, plugin); + }); + }); + } - this.menu.addItem((item) => { + /** + * Open the shared date picker and record completion date-only (storage keys are + * date-only, so the picker offers no time). Cancel is a no-op. + */ + private pickCompletionDate(task: TaskInfo, plugin: TaskNotesPlugin): void { + this.menu.hide(); + const modal = new DateTimePickerModal(plugin.app, { + currentDate: null, + title: this.t("contextMenus.task.completion.pickDateTitle"), + showTime: false, + plugin, + onSelect: (date) => { + if (!date) { + return; + } + const picked = parseDateToUTC(getDatePart(date)); + const resolution = resolveCompletionDate(task, "onPicked", { pickedDate: picked }); + if (!resolution.available) { + return; + } + void this.dispatchCompletion(task, plugin, resolution.date); + }, + }); + modal.open(); + } + + private async dispatchCompletion( + task: TaskInfo, + plugin: TaskNotesPlugin, + resolvedDate: Date + ): Promise { + try { + if (task.recurrence) { + // Completion-anchored recurrences re-anchor DTSTART from the recorded date. + await plugin.toggleRecurringTaskComplete(task, resolvedDate); + } else { + const completedStatus = plugin.statusManager.getCompletedStatuses()[0]; + if (!completedStatus) { + new Notice(this.t("contextMenus.task.completion.noCompletedStatus")); + return; + } + await plugin.updateTaskProperty(task, "status", completedStatus, { + completionDate: formatDateForStorage(resolvedDate), + }); + } + this.options.onUpdate?.(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + tasknotesLogger.error("Error completing task:", { + category: "persistence", + operation: "completing-task", + details: { taskPath: task.path }, + error: errorMessage, + }); + new Notice( + this.t("contextMenus.task.completion.completeFailure", { + message: errorMessage, + }) + ); + } + } + + private async uncompleteNonRecurringTask( + task: TaskInfo, + plugin: TaskNotesPlugin + ): Promise { + try { + // Revert to the default open status; the status pipeline clears + // completedDate for a non-completed status (mirrors uncompleteTask). + const openStatus = plugin.settings.defaultTaskStatus ?? "open"; + await plugin.updateTaskProperty(task, "status", openStatus); + this.options.onUpdate?.(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + tasknotesLogger.error("Error marking task incomplete:", { + category: "persistence", + operation: "uncompleting-task", + details: { taskPath: task.path }, + error: errorMessage, + }); + new Notice( + this.t("contextMenus.task.completion.completeFailure", { + message: errorMessage, + }) + ); + } + } + + private addSkipMenuItem(task: TaskInfo, plugin: TaskNotesPlugin, menu: Menu): void { + // Not routed through the four-mode resolver: that would drop the completion-anchor guard. + const skipOccurrenceDate = this.options.occurrenceDate; + const skipLabelDate = skipOccurrenceDate ?? getRecurringTaskActionDate(task); + const skipDateStr = formatDateForStorage(skipLabelDate); + const isSkippedForDate = task.skipped_instances?.includes(skipDateStr) || false; + + menu.addItem((item) => { item.setTitle( isSkippedForDate ? this.t("contextMenus.task.unskipInstance") @@ -899,10 +1099,7 @@ export class TaskContextMenu { item.setIcon(isSkippedForDate ? "undo" : "x-circle"); item.onClick(async () => { try { - await plugin.taskService.toggleRecurringTaskSkipped( - task, - this.options.targetDate - ); + await plugin.taskService.toggleRecurringTaskSkipped(task, skipOccurrenceDate); this.options.onUpdate?.(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -920,10 +1117,6 @@ export class TaskContextMenu { } }); }); - - if (!this.options.promoteOccurrenceControls) { - this.addOccurrenceNoteMenuItem(task, plugin); - } } private addPromotedOccurrenceControls(task: TaskInfo, plugin: TaskNotesPlugin): boolean { @@ -955,13 +1148,26 @@ export class TaskContextMenu { await openOrCreateOccurrenceNote({ plugin, parentTask: task, - targetDate: this.options.targetDate, + targetDate: this.getOccurrenceNoteTargetDate(task), onUpdate: this.options.onUpdate, }); }); }); } + private getOccurrenceNoteTargetDate(task: TaskInfo): Date { + if (this.options.promoteOccurrenceControls || task.recurrence_anchor !== "completion") { + return this.options.targetDate; + } + + try { + const scheduledDate = getDatePart(task.scheduled || ""); + return scheduledDate ? parseDateToUTC(scheduledDate) : this.options.targetDate; + } catch { + return this.options.targetDate; + } + } + private addMaterializedOccurrenceMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void { this.menu.addItem((item) => { item.setTitle("Open recurring parent"); diff --git a/src/core/VaultMutationService.ts b/src/core/VaultMutationService.ts index a8aa13ec6..647ce0275 100644 --- a/src/core/VaultMutationService.ts +++ b/src/core/VaultMutationService.ts @@ -1,13 +1,78 @@ import type { App, TFile } from "obsidian"; +type FrontmatterMutationApp = { + fileManager: { + processFrontMatter( + file: TFile, + update: (frontmatter: Record) => void + ): Promise; + }; +}; + +type FileContentMutationApp = { + vault: { + process(file: TFile, update: (content: string) => string): Promise; + }; +}; + +const vaultMutationQueues = new WeakMap>(); + +/** + * Serialize TaskNotes mutations for one vault file while allowing unrelated + * files to update concurrently. The queue complements Obsidian's atomic write + * APIs when a workflow must fall back between different mutation APIs. + */ +export async function withVaultFileMutation( + file: TFile, + mutation: () => Promise +): Promise { + const previousMutation = vaultMutationQueues.get(file) ?? Promise.resolve(); + const currentMutation = previousMutation.catch(() => undefined).then(mutation); + vaultMutationQueues.set(file, currentMutation); + + try { + return await currentMutation; + } finally { + if (vaultMutationQueues.get(file) === currentMutation) { + vaultMutationQueues.delete(file); + } + } +} + export async function processVaultFrontMatter( - app: App, + app: FrontmatterMutationApp, + file: TFile, + update: (frontmatter: Record) => void +): Promise { + await withVaultFileMutation(file, () => + processVaultFrontMatterWithinMutation(app, file, update) + ); +} + +export async function processVaultFrontMatterWithinMutation( + app: FrontmatterMutationApp, file: TFile, update: (frontmatter: Record) => void ): Promise { await app.fileManager.processFrontMatter(file, update); } +export async function processVaultFile( + app: FileContentMutationApp, + file: TFile, + update: (content: string) => string +): Promise { + return withVaultFileMutation(file, () => processVaultFileWithinMutation(app, file, update)); +} + +export async function processVaultFileWithinMutation( + app: FileContentMutationApp, + file: TFile, + update: (content: string) => string +): Promise { + return app.vault.process(file, update); +} + export async function createVaultFile(app: App, path: string, content: string): Promise { return app.vault.create(path, content); } @@ -17,7 +82,7 @@ export async function createVaultFolder(app: App, path: string): Promise { } export async function modifyVaultFile(app: App, file: TFile, content: string): Promise { - await app.vault.modify(file, content); + await withVaultFileMutation(file, () => app.vault.modify(file, content)); } export async function renameVaultFile(app: App, file: TFile, newPath: string): Promise { diff --git a/src/editor/MarkdownWidgetContext.ts b/src/editor/MarkdownWidgetContext.ts index 2c60ce8fe..ea6b0795a 100644 --- a/src/editor/MarkdownWidgetContext.ts +++ b/src/editor/MarkdownWidgetContext.ts @@ -45,11 +45,18 @@ export function shouldSkipMarkdownWidgetEditor( try { const editorInfo = view.state.field(editorInfoField, false) as | { - leaf?: WorkspaceLeaf; + leaf?: { + parent?: unknown; + view?: { getMode?: () => string }; + }; containerEl?: HTMLElement; } | undefined; + if (editorInfo?.leaf?.view?.getMode?.() === "preview") { + return true; + } + if (isDetachedLeaf(editorInfo?.leaf)) { return true; } diff --git a/src/editor/ReadingModeInjectionScheduler.ts b/src/editor/ReadingModeInjectionScheduler.ts index 18280c750..8f9ab9929 100644 --- a/src/editor/ReadingModeInjectionScheduler.ts +++ b/src/editor/ReadingModeInjectionScheduler.ts @@ -16,11 +16,13 @@ interface InjectionState { */ export class ReadingModeInjectionScheduler { private readonly states = new WeakMap(); + private disposed = false; schedule( leaf: WorkspaceLeaf, run: (context: ReadingModeInjectionContext) => Promise ): void { + if (this.disposed) return; const state = this.getState(leaf); state.version += 1; @@ -33,6 +35,10 @@ export class ReadingModeInjectionScheduler { void this.runLoop(leaf, run); } + dispose(): void { + this.disposed = true; + } + private getState(leaf: WorkspaceLeaf): InjectionState { let state = this.states.get(leaf); if (!state) { @@ -57,12 +63,13 @@ export class ReadingModeInjectionScheduler { state.rerun = false; const runVersion = state.version; await run({ - isCurrent: () => this.states.get(leaf)?.version === runVersion, + isCurrent: () => + !this.disposed && this.states.get(leaf)?.version === runVersion, }); - } while (state.rerun); + } while (state.rerun && !this.disposed); } finally { state.running = false; - if (state.rerun) { + if (state.rerun && !this.disposed) { state.running = true; void this.runLoop(leaf, run); } diff --git a/src/editor/ReadingModeWidgetObserver.ts b/src/editor/ReadingModeWidgetObserver.ts index 4579ae31c..01f71fd0e 100644 --- a/src/editor/ReadingModeWidgetObserver.ts +++ b/src/editor/ReadingModeWidgetObserver.ts @@ -1,6 +1,24 @@ import { MarkdownView, WorkspaceLeaf } from "obsidian"; import { shouldSkipMarkdownWidgetLeaf } from "./MarkdownWidgetContext"; +/** + * How long after the last scroll event re-injection stays deferred. + * + * Obsidian's virtualised reading view deletes direct children of + * `.markdown-preview-sizer` on every render pass (`setChildrenInPlace`), so a + * widget injected between sections is removed again while the user scrolls. + * Re-injecting per frame against that churn perturbs the renderer's scroll + * model and makes the note visibly jump (#2255). These widgets sit at the top + * or bottom of the note, so deferring their return until scrolling settles is + * invisible in practice and stops the add/remove cycle. + */ +export const DEFAULT_SCROLL_QUIET_PERIOD_MS = 200; + +export interface ReadingModeObserverOptions { + /** Override the quiet period; 0 disables scroll deferral (used by tests). */ + scrollQuietPeriodMs?: number; +} + type FrameHandle = { id: number; cancel: () => void; @@ -22,6 +40,13 @@ function scheduleBeforePaint(win: Window, callback: () => void): FrameHandle { }; } +function nowIn(win: Window): number { + if (typeof win.performance?.now === "function") { + return win.performance.now(); + } + return Date.now(); +} + function nodeContainsSelector(node: Node, selector: string): boolean { if (node.nodeType !== Node.ELEMENT_NODE) { return false; @@ -59,14 +84,27 @@ export function observeReadingModeWidgetMutations( scheduleInjection: (leaf: WorkspaceLeaf) => void, observedContainers: WeakSet, cleanupCallbacks: Array<() => void>, - shouldRefresh: (leaf: WorkspaceLeaf) => boolean + shouldRefresh: (leaf: WorkspaceLeaf) => boolean, + options: ReadingModeObserverOptions = {} ): void { const containerEl = getReadingModeContainer(leaf); if (!containerEl || observedContainers.has(containerEl)) { return; } + const scrollQuietPeriodMs = options.scrollQuietPeriodMs ?? DEFAULT_SCROLL_QUIET_PERIOD_MS; + let pendingFrame: FrameHandle | null = null; + let lastScrollAt = Number.NEGATIVE_INFINITY; + + const handleScroll = () => { + lastScrollAt = nowIn(containerEl.ownerDocument.defaultView ?? window); + }; + + // Scroll events do not bubble, so capture them from the container down to + // catch whichever descendant is the actual preview scroller. + containerEl.addEventListener("scroll", handleScroll, { capture: true, passive: true }); + const requestRefresh = () => { if (pendingFrame) { return; @@ -80,9 +118,19 @@ export function observeReadingModeWidgetMutations( } const sizer = containerEl.querySelector(".markdown-preview-sizer"); - if (sizer && !sizer.querySelector(widgetSelector)) { - scheduleInjection(leaf); + if (!sizer || sizer.querySelector(widgetSelector)) { + return; } + + // While scrolling, Obsidian's own virtualisation keeps removing the + // widget. Re-injecting every frame only creates DOM churn and scroll + // corrections, so wait until scrolling settles before restoring it. + if (scrollQuietPeriodMs > 0 && nowIn(win) - lastScrollAt < scrollQuietPeriodMs) { + requestRefresh(); + return; + } + + scheduleInjection(leaf); }); }; @@ -100,6 +148,7 @@ export function observeReadingModeWidgetMutations( observedContainers.add(containerEl); cleanupCallbacks.push(() => { pendingFrame?.cancel(); + containerEl.removeEventListener("scroll", handleScroll, { capture: true }); observer.disconnect(); }); } diff --git a/src/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 58ede6e96..c469a0711 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -81,6 +81,30 @@ interface HTMLElementWithComponent extends HTMLElement { component?: Component; } +function getOwnedReadingModeRelationshipWidgets(view: MarkdownView): HTMLElementWithComponent[] { + const widgetParents = new Set(); + const previewSizer = view.previewMode.containerEl.querySelector( + ".markdown-preview-sizer" + ); + const editorSizer = view.containerEl.querySelector(".cm-sizer"); + if (previewSizer) widgetParents.add(previewSizer); + if (editorSizer) widgetParents.add(editorSizer); + + return Array.from(widgetParents).flatMap((parent) => + getHTMLElementChildren(parent).filter( + (child): child is HTMLElementWithComponent => + child.classList.contains(CSS_RELATIONSHIPS_WIDGET) + ) + ); +} + +function removeOwnedReadingModeRelationshipWidgets(view: MarkdownView): void { + getOwnedReadingModeRelationshipWidgets(view).forEach((widget) => { + widget.component?.unload(); + widget.remove(); + }); +} + function getHTMLElementChildren(element: HTMLElement): HTMLElement[] { return Array.from(element.children).filter((child): child is HTMLElement => child.instanceOf(HTMLElement) @@ -180,13 +204,13 @@ function getRenderedElementBottom(element: HTMLElement): number | null { return bottom; } -function getRenderedLinesBottom(lines: HTMLElement[]): number | null { +function getRenderedContentBottom(elements: HTMLElement[]): number | null { let bottom: number | null = null; - for (const line of lines) { - const lineBottom = getRenderedElementBottom(line); - if (lineBottom !== null && (bottom === null || lineBottom > bottom)) { - bottom = lineBottom; + for (const element of elements) { + const elementBottom = getRenderedElementBottom(element); + if (elementBottom !== null && (bottom === null || elementBottom > bottom)) { + bottom = elementBottom; } } @@ -201,10 +225,7 @@ export function applyRelationshipsBottomOffset(container: HTMLElement, widget: H return; } - const lines = getHTMLElementChildren(cmContent).filter((child) => - child.classList.contains("cm-line") - ); - const contentBottom = getRenderedLinesBottom(lines); + const contentBottom = getRenderedContentBottom(getHTMLElementChildren(cmContent)); const contentContainer = cmContent.closest(".cm-contentContainer"); if (contentBottom === null || !contentContainer) { return; @@ -511,24 +532,28 @@ class RelationshipsDecorationsPlugin implements PluginValue { private cleanupOrphanedWidgets(view: EditorView): void { try { - // Remove any widget DOM that might exist from previous or overlapping instances. - const container = view.dom.closest(".workspace-leaf-content"); + // Remove only widgets owned by this editor. Embedded notes manage their own widgets. + const container = view.dom + .closest(".markdown-source-view") + ?.querySelector(".cm-sizer"); if (!container) { tasknotesLogger.debug( - "[TaskNotes] Could not find workspace-leaf-content for orphan cleanup", + "[TaskNotes] Could not find .cm-sizer for orphan cleanup", { category: "stale-data", - operation: "find-workspace-leaf-content-orphan-cleanup", + operation: "find-cm-sizer-orphan-cleanup", } ); return; } - container.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`).forEach((el) => { - const holder = el as HTMLElementWithComponent; - holder.component?.unload(); - el.remove(); - }); + getHTMLElementChildren(container) + .filter((child) => child.classList.contains(CSS_RELATIONSHIPS_WIDGET)) + .forEach((widget) => { + const holder = widget as HTMLElementWithComponent; + holder.component?.unload(); + widget.remove(); + }); this.currentWidget = null; this.widgetContainer = null; } catch (error) { @@ -625,8 +650,8 @@ class RelationshipsDecorationsPlugin implements PluginValue { if (position === "top") { // Try to find task card widget first (should come before relationships) // RISK: Relies on task card widget class name - const taskCardWidget = targetContainer.querySelector( - ".tasknotes-task-card-note-widget" + const taskCardWidget = getHTMLElementChildren(targetContainer).find((child) => + child.classList.contains("tasknotes-task-card-note-widget") ); if (taskCardWidget) { // Insert after task card widget to maintain order @@ -710,21 +735,16 @@ async function injectReadingModeWidget( if (!isTaskNote && !isProjectNote) { // Preserve same-file widgets while Obsidian is rebuilding metadata. try { - const previewView = view.previewMode; - const containerEl = previewView.containerEl; - const existingWidgets = Array.from( - containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`) - ); + const existingWidgets = getOwnedReadingModeRelationshipWidgets(view); const hasWidgetForDifferentFile = existingWidgets.some( (widget) => widget.dataset.notePath !== file.path ); const isConfirmedNonRelationshipsNote = metadata !== null; if (hasWidgetForDifferentFile || isConfirmedNonRelationshipsNote) { - existingWidgets.forEach((el) => { - const holder = el as HTMLElementWithComponent; - holder.component?.unload(); - el.remove(); + existingWidgets.forEach((widget) => { + widget.component?.unload(); + widget.remove(); }); } } catch (error) { @@ -740,21 +760,18 @@ async function injectReadingModeWidget( return; } + let widget: HTMLElementWithComponent | null = null; try { // Remove any existing widgets first const previewView = view.previewMode; const containerEl = previewView.containerEl; - containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`).forEach((el) => { - const holder = el as HTMLElementWithComponent; - holder.component?.unload(); - el.remove(); - }); + removeOwnedReadingModeRelationshipWidgets(view); const position = plugin.settings.relationshipsPosition || "bottom"; const notePath = file.path; // Create the widget - const widget = await createRelationshipsWidget(plugin, notePath); + widget = await createRelationshipsWidget(plugin, notePath); if (context && !context.isCurrent()) { widget.component?.unload(); widget.remove(); @@ -765,6 +782,8 @@ async function injectReadingModeWidget( // RISK: Relies on Obsidian's internal DOM structure const sizer = containerEl.querySelector(".markdown-preview-sizer"); if (!sizer) { + widget.component?.unload(); + widget.remove(); tasknotesLogger.warn( "[TaskNotes] Could not find .markdown-preview-sizer for relationships in reading mode", { @@ -778,7 +797,9 @@ async function injectReadingModeWidget( // Position the widget if (position === "top") { // Try to find task card widget first (should come before relationships) - const taskCardWidget = sizer.querySelector(".tasknotes-task-card-note-widget"); + const taskCardWidget = getHTMLElementChildren(sizer).find((child) => + child.classList.contains("tasknotes-task-card-note-widget") + ); if (taskCardWidget) { // Insert after task card widget to maintain order insertAfterElement(taskCardWidget, widget); @@ -789,6 +810,8 @@ async function injectReadingModeWidget( insertRelationshipsWidgetAtBottom(sizer, widget); } } catch (error) { + widget?.component?.unload(); + widget?.remove(); tasknotesLogger.error("[TaskNotes] Error injecting relationships widget in reading mode:", { category: "persistence", operation: "injecting-relationships-widget-reading-mode", @@ -940,6 +963,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { // Return cleanup function return () => { + scheduler.dispose(); if (debounceTimer) window.clearTimeout(debounceTimer); metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); metadataDebounceTimers.clear(); @@ -949,5 +973,10 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { workspaceRefs.forEach((ref) => plugin.app.workspace.offref(ref)); metadataCacheRefs.forEach((ref) => plugin.app.metadataCache.offref(ref)); dependencyCacheRefs.forEach((ref) => plugin.dependencyCache?.offref(ref)); + plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + if (leaf.view instanceof MarkdownView) { + removeOwnedReadingModeRelationshipWidgets(leaf.view); + } + }); }; } diff --git a/src/editor/TaskCardNoteDecorations.ts b/src/editor/TaskCardNoteDecorations.ts index e07f546fd..605644501 100644 --- a/src/editor/TaskCardNoteDecorations.ts +++ b/src/editor/TaskCardNoteDecorations.ts @@ -901,6 +901,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { if (canvasDebounceTimer) window.clearTimeout(canvasDebounceTimer); metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); metadataDebounceTimers.clear(); + scheduler.dispose(); markdownWidgetObserverCleanups.forEach((cleanup) => cleanup()); canvasObservers.forEach((observer) => observer.disconnect()); canvasInteractionCleanups.forEach((cleanup) => cleanup()); diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2a427eb2f..7be66f416 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1386,6 +1386,11 @@ export const en: TranslationTree = { name: "Default visible properties", description: "Choose which properties appear on task cards by default.", }, + completionSubmenu: { + name: "Group complete and skip actions in a submenu", + description: + "Nest the complete and skip actions under a submenu in the task context menu. Turn off to show them directly in the menu.", + }, propertyGroups: { coreProperties: "Core properties", organization: "ORGANIZATION", @@ -3045,6 +3050,25 @@ export const en: TranslationTree = { markIncomplete: "Mark incomplete for this date", skipInstance: "Skip instance", unskipInstance: "Unskip instance", + completion: { + submenu: "Mark complete or skip", + submenuCompleteOnly: "Mark complete", + completeToday: "Completed today", + completeAsScheduled: "Completed on schedule", + completeOnDue: "Completed on due date", + completeOnPicked: "Completed on (pick date)", + markIncomplete: "Mark incomplete", + noScheduledDate: "No scheduled date on this task", + noDueDate: "No due date on this task", + noPickedDate: "No date selected", + noCompletedStatus: "No completed status configured", + pickDateTitle: "Complete on date", + completeFailure: "Failed to update task completion: {message}", + clearInstancesConfirmTitle: "Clear recorded instances?", + clearInstancesConfirmMessage: + "Rescheduling will clear these recorded completed/skipped instances on or after the new date: {dates}. They will no longer be marked complete or skipped. Continue?", + clearInstancesConfirmButton: "Reschedule and clear", + }, quickReminders: { atTime: "At time of event", fiveMinutes: "5 minutes before", diff --git a/src/main.ts b/src/main.ts index 643880fd4..4a967ff72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -101,6 +101,7 @@ import { } from "./settings/settingsPersistence"; import { startDateChangeDetection } from "./bootstrap/dateChangeDetection"; import { createTaskNotesLogger } from "./utils/tasknotesLogger"; +import { sanitizeLinkAliasText } from "./utils/linkAliasUtils"; import { TASKNOTES_RUNTIME_LIFECYCLE_RAW_EVENTS } from "./api/runtime-api"; import { createTaskNotesPerformanceProfiler, @@ -314,7 +315,9 @@ export default class TaskNotesPlugin extends Plugin { this.migrationPromise = this.performEarlyMigrationCheck(); initializeCalendarProviders(this); - await registerBasesIntegration(this); + // Not awaited: if Bases has not loaded yet this schedules a retry timer, + // and initializeAfterLayoutReady attempts registration again. + void registerBasesIntegration(this); // Defer expensive initialization until layout is ready this.app.workspace.onLayoutReady(() => { @@ -1145,18 +1148,36 @@ export default class TaskNotesPlugin extends Plugin { task: TaskInfo, property: keyof TaskInfo, value: TaskInfo[keyof TaskInfo], - options: { silent?: boolean } = {} + options: { + silent?: boolean; + completionDate?: string; + confirmClearInstances?: (cleared: { + complete: string[]; + skipped: string[]; + }) => Promise; + } = {} ): Promise { try { - const updatedTask = await this.taskService.updateProperty( - task, - property, - value, - options - ); + // A declined clear-confirmation aborts the reschedule, so the success notice is suppressed below. + let cancelledByUser = false; + const confirmClearInstances = + options.confirmClearInstances ?? + (options.silent + ? undefined + : async (cleared: { complete: string[]; skipped: string[] }) => { + const proceed = await this.confirmClearRescheduledInstances(cleared); + if (!proceed) { + cancelledByUser = true; + } + return proceed; + }); - // Provide user feedback unless silent - if (!options.silent) { + const updatedTask = await this.taskService.updateProperty(task, property, value, { + ...options, + confirmClearInstances, + }); + + if (!options.silent && !cancelledByUser) { if (property === "status") { const statusValue = typeof value === "string" ? value : String(value); const statusConfig = this.statusManager.getStatusConfig(statusValue); @@ -1178,6 +1199,28 @@ export default class TaskNotesPlugin extends Plugin { } } + /** + * Ask the user to confirm clearing recorded completed/skipped instances that a + * reschedule would remove (those on or after the new scheduled date). + */ + private async confirmClearRescheduledInstances(cleared: { + complete: string[]; + skipped: string[]; + }): Promise { + const { showConfirmationModal } = await import("./modals/ConfirmationModal"); + const dates = Array.from(new Set([...cleared.complete, ...cleared.skipped])).sort(); + return showConfirmationModal(this.app, { + title: this.i18n.translate("contextMenus.task.completion.clearInstancesConfirmTitle"), + message: this.i18n.translate( + "contextMenus.task.completion.clearInstancesConfirmMessage", + { dates: dates.join(", ") } + ), + confirmText: this.i18n.translate( + "contextMenus.task.completion.clearInstancesConfirmButton" + ), + }); + } + /** * Toggles a recurring task's completion status for the selected date */ @@ -1403,7 +1446,10 @@ export default class TaskNotesPlugin extends Plugin { void (async () => { const value = date && time ? combineDateAndTime(date, time) : date || undefined; - await this.taskService.updateProperty(task, field, value); + await this.taskService.updateProperty(task, field, value, { + confirmClearInstances: (cleared) => + this.confirmClearRescheduledInstances(cleared), + }); })(); }, }); @@ -2014,7 +2060,7 @@ export default class TaskNotesPlugin extends Plugin { file, sourcePath, "", - task.title // Use task title as alias + sanitizeLinkAliasText(task.title) ); // Insert the link at the determined insertion point diff --git a/src/modals/TimeEntryEditorModal.ts b/src/modals/TimeEntryEditorModal.ts index 2ab1ed5bb..a532ed2d6 100644 --- a/src/modals/TimeEntryEditorModal.ts +++ b/src/modals/TimeEntryEditorModal.ts @@ -193,6 +193,54 @@ export class TimeEntryEditorModal extends Modal { cls: "time-entry-editor-modal__description-editor-container", }); + this.renderLazyDescriptionEditor(entry, editorContainer); + } + + private renderLazyDescriptionEditor(entry: TimeEntry, editorContainer: HTMLElement): void { + const textarea = editorContainer.createEl("textarea", { + cls: "time-entry-editor-modal__description-editor-fallback", + placeholder: this.translate("modals.timeEntryEditor.descriptionPlaceholder"), + }); + textarea.value = entry.description || ""; + + textarea.addEventListener("input", () => { + entry.description = textarea.value || undefined; + }); + + textarea.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.save(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.close(); + } + }); + + let hydrated = false; + const hydrateMarkdownEditor = () => { + if (hydrated) { + return; + } + hydrated = true; + + entry.description = textarea.value || undefined; + editorContainer.empty(); + + const editor = this.createDescriptionMarkdownEditor(entry, editorContainer); + if (editor) { + this.descriptionEditors.push(editor); + } + this.focusDescriptionEditor(editorContainer); + }; + + textarea.addEventListener("focus", hydrateMarkdownEditor, { once: true }); + } + + private createDescriptionMarkdownEditor( + entry: TimeEntry, + editorContainer: HTMLElement + ): EmbeddableMarkdownEditor | null { const editor = createTaskModalMarkdownEditor(this.app, editorContainer, { value: entry.description || "", placeholder: this.translate("modals.timeEntryEditor.descriptionPlaceholder"), @@ -205,9 +253,14 @@ export class TimeEntryEditorModal extends Modal { onTab: () => false, }); - if (editor) { - this.descriptionEditors.push(editor); - } + return editor; + } + + private focusDescriptionEditor(editorContainer: HTMLElement): void { + window.setTimeout(() => { + const focusTarget = editorContainer.querySelector(".cm-content, textarea"); + focusTarget?.focus(); + }, 0); } private cleanupDescriptionEditors(): void { diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 68ab065a5..8d85747d6 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -16,6 +16,7 @@ import { validateCalendarId, validateEventId, validateRequired } from "./validat import { CalendarProvider, ProviderCalendar } from "./CalendarProvider"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; +import { normalizeCalendarDescription } from "../utils/calendarDescription"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/GoogleCalendarService" }); @@ -163,6 +164,14 @@ export class GoogleCalendarService extends CalendarProvider { return this.availableCalendars; } + getConnectionGeneration(): number { + return this.oauthService.getConnectionGeneration("google"); + } + + async isConnectionGenerationCurrent(expectedGeneration: number): Promise { + return this.oauthService.isConnectionGenerationCurrent("google", expectedGeneration); + } + /** * Gets the list of enabled calendar IDs from settings */ @@ -499,7 +508,7 @@ export class GoogleCalendarService extends CalendarProvider { id: `google-${calendarId}-${googleEvent.id}`, subscriptionId: `google-${calendarId}`, title: googleEvent.summary || "Untitled Event", - description: googleEvent.description, + description: normalizeCalendarDescription(googleEvent.description), start: start, end: end, allDay: allDay, @@ -693,7 +702,8 @@ export class GoogleCalendarService extends CalendarProvider { }; colorId?: string; recurrence?: string[]; - } + }, + expectedConnectionGeneration?: number ): Promise { // Validate inputs validateCalendarId(calendarId); @@ -701,7 +711,10 @@ export class GoogleCalendarService extends CalendarProvider { validateRequired(updates, "updates"); try { - const token = await this.oauthService.getValidToken("google"); + const token = await this.oauthService.getValidToken( + "google", + expectedConnectionGeneration + ); // First, get the current event to merge with updates const getResponse = await this.withRetry(async () => { @@ -852,7 +865,8 @@ export class GoogleCalendarService extends CalendarProvider { }; colorId?: string; recurrence?: string[]; - } + }, + expectedConnectionGeneration?: number ): Promise { // Validate inputs validateCalendarId(calendarId); @@ -865,7 +879,10 @@ export class GoogleCalendarService extends CalendarProvider { validateRequired(event.end, "event.end"); try { - const token = await this.oauthService.getValidToken("google"); + const token = await this.oauthService.getValidToken( + "google", + expectedConnectionGeneration + ); // Build Google Calendar API payload const payload: GoogleCalendarEventPayload = { @@ -952,13 +969,20 @@ export class GoogleCalendarService extends CalendarProvider { /** * Deletes a Google Calendar event */ - async deleteEvent(calendarId: string, eventId: string): Promise { + async deleteEvent( + calendarId: string, + eventId: string, + expectedConnectionGeneration?: number + ): Promise { // Validate inputs validateCalendarId(calendarId); validateEventId(eventId); try { - const token = await this.oauthService.getValidToken("google"); + const token = await this.oauthService.getValidToken( + "google", + expectedConnectionGeneration + ); await this.withRetry(async () => { return await requestUrl({ diff --git a/src/services/ICSNoteService.ts b/src/services/ICSNoteService.ts index 558d170de..8a6776d09 100644 --- a/src/services/ICSNoteService.ts +++ b/src/services/ICSNoteService.ts @@ -14,6 +14,7 @@ import { processTemplate, ICSTemplateData } from "../utils/templateProcessor"; import type { InterpolationValues, TranslationKey } from "../i18n"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; +import { processVaultFrontMatter } from "./VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/ICSNoteService" }); @@ -608,17 +609,16 @@ export class ICSNoteService { } // Update the note's frontmatter to include the ICS event ID - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const icsEventIdField = this.plugin.fieldMapper.toUserField("icsEventId"); // Get existing ICS event IDs or create new array - let existingIds = frontmatter[icsEventIdField]; - if (!existingIds) { - existingIds = []; - } else if (!Array.isArray(existingIds)) { - // Convert single value to array for backwards compatibility - existingIds = [existingIds]; - } + const existingValue = frontmatter[icsEventIdField]; + const existingIds: unknown[] = !existingValue + ? [] + : Array.isArray(existingValue) + ? [...existingValue] + : [existingValue]; // Add new event ID if not already present if (!existingIds.includes(icsEvent.id)) { diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 147956c47..8766019ac 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -19,6 +19,7 @@ import { splitListPreservingLinksAndQuotes } from "../utils/stringSplit"; import { shouldShowFilenameShortenedNotice } from "../utils/filenameGenerator"; import type { InterpolationValues, TranslationKey } from "../i18n"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { sanitizeGeneratedLinkAlias, sanitizeLinkAliasText } from "../utils/linkAliasUtils"; import { publishUserNotice } from "../core/userNotices"; import { modifyVaultFile } from "./VaultMutationService"; @@ -90,13 +91,6 @@ function splitBlockquotePrefix(line: string): { return { leadingWhitespace, blockquotePrefix, content }; } -function getWikilinkDisplayText(linkText: string): string { - const display = linkText.includes("|") - ? linkText.split("|").pop() || linkText - : linkText; - return display.split("/").pop()?.replace(/\.md$/i, "") || display; -} - export class InstantTaskConvertService { private plugin: TaskNotesPlugin; private statusManager: StatusManager; @@ -855,9 +849,9 @@ export class InstantTaskConvertService { return String(label).trim(); }); - cleanTitle = cleanTitle.replace(/\[\[([^[\]]+)\]\]/g, (match, inner) => { + cleanTitle = cleanTitle.replace(/\[\[([^[\]]+)\]\]/g, (match) => { links.push(match); - return getWikilinkDisplayText(String(inner)); + return sanitizeLinkAliasText(match); }); const uniqueLinks = [...new Set(links)]; @@ -867,49 +861,6 @@ export class InstantTaskConvertService { }; } - private sanitizeGeneratedLinkAlias(linkText: string): string { - if (linkText.startsWith("[[") && linkText.endsWith("]]")) { - const inner = linkText.slice(2, -2); - const aliasSeparator = inner.indexOf("|"); - if (aliasSeparator === -1) { - return linkText; - } - - const target = inner.slice(0, aliasSeparator); - const alias = inner.slice(aliasSeparator + 1); - const sanitizedAlias = this.sanitizeLinkAliasText(alias); - - return sanitizedAlias ? `[[${target}|${sanitizedAlias}]]` : `[[${target}]]`; - } - - if (linkText.startsWith("[") && linkText.endsWith(")")) { - const aliasSeparator = linkText.lastIndexOf("]("); - if (aliasSeparator <= 0) { - return linkText; - } - - const alias = linkText.slice(1, aliasSeparator); - const destination = linkText.slice(aliasSeparator + 2, -1); - const sanitizedAlias = this.sanitizeLinkAliasText(alias); - - return sanitizedAlias ? `[${sanitizedAlias}](${destination})` : linkText; - } - - return linkText; - } - - private sanitizeLinkAliasText(alias: string): string { - let sanitized = alias.replace(/\[([^\]]+)\]\((<[^>]+>|[^)]+)\)/g, (_match, label) => - String(label).trim() - ); - - sanitized = sanitized.replace(/\[\[([^[\]]+)\]\]/g, (_match, inner) => - getWikilinkDisplayText(String(inner)).trim() - ); - - return sanitized.replace(/\s+/g, " ").trim(); - } - private appendPreservedTitleLinks(details: string, links: string[]): string { const linksToAppend = links.filter((link) => !details.includes(link)); if (linksToAppend.length === 0) { @@ -1055,7 +1006,7 @@ export class InstantTaskConvertService { const sourcePath = currentFile?.path || ""; // Use Obsidian's generateMarkdownLink (respects user's link format settings) - const properLink = this.sanitizeGeneratedLinkAlias( + const properLink = sanitizeGeneratedLinkAlias( this.plugin.app.fileManager.generateMarkdownLink(file, sourcePath) ); @@ -1603,7 +1554,7 @@ export class InstantTaskConvertService { const currentFile = this.plugin.app.workspace.getActiveFile(); const sourcePath = currentFile?.path || ""; - const properLink = this.sanitizeGeneratedLinkAlias( + const properLink = sanitizeGeneratedLinkAlias( this.plugin.app.fileManager.generateMarkdownLink(file, sourcePath) ); diff --git a/src/services/OAuthService.ts b/src/services/OAuthService.ts index 5be0a3fc5..9fed0b255 100644 --- a/src/services/OAuthService.ts +++ b/src/services/OAuthService.ts @@ -14,6 +14,12 @@ type HttpModuleLike = { createServer(handler?: (req: HTTPRequestLike, res: HTTPResponseLike) => void): HTTPServerLike; }; +type ElectronModuleLike = { + shell?: { + openExternal?: (url: string) => Promise | void; + }; +}; + let cachedHttpModule: HttpModuleLike | null = null; function ensureHttpModule(): HttpModuleLike { @@ -180,7 +186,7 @@ export class OAuthService { ); // Open browser to authorization URL - window.open(authUrl, "_blank"); + await this.openAuthorizationUrl(authUrl); // Wait for callback with timeout const code = await this.waitForCallback(state, 300000); // 5 minute timeout @@ -215,6 +221,26 @@ export class OAuthService { } } + private async openAuthorizationUrl(authUrl: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies -- OAuth must bypass Obsidian's in-app Web Viewer and use the system browser on desktop. + const electron = require("electron") as ElectronModuleLike; + const shell = electron.shell; + if (shell?.openExternal) { + await shell.openExternal(authUrl); + return; + } + } catch (error) { + tasknotesLogger.warn("Failed to open OAuth URL in system browser; falling back to window.open.", { + category: "provider", + operation: "oauth-open-external", + error, + }); + } + + window.open(authUrl, "_blank"); + } + /** * Finds an available port in the given range */ @@ -700,8 +726,13 @@ export class OAuthService { * Uses mutex pattern to prevent race conditions when multiple API calls * happen simultaneously with an expired token. */ - async getValidToken(provider: OAuthProvider): Promise { + async getValidToken( + provider: OAuthProvider, + expectedGeneration?: number + ): Promise { + this.assertExpectedConnectionGeneration(provider, expectedGeneration); const connection = await this.getConnection(provider); + this.assertExpectedConnectionGeneration(provider, expectedGeneration); if (!connection) { throw new TokenExpiredError(provider); } @@ -715,6 +746,7 @@ export class OAuthService { const pendingRefresh = this.tokenRefreshPromises.get(provider); if (pendingRefresh) { const newTokens = await pendingRefresh; + this.assertExpectedConnectionGeneration(provider, expectedGeneration); return newTokens.accessToken; } @@ -727,12 +759,26 @@ export class OAuthService { this.tokenRefreshPromises.set(provider, refreshPromise); const newTokens = await refreshPromise; + this.assertExpectedConnectionGeneration(provider, expectedGeneration); return newTokens.accessToken; } + this.assertExpectedConnectionGeneration(provider, expectedGeneration); return connection.tokens.accessToken; } + private assertExpectedConnectionGeneration( + provider: OAuthProvider, + expectedGeneration: number | undefined + ): void { + if ( + expectedGeneration !== undefined && + this.getConnectionGeneration(provider) !== expectedGeneration + ) { + throw new Error(`${provider} OAuth connection changed during calendar operation`); + } + } + /** * Stores an OAuth connection in Obsidian SecretStorage. */ @@ -778,17 +824,35 @@ export class OAuthService { return connection !== null; } + getConnectionGeneration(provider: OAuthProvider): number { + return this.connectionGenerations.get(provider) ?? 0; + } + + async isConnectionGenerationCurrent( + provider: OAuthProvider, + expectedGeneration: number + ): Promise { + return ( + this.getConnectionGeneration(provider) === expectedGeneration && + (await this.isConnected(provider)) + ); + } + /** * Disconnects from a provider (revokes tokens and removes stored data) */ async disconnect(provider: OAuthProvider): Promise { + const connectionGeneration = this.getConnectionGeneration(provider); const connection = await this.getConnection(provider); if (!connection) { return; } // Clear local tokens first so an interrupted or concurrent refresh cannot reconnect. - this.clearConnection(provider); + // If the connection changed while it was being read, leave the newer connection alone. + if (!this.clearConnection(provider, connectionGeneration)) { + return; + } // Revoke tokens on the OAuth provider's server. await this.revokeToken(provider, connection.tokens.accessToken); diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index cd0d1095a..43f371315 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -15,7 +15,11 @@ import { TokenRefreshError } from "./errors"; import { GOOGLE_CALENDAR_CONSTANTS } from "./constants"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; -import { modifyVaultFile, processVaultFrontMatter } from "./VaultMutationService"; +import { + processVaultFileWithinMutation, + processVaultFrontMatterWithinMutation, + withVaultFileMutation, +} from "./VaultMutationService"; import type { PerformanceProfilerDetails } from "../utils/PerformanceProfiler"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/TaskCalendarSyncService" }); @@ -94,6 +98,13 @@ function isAlreadyDeletedError(error: unknown): boolean { return status === 404 || status === 410; } +class GoogleCalendarConnectionChangedError extends Error { + constructor() { + super("Google Calendar connection changed while synchronization was in progress"); + this.name = "GoogleCalendarConnectionChangedError"; + } +} + /** * Service for syncing TaskNotes tasks to Google Calendar. * Handles creating, updating, and deleting calendar events when tasks change. @@ -114,6 +125,9 @@ export class TaskCalendarSyncService { /** Serialized frontmatter writes keyed by task path to avoid concurrent YAML edits */ private static googleCalendarFrontmatterWrites: Map> = new Map(); + /** Serialized plugin-data updates for the orphaned-event deletion queue. */ + private static googleCalendarDeletionQueueWrite: Promise = Promise.resolve(); + private plugin: TaskNotesPlugin; private googleCalendarService: GoogleCalendarService; private rateLimitChain: Promise = Promise.resolve(); @@ -136,6 +150,7 @@ export class TaskCalendarSyncService { /** Last calendar-relevant task fingerprint persisted after successful syncs */ private calendarFingerprints: Map | null = null; + private destroyed = false; constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; @@ -192,6 +207,7 @@ export class TaskCalendarSyncService { TaskCalendarSyncService.taskEventIdCache.clear(); TaskCalendarSyncService.taskExceptionEventIdCache.clear(); TaskCalendarSyncService.googleCalendarFrontmatterWrites.clear(); + TaskCalendarSyncService.googleCalendarDeletionQueueWrite = Promise.resolve(); } private getTaskEventIdCacheKey(taskPath: string, calendarId?: string): string { @@ -232,6 +248,7 @@ export class TaskCalendarSyncService { * Clean up pending timers (call on plugin unload) */ destroy(): void { + this.destroyed = true; for (const timer of this.pendingSyncs.values()) { window.clearTimeout(timer); } @@ -301,13 +318,36 @@ export class TaskCalendarSyncService { const settings = this.plugin.settings.googleCalendarExport; const enabled = settings.enabled; const hasTargetCalendar = !!settings.targetCalendarId; - // Check if Google Calendar is connected by verifying calendars are available - // (populated during GoogleCalendarService.initialize() when OAuth is connected) + // Calendar availability is the synchronous readiness signal. Metadata writes + // additionally use the OAuth connection generation captured before network work. const isConnected = this.googleCalendarService.getAvailableCalendars().length > 0; return enabled && hasTargetCalendar && isConnected; } + private getConnectionGeneration(): number { + return this.googleCalendarService.getConnectionGeneration?.() ?? 0; + } + + private async assertConnectionGenerationCurrent( + expectedConnectionGeneration: number | undefined + ): Promise { + if (this.destroyed) { + throw new GoogleCalendarConnectionChangedError(); + } + if (expectedConnectionGeneration === undefined) { + return; + } + + const isCurrent = + (await this.googleCalendarService.isConnectionGenerationCurrent?.( + expectedConnectionGeneration + )) ?? true; + if (!isCurrent) { + throw new GoogleCalendarConnectionChangedError(); + } + } + /** * Start retrying persisted calendar recovery work. */ @@ -352,7 +392,9 @@ export class TaskCalendarSyncService { private isDeletionQueueReady(): boolean { const settings = this.plugin.settings.googleCalendarExport; const isConnected = this.googleCalendarService.getAvailableCalendars().length > 0; - return !!settings?.enabled && !!settings?.syncOnTaskDelete && isConnected; + // Entries already in this queue represent remote events TaskNotes committed + // to remove, including orphan cleanup after a failed metadata write. + return !!settings?.enabled && isConnected; } private isSyncQueueReady(): boolean { @@ -396,13 +438,25 @@ export class TaskCalendarSyncService { return data?.[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] || []; } - private async saveDeletionQueue(queue: PendingGoogleCalendarDeletion[]): Promise { - const data = await this.plugin.loadPluginDataForSafeWrite( - "save-google-calendar-deletion-queue" - ); - if (!data) return; - data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] = queue; - await this.plugin.saveData(data); + private async mutateDeletionQueue( + mutation: ( + queue: PendingGoogleCalendarDeletion[] + ) => PendingGoogleCalendarDeletion[] | null + ): Promise { + const previousMutation = TaskCalendarSyncService.googleCalendarDeletionQueueWrite; + const currentMutation = previousMutation.catch(() => undefined).then(async () => { + const data = await this.plugin.loadPluginDataForSafeWrite( + "save-google-calendar-deletion-queue" + ); + if (!data) return; + const queue = (data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] || []) as PendingGoogleCalendarDeletion[]; + const updatedQueue = mutation(queue); + if (updatedQueue === null) return; + data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] = updatedQueue; + await this.plugin.saveData(data); + }); + TaskCalendarSyncService.googleCalendarDeletionQueueWrite = currentMutation; + await currentMutation; } private async getEventIndex(): Promise { @@ -719,13 +773,11 @@ export class TaskCalendarSyncService { } private async removeFromDeletionQueue(calendarId: string, eventId: string): Promise { - const queue = await this.getDeletionQueue(); const key = this.getDeletionQueueKey({ calendarId, eventId }); - const filteredQueue = queue.filter((item) => this.getDeletionQueueKey(item) !== key); - - if (filteredQueue.length !== queue.length) { - await this.saveDeletionQueue(filteredQueue); - } + await this.mutateDeletionQueue((queue) => { + const updatedQueue = queue.filter((item) => this.getDeletionQueueKey(item) !== key); + return updatedQueue.length === queue.length ? null : updatedQueue; + }); } private async queueCalendarDeletion( @@ -736,39 +788,43 @@ export class TaskCalendarSyncService { attempted = false ): Promise { const now = Date.now(); - const queue = await this.getDeletionQueue(); const key = this.getDeletionQueueKey({ calendarId, eventId }); - const existing = queue.find((item) => this.getDeletionQueueKey(item) === key); const lastError = error ? getErrorMessage(error) : undefined; - if (existing) { - existing.taskPath = taskPath; - if (attempted) { - existing.attempts += 1; - existing.lastAttemptAt = now; - } - if (lastError) { - existing.lastError = lastError; + await this.mutateDeletionQueue((queue) => { + const existing = queue.find((item) => this.getDeletionQueueKey(item) === key); + if (existing) { + existing.taskPath = taskPath; + if (attempted) { + existing.attempts += 1; + existing.lastAttemptAt = now; + } + if (lastError) { + existing.lastError = lastError; + } + return queue; } - } else { - queue.push({ - taskPath, - calendarId, - eventId, - createdAt: now, - attempts: attempted ? 1 : 0, - lastAttemptAt: attempted ? now : undefined, - lastError, - }); - } - await this.saveDeletionQueue(queue); + return [ + ...queue, + { + taskPath, + calendarId, + eventId, + createdAt: now, + attempts: attempted ? 1 : 0, + lastAttemptAt: attempted ? now : undefined, + lastError, + }, + ]; + }); } private async deleteOrQueueCalendarEvent( taskPath: string, calendarId: string, - eventId: string + eventId: string, + expectedConnectionGeneration?: number ): Promise { if (!this.plugin.settings.googleCalendarExport.syncOnTaskDelete) { return true; @@ -784,10 +840,17 @@ export class TaskCalendarSyncService { return false; } + const connectionGeneration = + expectedConnectionGeneration ?? this.getConnectionGeneration(); try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent(calendarId, eventId) - ); + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.deleteEvent( + calendarId, + eventId, + connectionGeneration + ); + }); await this.removeFromDeletionQueue(calendarId, eventId); return true; } catch (error: unknown) { @@ -807,10 +870,26 @@ export class TaskCalendarSyncService { } } - private async clearTaskEventIdIfMatching(item: PendingGoogleCalendarDeletion): Promise { + private async clearTaskEventIdIfMatching( + item: PendingGoogleCalendarDeletion, + expectedConnectionGeneration: number + ): Promise { const task = await this.plugin.cacheManager.getTaskInfo(item.taskPath); if (task?.googleCalendarEventId === item.eventId) { - await this.removeTaskEventId(item.taskPath); + await this.removeTaskEventId(item.taskPath, expectedConnectionGeneration); + return; + } + + if (task && this.getTaskExceptionEventId(task) === item.eventId) { + await this.saveTaskExceptionMetadata( + item.taskPath, + { + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + }, + item.calendarId, + expectedConnectionGeneration + ); } } @@ -874,6 +953,11 @@ export class TaskCalendarSyncService { let changedTasks = 0; let linkedTasks = 0; let baselineTasks = 0; + let createdTasks = 0; + + // An empty fingerprint map means this vault was never reconciled: every task is + // unknown, so baseline all of them instead of flooding the calendar on first run. + const isFirstReconciliation = fingerprints.size === 0; for (const task of tasks) { activeTaskPaths.add(task.path); @@ -881,6 +965,21 @@ export class TaskCalendarSyncService { const previousFingerprint = fingerprints.get(task.path); if (previousFingerprint === undefined) { + // A task file that appeared while Obsidian was closed (external sync, git pull, + // another device) has no fingerprint yet. Treat it the way the live path in + // handleExternalTaskFileUpdated already does: export it, instead of baselining + // it into permanent invisibility. + if ( + !isFirstReconciliation && + settings.syncOnTaskCreate && + !this.hasTaskCalendarLink(task) && + this.isTaskCalendarEligible(task) + ) { + createdTasks++; + await this.syncTaskToCalendar(task); + continue; + } + fingerprints.set(task.path, fingerprint); changed = true; baselineTasks++; @@ -927,6 +1026,7 @@ export class TaskCalendarSyncService { this.profileGauge("initializeExternalFileReconciliation.linkedTasks", linkedTasks); this.profileGauge("initializeExternalFileReconciliation.changedTasks", changedTasks); this.profileGauge("initializeExternalFileReconciliation.baselineTasks", baselineTasks); + this.profileGauge("initializeExternalFileReconciliation.createdTasks", createdTasks); this.profileGauge( "initializeExternalFileReconciliation.removedFingerprints", removedFingerprints @@ -1228,25 +1328,34 @@ export class TaskCalendarSyncService { for (const item of queue) { dedupedQueue.set(this.getDeletionQueueKey(item), item); } + const processedSnapshots = new Map( + Array.from(dedupedQueue, ([key, item]) => [key, JSON.stringify(item)]) + ); const remainingItems: PendingGoogleCalendarDeletion[] = []; for (const item of dedupedQueue.values()) { + const connectionGeneration = this.getConnectionGeneration(); try { const deletionStillNeeded = await this.isQueuedDeletionStillNeeded(item); if (!deletionStillNeeded) { continue; } - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent(item.calendarId, item.eventId) - ); - await this.clearTaskEventIdIfMatching(item); + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.deleteEvent( + item.calendarId, + item.eventId, + connectionGeneration + ); + }); + await this.clearTaskEventIdIfMatching(item, connectionGeneration); await this.removeEventIndexForEvent(item.calendarId, item.eventId); results.deleted++; } catch (error: unknown) { if (isAlreadyDeletedError(error)) { - await this.clearTaskEventIdIfMatching(item); + await this.clearTaskEventIdIfMatching(item, connectionGeneration); await this.removeEventIndexForEvent(item.calendarId, item.eventId); results.deleted++; continue; @@ -1268,8 +1377,24 @@ export class TaskCalendarSyncService { } } - results.remaining = remainingItems.length; - await this.saveDeletionQueue(remainingItems); + const processedKeys = new Set(dedupedQueue.keys()); + await this.mutateDeletionQueue((currentQueue) => { + const nextQueue = currentQueue.filter((item) => { + const key = this.getDeletionQueueKey(item); + if (!processedKeys.has(key)) { + return true; + } + return processedSnapshots.get(key) !== JSON.stringify(item); + }); + for (const item of remainingItems) { + const key = this.getDeletionQueueKey(item); + if (!nextQueue.some((candidate) => this.getDeletionQueueKey(candidate) === key)) { + nextQueue.push(item); + } + } + results.remaining = nextQueue.length; + return nextQueue; + }); this.profileGauge("processDeletionQueue.deleted", results.deleted); this.profileGauge("processDeletionQueue.failed", results.failed); this.profileGauge("processDeletionQueue.remaining", results.remaining); @@ -1407,37 +1532,95 @@ export class TaskCalendarSyncService { private async writeGoogleCalendarFrontmatterFields( taskPath: string, file: TFile, - updates: Record + updates: Record, + expectedConnectionGeneration?: number ): Promise { - await this.withGoogleCalendarFrontmatterWriteLock(taskPath, async () => { - try { - await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { - for (const [fieldName, value] of Object.entries(updates)) { - this.writeOptionalFrontmatterField(frontmatter, fieldName, value, true); + await this.withGoogleCalendarFrontmatterWriteLock(taskPath, () => + withVaultFileMutation(file, async () => { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + const previousValues = new Map(); + try { + await processVaultFrontMatterWithinMutation( + this.plugin.app, + file, + (frontmatter) => { + for (const [fieldName, value] of Object.entries(updates)) { + previousValues.set(fieldName, frontmatter[fieldName]); + this.writeOptionalFrontmatterField(frontmatter, fieldName, value, true); + } + } + ); + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + } catch (error) { + if (error instanceof GoogleCalendarConnectionChangedError) { + await this.restoreGoogleCalendarFrontmatterFields(file, previousValues); + throw error; + } + if (!this.isDuplicateYamlKeyError(error)) { + throw error; } - }); - } catch (error) { - if (!this.isDuplicateYamlKeyError(error)) { - throw error; - } - await this.rewriteGoogleCalendarFrontmatterFields(file, updates); - } - }); + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + const originalContent = await this.rewriteGoogleCalendarFrontmatterFields( + file, + updates + ); + try { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + } catch (generationError) { + await processVaultFileWithinMutation( + this.plugin.app, + file, + () => originalContent + ); + throw generationError; + } + } + }) + ); } private async rewriteGoogleCalendarFrontmatterFields( file: TFile, updates: Record + ): Promise { + const googleCalendarFields = this.getGoogleCalendarFrontmatterFields(); + let originalContent = ""; + await processVaultFileWithinMutation(this.plugin.app, file, (content) => { + originalContent = content; + return this.replaceFrontmatterFields(content, updates, googleCalendarFields); + }); + return originalContent; + } + + private async restoreGoogleCalendarFrontmatterFields( + file: TFile, + previousValues: Map ): Promise { - const content = await this.plugin.app.vault.read(file); - const repaired = this.replaceFrontmatterFields(content, updates); - await modifyVaultFile(this.plugin.app, file, repaired); + if (previousValues.size === 0) { + return; + } + + await processVaultFrontMatterWithinMutation(this.plugin.app, file, (frontmatter) => { + for (const [fieldName, value] of previousValues) { + this.writeOptionalFrontmatterField(frontmatter, fieldName, value, true); + } + }); + } + + private getGoogleCalendarFrontmatterFields(): Set { + return new Set([ + this.plugin.fieldMapper.toUserField("googleCalendarEventId"), + this.plugin.fieldMapper.toUserField("googleCalendarExceptionEventId"), + this.plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled"), + this.plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates"), + ]); } private replaceFrontmatterFields( content: string, - updates: Record + updates: Record, + googleCalendarFields: Set ): string { const match = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)([\s\S]*)$/); if (!match) { @@ -1446,8 +1629,9 @@ export class TaskCalendarSyncService { const [, opening, frontmatterText, closing, body] = match; const newline = opening.includes("\r\n") ? "\r\n" : "\n"; - const filteredLines = this.removeFrontmatterFields( + const filteredLines = this.deduplicateGoogleCalendarFrontmatterFields( frontmatterText.split(/\r?\n/), + googleCalendarFields, new Set(Object.keys(updates)) ); const serializedUpdates = Object.entries(updates) @@ -1460,41 +1644,59 @@ export class TaskCalendarSyncService { return `${opening}${nextFrontmatter}${closing}${body}`; } - private removeFrontmatterFields(lines: string[], fieldNames: Set): string[] { + private deduplicateGoogleCalendarFrontmatterFields( + lines: string[], + googleCalendarFields: Set, + updatedFields: Set + ): string[] { + const lastFieldIndexes = new Map(); + for (let index = 0; index < lines.length; index++) { + const fieldName = this.getTopLevelFrontmatterFieldName(lines[index]); + if (fieldName && googleCalendarFields.has(fieldName)) { + lastFieldIndexes.set(fieldName, index); + } + } + const result: string[] = []; let index = 0; - while (index < lines.length) { - if (this.isFrontmatterFieldLine(lines[index], fieldNames)) { + const fieldName = this.getTopLevelFrontmatterFieldName(lines[index]); + if (!fieldName || !googleCalendarFields.has(fieldName)) { + result.push(lines[index]); index++; - while (index < lines.length && /^[\t ]/.test(lines[index])) { - index++; - } continue; } - result.push(lines[index]); + const keepField = !updatedFields.has(fieldName) && lastFieldIndexes.get(fieldName) === index; + if (keepField) { + result.push(lines[index]); + } index++; + while (index < lines.length && /^[\t ]/.test(lines[index])) { + if (keepField) { + result.push(lines[index]); + } + index++; + } } while (result.length > 0 && result[result.length - 1].trim() === "") { result.pop(); } - return result; } - private isFrontmatterFieldLine(line: string, fieldNames: Set): boolean { + private getTopLevelFrontmatterFieldName(line: string): string | null { if (/^\s/.test(line)) { - return false; + return null; } const separatorIndex = line.indexOf(":"); if (separatorIndex <= 0) { - return false; + return null; } - return fieldNames.has(line.slice(0, separatorIndex).trim()); + return line.slice(0, separatorIndex).trim(); } private shouldWriteFrontmatterValue(value: unknown): boolean { @@ -1507,21 +1709,29 @@ export class TaskCalendarSyncService { private async saveTaskEventId( taskPath: string, eventId: string, - calendarId?: string + calendarId?: string, + expectedConnectionGeneration?: number ): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); if (!(file instanceof TFile)) { - tasknotesLogger.warn(`Cannot save event ID: file not found at ${taskPath}`, { + const error = new Error(`Cannot save event ID: file not found at ${taskPath}`); + tasknotesLogger.warn(error.message, { category: "provider", operation: "save-event-id-file-not-found", }); + if (expectedConnectionGeneration !== undefined) { + throw error; + } return; } const fieldName = this.plugin.fieldMapper.toUserField("googleCalendarEventId"); - await this.writeGoogleCalendarFrontmatterFields(taskPath, file, { - [fieldName]: eventId, - }); + await this.writeGoogleCalendarFrontmatterFields( + taskPath, + file, + { [fieldName]: eventId }, + expectedConnectionGeneration + ); TaskCalendarSyncService.taskEventIdCache.set( this.getTaskEventIdCacheKey(taskPath, calendarId), eventId @@ -1537,7 +1747,10 @@ export class TaskCalendarSyncService { /** * Remove the Google Calendar event ID from the task's frontmatter */ - private async removeTaskEventId(taskPath: string): Promise { + private async removeTaskEventId( + taskPath: string, + expectedConnectionGeneration?: number + ): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); if (!(file instanceof TFile)) { tasknotesLogger.warn(`Cannot remove event ID: file not found at ${taskPath}`, { @@ -1550,9 +1763,12 @@ export class TaskCalendarSyncService { } const fieldName = this.plugin.fieldMapper.toUserField("googleCalendarEventId"); - await this.writeGoogleCalendarFrontmatterFields(taskPath, file, { - [fieldName]: undefined, - }); + await this.writeGoogleCalendarFrontmatterFields( + taskPath, + file, + { [fieldName]: undefined }, + expectedConnectionGeneration + ); TaskCalendarSyncService.clearTaskEventIdCache(taskPath); await this.removeEventIndexForTask(taskPath); } @@ -1567,14 +1783,21 @@ export class TaskCalendarSyncService { | "googleCalendarMovedOriginalDates" > >, - calendarId?: string + calendarId?: string, + expectedConnectionGeneration?: number ): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); if (!(file instanceof TFile)) { - tasknotesLogger.warn( - `Cannot save recurring exception metadata: file not found at ${taskPath}`, - { category: "provider", operation: "save-exception-metadata-file-not-found" } + const error = new Error( + `Cannot save recurring exception metadata: file not found at ${taskPath}` ); + tasknotesLogger.warn(error.message, { + category: "provider", + operation: "save-exception-metadata-file-not-found", + }); + if (expectedConnectionGeneration !== undefined) { + throw error; + } return; } @@ -1605,7 +1828,8 @@ export class TaskCalendarSyncService { await this.writeGoogleCalendarFrontmatterFields( taskPath, file, - frontmatterUpdates + frontmatterUpdates, + expectedConnectionGeneration ); } @@ -1639,13 +1863,21 @@ export class TaskCalendarSyncService { frontmatter[fieldName] = value; } - private async clearTaskGoogleCalendarMetadata(taskPath: string): Promise { - await this.removeTaskEventId(taskPath); - await this.saveTaskExceptionMetadata(taskPath, { - googleCalendarExceptionEventId: undefined, - googleCalendarExceptionOriginalScheduled: undefined, - googleCalendarMovedOriginalDates: undefined, - }); + private async clearTaskGoogleCalendarMetadata( + taskPath: string, + expectedConnectionGeneration?: number + ): Promise { + await this.removeTaskEventId(taskPath, expectedConnectionGeneration); + await this.saveTaskExceptionMetadata( + taskPath, + { + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + googleCalendarMovedOriginalDates: undefined, + }, + undefined, + expectedConnectionGeneration + ); } /** @@ -2217,14 +2449,20 @@ export class TaskCalendarSyncService { private async createCalendarEventForTask( task: TaskInfo, eventData: CalendarEventPayload, - calendarId: string + calendarId: string, + expectedConnectionGeneration: number ): Promise { - const createdEvent = await this.withGoogleRateLimit(() => - this.googleCalendarService.createEvent(calendarId, { - ...eventData, - isAllDay: !!eventData.start.date, - }) - ); + const createdEvent = await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + return this.googleCalendarService.createEvent( + calendarId, + { + ...eventData, + isAllDay: !!eventData.start.date, + }, + expectedConnectionGeneration + ); + }); // Extract the actual event ID from the ICSEvent ID format. // Format is "google-{calendarId}-{eventId}". Calendar IDs can contain @@ -2234,7 +2472,17 @@ export class TaskCalendarSyncService { ? createdEvent.id.slice(prefix.length) : createdEvent.id; - await this.saveTaskEventId(task.path, eventId, calendarId); + try { + await this.saveTaskEventId( + task.path, + eventId, + calendarId, + expectedConnectionGeneration + ); + } catch (error) { + await this.queueCalendarDeletion(task.path, calendarId, eventId, error); + throw error; + } return eventId; } @@ -2309,7 +2557,8 @@ export class TaskCalendarSyncService { private async syncRecurringExceptionEvent( task: TaskInfo, - targetCalendarId: string + targetCalendarId: string, + expectedConnectionGeneration: number ): Promise { const hasActiveException = this.shouldCreateDetachedRecurringException(task); const existingExceptionEventId = this.getTaskExceptionEventId(task); @@ -2319,7 +2568,8 @@ export class TaskCalendarSyncService { const deleted = await this.deleteOrQueueCalendarEvent( task.path, targetCalendarId, - existingExceptionEventId + existingExceptionEventId, + expectedConnectionGeneration ); if (!deleted) { throw new Error( @@ -2334,7 +2584,8 @@ export class TaskCalendarSyncService { googleCalendarExceptionEventId: undefined, googleCalendarExceptionOriginalScheduled: undefined, }, - targetCalendarId + targetCalendarId, + expectedConnectionGeneration ); return; } @@ -2346,13 +2597,15 @@ export class TaskCalendarSyncService { if (existingExceptionEventId) { try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent( + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + return this.googleCalendarService.updateEvent( targetCalendarId, existingExceptionEventId, - eventData - ) - ); + eventData, + expectedConnectionGeneration + ); + }); return; } catch (error: unknown) { if (getErrorStatus(error) !== 404) { @@ -2363,7 +2616,8 @@ export class TaskCalendarSyncService { { googleCalendarExceptionEventId: undefined, }, - targetCalendarId + targetCalendarId, + expectedConnectionGeneration ); } } @@ -2373,33 +2627,50 @@ export class TaskCalendarSyncService { TaskCalendarSyncService.pendingExceptionEventCreates.get(createCacheKey); if (pendingCreate) { const eventId = await pendingCreate; - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData) - ); + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + return this.googleCalendarService.updateEvent( + targetCalendarId, + eventId, + eventData, + expectedConnectionGeneration + ); + }); return; } - const createPromise = this.withGoogleRateLimit(() => - this.googleCalendarService.createEvent(targetCalendarId, { - ...eventData, - isAllDay: !!eventData.start.date, - }) - ).then(async (createdEvent) => { + const createPromise = this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(expectedConnectionGeneration); + return this.googleCalendarService.createEvent( + targetCalendarId, + { + ...eventData, + isAllDay: !!eventData.start.date, + }, + expectedConnectionGeneration + ); + }).then(async (createdEvent) => { const prefix = `google-${targetCalendarId}-`; const eventId = createdEvent.id.startsWith(prefix) ? createdEvent.id.slice(prefix.length) : createdEvent.id; - await this.saveTaskExceptionMetadata( - task.path, - { - googleCalendarExceptionEventId: eventId, - googleCalendarExceptionOriginalScheduled: getDatePart( - task.googleCalendarExceptionOriginalScheduled || "" - ), - }, - targetCalendarId - ); + try { + await this.saveTaskExceptionMetadata( + task.path, + { + googleCalendarExceptionEventId: eventId, + googleCalendarExceptionOriginalScheduled: getDatePart( + task.googleCalendarExceptionOriginalScheduled || "" + ), + }, + targetCalendarId, + expectedConnectionGeneration + ); + } catch (error) { + await this.queueCalendarDeletion(task.path, targetCalendarId, eventId, error); + throw error; + } return eventId; }); TaskCalendarSyncService.pendingExceptionEventCreates.set( @@ -2424,9 +2695,11 @@ export class TaskCalendarSyncService { async syncTaskToCalendar( task: TaskInfo, previous?: TaskInfo, - options: { queueOnFailure?: boolean } = {} + options: { queueOnFailure?: boolean; connectionGeneration?: number } = {} ): Promise { const queueOnFailure = options.queueOnFailure ?? true; + const connectionGeneration = + options.connectionGeneration ?? this.getConnectionGeneration(); if (!this.isTaskCalendarEligible(task)) { return true; @@ -2483,13 +2756,15 @@ export class TaskCalendarSyncService { if (existingEventId) { // Update existing event - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent( + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.updateEvent( targetCalendarId, existingEventId, - eventData - ) - ); + eventData, + connectionGeneration + ); + }); } else { const createCacheKey = this.getTaskEventIdCacheKey( task.path, @@ -2499,14 +2774,21 @@ export class TaskCalendarSyncService { TaskCalendarSyncService.pendingEventCreates.get(createCacheKey); if (pendingCreate) { const eventId = await pendingCreate; - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData) - ); + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.updateEvent( + targetCalendarId, + eventId, + eventData, + connectionGeneration + ); + }); } else { const createPromise = this.createCalendarEventForTask( task, eventData, - targetCalendarId + targetCalendarId, + connectionGeneration ); TaskCalendarSyncService.pendingEventCreates.set( createCacheKey, @@ -2526,20 +2808,35 @@ export class TaskCalendarSyncService { } if (this.shouldSyncAsRecurring(task) || this.hasStoredRecurringExceptionMetadata(task)) { - await this.syncRecurringExceptionEvent(task, targetCalendarId); + await this.syncRecurringExceptionEvent( + task, + targetCalendarId, + connectionGeneration + ); } + await this.assertConnectionGenerationCurrent(connectionGeneration); await this.recordCalendarSyncFingerprint(task); return true; } catch (error: unknown) { + if (error instanceof GoogleCalendarConnectionChangedError) { + if (queueOnFailure && settings.enabled) { + await this.queueTaskSync(task.path, error); + } + return false; + } + // Check if it's a 404 error (event was deleted externally) if (getErrorStatus(error) === 404 && existingEventId) { // Clear the stale link and retry as create - await this.removeTaskEventId(task.path); + await this.removeTaskEventId(task.path, connectionGeneration); // Retry without the link - refetch task to get updated version const updatedTask = await this.plugin.cacheManager.getTaskInfo(task.path); if (updatedTask) { - return this.syncTaskToCalendar(updatedTask, previous, options); + return this.syncTaskToCalendar(updatedTask, previous, { + ...options, + connectionGeneration, + }); } } @@ -2702,15 +2999,17 @@ export class TaskCalendarSyncService { return; } + const connectionGeneration = this.getConnectionGeneration(); this.cancelPendingTaskUpdate(task.path); await this.waitForInFlightTaskSync(task.path); - const completionPromise = this.executeTaskCompletion(task); + const completionPromise = this.executeTaskCompletion(task, connectionGeneration); this.inFlightSyncs.set(task.path, completionPromise); try { const completed = await completionPromise; if (completed) { + await this.assertConnectionGenerationCurrent(connectionGeneration); await this.recordCalendarSyncFingerprint(task); } } finally { @@ -2720,11 +3019,16 @@ export class TaskCalendarSyncService { } } - private async executeTaskCompletion(task: TaskInfo): Promise { + private async executeTaskCompletion( + task: TaskInfo, + connectionGeneration: number + ): Promise { const settings = this.plugin.settings.googleCalendarExport; let existingEventId = this.getTaskEventId(task); if (!existingEventId) { - const synced = await this.syncTaskToCalendar(task); + const synced = await this.syncTaskToCalendar(task, undefined, { + connectionGeneration, + }); if (!synced) { return false; } @@ -2736,7 +3040,7 @@ export class TaskCalendarSyncService { // For recurring tasks, update EXDATE to exclude completed instance if (this.shouldSyncAsRecurring(task)) { - await this.updateRecurringEventExdates(task); + await this.updateRecurringEventExdates(task, connectionGeneration); return true; } @@ -2746,17 +3050,28 @@ export class TaskCalendarSyncService { ? this.buildEventDescription(task) : undefined; - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent(settings.targetCalendarId, existingEventId, { - summary: this.getCalendarEventTitle(task), - description, - }) - ); + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.updateEvent( + settings.targetCalendarId, + existingEventId, + { + summary: this.getCalendarEventTitle(task), + description, + }, + connectionGeneration + ); + }); + await this.assertConnectionGenerationCurrent(connectionGeneration); return true; } catch (error: unknown) { + if (error instanceof GoogleCalendarConnectionChangedError) { + await this.queueTaskSync(task.path, error); + return false; + } if (getErrorStatus(error) === 404) { // Event was deleted externally, clean up the link - await this.removeTaskEventId(task.path); + await this.removeTaskEventId(task.path, connectionGeneration); return false; } tasknotesLogger.error("[TaskCalendarSync] Failed to update completed task:", { @@ -2773,7 +3088,10 @@ export class TaskCalendarSyncService { * Updates a recurring event's EXDATE list when an instance is completed or skipped. * This adds EXDATE entries for completed/skipped instances to hide them from the calendar. */ - private async updateRecurringEventExdates(task: TaskInfo): Promise { + private async updateRecurringEventExdates( + task: TaskInfo, + connectionGeneration: number + ): Promise { if (!this.shouldSyncAsRecurring(task) || !task.recurrence) return; const settings = this.plugin.settings.googleCalendarExport; @@ -2792,19 +3110,33 @@ export class TaskCalendarSyncService { ? this.buildEventDescription(task) : undefined; - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent(settings.targetCalendarId, eventId, { - summary: this.getCalendarEventTitle(task), - description, - recurrence: recurrenceData.recurrence, - }) + await this.withGoogleRateLimit(async () => { + await this.assertConnectionGenerationCurrent(connectionGeneration); + return this.googleCalendarService.updateEvent( + settings.targetCalendarId, + eventId, + { + summary: this.getCalendarEventTitle(task), + description, + recurrence: recurrenceData.recurrence, + }, + connectionGeneration + ); + }); + await this.syncRecurringExceptionEvent( + task, + settings.targetCalendarId, + connectionGeneration ); - await this.syncRecurringExceptionEvent(task, settings.targetCalendarId); } } catch (error: unknown) { + if (error instanceof GoogleCalendarConnectionChangedError) { + await this.queueTaskSync(task.path, error); + return; + } if (getErrorStatus(error) === 404) { // Event was deleted externally, clean up the link - await this.removeTaskEventId(task.path); + await this.removeTaskEventId(task.path, connectionGeneration); return; } tasknotesLogger.error("[TaskCalendarSync] Failed to update recurring event EXDATEs:", { @@ -2814,7 +3146,7 @@ export class TaskCalendarSyncService { error: error, }); // Fall back to full resync - await this.syncTaskToCalendar(task); + await this.syncTaskToCalendar(task, undefined, { connectionGeneration }); } } @@ -2826,6 +3158,7 @@ export class TaskCalendarSyncService { return true; } + const connectionGeneration = this.getConnectionGeneration(); const settings = this.plugin.settings.googleCalendarExport; const existingEventId = this.getTaskEventId(task); const exceptionEventId = this.getTaskExceptionEventId(task); @@ -2854,7 +3187,8 @@ export class TaskCalendarSyncService { const deleted = await this.deleteOrQueueCalendarEvent( task.path, targetCalendarId, - eventId + eventId, + connectionGeneration ); if (!deleted) { return false; @@ -2862,9 +3196,17 @@ export class TaskCalendarSyncService { } // Only remove metadata when deletion succeeded or events are already gone. - await this.clearTaskGoogleCalendarMetadata(task.path); - await this.removeCalendarSyncFingerprint(task.path); - return true; + try { + await this.clearTaskGoogleCalendarMetadata(task.path, connectionGeneration); + await this.assertConnectionGenerationCurrent(connectionGeneration); + await this.removeCalendarSyncFingerprint(task.path); + return true; + } catch (error) { + if (error instanceof GoogleCalendarConnectionChangedError) { + return false; + } + throw error; + } } /** @@ -2879,6 +3221,7 @@ export class TaskCalendarSyncService { return true; } + const connectionGeneration = this.getConnectionGeneration(); const settings = this.plugin.settings.googleCalendarExport; const eventIds = [eventId, ...additionalEventIds].filter( (id): id is string => typeof id === "string" && id.length > 0 @@ -2903,7 +3246,12 @@ export class TaskCalendarSyncService { const results: boolean[] = []; for (const id of eventIds) { - const deleted = await this.deleteOrQueueCalendarEvent(taskPath, targetCalendarId, id); + const deleted = await this.deleteOrQueueCalendarEvent( + taskPath, + targetCalendarId, + id, + connectionGeneration + ); if (deleted) { await this.removeEventIndexForEvent(targetCalendarId, id); } @@ -2996,6 +3344,9 @@ export class TaskCalendarSyncService { let unlinkedCount = 0; for (const task of tasks) { + const connectionGeneration = deleteEvents + ? this.getConnectionGeneration() + : undefined; if (!task.googleCalendarEventId && !this.hasStoredRecurringExceptionMetadata(task)) { continue; } @@ -3022,7 +3373,8 @@ export class TaskCalendarSyncService { const deleted = await this.deleteOrQueueCalendarEvent( task.path, targetCalendarId, - eventId + eventId, + connectionGeneration ); if (!deleted) { deletionComplete = false; @@ -3039,9 +3391,17 @@ export class TaskCalendarSyncService { } // Remove Google Calendar metadata from task frontmatter. - await this.clearTaskGoogleCalendarMetadata(task.path); - await this.removeCalendarSyncFingerprint(task.path); - unlinkedCount++; + try { + await this.clearTaskGoogleCalendarMetadata(task.path, connectionGeneration); + await this.removeCalendarSyncFingerprint(task.path); + unlinkedCount++; + } catch (error) { + if (error instanceof GoogleCalendarConnectionChangedError) { + await this.queueTaskSync(task.path, error); + continue; + } + throw error; + } } publishUserNotice(this.plugin.emitter, diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 899390967..a5c911536 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -84,6 +84,7 @@ import { computeBlockedByUpdate, } from "./task-service/taskBlockingRelationships"; import { resolveTaskPropertyFrontmatterField } from "./task-service/taskPropertyFrontmatterField"; +import { processVaultFile, processVaultFrontMatter } from "./VaultMutationService"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/TaskService" }); @@ -573,7 +574,14 @@ export class TaskService { task: TaskInfo, property: keyof TaskInfo, value: unknown, - options: { silent?: boolean } = {} + options: { + silent?: boolean; + completionDate?: string; + confirmClearInstances?: (cleared: { + complete: string[]; + skipped: string[]; + }) => Promise; + } = {} ): Promise { try { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); @@ -584,13 +592,18 @@ export class TaskService { // Get fresh task data to prevent overwrites const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task; + // Only affects non-recurring status completions (the frontmatter write is + // guarded by `property === "status" && !recurring`). + const completionDateString = + options.completionDate ?? this.getCompletionDateForTask(freshTask); + // Step 1: Construct new state in memory using fresh data const updatePlan = buildTaskPropertyUpdatePlan({ freshTask, property, value, currentTimestamp: getCurrentTimestamp(), - currentDateString: this.getCompletionDateForTask(freshTask), + currentDateString: completionDateString, normalizeStatusValue: (candidate) => this.normalizeStatusValue(candidate), isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), }); @@ -606,8 +619,50 @@ export class TaskService { applyGoogleCalendarRecurringExceptionCleanup(updatePlan.updatedTask); } + // Reschedule reactivates the timeline: drop instances on/after the new date + // (kept as YYYY-MM-DD, so a lexicographic compare is chronological). + let rescheduleClearedOccurrence = false; + if ( + property === "scheduled" && + freshTask.recurrence && + typeof freshTask.scheduled === "string" && + typeof updatePlan.normalizedValue === "string" && + updatePlan.normalizedValue.length > 0 + ) { + const previousScheduledDateStr = getDatePart(freshTask.scheduled); + const scheduledDateStr = getDatePart(updatePlan.normalizedValue); + const scheduledDateChanged = previousScheduledDateStr !== scheduledDateStr; + const completeInstances = freshTask.complete_instances ?? []; + const skippedInstances = freshTask.skipped_instances ?? []; + const removedComplete = completeInstances.filter((d) => d >= scheduledDateStr); + const removedSkipped = skippedInstances.filter((d) => d >= scheduledDateStr); + if ( + scheduledDateChanged && + (removedComplete.length > 0 || removedSkipped.length > 0) + ) { + // Give the caller a chance to confirm the destructive clear before + // anything is written; a false result aborts the whole reschedule. + if (options.confirmClearInstances) { + const proceed = await options.confirmClearInstances({ + complete: removedComplete, + skipped: removedSkipped, + }); + if (!proceed) { + return freshTask; + } + } + rescheduleClearedOccurrence = true; + updatePlan.updatedTask.complete_instances = completeInstances.filter( + (d) => d < scheduledDateStr + ); + updatePlan.updatedTask.skipped_instances = skippedInstances.filter( + (d) => d < scheduledDateStr + ); + } + } + // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { // Use field mapper to get the correct frontmatter property name const fieldName = resolveTaskPropertyFrontmatterField( this.plugin.fieldMapper, @@ -628,7 +683,7 @@ export class TaskService { normalizeStatusValue: (candidate) => this.normalizeStatusValue(candidate), isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), - currentDateString: this.getCompletionDateForTask(freshTask), + currentDateString: completionDateString, }); this.writeOptionalFrontmatterField( @@ -641,6 +696,19 @@ export class TaskService { this.plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates"), updatePlan.updatedTask.googleCalendarMovedOriginalDates ); + + if (rescheduleClearedOccurrence) { + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField("completeInstances"), + updatePlan.updatedTask.complete_instances + ); + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField("skippedInstances"), + updatePlan.updatedTask.skipped_instances + ); + } }); // Step 3: Run post-write side effects (cache, events, webhooks, calendar, auto-archive) @@ -1205,7 +1273,7 @@ export class TaskService { } const updatedTask: TaskInfo = { ...task, ...updates }; - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { this.applyModelTaskUpdatesToFrontmatter(frontmatter, updates); }); @@ -1259,7 +1327,7 @@ export class TaskService { const { updatedTask, isCurrentlyArchived, dateModified } = archivePlan; // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); applyTaskArchiveFrontmatterChange({ frontmatter, @@ -1449,7 +1517,7 @@ export class TaskService { const { updatedTask, newEntry } = timeTrackingPlan; // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries"); const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); applyStartTimeTrackingFrontmatterChange({ @@ -1527,7 +1595,7 @@ export class TaskService { const { updatedTask } = timeTrackingPlan; // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries"); const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); applyStopTimeTrackingFrontmatterChange({ @@ -1776,7 +1844,7 @@ export class TaskService { const { updatedTask, dateStr, newComplete, targetDate } = recurringPlan; // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const completeInstancesField = this.plugin.fieldMapper.toUserField("completeInstances"); const skippedInstancesField = this.plugin.fieldMapper.toUserField("skippedInstances"); const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); @@ -1805,20 +1873,24 @@ export class TaskService { // Step 2b: Reset checkboxes in task body when completing (if setting enabled) if (newComplete && this.plugin.settings.resetCheckboxesOnRecurrence) { - const currentContent = await this.plugin.app.vault.read(file); - const { frontmatter: frontmatterText, body } = splitFrontmatterAndBody(currentContent); - const { content: resetBody, changed } = resetMarkdownCheckboxes(body); + let resetDetails: string | null = null; + await processVaultFile(this.plugin.app, file, (currentContent) => { + const { frontmatter: frontmatterText, body } = + splitFrontmatterAndBody(currentContent); + const { content: resetBody, changed } = resetMarkdownCheckboxes(body); + if (!changed) { + return currentContent; + } - if (changed) { const frontmatterBlock = frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : ""; const finalBody = resetBody.trimEnd(); - const newContent = - finalBody.length > 0 ? `${frontmatterBlock}${finalBody}\n` : frontmatterBlock; - await this.plugin.app.vault.modify(file, newContent); + resetDetails = resetBody.replace(/\r\n/g, "\n").trimEnd(); + return finalBody.length > 0 ? `${frontmatterBlock}${finalBody}\n` : frontmatterBlock; + }); - // Update the details field in the returned task - updatedTask.details = resetBody.replace(/\r\n/g, "\n").trimEnd(); + if (resetDetails !== null) { + updatedTask.details = resetDetails; } } @@ -1926,7 +1998,7 @@ export class TaskService { const { updatedTask, dateStr, newSkipped, targetDate } = recurringPlan; // Step 3: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const skippedField = this.plugin.fieldMapper.toUserField("skippedInstances"); const completeField = this.plugin.fieldMapper.toUserField("completeInstances"); const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); @@ -2021,7 +2093,7 @@ export class TaskService { const { updatedTask } = deletePlan; // Step 2: Persist to file - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries"); const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); applyDeleteTimeEntryFrontmatterChange({ diff --git a/src/services/VaultMutationService.ts b/src/services/VaultMutationService.ts index d44bcff80..63db41e24 100644 --- a/src/services/VaultMutationService.ts +++ b/src/services/VaultMutationService.ts @@ -2,6 +2,10 @@ export { createVaultFile, createVaultFolder, modifyVaultFile, + processVaultFile, + processVaultFileWithinMutation, processVaultFrontMatter, + processVaultFrontMatterWithinMutation, renameVaultFile, + withVaultFileMutation, } from "../core/VaultMutationService"; diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index a60445c3b..2fe83ca37 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -201,9 +201,17 @@ export class TaskCreationService { runtime.app.vault ); const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + // A templated occurrence filename intentionally differs from the + // title, but still represents it as long as the generated name was + // used as-is (no collision suffix) and nothing was lost to + // filename sanitization. + const expectedFilename = + occurrenceFilenameTemplate && taskData.occurrence_date + ? baseFilename + : filenameTitle; const titleIsRepresentedByFilename = runtime.settings.storeTitleInFilename && - uniqueFilename === filenameTitle && + uniqueFilename === expectedFilename && title === filenameTitle; const completeTaskData: Partial = { diff --git a/src/services/task-service/TaskUpdateService.ts b/src/services/task-service/TaskUpdateService.ts index 486f6e596..eee4a475e 100644 --- a/src/services/task-service/TaskUpdateService.ts +++ b/src/services/task-service/TaskUpdateService.ts @@ -14,6 +14,7 @@ import { type TaskUpdateFieldMapper, } from "./taskUpdatePlanning"; import { createTaskNotesLogger } from "../../utils/tasknotesLogger"; +import { processVaultFile, processVaultFrontMatter } from "../VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/TaskService/TaskUpdateService" }); @@ -142,7 +143,7 @@ export class TaskUpdateService { ? getCurrentDateString() : ""; - await runtime.app.fileManager.processFrontMatter(file, (frontmatter) => { + await processVaultFrontMatter(runtime.app, file, (frontmatter) => { const frontmatterResult = applyTaskUpdateFrontmatterChange({ frontmatter, originalTask, @@ -174,14 +175,15 @@ export class TaskUpdateService { if (normalizedDetails !== null) { const targetFile = runtime.app.vault.getAbstractFileByPath(newPath); if (targetFile instanceof TFile) { - const currentContent = await runtime.app.vault.read(targetFile); - const { frontmatter: frontmatterText } = - splitFrontmatterAndBody(currentContent); - const frontmatterBlock = - frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : ""; - const bodyContent = normalizedDetails.trimEnd(); - const finalBody = bodyContent.length > 0 ? `${bodyContent}\n` : ""; - await runtime.app.vault.modify(targetFile, `${frontmatterBlock}${finalBody}`); + await processVaultFile(runtime.app, targetFile, (currentContent) => { + const { frontmatter: frontmatterText } = + splitFrontmatterAndBody(currentContent); + const frontmatterBlock = + frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : ""; + const bodyContent = normalizedDetails.trimEnd(); + const finalBody = bodyContent.length > 0 ? `${bodyContent}\n` : ""; + return `${frontmatterBlock}${finalBody}`; + }); } } diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index 8ea1d38b3..bff215b1e 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -149,6 +149,49 @@ function moveScheduledDateToOccurrence( return `${occurrenceDateStr}${scheduled.slice(scheduledDatePart.length)}`; } +function withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + task: TaskInfo, + originalTask: TaskInfo, + actionDate: string +): TaskInfo { + if ((originalTask.recurrence_anchor || "scheduled") !== "scheduled") { + return task; + } + + const replacedDates = new Set(); + for (const date of originalTask.googleCalendarMovedOriginalDates || []) { + const normalized = getDatePart(date); + if (normalized) { + replacedDates.add(normalized); + } + } + + const pendingOriginal = getDatePart( + originalTask.googleCalendarExceptionOriginalScheduled || "" + ); + const movedDate = getDatePart(originalTask.scheduled || ""); + if (pendingOriginal && pendingOriginal !== actionDate && movedDate === actionDate) { + replacedDates.add(pendingOriginal); + } + + // Moved occurrences replace their original series dates. Mark those dates as + // processed only for this calculation; persisted state stays keyed to moved dates. + const completeInstances = new Set(getStringArray(task.complete_instances)); + const skippedInstances = getStringArray(task.skipped_instances); + const skippedInstanceSet = new Set(skippedInstances); + const additionalSkippedInstances = Array.from(replacedDates) + .filter((date) => !completeInstances.has(date) && !skippedInstanceSet.has(date)) + .sort(); + if (additionalSkippedInstances.length === 0) { + return task; + } + + return { + ...task, + skipped_instances: [...skippedInstances, ...additionalSkippedInstances], + }; +} + export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date { if (date) { return date; @@ -225,7 +268,7 @@ export function buildRecurringTaskCompletePlan({ // due-date offset calculation and next-occurrence search. // Pass max(today, instanceDate) as the floor so future-dated tasks advance past // the current cycle rather than jumping back to today's nearest occurrence. - const taskForNextOccurrence = owningRecurrenceDate + const taskForNextOccurrenceBase = owningRecurrenceDate ? { ...updatedTask, scheduled: moveScheduledDateToOccurrence( @@ -234,6 +277,11 @@ export function buildRecurringTaskCompletePlan({ ), } : updatedTask; + const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + taskForNextOccurrenceBase, + freshTask, + dateStr + ); const todayStr = getTodayString(); const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( @@ -353,7 +401,7 @@ export function buildRecurringTaskSkippedPlan({ updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr); } - const taskForNextOccurrence = owningRecurrenceDate + const taskForNextOccurrenceBase = owningRecurrenceDate ? { ...updatedTask, scheduled: moveScheduledDateToOccurrence( @@ -362,6 +410,11 @@ export function buildRecurringTaskSkippedPlan({ ), } : updatedTask; + const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + taskForNextOccurrenceBase, + freshTask, + dateStr + ); const todayStr = getTodayString(); const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..460569e00 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -307,7 +307,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "contexts") && - updates.contexts === undefined + (!Array.isArray(updates.contexts) || updates.contexts.length === 0) ) { delete frontmatter[fieldMapper.toUserField("contexts")]; } @@ -356,7 +356,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "blockedBy") && - updates.blockedBy === undefined + (!Array.isArray(updates.blockedBy) || updates.blockedBy.length === 0) ) { delete frontmatter[fieldMapper.toUserField("blockedBy")]; } diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 0e77749a2..9886b3488 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -335,6 +335,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Task card in note defaults showTaskCardInNote: true, showCompletedTaskStrikethrough: true, + completionMenuAsSubmenu: true, // Task card expandable subtasks defaults showExpandableSubtasks: true, expandSubtasksByDefault: false, diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 10c9a6c56..a6f7eb8f1 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -107,6 +107,18 @@ export function renderAppearanceTab( setting.setDesc(`Currently showing: ${currentLabels.join(", ")}`); setting.settingEl.addClass("settings-view__group-description"); }); + + group.addSetting((setting) => + void configureToggleSetting(setting, { + name: translate("settings.appearance.taskCards.completionSubmenu.name"), + desc: translate("settings.appearance.taskCards.completionSubmenu.description"), + getValue: () => plugin.settings.completionMenuAsSubmenu, + setValue: async (value: boolean) => { + plugin.settings.completionMenuAsSubmenu = value; + save(); + }, + }) + ); } ); diff --git a/src/types/settings.ts b/src/types/settings.ts index 32d6b9882..72926c30c 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -188,6 +188,8 @@ export interface TaskNotesSettings { expandSubtasksByDefault: boolean; // Subtask chevron position in task cards subtaskChevronPosition: "left" | "right"; + // Nest the complete/skip context-menu actions under a submenu (vs. flat menu items) + completionMenuAsSubmenu: boolean; // Filter toolbar layout viewsButtonAlignment: "left" | "right"; // Overdue behavior settings diff --git a/src/ui/ICSCard.ts b/src/ui/ICSCard.ts index 4ec81ef5d..16a5c63dd 100644 --- a/src/ui/ICSCard.ts +++ b/src/ui/ICSCard.ts @@ -1,6 +1,6 @@ import { setIcon, setTooltip } from "obsidian"; import TaskNotesPlugin from "../main"; -import { ICSEvent } from "../types"; +import { ICSEvent, ICSSubscription } from "../types"; import { ICSEventContextMenu } from "../components/ICSEventContextMenu"; import { formatTime } from "../utils/dateUtils"; import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; @@ -46,6 +46,31 @@ function renderRelatedNoteIndicator( }); } +function getEventSourceName( + icsEvent: ICSEvent, + plugin: TaskNotesPlugin, + subscription: ICSSubscription | undefined +): string { + if (subscription?.name) { + return subscription.name; + } + + const provider = plugin.calendarProviderRegistry?.findProviderForEvent(icsEvent); + if (provider) { + const { calendarId } = provider.extractEventIds(icsEvent); + const calendar = provider + .getAvailableCalendars() + .find( + (candidate) => + candidate.id === calendarId || + (calendarId === "primary" && candidate.primary === true) + ); + return calendar?.summary || provider.providerName; + } + + return plugin.i18n.translate("ui.icsCard.calendarFallback"); +} + function formatTimeRange(icsEvent: ICSEvent, plugin: TaskNotesPlugin): string { try { if (!icsEvent.start) return ""; @@ -85,12 +110,12 @@ export function createICSEventCard( card.dataset.relatedNoteCount = String(opts.relatedNoteCount); } - // Determine subscription color and name + // Determine subscription color and source name const subscription = plugin.icsSubscriptionService ?.getSubscriptions() .find((s) => s.id === icsEvent.subscriptionId); const color = icsEvent.color || subscription?.color || "var(--color-accent)"; - const sourceName = subscription?.name || plugin.i18n.translate("ui.icsCard.calendarFallback"); + const sourceName = getEventSourceName(icsEvent, plugin, subscription); // Main row const mainRow = card.createDiv({ cls: "task-card__main-row" }); @@ -228,7 +253,7 @@ export function updateICSEventCard( ?.getSubscriptions() .find((s) => s.id === icsEvent.subscriptionId); const color = icsEvent.color || subscription?.color || "var(--color-accent)"; - const sourceName = subscription?.name || plugin.i18n.translate("ui.icsCard.calendarFallback"); + const sourceName = getEventSourceName(icsEvent, plugin, subscription); // Update icon color on wrapper to propagate to svg (icons use currentColor) element.style.setProperty("--current-status-color", color); diff --git a/src/ui/completionDateResolver.ts b/src/ui/completionDateResolver.ts new file mode 100644 index 000000000..8d07877f4 --- /dev/null +++ b/src/ui/completionDateResolver.ts @@ -0,0 +1,76 @@ +import type { TaskInfo } from "../types"; +import { + createUTCDateFromLocalCalendarDate, + getDatePart, + getTodayLocal, + parseDateToUTC, +} from "../utils/dateUtils"; + +/** + * The completion "modes" the context menu offers. UI-only: each resolves to a + * concrete date here; no service method learns about "modes". + */ +export type CompletionMode = "today" | "asScheduled" | "onDue" | "onPicked"; + +export interface CompletionDateContext { + /** + * The clicked recurring occurrence, when the producing view addresses a + * concrete occurrence (Calendar). Takes precedence over the task's own + * `scheduled` for `asScheduled`. + */ + occurrenceDate?: Date; + /** The date/time chosen via the "Complete on…" picker (`onPicked` only). */ + pickedDate?: Date; +} + +/** + * Discriminated result. `available: false` reports *why* the mode cannot resolve + * (via an i18n key) rather than silently substituting a fallback date, so the + * menu can render the item disabled with a reason tooltip. + */ +export type CompletionDateResolution = + | { available: true; date: Date } + | { available: false; reasonKey: string }; + +function toUtcDay(dateString: string | undefined): Date | null { + if (!dateString) { + return null; + } + const datePart = getDatePart(dateString); + if (!datePart) { + return null; + } + return parseDateToUTC(datePart); +} + +/** + * Resolve a completion mode to a concrete date. `asScheduled`/`onDue` report + * unavailable (not substituting) so the caller can disable the option; + * completion-anchored `asScheduled` resolves to `scheduled` — an explicit re-anchor. + */ +export function resolveCompletionDate( + task: TaskInfo, + mode: CompletionMode, + ctx: CompletionDateContext = {} +): CompletionDateResolution { + switch (mode) { + case "today": + return { available: true, date: createUTCDateFromLocalCalendarDate(getTodayLocal()) }; + case "asScheduled": { + const date = ctx.occurrenceDate ?? toUtcDay(task.scheduled); + return date + ? { available: true, date } + : { available: false, reasonKey: "contextMenus.task.completion.noScheduledDate" }; + } + case "onDue": { + const date = toUtcDay(task.due); + return date + ? { available: true, date } + : { available: false, reasonKey: "contextMenus.task.completion.noDueDate" }; + } + case "onPicked": + return ctx.pickedDate + ? { available: true, date: ctx.pickedDate } + : { available: false, reasonKey: "contextMenus.task.completion.noPickedDate" }; + } +} diff --git a/src/ui/taskCardContextMenu.ts b/src/ui/taskCardContextMenu.ts index 0e2c38441..11bb243ae 100644 --- a/src/ui/taskCardContextMenu.ts +++ b/src/ui/taskCardContextMenu.ts @@ -68,7 +68,7 @@ export async function showTaskContextMenu( taskPath: string, plugin: TaskNotesPlugin, targetDate: Date, - options: { promoteOccurrenceControls?: boolean } = {} + options: { promoteOccurrenceControls?: boolean; occurrenceDate?: Date } = {} ): Promise { const file = plugin.app.vault.getAbstractFileByPath(taskPath); const showFileMenuFallback = () => { @@ -88,6 +88,7 @@ export async function showTaskContextMenu( task, plugin, targetDate, + occurrenceDate: options.occurrenceDate, promoteOccurrenceControls: options.promoteOccurrenceControls, onUpdate: () => { plugin.app.workspace.trigger("tasknotes:refresh-views"); diff --git a/src/utils/calendarDescription.ts b/src/utils/calendarDescription.ts new file mode 100644 index 000000000..7fc07320e --- /dev/null +++ b/src/utils/calendarDescription.ts @@ -0,0 +1,259 @@ +import { sanitizeHTMLToDom } from "obsidian"; + +/** + * Plain-text normalization for calendar event descriptions. + * + * The Google Calendar API documents the event `description` field as one that + * "can contain HTML". Google Calendar and third-party integrations therefore + * store markup such as `

`, `
`, `