diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 343577d4e..57b1e908b 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -22,12 +22,12 @@ Default port is `8080`. ## Authentication -Authentication is optional. +Authentication is required for both HTTP API and MCP requests. -- If `apiAuthToken` is empty, all API requests are accepted. -- If `apiAuthToken` is set, send `Authorization: Bearer `. -- Set a token for any workflow where local browser pages, scripts, or other - desktop apps are not fully trusted. +- Send `Authorization: Bearer ` using the token in Integrations settings. +- If `apiAuthToken` is empty at startup, TaskNotes generates and saves a token before starting the server. +- Clearing the token does not enable unauthenticated access; requests are rejected until a token is configured or generated at the next server start. +- Existing clients that omitted authentication must be updated to send the token. Example: diff --git a/docs/calendar-setup.md b/docs/calendar-setup.md index 2cb2835d3..d0a73945b 100644 --- a/docs/calendar-setup.md +++ b/docs/calendar-setup.md @@ -55,6 +55,12 @@ Verify credentials first, then confirm loopback redirect configuration. TaskNote For Google 403 `access_denied` errors during sign-in, confirm the account is listed as a test user in the Google Auth Platform audience settings while your OAuth app is in testing mode. +**Google `400 redirect_uri_mismatch`** + +Check that the credentials entered in TaskNotes belong to an OAuth client of type **Desktop app**, not **Web application**. TaskNotes uses `http://127.0.0.1:` and the operating system chooses the port for each connection attempt. A Web application client configured with a fixed `8080` redirect may work for one attempt but is not compatible with this dynamic-port setup. A `flowName` query parameter is not part of TaskNotes' callback URI and should not be added as a required redirect. + +If a Desktop app client still fails, report your TaskNotes and Obsidian versions, operating system, client application type, and only the `redirect_uri` from Google's error details. Do not post the full authorization URL, client secret, authorization code, state, tokens, or exported settings. Adding a test user addresses testing-mode `403 access_denied`, not a redirect mismatch. + **"Failed to fetch events"** Disconnect and reconnect to refresh OAuth tokens, then re-check provider-side calendar permissions. diff --git a/docs/features/recurring-tasks.md b/docs/features/recurring-tasks.md index 3812e9815..7b616735d 100644 --- a/docs/features/recurring-tasks.md +++ b/docs/features/recurring-tasks.md @@ -265,7 +265,9 @@ Recurring task logic uses a UTC anchor approach: - Display adapts to local timezone - Prevents common off-by-one date issues -In other words, calculations stay stable internally while display remains local, which avoids drift when traveling or sharing vaults across timezones. +The recurrence editor preserves the anchor's calendar date and clock components when opening and saving a rule; it does not reinterpret an existing anchor through the device's timezone. Timed anchors are serialized as `YYYYMMDDTHHMMSSZ`, including seconds. Existing anchors without `Z` are normalized on save using the same clock components, rather than silently shifting their time. + +Completing an instance of a **completion-anchored** task moves the anchor to the selected completion day while retaining its existing clock components. Date-only anchors remain date-only. Completing a **scheduled-anchored** task leaves its anchor unchanged. This time-preserving behavior applies to instance completion; explicitly replacing the recurrence rule can still remove or change its time. ## Backward Compatibility diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 21617927f..7c79520a9 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,18 +32,36 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> -## Fixed - -- (#1849) Fixed context menus stacking on top of each other. Only one menu stays open at a time, and clicking the same indicator again closes its menu. This previously applied to date fields only, and now covers priority, status, recurrence, reminders, task, ICS event, and batch menus. - - Thanks to @3zra47 for reporting and @YBKF for the contribution. - ## 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/). + - Completion-date actions, submenu settings, and rescheduling confirmations are translated into all supported languages. - Thanks to @renatomen for the contribution. ## Fixed +- (#2291) Preserved background task updates when saving an older task edit window, including recurring completion history and fields removed by another writer. Thanks to @martin-forge for the fix. +- (#2294) Stopped plugin startup when an existing settings file cannot be read, preserving it for recovery instead of starting services with default settings. Thanks to @GGlider for reporting settings loss in #1591 and @martin-forge for the fix. +- (#2322) Fixed metadata edits deleting the saved title when a filename cannot represent it, such as for long titles. Existing title properties are preserved on unrelated edits; this does not recover previously deleted titles. Thanks to @1dezer1 for reporting. +- (#2255, #2300) Fixed task cards repeatedly disappearing and being restored while scrolling in Reading mode, which could pull the note down to the bottom. Thanks to @logicelf for the diagnosis and @nelsonlove for the fix. +- (#2318) Kept calendar events chronological when a Base sort is configured, using Base order for tasks at the same time. Thanks to @ky1ejs for reporting #1411 and @martin-forge for the fix. +- (#2319) Avoided duplicate recurring-task projections when an original occurrence date is already recorded by a calendar move. Completion and skip history remain visible when requested. Moves without recorded original-date metadata are not covered by this fix. Thanks to @martin-forge for the fix. +- (#2309) Fixed Google calendars enabled through the `primary` alias using the wrong color, ignoring visibility toggles, or losing their calendar name on event cards and linked notes. Thanks to @martin-forge for the fix. +- (#2279) Fixed Google Calendar authorization stalling when the system browser opens but does not report back that it launched. Authorization now completes or times out independently, and overlapping attempts cannot stop the active connection attempt. Thanks to @DamienDLR for the diagnosis. +- (#2298, #2299) Kept timed recurrence anchors when saving custom recurrence or completing a completion-anchored instance. Timed anchors are written with `Z`; their clock components, including seconds, are preserved without a device-timezone conversion. Date-only rules and scheduled-anchor completion are unchanged. See [Recurring Tasks](https://tasknotes.dev/features/recurring-tasks/). Thanks to @wellsbk1 for reporting. +- (#2043, #2055) Fixed tasks created with the default Subtasks view's **+** button missing their project and immediately disappearing from the view. Thanks to @kudrmax for reporting and @ther12k for the initial fix. +- (#2284) Prevented repeated activation of instant checkbox conversion from creating duplicate TaskNotes while the first conversion is still in progress. Thanks to @doyoonear for reporting. +- (#2314) Fixed rescheduled ICS recurring events appearing at their original time and cancelled occurrences remaining visible. Thanks to @benschifman for reporting. +- (#1849) Fixed context menus stacking on top of each other. Only one menu stays open at a time, and clicking the same indicator again closes its menu. This previously applied to date fields only, and now covers priority, status, recurrence, reminders, task, ICS event, and batch menus. + - Thanks to @3zra47 for reporting and @YBKF for the contribution. - (#2303) Removed the extra space after inline task links on desktop while retaining the task menu on touch devices. - Thanks to @nelsonlove for the fix. + +## Changed + +- (#2316) Clarified the Google OAuth Desktop app setup and redirect-mismatch troubleshooting, including why fixed Web application redirect URLs are not reliable with dynamic callback ports. See [Calendar Setup](https://tasknotes.dev/calendar-setup/). Thanks to @TomaszGaweda for reporting and following up. + +## Security + +- (#2292) Required authentication for local API/MCP listeners. When no token is configured, TaskNotes generates and saves one before listening. Clients that previously omitted authentication must supply the token from Integrations settings. OAuth callbacks now use an OS-assigned loopback port, accept each authorization state only once, and return fixed pages without reflecting callback text. Thanks to @martin-forge for the hardening. diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index 35231667a..ac18b9e5d 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -95,7 +95,7 @@ HTTP API settings control the local server lifecycle, listening port, and reques Changes to API enablement or port require an Obsidian restart to take effect. !!! warning - The HTTP API binds to loopback only and browser CORS is limited to loopback origins. If the authentication token is empty, local API requests are still unauthenticated. Set a token unless your local environment is fully trusted. + The HTTP API binds to loopback only and browser CORS is limited to loopback and supported extension origins. Authentication is required for HTTP API and MCP requests. If the token is empty when the server starts, TaskNotes generates and saves one. Copy it into your clients' authentication settings; clearing it does not enable unauthenticated access. ## Webhooks diff --git a/docs/settings/task-properties.md b/docs/settings/task-properties.md index c2fece052..625a62b85 100644 --- a/docs/settings/task-properties.md +++ b/docs/settings/task-properties.md @@ -27,7 +27,7 @@ Property keys are YAML/frontmatter keys. TaskNotes can read and write keys that The task title property. Configuration options: - **Property key**: Frontmatter field name (default: `title`) -- **Store title in filename**: When enabled, the task title is stored in the filename instead of frontmatter. The filename updates when the title changes. +- **Store title in filename**: When enabled, the task title is stored in the filename instead of frontmatter when the filename can represent it. Long, sanitized, or duplicate filenames may require a title property to preserve the full text. Editing other properties preserves an existing title property. The filename updates when the title changes. - **Filename format**: When "Store title in filename" is disabled, choose how filenames are generated: - Title-based - Zettelkasten-style diff --git a/i18n.manifest.json b/i18n.manifest.json index 801d7efc3..2d8b2a796 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1215,7 +1215,7 @@ "settings.integrations.httpApi.port.description": "b6428bfa6964ba7f4e87759cca444726cb674f67", "settings.integrations.httpApi.port.placeholder": "7507d41ecbd162a0d6dfdaaa9988a91184351735", "settings.integrations.httpApi.authToken.name": "1ba4dadc61225e43b73eaa19ae9c57856129ff60", - "settings.integrations.httpApi.authToken.description": "93591c952b15b9261d1b5d5700e04a92329c45bd", + "settings.integrations.httpApi.authToken.description": "4645950dc83ea5b5601e58223affbd795b962da4", "settings.integrations.httpApi.authToken.placeholder": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", "settings.integrations.httpApi.mcp.enable.name": "9eba0b2f5b88c4347b23d9fa6c942fc6aa0482f8", "settings.integrations.httpApi.mcp.enable.description": "8d7793dc5646fed97cd0505769894e69227f59c4", diff --git a/i18n.state.json b/i18n.state.json index 1d6968638..dcd6c7640 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -3268,8 +3268,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "6dcf0eab268427f03a4e65a6caa6da86bf7b388b" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "2fdbfa73a513d5d69b769263fb11b25d3b8c08f8" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "167aef262bcc262ca8a37b1552d786c258b7fc24" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8b854e53ad33addbdd700ee4851887dbc68b254c" @@ -4859,8 +4865,8 @@ "translation": "e50713ea8f3f9cd33d13e62b2f9446506bba0ea3" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "7b09fe93056b62b5218c41914590ac669a44a352" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "74c173787c7db629c78eeb2bcfa56a8c053a5f05" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -7390,22 +7396,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "0a57de7810546555c26e2143784684bb9050c54d" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "fb35a43018d9e813bf25fa365f65a2395503567c" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "6147a9a4bab5cb13fb58996d23c4e5185d4efa06" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "4430796d4dab9d0e0d41d91a2815c1a0c415e561" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "7a88925cb5deee291d3a9fd3a14cc9e7a1e09d6d" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "f240b620cae1844280a05644bc343c65617fc245" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "9e91f467e96d95a96c0c08712f01e15175e94a2c" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "e268ab82d15368faa74e78b195831b115b7bf79d" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "cd49832ecc8b96aec20ecf65866fc70f3eed9ec4" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "8feb24418efb9f9da6b910e1ae3fbf6b3230cd99" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "f71d3a64590161a0c19bb45fd8602763b1d6a11f" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "7499b70999db16d10b09ec3a6334b04c1b6adae3" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "4f3f5712c9bdade6760ddc4b78b3e027d91b1451" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "fb36b0c1dd7d7bf0d2a1a2f9da1421f32e79e008" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "13df0bb20a8dbaafcca58593d188d7cc63b5bdd2" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "3fcf2fa1c31f3c78f79f933c48b6324cc99b0123" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "13edcc9c4ebe474df17518c264115a00ddf30433" @@ -12176,8 +12230,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "7544b9bce2f306bf0f132f20496495986a8c0232" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "0a2e5ad9e9a9a534dc31b53d4e042d6a1b3a9330" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "ee27d372d05e79268c91e11e08e429a03a79281b" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "a3fde73e81c95a92d5645faa11a31f43498671bf" @@ -13767,8 +13827,8 @@ "translation": "6fe94ef62939d4193d6fa157862ab7d5e221928d" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "e375df41ff06e369cdca472d6fd0a2ab09d98996" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "c2bae8e5fb8112db9f4082e1a7b152ba60a3c6c2" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -16298,22 +16358,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "6662d5014b5e1c387db5e337fe23e5ac85f8be30" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "ab6d864f1aaa0357f985a8a16b1c49ef07add8bf" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "158cd141faf78ca951b75c817724c174095fa413" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "db22ce2804b5c7e73f37bc6b94112ed9ab20d03b" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "20db99bbc96c48c63fa2dcc02ebb060f3c47be95" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "19a7c3be533cc15bfe3c5a1758b435ec9cf060a9" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "de6997ad687f61d5900972baa0dba18ce8af98f3" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "f87958507758ea1ce9323272b21b5106617c7083" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "79f6ff223113350ed3b12d9cb1f7afb67e0e0e53" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "ba3393f5b5ffc301bf4e8d9c9bce5ef678137a7d" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "0c0a065e5c927100e3fc50fea47ba0942f126c35" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "4b78455e7a3a9514673820111136e75d4dcaacfc" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "ebaddda9e50b0c0408871ec10134f838e03f76ba" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "87d93a603481be15788ac6ece380999c7de93b8f" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "c12065c1972a50ba26f0ddbadc947fc6fdc77380" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "7a2bdcee2aa038bdb19774653831269f75c02907" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "2838cecba7368421d90e0f07c0d0f1e28830ffa4" @@ -21084,8 +21192,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4e2b33fa3b0ae5c5bb3ad726108d1eb4b4da505f" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "27d99f21b7196f2db168c263d917fd2031b04692" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "8e928a965d5a6ed80df48990d12c5ff8ed11d6ec" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "45c3c60132d0d7392905160e46d902e841ae8053" @@ -22675,8 +22789,8 @@ "translation": "7825b9593525f7e3ea8068d78e10507085cd174e" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "30d4ba2d20f74f7706387efcb8d3fe812cd92352" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "d132b21f1a09c216bce09c229759f17501d04d79" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -25206,22 +25320,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "5bff694dc9c18a26295d3e8ec04ded2a3b7f8424" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "9cb51be1903987a99c1ee4f0796fa9939dc6433c" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "12e96543c7ea4380dbcf7415809fea980bcc01a5" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "afb42ba3acc8ed5ecbcb7694ea1e47ee8f088779" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "1491689d87182a7fefc71328457ba27e8e6b52d1" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "a7e273b03eed8aa589490f813e24f3a0f9141460" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "c13c9a5e539bb8d0270d4c704e0077109e9ef717" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "6864bd7377c69503b7d4282f194004f12412dfd3" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "8e437489903b61af34bb03872c25ba17ca423ed6" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "eb86eda2c020e0ed6b4359941d6c7415a5df3822" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "366b4c3148d415d666bde1c7dc2a484ba274a762" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "026eb19d9e632036a096a44bb405d3dfa313343e" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "67abdd2334c465278f8d8c68cdb2430101d1f61e" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "0a572443c6451bd7e49ec135d6ddd4c987012e45" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "04f58b9bcf30ccb6c339169794b3828671052533" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "1cab30ea233ae5f2c40556a60f085e604d69f50d" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "c6fa1b8853d66e5ede8d309bb9f6d82dc4f10657" @@ -29992,8 +30154,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "de48cc0c5751bfd7eb1c2a3f6043c0dcd7382581" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "ee6ed3188fc7fb7fe050fbbcca620a42cf5d19a8" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "603ae9f86f179d632ce3a8057efd67461c7ead49" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "5547ff8f91ea1468c73df95fe41ba7ae215a21a1" @@ -31583,8 +31751,8 @@ "translation": "4547f427f7ad30077971b37d14a3d18392ffb209" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "6e2ff24c2638f4cc2d9d3ce107bd030792571de6" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "f0956cfefe9d470fa5920f96fc6a9c9fe8f34071" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -34114,22 +34282,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "c3cf463c271395fd1ffd1994cf2b13c01ef50a04" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "6e5bd0a302c14f7ed47c17d04a0860eba555de4e" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "8ddb61c2015795725a0f8e60ffeecd789f66cffa" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "1444904e1df31bd00711aae8551f729ac2f58e5e" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "5582f6831860a2cb621e37fff33599542d7b2a7a" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "233215b0fe89c8bc732dd29374520d5f5596bb37" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "c6dc80ac237815dddb307d91e1064c261ae86ff2" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "75b3f08986f5b115560b29dd223b7e171ab3ea1e" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "5acd194579c1edeb30929c91d11bfac029fbb68a" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "bbc774493e5128a63350548579041d9e3c54a4a0" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "fe4b45ba9735edd2e6f0668f5e66fc4b4f3530a3" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "a947ffd654b1c697ce57c09702b4242e4930a5af" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "c9dcc91c562f5e766e23bcb18f4c75238816c23e" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "5d8ea924bc085527dd9bd6d270c801c2ed4b8bc7" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "aac2817ac94d9d972e4d7bdd24de0fa811ab8529" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "47ce2f07262e32e5ed2ede1558f0129a11c12ce1" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7fdead3a6d8aaccda120699da7e45e369edc108c" @@ -38900,8 +39116,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "8c6ff390fde8a9506470b23ec2db414c267a6909" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "6e442346f305135bb28f1c143fae5b18b58c358c" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "23d113a50b2569944eb705d2a04e645f98684e2a" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "cc315ebd9ef6c843c3663a2a97dba8eda9edb868" @@ -40491,8 +40713,8 @@ "translation": "e3762218f782e767ec7710ad5ae8af573a53e91d" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "a263a2cde1bf51f44c1ad923f2cd1cd3cfd31ba4" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "e102ad3dbcaec0d0599191fe1fd57751220daed8" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -43022,22 +43244,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "3ce7fbb0fdcad7359dabe263332d0dea17aa9736" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "319d4f40bce8743913e348f8f9a35ecaf2925192" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "ecc82466ef880e875aaf35e7a9eb33bd7fc327ba" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "6a6d497e4620c6d683f797a228473ea473611038" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "940c7aab2824882bea49787acf748ca297c23d55" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "28ea3e03a3f9da23f094ad01641c68cf7b762e46" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "233784ab7ba19b26cd9f73d619366ddb95c76026" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "c17f112cf45b293e6b5b45ef8fbb2a8beeb40e49" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "c8fd19bcd4695a38fd6ab1f4a349e58e426b86a1" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "f8ccfec2150c8fee4314f15ef14af559e7ebb920" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "2b6f8050b7e4c141d6ea9fdcf2b7e58b3c343578" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "174fcb174b906ef6b37555e6c87495450c80c6d6" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "8e137d4479a1718abf4a0544e130f09b8a2ae935" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "561200ba0584663d80596ae4d4100ba894dbc044" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "dff785cdaf1c28e100e9513fa1e74b6dd319503b" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "da2f6d41ad253e0b67fe67e1a694c52e6f855f8b" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "ee19ba1d573cbe4fb6cbbab4cb59e86260dd132a" @@ -47808,8 +48078,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "4a1cd9917a4b43ae471d4c2a299469db22cd0210" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "9ea378ca9cb5573730bce8af9864148df8006622" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "0356819c8080c8928228884cf40880bfecb56fcb" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "8a083e47d10d9cb24fbe3039f3247ffb08f7f97c" @@ -49399,8 +49675,8 @@ "translation": "a290148f2f26d6fc99f0343e99f8bc1d17cf550d" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "aa61e66634c6a1e7c6a0e46ef3ea50769da9dde8" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "2bf0b0029283a34afa81afbb40323a6af90e768b" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -51930,22 +52206,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "388c2e1dc04f5c4d244f55ce80566f3b538729b7" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "c72c8bd88325a3a7f1123bae3a3d6353d63469d8" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "bd169b261b9965a488e7eab33ad6b7c8dea01e4a" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "01135463135e36e46c03e775509a6bfb26441129" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "dbf11169556f0cffd94bd24c7b06435d22af0048" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "76ceb2a1e2cb80b98c4e395bf9454742f1f037e2" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "de6997ad687f61d5900972baa0dba18ce8af98f3" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "baaa3d48044ce8380571153e8101fc86725fc082" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "8171b579b7d69cc7c509aff643491f37ba8e05d7" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "d926271a78463c6901f243934439c0504bac7d82" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "e6a7111798b43c5d185c9bcb5f5fb4ea0749d54b" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "7d0ed9cf49a50475aa351784e43ec2a368fcb8d2" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "30d33e3953d6c7600156b4fd58bf810f38e87b8d" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "bc41aaa288260dda23385fa10d8130c8a9d9c9ad" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "adc1f36abdcd08bb4e15165975dfa5430b4d940e" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "153864509d623024e9375d2e7857a986631d7898" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "a2936a5b99f3ba9aef0ef806a85097d30db91392" @@ -56716,8 +57040,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "046d0996624d117cbbafac2278b6c7a1bf2d99c3" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "88ed83100a3b43d02b46c0737364cccb9a807c1e" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "5f48ee564c4f89ed458b2057c0a11c20cfdbe171" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "273a4585152f46d25617f5b68785c0aaa5ad0743" @@ -58307,8 +58637,8 @@ "translation": "9a20576cbad2401545a21cce3a90dc01cb77f9cf" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "37f0caa39b27e442e09f8137760cefb2943b21ee" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "dcccbcc0d0fc6924eba4be25691241a587c005fb" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -60838,22 +61168,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "a0803404bf4c1b8d0c346c9952563acee53b8186" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "ddd2d1dc281bc53b5fd7297a7c6dfc9272e392da" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "053a2fccc4a2b39303610cbaa90b5e7f62f6558c" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "dec9ee4a6da5cff1b310cdc577004177efb3e622" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "043e1b98c54b1cb30cbf5dcbac82129bb5a1a813" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "3f02912f680591502162b32e2159c23e5a16fcba" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "7299989df6e462428d37cd94fb7b3d4fa76fdd1e" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "5944191f7b6b34ddb1276e8e8994d68382e612dc" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "74e274e1b8c0936cb2dc2a8b3701ff3459f3afd1" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "d8cbc3f852e658156f4825f2829bedafcd56cb52" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "8ca43b557a5a42c5e211f2f68c0a2e0b270d6d9c" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "7a740338adc1bcd50f9c0c7ad99b898a93c40097" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "9e999043926a0a4a4f9c81a2179942075bdd748d" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "9686a20bb04275b5b7295c30ee8fae9ad413eac9" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "8b573ae6a01e63c85d99462619d5ab18c5906d0e" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "12cc2a87108fe591eeb372bcd4bb7b3a66fac5d7" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "0a461c2b22896eac59c259415fdbbe96b207b9bc" @@ -65624,8 +66002,14 @@ "source": "1673317e8fda5fc944e0458b5e51563c048e2dcd", "translation": "b0935ca50aeb4136e0ed0229514b3e25e05bb98f" }, - "settings.appearance.taskCards.completionSubmenu.name": null, - "settings.appearance.taskCards.completionSubmenu.description": null, + "settings.appearance.taskCards.completionSubmenu.name": { + "source": "599d4040155ef8e0e7e6c70cdd3cb24583b2302f", + "translation": "27e0084553264b7ad3ff1d1d6fd235e6c53974db" + }, + "settings.appearance.taskCards.completionSubmenu.description": { + "source": "edc8f49c252f25483a01c4675be3e296ba2623c9", + "translation": "176419c263c0f972df1c0fd4283445c919a13c69" + }, "settings.appearance.taskCards.propertyGroups.coreProperties": { "source": "acae0b9efe790c7be9c7a70dd1db4826a9eef7f7", "translation": "6503db48028c919fb82aa2f1b6beb676980bc93e" @@ -67215,8 +67599,8 @@ "translation": "a154370a00ca79783bcc05897a84f78a62897467" }, "settings.integrations.httpApi.authToken.description": { - "source": "93591c952b15b9261d1b5d5700e04a92329c45bd", - "translation": "c65d53e493377a541ba0bdebbe2e9a092453f45d" + "source": "4645950dc83ea5b5601e58223affbd795b962da4", + "translation": "0cfe0078a28e054b5c0bf3b551e7543415979f8e" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -69746,22 +70130,70 @@ "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.completion.submenu": { + "source": "54a51295b3ea49320049e2c7a225326711e0a571", + "translation": "c0e9c6a5398ac12a965980b3c97fa747247d3f2e" + }, + "contextMenus.task.completion.submenuCompleteOnly": { + "source": "0c018491b96aadfff5ebeb45434a2945bdd12741", + "translation": "1bf6db8d2eb22456e00bdb5406d4333206119ff1" + }, + "contextMenus.task.completion.completeToday": { + "source": "f86a2e78fa25078c77c8b36a58c7adabfdc0b725", + "translation": "445ccd121c337b7c6c346fea023b490f72fd0718" + }, + "contextMenus.task.completion.completeAsScheduled": { + "source": "5816ce12a13d6c586d600d2a38ca8fd669bfac38", + "translation": "88b63fe3a92bc81472b49c66b9a670db679dc3c9" + }, + "contextMenus.task.completion.completeOnDue": { + "source": "d915b497e5849ebf0a27d1ed54173a355fe96232", + "translation": "98310ed382c45d840e31e2c71727061f18a48a8e" + }, + "contextMenus.task.completion.completeOnPicked": { + "source": "9c2baea91e78091a5c390492c920218cc2a66146", + "translation": "d5e565e2da58b04544b34d9d6779db38de81d796" + }, + "contextMenus.task.completion.markIncomplete": { + "source": "3ae756e7c4063158572bde2513eeebd18a7ffb62", + "translation": "e54fef935d146321fd4452e3b420e0cf7944a5bb" + }, + "contextMenus.task.completion.noScheduledDate": { + "source": "b26b6e02e0a284a7ae4d1326db014438a8d8f860", + "translation": "57598e605946df7f961a61258dd8a6aeef64bfd9" + }, + "contextMenus.task.completion.noDueDate": { + "source": "7fd1fe06f971d95e436f7ee5b7c3ce4390e66ddb", + "translation": "7e7104a73f227ba5ad6b80121a24b5da62ad989d" + }, + "contextMenus.task.completion.noPickedDate": { + "source": "d4ee6469f6d100f64fd1e5f328c800c1ee3f2788", + "translation": "790f92adfbcddf048eb51d647566810ae3042404" + }, + "contextMenus.task.completion.noCompletedStatus": { + "source": "17a5c4be21c3ae7b7320d3ee28ca1cb611221530", + "translation": "ff21fa9df7b2fcf477b103e38f9497c52e902471" + }, + "contextMenus.task.completion.pickDateTitle": { + "source": "b7bb83924b90b81af703aedc3bb16bdc4c239ec5", + "translation": "9d73c320249b5f88f0185f1bcfca0aaac6c7be46" + }, + "contextMenus.task.completion.completeFailure": { + "source": "2bd697dbae7139872c337782e64b31b0f78b4a25", + "translation": "a0335cf758fa1d78e17073b617d2b22864f4e234" + }, + "contextMenus.task.completion.clearInstancesConfirmTitle": { + "source": "a819ff5087a10bfd7d0300249ce038f90e818702", + "translation": "f9e6092fe724099587f37624e40805e4ad4c1e36" + }, + "contextMenus.task.completion.clearInstancesConfirmMessage": { + "source": "2220d4a9ab7e1b7f1224c48a64a310d0ea2caecc", + "translation": "85072a4696d06a0707863d4fab08646ee6d4af93" + }, + "contextMenus.task.completion.clearInstancesConfirmButton": { + "source": "3bdcd7189cfa14996da1fdf37761df828a182d7e", + "translation": "ba9f060abfbfa2c87f1635a3e3a933cb37ad9d84" + }, "contextMenus.task.quickReminders.atTime": { "source": "87270e0d6c46f3d0c3510c6c2a2f1e31e8b0ab60", "translation": "7aaeb02feb12bd8334344d70a17e16b367174054" diff --git a/src/api/httpTypes.ts b/src/api/httpTypes.ts index 9774ab23b..b631ac8d6 100644 --- a/src/api/httpTypes.ts +++ b/src/api/httpTypes.ts @@ -16,6 +16,7 @@ export interface HTTPResponseLike { } export interface HTTPServerLike { + address?(): { port: number } | string | null; listening?: boolean; listen(port: number, callback?: () => void): void; listen(port: number, hostname: string, callback?: () => void): void; diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index 9ca0bf0b6..9afc3b873 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -79,7 +79,7 @@ import { getCalendarConfigValue as getCalendarConfigValueFromSnapshot, } from "./calendarConfigSnapshot"; import { buildCalendarPropertyEvent } from "./calendarPropertyEvents"; -import { buildExternalCalendarEvents } from "./calendarExternalEvents"; +import { buildExternalCalendarEvents, setProviderCalendarToggle } from "./calendarExternalEvents"; import { decorateCalendarIcsEventElement, getCalendarRelatedNoteTooltip, @@ -179,7 +179,9 @@ export function getTaskNotesCalendarEventOrder(sortConfig: unknown): string { if (!hasBasesCalendarSortConfig(sortConfig)) { return DEFAULT_CALENDAR_EVENT_ORDER; } - return `${TASKNOTES_CALENDAR_SORT_INDEX},${DEFAULT_CALENDAR_EVENT_ORDER}`; + // Bases ranks break ties at the same placement. They must not put a timed + // task ahead of an earlier appointment that has no Bases result index. + return `start,allDay,${TASKNOTES_CALENDAR_SORT_INDEX},-duration,title`; } function getCalendarEventSortPath(event: EventInput): string | null { @@ -918,7 +920,11 @@ export class CalendarView extends BasesViewBase { const calendars = this.plugin.googleCalendarService.getAvailableCalendars(); for (const cal of calendars) { const key = `showGoogleCalendar_${cal.id}`; - this.googleCalendarToggles.set(cal.id, this.getConfigOption(key, true)); + setProviderCalendarToggle( + this.googleCalendarToggles, + cal, + this.getConfigOption(key, true) + ); } } diff --git a/src/bases/MiniCalendarView.ts b/src/bases/MiniCalendarView.ts index 18bb7e7e3..f27a348e4 100644 --- a/src/bases/MiniCalendarView.ts +++ b/src/bases/MiniCalendarView.ts @@ -36,6 +36,8 @@ import { import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { createElementInDocument } from "../utils/documentDom"; +import { findProviderCalendar } from "../services/CalendarProvider"; +import { setProviderCalendarToggle } from "./calendarExternalEvents"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/MiniCalendarView" }); @@ -209,8 +211,9 @@ export class MiniCalendarView extends BasesViewBase { if (this.plugin.googleCalendarService) { for (const calendar of this.plugin.googleCalendarService.getAvailableCalendars()) { - this.googleCalendarToggles.set( - calendar.id, + setProviderCalendarToggle( + this.googleCalendarToggles, + calendar, getToggleValue(`showGoogleCalendar_${calendar.id}`) ); } @@ -447,17 +450,13 @@ export class MiniCalendarView extends BasesViewBase { return; } - const calendars = new Map( - this.plugin.googleCalendarService - .getAvailableCalendars() - .map((calendar) => [calendar.id, calendar]) - ); + const calendars = this.plugin.googleCalendarService.getAvailableCalendars(); for (const icsEvent of this.plugin.googleCalendarService.getAllEvents()) { const calendarId = icsEvent.subscriptionId.replace("google-", ""); if (this.googleCalendarToggles.get(calendarId) === false) continue; - const calendar = calendars.get(calendarId); + const calendar = findProviderCalendar(calendars, calendarId); this.indexExternalEvent( icsEvent, calendar?.summary || "Google Calendar", diff --git a/src/bases/basesFilterDefaults.ts b/src/bases/basesFilterDefaults.ts index 7b1270185..63eff2fa1 100644 --- a/src/bases/basesFilterDefaults.ts +++ b/src/bases/basesFilterDefaults.ts @@ -1,4 +1,5 @@ import type { FieldMappingKey } from "../types"; +import { formatProjectEntryLinkExpression } from "../templates/defaultBasesFiles"; import type { UserMappedField } from "../types/settings"; import type { BasesCreateFileFrontmatter, @@ -120,7 +121,15 @@ function applyFilterRuleDefault( /^(.+?)\.contains\(this\.file\.asLink\(\)\)$/ ); if (currentFileContainsMatch) { - const property = normalizeFilterProperty(currentFileContainsMatch[1], options); + let expression = currentFileContainsMatch[1].trim(); + // Only unwrap the link normalization we generate ourselves. Arbitrary + // map/filter transformations do not imply a writable source default. + const normalization = `.map(${formatProjectEntryLinkExpression("value")})`; + if (expression.endsWith(normalization)) { + expression = expression.slice(0, -normalization.length) + .replace(/^file\.hasLink\(this\.file\)\s*&&\s*/, ""); + } + const property = normalizeFilterProperty(expression, options); const currentFileLink = resolveCurrentFileLink(options.currentFileLink); if (property && currentFileLink) { addFrontmatterDefault(defaults, property, currentFileLink, options.fieldMapper); @@ -139,6 +148,8 @@ function normalizeFilterProperty( options: BasesFilterDefaultOptions ): string | null { let property = propertyExpression.trim(); + + const listMatch = property.match(/^list\((.+)\)$/); if (listMatch) { property = listMatch[1].trim(); diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 0a25be2c2..8d5281b18 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -1141,6 +1141,13 @@ export function generateRecurringTaskInstances( const hasOriginalTime = hasTimeComponent(task.scheduled); const templateTime = getRecurringTime(task); const nextScheduledDate = getDatePart(task.scheduled); + // A moved occurrence is represented at its current scheduled placement. + // Its original rule date must not also become a projected task. Recorded + // completions/skips are handled separately below and remain available. + const movedOriginalDates = new Set(task.googleCalendarMovedOriginalDates || []); + if (task.googleCalendarExceptionOriginalScheduled) { + movedOriginalDates.add(getDatePart(task.googleCalendarExceptionOriginalScheduled)); + } const spanDayOffset = showScheduledToDueSpan ? getScheduledToDueSpanDayOffset(task) : null; const shouldCreateRecurringSpan = spanDayOffset !== null; const recurringSearchStartDate = shouldCreateRecurringSpan @@ -1227,7 +1234,7 @@ export function generateRecurringTaskInstances( } // Skip if conflicts with next scheduled occurrence - if (instanceDate === nextScheduledDate) { + if (instanceDate === nextScheduledDate || movedOriginalDates.has(instanceDate)) { continue; } diff --git a/src/bases/calendarDataSignature.ts b/src/bases/calendarDataSignature.ts index f1e3469e3..3ea7a1fa4 100644 --- a/src/bases/calendarDataSignature.ts +++ b/src/bases/calendarDataSignature.ts @@ -18,6 +18,8 @@ const CALENDAR_DATA_SIGNATURE_FIELDS: FieldMappingKey[] = [ "blockedBy", "icsEventId", "googleCalendarEventId", + "googleCalendarExceptionOriginalScheduled", + "googleCalendarMovedOriginalDates", "reminders", "sortOrder", ]; diff --git a/src/bases/calendarExternalEvents.ts b/src/bases/calendarExternalEvents.ts index 45b56c9c0..091206c99 100644 --- a/src/bases/calendarExternalEvents.ts +++ b/src/bases/calendarExternalEvents.ts @@ -2,6 +2,7 @@ import type { EventInput } from "@fullcalendar/core"; import type TaskNotesPlugin from "../main"; import type { ICSEvent } from "../types"; import { createICSEvent } from "./calendar-core"; +import { PRIMARY_CALENDAR_ALIAS, ProviderCalendar } from "../services/CalendarProvider"; export type ExternalCalendarProvider = "ics" | "google" | "microsoft"; @@ -35,6 +36,22 @@ export function getExternalCalendarToggleId( return event.subscriptionId; } +/** + * Registers a provider calendar's visibility toggle. Calendars fetched under the + * primary alias carry that alias as their calendar id, so the account's own + * calendar has to answer to both keys for its toggle to take effect. + */ +export function setProviderCalendarToggle( + toggles: Map, + calendar: Pick, + visible: boolean +): void { + toggles.set(calendar.id, visible); + if (calendar.primary) { + toggles.set(PRIMARY_CALENDAR_ALIAS, visible); + } +} + export function shouldIncludeExternalCalendarEvent( event: Pick, provider: ExternalCalendarProvider, diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 88b2ff3df..99fdfc47d 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -198,6 +198,8 @@ function createTaskInfoFromProperties( "icsEventId", "complete_instances", "skipped_instances", + "googleCalendarExceptionOriginalScheduled", + "googleCalendarMovedOriginalDates", "blockedBy", "blocking", "sortOrder", @@ -267,6 +269,10 @@ function createTaskInfoFromProperties( icsEventId: toStringArray(props.icsEventId), complete_instances: toStringArray(props.complete_instances), skipped_instances: toStringArray(props.skipped_instances), + googleCalendarExceptionOriginalScheduled: toOptionalString( + props.googleCalendarExceptionOriginalScheduled + ), + googleCalendarMovedOriginalDates: toStringArray(props.googleCalendarMovedOriginalDates), blockedBy: toDependencies(props.blockedBy), blocking: blockingTasks.length > 0 ? blockingTasks : undefined, isBlocked: isBlocked, diff --git a/src/components/RecurrenceContextMenu.ts b/src/components/RecurrenceContextMenu.ts index 7d6e3ffb3..05844e6e8 100644 --- a/src/components/RecurrenceContextMenu.ts +++ b/src/components/RecurrenceContextMenu.ts @@ -219,12 +219,12 @@ function getScheduledDateTimePart(scheduledDate?: string): string | undefined { return undefined; } - const timeMatch = scheduledDate.match(/T(\d{2}):(\d{2})/); + const timeMatch = scheduledDate.match(/T(\d{2}):(\d{2})(?::(\d{2}))?/); if (!timeMatch) { return undefined; } - return `${timeMatch[1]}${timeMatch[2]}00Z`; + return `${timeMatch[1]}${timeMatch[2]}${timeMatch[3] ?? "00"}Z`; } function getOrdinal(n: number): string { @@ -271,7 +271,7 @@ export function buildRecurrenceOptions(input: BuildRecurrenceOptionsInput): Recu ); if (existingDtstartMatch && existingDtstartMatch[1].includes("T")) { // Extract time part from existing DTSTART - const existingTime = existingDtstartMatch[1].split("T")[1]; + const existingTime = existingDtstartMatch[1].split("T")[1].replace(/Z$/, "") + "Z"; startDTSTART = `${startDTSTART}T${existingTime}`; } } @@ -388,8 +388,12 @@ export function buildCustomRecurrenceRule(input: CustomRecurrenceRuleInput): str let dtstartFormatted = input.dtstart.replace(/-/g, ""); if (input.dtstartTime) { - const timeFormatted = input.dtstartTime.replace(":", "") + "00"; - dtstartFormatted = `${dtstartFormatted}T${timeFormatted}`; + // Match the model's calendar-anchor convention: retain the entered + // clock components, just as parsing and preset generation do. Do not + // reinterpret an existing anchor through the machine's timezone. + const timeParts = input.dtstartTime.split(":"); + const timeFormatted = `${timeParts[0]}${timeParts[1]}${timeParts[2] ?? "00"}`; + dtstartFormatted = `${dtstartFormatted}T${timeFormatted}Z`; } parts.push(`DTSTART:${dtstartFormatted}`); @@ -625,9 +629,9 @@ class CustomRecurrenceModal extends Modal { // Check if we should preserve time from scheduled date if (this.scheduledDate && this.scheduledDate.includes("T")) { - const timeMatch = this.scheduledDate.match(/T(\d{2}):(\d{2})/); + const timeMatch = this.scheduledDate.match(/T(\d{2}):(\d{2})(?::(\d{2}))?/); if (timeMatch) { - this.dtstartTime = `${timeMatch[1]}:${timeMatch[2]}`; + this.dtstartTime = `${timeMatch[1]}:${timeMatch[2]}:${timeMatch[3] ?? "00"}`; } } return; @@ -651,7 +655,7 @@ class CustomRecurrenceModal extends Modal { if (value.length > 8 && value.includes("T")) { const timeStr = value.slice(9); // Get HHMMSSZ part if (timeStr.length >= 4) { - this.dtstartTime = `${timeStr.slice(0, 2)}:${timeStr.slice(2, 4)}`; + this.dtstartTime = `${timeStr.slice(0, 2)}:${timeStr.slice(2, 4)}:${timeStr.slice(4, 6) || "00"}`; } } } else { @@ -754,6 +758,7 @@ class CustomRecurrenceModal extends Modal { .setDesc("The time when recurring instances should appear (optional)") .addText((text) => { text.inputEl.type = "time"; + text.inputEl.step = "1"; text.setValue(this.dtstartTime).onChange((value) => { this.dtstartTime = value; }); diff --git a/src/editor/InstantConvertButtons.ts b/src/editor/InstantConvertButtons.ts index 1d1261fe5..cff08d117 100644 --- a/src/editor/InstantConvertButtons.ts +++ b/src/editor/InstantConvertButtons.ts @@ -41,9 +41,17 @@ class ConvertButtonWidget extends WidgetType { const iconSpan = button.createSpan({ cls: "instant-convert-button__icon" }); setIcon(iconSpan, "file-plus"); + let activationInProgress = false; const handleActivation = (e: Event) => { e.preventDefault(); e.stopPropagation(); + if (activationInProgress) { + return; + } + + activationInProgress = true; + button.disabled = true; + button.setAttribute("aria-busy", "true"); void (async () => { try { // Validate button state before proceeding @@ -84,6 +92,10 @@ class ConvertButtonWidget extends WidgetType { operation: "convert-button-click-handler", error: error, }); + } finally { + activationInProgress = false; + button.disabled = false; + button.removeAttribute("aria-busy"); } })(); }; diff --git a/src/editor/MarkdownWidgetInsertion.ts b/src/editor/MarkdownWidgetInsertion.ts index fe2e26fa0..fa4cca92f 100644 --- a/src/editor/MarkdownWidgetInsertion.ts +++ b/src/editor/MarkdownWidgetInsertion.ts @@ -58,6 +58,63 @@ export function getMetadataOrHeaderInsertionReference(container: HTMLElement): C return container.firstChild; } +/** + * Places a widget inside the note's header block, after the properties. + * + * Reading mode is virtualised: every render pass ends with + * `sizerEl.setChildrenInPlace([pusherEl, ...shownSections])`, which deletes any + * direct child of the sizer that Obsidian did not put there. A widget injected + * between sections is therefore removed on each pass and re-injected on the next + * frame, and that add/remove cycle drags the scroll position down the note (#2255). + * + * `setChildrenInPlace` only manages *direct* children, so nesting the widget one + * level deeper leaves it untouched by the render pass. Nesting also fixes the height + * accounting: `measureSection` takes a section's height as the gap between its own + * `offsetTop` and its next sibling's, so a widget inside the header is counted in + * that section instead of falling between two sections and being counted in neither. + * + * Obsidian still detaches the whole header when it scrolls out of the render window, + * taking the widget with it. That is fine and must not be fought: callers check + * `hasMetadataOrHeaderAnchor` first and skip injection while the header is gone, + * because the fallback placement would land the widget in the middle of the text + * being read. + * + * Returns false when there is no header to nest into. + */ +export function insertInsideHeaderAnchor(container: HTMLElement, widget: HTMLElement): boolean { + const header = findDirectHeader(container); + if (!header) { + return false; + } + + // Anchor on the properties block rather than the end of the header. Obsidian + // builds the header in stages, so appending to it during an early pass puts the + // widget above the properties, which then render underneath and shove it down - + // a visible jolt on every note open. Waiting for the properties block means the + // widget is placed once, in its final position. + const metadata = header.querySelector(".metadata-container"); + if (!metadata) { + return false; + } + + metadata.insertAdjacentElement("afterend", widget); + return true; +} + +/** + * Whether the header the widget is nested into is currently rendered. + * + * Obsidian detaches sections that scroll out of the render window, and the note's + * `.mod-header.mod-ui` is one of them. While it is gone there is nowhere correct to + * put the widget: `getMetadataOrHeaderInsertionReference` would fall through to the + * preview pusher, whose next sibling is the first section that is *currently + * rendered* rather than the first section of the note (#2255). + */ +export function hasMetadataOrHeaderAnchor(container: HTMLElement): boolean { + const header = findDirectHeader(container); + return header !== null && header.querySelector(".metadata-container") !== null; +} + export function insertAfterMetadataOrHeader(container: HTMLElement, widget: HTMLElement): void { container.insertBefore(widget, getMetadataOrHeaderInsertionReference(container)); } diff --git a/src/editor/ReadingModeWidgetObserver.ts b/src/editor/ReadingModeWidgetObserver.ts index 01f71fd0e..fdb25df00 100644 --- a/src/editor/ReadingModeWidgetObserver.ts +++ b/src/editor/ReadingModeWidgetObserver.ts @@ -1,5 +1,6 @@ import { MarkdownView, WorkspaceLeaf } from "obsidian"; import { shouldSkipMarkdownWidgetLeaf } from "./MarkdownWidgetContext"; +import { hasMetadataOrHeaderAnchor } from "./MarkdownWidgetInsertion"; /** * How long after the last scroll event re-injection stays deferred. @@ -17,6 +18,16 @@ export const DEFAULT_SCROLL_QUIET_PERIOD_MS = 200; export interface ReadingModeObserverOptions { /** Override the quiet period; 0 disables scroll deferral (used by tests). */ scrollQuietPeriodMs?: number; + /** + * Only inject while the note's header is rendered. + * + * For widgets nested inside `.mod-header.mod-ui`, which Obsidian detaches once + * the reader scrolls past the top of the note. Injecting then falls back to the + * preview pusher and lands the widget in the middle of the text being read + * (#2255). Off by default: widgets positioned elsewhere in the note, such as the + * relationships widget at the bottom, must still inject when the header is gone. + */ + requireHeaderAnchor?: boolean; } type FrameHandle = { @@ -93,6 +104,7 @@ export function observeReadingModeWidgetMutations( } const scrollQuietPeriodMs = options.scrollQuietPeriodMs ?? DEFAULT_SCROLL_QUIET_PERIOD_MS; + const requireHeaderAnchor = options.requireHeaderAnchor ?? false; let pendingFrame: FrameHandle | null = null; let lastScrollAt = Number.NEGATIVE_INFINITY; @@ -122,6 +134,15 @@ export function observeReadingModeWidgetMutations( return; } + // A header-nested widget has nowhere correct to go while the header is + // scrolled out of the render window: injection would fall back to the preview + // pusher and land it in the middle of the text being read (#2255). This + // observer runs again when the header returns, so skipping defers the widget + // rather than dropping it. Only applies to callers that opted in. + if (requireHeaderAnchor && !hasMetadataOrHeaderAnchor(sizer)) { + 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. diff --git a/src/editor/TaskCardNoteDecorations.ts b/src/editor/TaskCardNoteDecorations.ts index 605644501..93afa5f49 100644 --- a/src/editor/TaskCardNoteDecorations.ts +++ b/src/editor/TaskCardNoteDecorations.ts @@ -60,7 +60,10 @@ import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, } from "./MarkdownWidgetContext"; -import { insertAfterMetadataOrHeader } from "./MarkdownWidgetInsertion"; +import { + insertAfterMetadataOrHeader, + insertInsideHeaderAnchor, +} from "./MarkdownWidgetInsertion"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Editor/TaskCardNoteDecorations" }); @@ -643,7 +646,17 @@ async function injectReadingModeWidget( return; } - insertAfterMetadataOrHeader(sizer, widget); + // Nest inside the header rather than sitting between sections. Obsidian + // deletes unexpected *direct* children of the sizer on every render pass, so + // a widget placed between sections churns and drags the scroll position down + // the note (#2255). While the header is scrolled out of the render window + // there is nowhere correct to put the widget, so skip and let the observer + // re-run once it is back. + if (!insertInsideHeaderAnchor(sizer, widget)) { + widget.component?.unload(); + widget.remove(); + return; + } } catch (error) { tasknotesLogger.error("[TaskNotes] Error injecting task card widget in reading mode:", { category: "persistence", @@ -737,7 +750,10 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { scheduleInjection, observedMarkdownContainers, markdownWidgetObserverCleanups, - shouldRefreshMarkdownLeaf + shouldRefreshMarkdownLeaf, + // The card nests inside the header, so it must not be re-injected while + // Obsidian has that section detached. + { requireHeaderAnchor: true } ); }; const observeMarkdownLeaves = () => { diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 531055a6a..f778ead0c 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1296,6 +1296,10 @@ export const de: TranslationTree = { name: "Standard sichtbare Eigenschaften", description: "Wähle, welche Eigenschaften standardmäßig auf Aufgabenkarten erscheinen." }, + completionSubmenu: { + name: "Abschluss- und Überspringen-Aktionen in einem Untermenü gruppieren", + description: "Zeige die Aktionen zum Abschließen und Überspringen in einem Untermenü des Aufgaben-Kontextmenüs. Deaktiviere diese Option, um sie direkt im Menü anzuzeigen." + }, propertyGroups: { coreProperties: "KERNEIGENSCHAFTEN", organization: "ORGANISATION", @@ -1974,7 +1978,7 @@ export const de: TranslationTree = { }, authToken: { name: "API-Authentifizierungstoken", - description: "Token für API-Authentifizierung erforderlich (leer lassen für keine Authentifizierung)", + description: "Für die API-Authentifizierung erforderlich. Ist das Feld leer, wird beim Serverstart ein Token generiert.", placeholder: "dein-geheimes-token" }, mcp: { @@ -2876,6 +2880,24 @@ export const de: TranslationTree = { markIncomplete: "Als unvollständig für dieses Datum markieren", skipInstance: "Instanz überspringen", unskipInstance: "Instanz nicht überspringen", + completion: { + submenu: "Als abgeschlossen markieren oder überspringen", + submenuCompleteOnly: "Als abgeschlossen markieren", + completeToday: "Heute abgeschlossen", + completeAsScheduled: "Am geplanten Datum abgeschlossen", + completeOnDue: "Am Fälligkeitsdatum abgeschlossen", + completeOnPicked: "Abgeschlossen am (Datum wählen)", + markIncomplete: "Als unvollständig markieren", + noScheduledDate: "Für diese Aufgabe ist kein geplantes Datum festgelegt", + noDueDate: "Für diese Aufgabe ist kein Fälligkeitsdatum festgelegt", + noPickedDate: "Kein Datum ausgewählt", + noCompletedStatus: "Kein Status für abgeschlossene Aufgaben konfiguriert", + pickDateTitle: "Abschlussdatum wählen", + completeFailure: "Aufgabenabschluss konnte nicht aktualisiert werden: {message}", + clearInstancesConfirmTitle: "Aufgezeichnete Instanzen löschen?", + clearInstancesConfirmMessage: "Durch die Neuplanung werden diese aufgezeichneten abgeschlossenen oder übersprungenen Instanzen am oder nach dem neuen Datum gelöscht: {dates}. Sie werden nicht mehr als abgeschlossen oder übersprungen markiert sein. Fortfahren?", + clearInstancesConfirmButton: "Neu planen und löschen" + }, quickReminders: { atTime: "Zur Zeit des Events", fiveMinutes: "5 Minuten vorher", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 7be66f416..81e0f654b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2118,7 +2118,7 @@ export const en: TranslationTree = { }, authToken: { name: "API authentication token", - description: "Token required for API authentication (leave empty for no auth)", + description: "Required for API authentication. If empty, a token is generated when the server starts.", placeholder: "your-secret-token", }, mcp: { diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 62722a5c2..85cf6f313 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1296,6 +1296,10 @@ export const es: TranslationTree = { name: "Propiedades visibles predeterminadas", description: "Elegir qué propiedades aparecen en las tarjetas de tareas por defecto." }, + completionSubmenu: { + name: "Agrupar las acciones de completar y omitir en un submenú", + description: "Coloca las acciones de completar y omitir en un submenú del menú contextual de la tarea. Desactiva esta opción para mostrarlas directamente en el menú." + }, propertyGroups: { coreProperties: "PROPIEDADES PRINCIPALES", organization: "ORGANIZACIÓN", @@ -1974,7 +1978,7 @@ export const es: TranslationTree = { }, authToken: { name: "Token de autenticación API", - description: "Token requerido para autenticación API (dejar vacío para sin autenticación)", + description: "Obligatorio para la autenticación de la API. Si está vacío, se genera un token al iniciar el servidor.", placeholder: "tu-token-secreto" }, mcp: { @@ -2876,6 +2880,24 @@ export const es: TranslationTree = { markIncomplete: "Marcar como incompleto para esta fecha", skipInstance: "Omitir instancia", unskipInstance: "Deshacer omisión de instancia", + completion: { + submenu: "Marcar como completada u omitir", + submenuCompleteOnly: "Marcar como completada", + completeToday: "Completada hoy", + completeAsScheduled: "Completada en la fecha programada", + completeOnDue: "Completada en la fecha de vencimiento", + completeOnPicked: "Completada el (elegir fecha)", + markIncomplete: "Marcar como incompleta", + noScheduledDate: "Esta tarea no tiene una fecha programada", + noDueDate: "Esta tarea no tiene una fecha de vencimiento", + noPickedDate: "No se ha seleccionado ninguna fecha", + noCompletedStatus: "No hay ningún estado de finalización configurado", + pickDateTitle: "Elegir fecha de finalización", + completeFailure: "No se pudo actualizar la finalización de la tarea: {message}", + clearInstancesConfirmTitle: "¿Borrar las instancias registradas?", + clearInstancesConfirmMessage: "Al reprogramar se borrarán estas instancias registradas como completadas u omitidas en la nueva fecha o después de ella: {dates}. Dejarán de estar marcadas como completadas u omitidas. ¿Continuar?", + clearInstancesConfirmButton: "Reprogramar y borrar" + }, quickReminders: { atTime: "A la hora del evento", fiveMinutes: "5 minutos antes", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 6b63ac044..e9c3c7aa0 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1296,6 +1296,10 @@ export const fr: TranslationTree = { name: "Propriétés visibles par défaut", description: "Choisissez quelles propriétés apparaissent sur les cartes de tâches par défaut." }, + completionSubmenu: { + name: "Regrouper les actions de fin et d'omission dans un sous-menu", + description: "Place les actions permettant de terminer ou d'ignorer une tâche dans un sous-menu du menu contextuel. Désactivez cette option pour les afficher directement dans le menu." + }, propertyGroups: { coreProperties: "PROPRIÉTÉS PRINCIPALES", organization: "ORGANISATION", @@ -1974,7 +1978,7 @@ export const fr: TranslationTree = { }, authToken: { name: "Jeton d'authentification API", - description: "Jeton requis pour l'authentification API (laisser vide pour pas d'authentification)", + description: "Requis pour l'authentification API. Si le champ est vide, un jeton est généré au démarrage du serveur.", placeholder: "votre-jeton-secret" }, mcp: { @@ -2876,6 +2880,24 @@ export const fr: TranslationTree = { markIncomplete: "Marquer comme incomplète pour cette date", skipInstance: "Ignorer l'instance", unskipInstance: "Ne plus ignorer l'instance", + completion: { + submenu: "Marquer comme terminée ou ignorer", + submenuCompleteOnly: "Marquer comme terminée", + completeToday: "Terminée aujourd'hui", + completeAsScheduled: "Terminée à la date planifiée", + completeOnDue: "Terminée à la date d'échéance", + completeOnPicked: "Terminée le (choisir une date)", + markIncomplete: "Marquer comme incomplète", + noScheduledDate: "Cette tâche n'a pas de date planifiée", + noDueDate: "Cette tâche n'a pas de date d'échéance", + noPickedDate: "Aucune date sélectionnée", + noCompletedStatus: "Aucun statut de tâche terminée n'est configuré", + pickDateTitle: "Choisir la date de fin", + completeFailure: "Impossible de mettre à jour la fin de la tâche : {message}", + clearInstancesConfirmTitle: "Effacer les instances enregistrées ?", + clearInstancesConfirmMessage: "La replanification effacera ces instances enregistrées comme terminées ou ignorées à la nouvelle date ou après celle-ci : {dates}. Elles ne seront plus marquées comme terminées ou ignorées. Continuer ?", + clearInstancesConfirmButton: "Replanifier et effacer" + }, quickReminders: { atTime: "À l'heure de l'événement", fiveMinutes: "5 minutes avant", diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index bbc1b08e9..8edb62c93 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1296,6 +1296,10 @@ export const ja: TranslationTree = { name: "デフォルト表示プロパティ", description: "タスクカードにデフォルトで表示するプロパティを選択します。" }, + completionSubmenu: { + name: "完了とスキップの操作をサブメニューにまとめる", + description: "完了とスキップの操作をタスクのコンテキストメニュー内のサブメニューに表示します。無効にすると、メニューに直接表示されます。" + }, propertyGroups: { coreProperties: "コアプロパティ", organization: "組織", @@ -1974,7 +1978,7 @@ export const ja: TranslationTree = { }, authToken: { name: "API認証トークン", - description: "API認証に必要なトークン(認証なしの場合は空白のままにする)", + description: "API認証に必要です。空欄の場合、サーバー起動時にトークンが生成されます。", placeholder: "あなたのシークレットトークン" }, mcp: { @@ -2876,6 +2880,24 @@ export const ja: TranslationTree = { markIncomplete: "この日付で未完了としてマーク", skipInstance: "インスタンスをスキップ", unskipInstance: "インスタンスのスキップを解除", + completion: { + submenu: "完了としてマークまたはスキップ", + submenuCompleteOnly: "完了としてマーク", + completeToday: "今日完了", + completeAsScheduled: "予定日に完了", + completeOnDue: "期限日に完了", + completeOnPicked: "指定日に完了(日付を選択)", + markIncomplete: "未完了としてマーク", + noScheduledDate: "このタスクには予定日が設定されていません", + noDueDate: "このタスクには期限日が設定されていません", + noPickedDate: "日付が選択されていません", + noCompletedStatus: "完了ステータスが設定されていません", + pickDateTitle: "完了日を選択", + completeFailure: "タスクの完了状態を更新できませんでした: {message}", + clearInstancesConfirmTitle: "記録されたインスタンスを消去しますか?", + clearInstancesConfirmMessage: "予定を変更すると、新しい日付以降に完了またはスキップとして記録された次のインスタンスが消去されます: {dates}。これらは完了またはスキップとしてマークされなくなります。続行しますか?", + clearInstancesConfirmButton: "予定を変更して消去" + }, quickReminders: { atTime: "イベント時刻に", fiveMinutes: "5分前", diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index 938227e07..27829cc9d 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -1292,6 +1292,10 @@ export const ko: TranslationTree = { name: "기본 표시 속성", description: "작업 카드에 기본으로 표시할 속성을 선택합니다." }, + completionSubmenu: { + name: "완료 및 건너뛰기 동작을 하위 메뉴로 묶기", + description: "작업의 컨텍스트 메뉴에서 완료 및 건너뛰기 동작을 하위 메뉴에 표시합니다. 끄면 메뉴에 바로 표시됩니다." + }, propertyGroups: { coreProperties: "핵심 속성", organization: "조직", @@ -1958,7 +1962,7 @@ export const ko: TranslationTree = { }, authToken: { name: "API 인증 토큰", - description: "API 인증에 필요한 토큰 (인증 없이 사용하려면 비워두세요)", + description: "API 인증에 필요합니다. 비워두면 서버 시작 시 토큰이 생성됩니다.", placeholder: "비밀-토큰" }, mcp: { @@ -2860,6 +2864,24 @@ export const ko: TranslationTree = { markIncomplete: "이 날짜에 미완료로 표시", skipInstance: "인스턴스 건너뛰기", unskipInstance: "인스턴스 건너뛰기 취소", + completion: { + submenu: "완료로 표시 또는 건너뛰기", + submenuCompleteOnly: "완료로 표시", + completeToday: "오늘 완료", + completeAsScheduled: "예정일에 완료", + completeOnDue: "마감일에 완료", + completeOnPicked: "선택한 날짜에 완료 (날짜 선택)", + markIncomplete: "미완료로 표시", + noScheduledDate: "이 작업에 예정일이 없습니다", + noDueDate: "이 작업에 마감일이 없습니다", + noPickedDate: "선택한 날짜가 없습니다", + noCompletedStatus: "완료 상태가 설정되어 있지 않습니다", + pickDateTitle: "완료 날짜 선택", + completeFailure: "작업 완료 상태를 업데이트하지 못했습니다: {message}", + clearInstancesConfirmTitle: "기록된 인스턴스를 지울까요?", + clearInstancesConfirmMessage: "일정을 변경하면 새 날짜 당일 또는 이후에 완료되거나 건너뛴 것으로 기록된 다음 인스턴스가 지워집니다: {dates}. 더 이상 완료 또는 건너뛰기로 표시되지 않습니다. 계속할까요?", + clearInstancesConfirmButton: "일정 변경 및 지우기" + }, quickReminders: { atTime: "이벤트 시간에", fiveMinutes: "5분 전", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 188a44419..338670a41 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1298,6 +1298,10 @@ export const pt: TranslationTree = { name: "Propriedades visíveis padrão", description: "Escolha quais propriedades aparecem nos cartões de tarefa por padrão." }, + completionSubmenu: { + name: "Agrupar as ações de concluir e pular em um submenu", + description: "Coloque as ações de concluir e pular em um submenu do menu de contexto da tarefa. Desative esta opção para exibi-las diretamente no menu." + }, propertyGroups: { coreProperties: "PROPRIEDADES PRINCIPAIS", organization: "ORGANIZAÇÃO", @@ -1976,7 +1980,7 @@ export const pt: TranslationTree = { }, authToken: { name: "Token de autenticação da API", - description: "Token necessário para autenticação da API (deixe em branco para sem autenticação)", + description: "Obrigatório para autenticação da API. Se estiver vazio, um token será gerado ao iniciar o servidor.", placeholder: "seu-token-secreto" }, mcp: { @@ -2878,6 +2882,24 @@ export const pt: TranslationTree = { markIncomplete: "Marcar como incompleta para esta data", skipInstance: "Pular instância", unskipInstance: "Desfazer pulo de instância", + completion: { + submenu: "Marcar como concluída ou pular", + submenuCompleteOnly: "Marcar como concluída", + completeToday: "Concluída hoje", + completeAsScheduled: "Concluída na data agendada", + completeOnDue: "Concluída na data de vencimento", + completeOnPicked: "Concluída em (escolher data)", + markIncomplete: "Marcar como incompleta", + noScheduledDate: "Esta tarefa não tem uma data agendada", + noDueDate: "Esta tarefa não tem uma data de vencimento", + noPickedDate: "Nenhuma data selecionada", + noCompletedStatus: "Nenhum status de conclusão configurado", + pickDateTitle: "Escolher data de conclusão", + completeFailure: "Falha ao atualizar a conclusão da tarefa: {message}", + clearInstancesConfirmTitle: "Limpar as instâncias registradas?", + clearInstancesConfirmMessage: "O reagendamento limpará estas instâncias registradas como concluídas ou puladas na nova data ou após ela: {dates}. Elas não estarão mais marcadas como concluídas ou puladas. Continuar?", + clearInstancesConfirmButton: "Reagendar e limpar" + }, quickReminders: { atTime: "Na hora do evento", fiveMinutes: "5 minutos antes", diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 19c45e007..1ef15c2fa 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1296,6 +1296,10 @@ export const ru: TranslationTree = { name: "Видимые свойства по умолчанию", description: "Выберите, какие свойства появляются на карточках задач по умолчанию." }, + completionSubmenu: { + name: "Объединять действия выполнения и пропуска в подменю", + description: "Размещать действия выполнения и пропуска в подменю контекстного меню задачи. Отключите, чтобы показывать их непосредственно в меню." + }, propertyGroups: { coreProperties: "ОСНОВНЫЕ СВОЙСТВА", organization: "ОРГАНИЗАЦИЯ", @@ -1974,7 +1978,7 @@ export const ru: TranslationTree = { }, authToken: { name: "Токен аутентификации API", - description: "Токен, необходимый для аутентификации API (оставьте пустым для отсутствия аутентификации)", + description: "Необходим для аутентификации API. Если поле пустое, токен создаётся при запуске сервера.", placeholder: "ваш-секретный-токен" }, mcp: { @@ -2876,6 +2880,24 @@ export const ru: TranslationTree = { markIncomplete: "Отметить как невыполненную для этой даты", skipInstance: "Пропустить экземпляр", unskipInstance: "Отменить пропуск экземпляра", + completion: { + submenu: "Отметить как выполненную или пропустить", + submenuCompleteOnly: "Отметить как выполненную", + completeToday: "Выполнена сегодня", + completeAsScheduled: "Выполнена в запланированную дату", + completeOnDue: "Выполнена в дату срока выполнения", + completeOnPicked: "Выполнена в выбранную дату", + markIncomplete: "Отметить как невыполненную", + noScheduledDate: "У этой задачи нет запланированной даты", + noDueDate: "У этой задачи нет срока выполнения", + noPickedDate: "Дата не выбрана", + noCompletedStatus: "Статус выполненной задачи не настроен", + pickDateTitle: "Выберите дату выполнения", + completeFailure: "Не удалось обновить выполнение задачи: {message}", + clearInstancesConfirmTitle: "Удалить записи об экземплярах?", + clearInstancesConfirmMessage: "Перенос удалит записи об этих выполненных или пропущенных экземплярах на новую дату или позже: {dates}. Они больше не будут отмечены как выполненные или пропущенные. Продолжить?", + clearInstancesConfirmButton: "Перенести и очистить" + }, quickReminders: { atTime: "Во время события", fiveMinutes: "За 5 минут", diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 0c6b0d1b6..ff63d7512 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1296,6 +1296,10 @@ export const zh: TranslationTree = { name: "默认可见属性", description: "选择默认在任务卡片上显示的属性。" }, + completionSubmenu: { + name: "将完成和跳过操作归入子菜单", + description: "在任务右键菜单的子菜单中显示完成和跳过操作。关闭后,这些操作将直接显示在菜单中。" + }, propertyGroups: { coreProperties: "核心属性", organization: "组织", @@ -1974,7 +1978,7 @@ export const zh: TranslationTree = { }, authToken: { name: "API认证令牌", - description: "API认证所需的令牌(留空表示无认证)", + description: "API认证必需的令牌。如果留空,将在服务器启动时生成令牌。", placeholder: "你的秘密令牌" }, mcp: { @@ -2875,6 +2879,24 @@ export const zh: TranslationTree = { markIncomplete: "标记此日期未完成", skipInstance: "跳过实例", unskipInstance: "取消跳过实例", + completion: { + submenu: "标记完成或跳过", + submenuCompleteOnly: "标记完成", + completeToday: "今天完成", + completeAsScheduled: "在计划日期完成", + completeOnDue: "在截止日期完成", + completeOnPicked: "在指定日期完成(选择日期)", + markIncomplete: "标记未完成", + noScheduledDate: "此任务没有计划日期", + noDueDate: "此任务没有截止日期", + noPickedDate: "未选择日期", + noCompletedStatus: "未配置已完成状态", + pickDateTitle: "选择完成日期", + completeFailure: "无法更新任务完成状态:{message}", + clearInstancesConfirmTitle: "清除已记录的实例?", + clearInstancesConfirmMessage: "重新安排将清除新日期当天及之后记录为已完成或已跳过的以下实例:{dates}。它们将不再标记为已完成或已跳过。是否继续?", + clearInstancesConfirmButton: "重新安排并清除" + }, quickReminders: { atTime: "在事件时间", fiveMinutes: "提前5分钟", diff --git a/src/main.ts b/src/main.ts index 4a967ff72..d1fe8ad43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -744,6 +744,10 @@ export default class TaskNotesPlugin extends Plugin { }, }); } + if (result.compromised) + throw new Error( + "TaskNotes settings are unreadable; restore settings before loading the plugin." + ); return result.data; } diff --git a/src/services/CalendarProvider.ts b/src/services/CalendarProvider.ts index 67847f2a1..b32922a7a 100644 --- a/src/services/CalendarProvider.ts +++ b/src/services/CalendarProvider.ts @@ -22,6 +22,29 @@ export interface ProviderCalendar { primary?: boolean; } +/** + * Google accepts `primary` as an alias for the signed-in account's own calendar. + * A calendar configured that way is fetched under the alias, so its events carry + * `primary` as their calendar id while the provider's calendar list reports the + * account's real calendar id. + */ +export const PRIMARY_CALENDAR_ALIAS = "primary"; + +/** + * Finds a provider calendar by id, resolving the primary alias to the account's + * own calendar so alias-configured events still match their calendar metadata. + */ +export function findProviderCalendar>( + calendars: readonly T[], + calendarId: string +): T | undefined { + return calendars.find( + (candidate) => + candidate.id === calendarId || + (calendarId === PRIMARY_CALENDAR_ALIAS && candidate.primary === true) + ); +} + /** * Event date/time configuration * Supports both all-day events (date) and timed events (dateTime + timeZone) diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 8d85747d6..f8a34b2d7 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -13,7 +13,7 @@ import { TokenExpiredError, } from "./errors"; import { validateCalendarId, validateEventId, validateRequired } from "./validation"; -import { CalendarProvider, ProviderCalendar } from "./CalendarProvider"; +import { CalendarProvider, findProviderCalendar, ProviderCalendar } from "./CalendarProvider"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; import { normalizeCalendarDescription } from "../utils/calendarDescription"; @@ -454,6 +454,20 @@ export class GoogleCalendarService extends CalendarProvider { } } + /** + * Resolves a calendar's color, including for calendars fetched under the + * primary alias, whose colors are cached under the account's real calendar id. + */ + private getCalendarColor(calendarId: string): string | undefined { + const directColor = this.calendarColors.get(calendarId); + if (directColor) { + return directColor; + } + + const calendar = findProviderCalendar(this.availableCalendars, calendarId); + return calendar ? this.calendarColors.get(calendar.id) : undefined; + } + /** * Converts a Google Calendar event to TaskNotes ICSEvent format */ @@ -492,7 +506,7 @@ export class GoogleCalendarService extends CalendarProvider { // Priority 2: Calendar-level color (from calendar metadata) if (!color) { - color = this.calendarColors.get(calendarId); + color = this.getCalendarColor(calendarId); } // Priority 3: Default Google Calendar blue diff --git a/src/services/HTTPAPIService.ts b/src/services/HTTPAPIService.ts index 7d8252072..7e6dac795 100644 --- a/src/services/HTTPAPIService.ts +++ b/src/services/HTTPAPIService.ts @@ -188,9 +188,9 @@ export class HTTPAPIService implements IWebhookNotifier { private authenticate(req: HTTPRequestLike): boolean { const authToken = this.plugin.settings.apiAuthToken; - // Skip auth if no token is configured + // A missing credential never opens an unauthenticated listener. if (!authToken) { - return true; + return false; } const authHeader = req.headers.authorization; @@ -291,7 +291,41 @@ export class HTTPAPIService implements IWebhookNotifier { return parseJSONBody(req); } + private starting: Promise | null = null; + async start(): Promise { + if (this.starting) return this.starting; + if (this.isRunning()) return; + const starting = this.startServer(); + this.starting = starting; + try { + await starting; + } finally { + this.starting = null; + } + } + + private async startServer(): Promise { + if (!Platform.isDesktop || !Platform.isDesktopApp) + throw new Error("The HTTP API is only available in the desktop app."); + if (!this.plugin.settings.apiAuthToken) { + const token = btoa( + String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))) + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + this.plugin.settings.apiAuthToken = token; + try { + await this.plugin.saveSettings(); + } catch (error) { + // A retry must persist a credential, not reuse an unsaved one. + if (this.plugin.settings.apiAuthToken === token) { + this.plugin.settings.apiAuthToken = ""; + } + throw error; + } + } return new Promise((resolve, reject) => { if (!Platform.isDesktop || !Platform.isDesktopApp) { reject(new Error("The HTTP API is only available in the desktop app.")); diff --git a/src/services/ICSNoteService.ts b/src/services/ICSNoteService.ts index 8a6776d09..cd76f9330 100644 --- a/src/services/ICSNoteService.ts +++ b/src/services/ICSNoteService.ts @@ -15,6 +15,7 @@ import type { InterpolationValues, TranslationKey } from "../i18n"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; import { processVaultFrontMatter } from "./VaultMutationService"; +import { findProviderCalendar } from "./CalendarProvider"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/ICSNoteService" }); @@ -60,10 +61,9 @@ export class ICSNoteService { .find((event) => event.id === trimmedEventId); if (googleEvent) { const calendarId = googleEvent.subscriptionId.replace("google-", ""); + const calendars = this.plugin.googleCalendarService?.getAvailableCalendars() ?? []; const subscriptionName = - this.plugin.googleCalendarService - ?.getAvailableCalendars() - .find((calendar) => calendar.id === calendarId)?.summary || "Google Calendar"; + findProviderCalendar(calendars, calendarId)?.summary || "Google Calendar"; return { event: googleEvent, subscriptionName }; } diff --git a/src/services/ICSSubscriptionService.ts b/src/services/ICSSubscriptionService.ts index bf27832a1..77e9b44fa 100644 --- a/src/services/ICSSubscriptionService.ts +++ b/src/services/ICSSubscriptionService.ts @@ -5,7 +5,6 @@ import { ICSSubscription, ICSEvent, ICSCache } from "../types"; import { EventEmitter } from "../utils/EventEmitter"; import TaskNotesPlugin from "../main"; import type { InterpolationValues, TranslationKey } from "../i18n"; -import { stringifyUnknown } from "../utils/stringUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; import { resolveTzidToIANA, wallTimeInZoneToUtcIso } from "../utils/icsTimezoneFallback"; @@ -520,11 +519,13 @@ export class ICSSubscriptionService extends EventEmitter { // Check if this is a modified instance (has RECURRENCE-ID) const recurrenceId = vevent.getFirstPropertyValue("recurrence-id"); - if (recurrenceId) { + if (recurrenceId instanceof ICAL.Time) { if (!modifiedInstances.has(uid)) { modifiedInstances.set(uid, new Map()); } - const recurrenceIdStr = stringifyUnknown(recurrenceId); + // Use the same ICAL.Time representation as iterator occurrences, + // not JSON serialization of the time's internal fields. + const recurrenceIdStr = recurrenceId.toString(); modifiedInstances.get(uid)!.set(recurrenceIdStr, event); } }); @@ -664,6 +665,10 @@ export class ICSSubscriptionService extends EventEmitter { // Check if this instance has been modified const modifiedEvent = modifiedForThisEvent.get(occurrenceStr); if (modifiedEvent) { + const modifiedStatus = modifiedEvent.component.getFirstPropertyValue("status"); + if (typeof modifiedStatus === "string" && modifiedStatus.toUpperCase() === "CANCELLED") { + continue; + } // Use the modified event instead const modifiedStart = modifiedEvent.startDate; const modifiedEnd = modifiedEvent.endDate; diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 8766019ac..6df404890 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -96,6 +96,7 @@ export class InstantTaskConvertService { private statusManager: StatusManager; private priorityManager: PriorityManager; private nlParser: NaturalLanguageParser; + private inFlightConversions = new WeakMap>>(); private translate(key: TranslationKey, variables?: InterpolationValues): string { return this.plugin.i18n.translate(key, variables); @@ -230,10 +231,44 @@ export class InstantTaskConvertService { } /** - * Instantly convert a checkbox task to a TaskNote without showing the modal - * Supports multi-line selection where additional lines become task details + * Instantly convert a checkbox task to a TaskNote without showing the modal. + * Repeated activation for the same editor line reuses the active conversion. */ async instantConvertTask(editor: Editor, lineNumber: number): Promise { + if (!editor) { + await this.performInstantConversion(editor, lineNumber); + return; + } + + let editorConversions = this.inFlightConversions.get(editor); + if (!editorConversions) { + editorConversions = new Map>(); + this.inFlightConversions.set(editor, editorConversions); + } + + const existingConversion = editorConversions.get(lineNumber); + if (existingConversion) { + await existingConversion; + return; + } + + const conversion = this.performInstantConversion(editor, lineNumber); + editorConversions.set(lineNumber, conversion); + + try { + await conversion; + } finally { + if (editorConversions.get(lineNumber) === conversion) { + editorConversions.delete(lineNumber); + } + } + } + + /** + * Performs one instant conversion after the caller has claimed its source line. + * Supports multi-line selection where additional lines become task details. + */ + private async performInstantConversion(editor: Editor, lineNumber: number): Promise { try { // Validate input parameters const validationResult = this.validateInputParameters(editor, lineNumber); diff --git a/src/services/OAuthService.ts b/src/services/OAuthService.ts index 9fed0b255..30938ab3d 100644 --- a/src/services/OAuthService.ts +++ b/src/services/OAuthService.ts @@ -137,7 +137,14 @@ export class OAuthService { * Standard OAuth flow with loopback redirect (client_id required, client_secret optional) * Used for desktop applications with PKCE for security */ + private authenticationInProgress = false; + private async authenticateStandard(provider: OAuthProvider): Promise { + // A second attempt must not close the first attempt's listener in finally. + if (this.authenticationInProgress) { + throw new Error("An OAuth authorization is already in progress"); + } + this.authenticationInProgress = true; try { const config = this.getConfig(provider); @@ -157,12 +164,7 @@ export class OAuthService { const codeChallenge = await this.generateCodeChallenge(codeVerifier); const state = this.generateState(); - // Find available port - const port = await this.findAvailablePort( - OAUTH_CONSTANTS.CALLBACK_PORT_START, - OAUTH_CONSTANTS.CALLBACK_PORT_END - ); - await this.startCallbackServer(port); + const port = await this.startCallbackServer(0); // Update redirect URI for this session const originalRedirectUri = config.redirectUri; @@ -185,11 +187,16 @@ export class OAuthService { `Opening browser for ${provider} authorization...` ); - // Open browser to authorization URL - await this.openAuthorizationUrl(authUrl); - - // Wait for callback with timeout - const code = await this.waitForCallback(state, 300000); // 5 minute timeout + // Register the resolver before the browser can return a fast callback. + const callback = this.waitForCallback(state, 300000); + // Electron can open the browser without ever settling openExternal. + // Let the callback/timeout drive the flow independently (#2279). + void this.openAuthorizationUrl(authUrl).catch((error: unknown) => { + this.pendingOAuthState.get(state)?.reject( + error instanceof Error ? error : new Error("Could not open OAuth browser") + ); + }); + const code = await callback; // Exchange code for tokens const tokens = await this.exchangeCodeForTokens(config, code, codeVerifier); @@ -202,6 +209,10 @@ export class OAuthService { `Successfully connected to ${provider} Calendar!` ); } finally { + // Clear a pending resolver even if opening the browser failed. + const pending = this.pendingOAuthState.get(state); + this.pendingOAuthState.delete(state); + pending?.reject(new Error("OAuth flow ended before authorization")); // Restore original redirect URI config.redirectUri = originalRedirectUri; } @@ -217,7 +228,11 @@ export class OAuthService { ); throw error; } finally { - await this.stopCallbackServer(); + try { + await this.stopCallbackServer(); + } finally { + this.authenticationInProgress = false; + } } } @@ -231,43 +246,19 @@ export class OAuthService { 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, - }); + 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 - */ - private async findAvailablePort(startPort: number, endPort: number): Promise { - const http = ensureHttpModule(); - - for (let port = startPort; port <= endPort; port++) { - try { - await new Promise((resolve, reject) => { - const server = http.createServer(); - server.once("error", reject); - server.once("listening", () => { - server.close(); - resolve(); - }); - server.listen(port, "127.0.0.1"); - }); - return port; - } catch { - // Port in use, try next one - continue; - } - } - - throw new Error(`No available ports found between ${startPort} and ${endPort}`); - } - /** * Generates a random code verifier for PKCE */ @@ -330,10 +321,10 @@ export class OAuthService { /** * Starts a temporary HTTP server to receive the OAuth callback */ - private async startCallbackServer(port: number): Promise { + private async startCallbackServer(port: number): Promise { return new Promise((resolve, reject) => { if (this.callbackServer) { - resolve(); // Already running + reject(new Error("An OAuth callback is already pending")); return; } @@ -363,7 +354,12 @@ export class OAuthService { }); this.callbackServer.listen(port, "127.0.0.1", () => { - resolve(); + const address = this.callbackServer?.address?.(); + if (!address || typeof address === "string") { + reject(new Error("OAuth listener did not expose its bound port")); + return; + } + resolve(address.port); }); }); } @@ -378,10 +374,9 @@ export class OAuthService { return; } - this.callbackServer.close(() => { - this.callbackServer = null; - resolve(); - }); + const server = this.callbackServer; + this.callbackServer = null; + server.close(() => resolve()); }); } @@ -389,70 +384,48 @@ export class OAuthService { * Handles incoming HTTP requests to the callback server */ private handleCallback(req: HTTPRequestLike, res: HTTPResponseLike): void { - const hostHeader = req.headers.host; - const host = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? "localhost"); - const url = new URL(req.url || "", `http://${host}`); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - // Send response to browser - res.writeHead(200, { "Content-Type": "text/html" }); - - if (error) { - res.end(` - - - OAuth Error - -

Authorization Failed

-

Error: ${error}

-

You can close this window.

- - - `); - - const pending = state ? this.pendingOAuthState.get(state) : null; - if (pending && state) { - pending.reject(new Error(`OAuth error: ${error}`)); - this.pendingOAuthState.delete(state); - } + const headers = { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }; + let url: URL; + try { + url = new URL(req.url || "/", "http://127.0.0.1"); + } catch { + res.writeHead(400, headers); + res.end("Invalid callback"); return; } - - if (!code || !state) { - res.end(` - - - OAuth Error - -

Invalid Callback

-

Missing required parameters.

-

You can close this window.

- - - `); + const state = url.searchParams.get("state"); + const pending = state ? this.pendingOAuthState.get(state) : undefined; + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + if ( + req.method !== "GET" || + url.pathname !== "/" || + !state || + !pending || + (!code && !error) + ) { + res.writeHead(400, headers); + res.end( + "Invalid callback

No pending authorization matches this callback.

" + ); return; } - - res.end(` - - - OAuth Success - -

Authorization Successful!

-

You can close this window and return to Obsidian.

- - - - `); - - // Resolve the pending promise - const pending = this.pendingOAuthState.get(state); - if (pending) { - pending.resolve(code); - this.pendingOAuthState.delete(state); - } + // Consume before responding: a replay cannot resolve the pending flow. + this.pendingOAuthState.delete(state); + res.writeHead(error ? 400 : 200, headers); + res.end( + error + ? "Authorization failed

Authorization was not completed. Return to Obsidian.

" + : "Authorization complete

You can close this window and return to Obsidian.

" + ); + if (error) pending.reject(new Error("OAuth authorization failed")); + else pending.resolve(code as string); } /** @@ -466,12 +439,17 @@ export class OAuthService { return; } - // Update the pending state with resolve/reject functions - pending.resolve = resolve; - pending.reject = reject; + // Release the timer on success, provider rejection, or browser failure. + pending.resolve = (code) => { + window.clearTimeout(timer); + resolve(code); + }; + pending.reject = (error) => { + window.clearTimeout(timer); + reject(error); + }; - // Set timeout - window.setTimeout(() => { + const timer = window.setTimeout(() => { if (this.pendingOAuthState.has(state)) { this.pendingOAuthState.delete(state); reject(new Error("OAuth timeout - authorization took too long")); @@ -910,11 +888,13 @@ export class OAuthService { * Ensures all resources are properly released to prevent memory leaks */ async destroy(): Promise { - // Stop HTTP callback server - await this.stopCallbackServer(); - - // Clear pending OAuth state + // Reject pending flows as well as clearing state, so callbacks and their + // timeout handles cannot remain orphaned when the plugin unloads. + for (const pending of this.pendingOAuthState.values()) { + pending.reject(new Error("OAuth authorization cancelled")); + } this.pendingOAuthState.clear(); + await this.stopCallbackServer(); // Clear token refresh state to prevent orphaned promises. this.tokenRefreshPromises.clear(); diff --git a/src/services/task-service/TaskUpdateService.ts b/src/services/task-service/TaskUpdateService.ts index eee4a475e..6b00fd852 100644 --- a/src/services/task-service/TaskUpdateService.ts +++ b/src/services/task-service/TaskUpdateService.ts @@ -130,20 +130,43 @@ export class TaskUpdateService { newPath = parentPath ? `${parentPath}/${newFilename}.md` : `${newFilename}.md`; } - const recurrenceUpdates = buildTaskUpdateRecurrenceUpdates({ - originalTask, - updates: taskUpdates, - maintainDueDateOffsetInRecurring: runtime.settings.maintainDueDateOffsetInRecurring, - }); + let recurrenceUpdates: Partial = {}; const normalizedDetails = normalizeTaskUpdateDetails(taskUpdates); let finalTags: string[] | undefined; const dateModified = getCurrentTimestamp(); const currentDateString = - taskUpdates.status !== undefined && !originalTask.recurrence - ? getCurrentDateString() - : ""; + taskUpdates.status !== undefined ? getCurrentDateString() : ""; await processVaultFrontMatter(runtime.app, file, (frontmatter) => { + // Rebase the caller's named changes on the bytes Obsidian is about + // to modify. A modal's TaskInfo can predate an external completion. + const current = runtime.fieldMapper.mapFromFrontmatter( + frontmatter, + originalTask.path, + runtime.settings.storeTitleInFilename + ); + originalTask = { + // Retain derived view/body context; persisted fields come from the fresh mapping. + id: originalTask.id, + basesData: originalTask.basesData, + details: originalTask.details, + blocking: originalTask.blocking, + isBlocked: originalTask.isBlocked, + isBlocking: originalTask.isBlocking, + hasSubtasks: originalTask.hasSubtasks, + path: originalTask.path, + title: originalTask.title, + status: originalTask.status, + priority: originalTask.priority, + archived: false, + ...current, + }; + recurrenceUpdates = buildTaskUpdateRecurrenceUpdates({ + originalTask, + updates: taskUpdates, + maintainDueDateOffsetInRecurring: + runtime.settings.maintainDueDateOffsetInRecurring, + }); const frontmatterResult = applyTaskUpdateFrontmatterChange({ frontmatter, originalTask, diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index bff215b1e..4a4803605 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -254,7 +254,13 @@ export function buildRecurringTaskCompletePlan({ dateStr ); if (updatedRecurrence) { - updatedTask.recurrence = updatedRecurrence; + // Instance completion selects a calendar day, not a new clock time. + // Carry the existing anchor's clock components forward without a + // local Date round trip (which could shift them across DST). + const anchorTime = freshTask.recurrence.match(/DTSTART:\d{8}T(\d{6})Z?(?=;|$)/)?.[1]; + updatedTask.recurrence = anchorTime + ? updatedRecurrence.replace(/DTSTART:(\d{8})(?=;|$)/, `DTSTART:$1T${anchorTime}Z`) + : updatedRecurrence; } } else if (!updatedTask.recurrence.includes("DTSTART:")) { const updatedRecurrence = addDTSTARTToRecurrenceRule(updatedTask); diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 460569e00..7000fbfd8 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -1,8 +1,5 @@ import type { FieldMappingKey, TaskInfo, TimeEntry } from "../../types"; -import { - addDTSTARTToRecurrenceRule, - updateToNextScheduledOccurrence, -} from "../../core/recurrence"; +import { addDTSTARTToRecurrenceRule, updateToNextScheduledOccurrence } from "../../core/recurrence"; import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, @@ -18,6 +15,11 @@ export type TaskUpdateInput = Partial & { }; export interface TaskUpdateFieldMapper { + mapFromFrontmatter: ( + frontmatter: unknown, + filePath: string, + storeTitleInFilename?: boolean + ) => Partial; mapToFrontmatter: ( taskData: Partial, taskTag?: string, @@ -196,8 +198,9 @@ export function applyTaskUpdateFrontmatterChange({ storeTitleInFilename, updateCompletedDateInFrontmatter, }: ApplyTaskUpdateFrontmatterChangeInput): ApplyTaskUpdateFrontmatterChangeResult { + // Publish only the named patch and its recurrence consequences. const completeTaskData: Partial = { - ...originalTask, + tags: getFrontmatterTags(frontmatter.tags), ...updates, ...recurrenceUpdates, dateModified, @@ -236,7 +239,14 @@ export function applyTaskUpdateFrontmatterChange({ removeUnsetMappedFields(frontmatter, { ...updates, ...recurrenceUpdates }, fieldMapper); - if (storeTitleInFilename) { + // Creation keeps a title property when the filename cannot represent it + // (for example, a long title needs a fallback filename). Metadata-only + // edits, including forms resubmitting the same title, must retain it. + if ( + storeTitleInFilename && + updates.title !== undefined && + updates.title !== originalTask.title + ) { delete frontmatter[fieldMapper.toUserField("title")]; } diff --git a/src/templates/defaultBasesFiles.ts b/src/templates/defaultBasesFiles.ts index 872ba8f1d..b6b17fc41 100644 --- a/src/templates/defaultBasesFiles.ts +++ b/src/templates/defaultBasesFiles.ts @@ -33,7 +33,7 @@ function formatDependencyEntryLinkExpression(entryExpression: string): string { return `${formatDependencyEntryFileExpression(entryExpression)}.asLink()`; } -function formatProjectEntryLinkExpression(entryExpression: string): string { +export function formatProjectEntryLinkExpression(entryExpression: string): string { return `file(${entryExpression}.replace(/^\\[[^\\]]+\\]\\((.*)\\)$/, "$1").replace(/%20/g, " ")).asLink()`; } diff --git a/src/ui/ICSCard.ts b/src/ui/ICSCard.ts index 16a5c63dd..daa63b3fb 100644 --- a/src/ui/ICSCard.ts +++ b/src/ui/ICSCard.ts @@ -4,6 +4,7 @@ import { ICSEvent, ICSSubscription } from "../types"; import { ICSEventContextMenu } from "../components/ICSEventContextMenu"; import { formatTime } from "../utils/dateUtils"; import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; +import { findProviderCalendar } from "../services/CalendarProvider"; export interface ICSCardOptions { showDate: boolean; @@ -58,13 +59,7 @@ function getEventSourceName( 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) - ); + const calendar = findProviderCalendar(provider.getAvailableCalendars(), calendarId); return calendar?.summary || provider.providerName; } diff --git a/styles/task-card-note-widget.css b/styles/task-card-note-widget.css index 676c7363a..89cdfe207 100644 --- a/styles/task-card-note-widget.css +++ b/styles/task-card-note-widget.css @@ -17,6 +17,21 @@ margin-inline: auto; } +/* + * Reading mode nests the widget inside `.mod-header.mod-ui`, after the properties + * block, so that Obsidian's virtualisation cannot delete it (see + * MarkdownWidgetInsertion and #2255). That host spaces differently from sitting + * between sections. Keep the native properties spacing and add a paragraph-sized + * gap between the widget and the note body, without broad :has invalidation. + */ + +.markdown-preview-view .mod-header .task-card-note-widget { + margin-block-start: 0; + /* The note's own gap between blocks, so the widget sits in the same vertical + rhythm as a paragraph rather than on a value of our choosing. */ + margin-block-end: var(--p-spacing); +} + .task-card-note-widget__card { /* Reset margins for the inner task card */ margin: 0; diff --git a/tests/services/OAuthService.callback.test.ts b/tests/services/OAuthService.callback.test.ts new file mode 100644 index 000000000..e6825e735 --- /dev/null +++ b/tests/services/OAuthService.callback.test.ts @@ -0,0 +1,35 @@ +import { OAuthService } from "../../src/services/OAuthService"; +import { OAuthSecretStore } from "../../src/services/OAuthSecretStore"; + +describe("OAuth callback validation", () => { + it("validates and consumes OAuth state before writing a static response", () => { + const store = new OAuthSecretStore({ getSecret: () => null, setSecret: () => {} }); + const service: any = new OAuthService({} as any, store); + const response: any = { writeHead: jest.fn(), end: jest.fn() }; + service.handleCallback( + { method: "GET", url: "/?error=%3Cscript%3E&state=unknown", headers: {} }, + response + ); + expect(response.writeHead.mock.calls[0][0]).toBe(400); + expect(response.end.mock.calls[0][0]).not.toContain("