From 14edea489054988b7d09a5e4e2f859660d6a5726 Mon Sep 17 00:00:00 2001 From: mcuste Date: Sat, 11 Jul 2026 12:35:26 +0200 Subject: [PATCH 01/18] move OAuth secrets to Secret Storage Keep calendar credentials and tokens out of data.json, where they could be exposed through version control, backups, or vault sync. --- PRIVACY.md | 12 +- docs/calendar-setup.md | 6 +- docs/features/calendar-integration.md | 3 +- docs/privacy.md | 12 +- docs/settings/integrations.md | 9 +- src/bootstrap/pluginBootstrap.ts | 6 +- src/main.ts | 31 +- src/services/OAuthSecretStore.ts | 261 +++++++++++++ src/services/OAuthService.ts | 158 ++++---- src/services/oauthSecretMigration.ts | 153 ++++++++ src/settings/defaults.ts | 5 +- src/settings/tabs/integrationsTab.ts | 204 ++++++---- src/types/settings.ts | 5 +- tests/helpers/obsidian-bridge.ts | 24 +- .../services/OAuthService.revocation.test.ts | 367 ++++++------------ .../OAuthService.secret-storage.test.ts | 171 ++++++++ ...issue-1964-microsoft-oauth-consent.test.ts | 42 +- tests/unit/services/OAuthSecretStore.test.ts | 164 ++++++++ 18 files changed, 1204 insertions(+), 429 deletions(-) create mode 100644 src/services/OAuthSecretStore.ts create mode 100644 src/services/oauthSecretMigration.ts create mode 100644 tests/services/OAuthService.secret-storage.test.ts create mode 100644 tests/unit/services/OAuthSecretStore.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index d9db3c346..57cffb3ab 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # TaskNotes Privacy Policy -Last updated: June 4, 2026 +Last updated: July 11, 2026 ## Overview @@ -30,9 +30,11 @@ Optional network features: ## OAuth Credentials and Tokens -- OAuth client credentials are configured by you in TaskNotes settings. -- Access and refresh tokens are stored locally on your device. -- You can disconnect providers at any time to revoke TaskNotes access. +- OAuth client IDs and client secrets are configured by you in TaskNotes settings. +- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage and encrypted at rest. +- These values are not written to TaskNotes' `data.json`. +- Secret Storage is local to the Obsidian installation and vault, so another device may require its own credentials and calendar connection. +- You can disconnect providers at any time to revoke TaskNotes access. Disconnecting retains the OAuth app credentials for reconnection; **Forget saved credentials** removes them from Secret Storage. ## Third-Party Services (When Enabled) @@ -50,7 +52,7 @@ Optional network features: ## Data Deletion You can stop TaskNotes processing by disabling the plugin. -You can remove plugin settings by uninstalling the plugin. +You can remove plugin settings by uninstalling the plugin. Before uninstalling, disconnect calendar providers and use **Forget saved credentials** to remove OAuth values from Obsidian Secret Storage. Your notes remain in your vault. ## Changes to This Policy diff --git a/docs/calendar-setup.md b/docs/calendar-setup.md index c37a01be7..e34d12528 100644 --- a/docs/calendar-setup.md +++ b/docs/calendar-setup.md @@ -43,7 +43,9 @@ Next, add delegated Microsoft Graph API permissions (`Calendars.Read`, `Calendar ## Security Notes -Credentials and tokens are stored locally in your Obsidian data. Tokens refresh automatically, calendar data syncs directly between your vault and provider, and you can disconnect at any time to revoke access. +OAuth client IDs, client secrets, and account tokens are stored locally in Obsidian Secret Storage and encrypted at rest. They are not written to TaskNotes' `data.json`. Tokens refresh automatically, calendar data syncs directly between your vault and provider, and you can disconnect at any time to revoke account access. + +Secret Storage is local to the Obsidian installation and vault. When setting up TaskNotes on another device, enter the OAuth app credentials and connect the calendar again. After disconnecting, use **Forget saved credentials** if you also want to remove the client ID and client secret. ## Troubleshooting @@ -59,4 +61,4 @@ Disconnect and reconnect to refresh OAuth tokens, then re-check provider-side ca **Connection lost after Obsidian restart** -Tokens should persist between sessions. If reconnection is required each restart, investigate vault or plugin-data file permissions. +Tokens should persist between sessions. If reconnection is required each restart, check whether Obsidian Secret Storage is available and retaining secrets for the vault. diff --git a/docs/features/calendar-integration.md b/docs/features/calendar-integration.md index 52b78949d..4386e4b71 100644 --- a/docs/features/calendar-integration.md +++ b/docs/features/calendar-integration.md @@ -1,6 +1,5 @@ # Calendar Integration - TaskNotes provides calendar integration through OAuth-connected calendar services, two Bases-powered calendar views, and read-only ICS calendar subscriptions. ## OAuth Calendar Integration @@ -31,7 +30,7 @@ OAuth calendar integration requires creating an OAuth application with your cale ### Token Management -TaskNotes stores OAuth access tokens and refresh tokens locally. Tokens are refreshed automatically before expiration. You can revoke access at any time through the integrations settings. +TaskNotes stores OAuth client credentials, access tokens, and refresh tokens in Obsidian Secret Storage, encrypted at rest and separate from TaskNotes' `data.json`. Tokens are refreshed automatically before expiration. You can revoke account access or forget the saved OAuth app credentials through the integrations settings. ## Calendar Views diff --git a/docs/privacy.md b/docs/privacy.md index d9db3c346..57cffb3ab 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # TaskNotes Privacy Policy -Last updated: June 4, 2026 +Last updated: July 11, 2026 ## Overview @@ -30,9 +30,11 @@ Optional network features: ## OAuth Credentials and Tokens -- OAuth client credentials are configured by you in TaskNotes settings. -- Access and refresh tokens are stored locally on your device. -- You can disconnect providers at any time to revoke TaskNotes access. +- OAuth client IDs and client secrets are configured by you in TaskNotes settings. +- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage and encrypted at rest. +- These values are not written to TaskNotes' `data.json`. +- Secret Storage is local to the Obsidian installation and vault, so another device may require its own credentials and calendar connection. +- You can disconnect providers at any time to revoke TaskNotes access. Disconnecting retains the OAuth app credentials for reconnection; **Forget saved credentials** removes them from Secret Storage. ## Third-Party Services (When Enabled) @@ -50,7 +52,7 @@ Optional network features: ## Data Deletion You can stop TaskNotes processing by disabling the plugin. -You can remove plugin settings by uninstalling the plugin. +You can remove plugin settings by uninstalling the plugin. Before uninstalling, disconnect calendar providers and use **Forget saved credentials** to remove OAuth values from Obsidian Secret Storage. Your notes remain in your vault. ## Changes to This Policy diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index 0b9c0eca9..504738df8 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -47,7 +47,7 @@ OAuth integration requires creating your own OAuth application with Google and/o ### Google Calendar -Provide **Client ID** and **Client Secret** from Google Cloud Console, then use **Connect Google Calendar** to complete OAuth loopback authentication. **Disconnect** revokes local credentials. +Provide **Client ID** and **Client Secret** from Google Cloud Console, then use **Connect Google Calendar** to complete OAuth loopback authentication. **Disconnect** revokes account tokens while retaining the OAuth app credentials for reconnection. Use **Forget saved credentials** to remove the client ID and client secret. The **Target calendar** setting used for exporting tasks to Google Calendar is also used as the default selection when creating a manual external calendar event from the calendar view. If the target calendar is unavailable, TaskNotes falls back to the provider's primary calendar. @@ -61,7 +61,7 @@ When connected, displays: ### Microsoft Outlook Calendar -Provide **Client ID** and **Client Secret** from Azure App Registration, then use **Connect Microsoft Calendar** to authenticate. **Disconnect** removes stored credentials and sync access. +Provide **Client ID** and **Client Secret** from Azure App Registration, then use **Connect Microsoft Calendar** to authenticate. **Disconnect** revokes account tokens while retaining the OAuth app credentials for reconnection. Use **Forget saved credentials** to remove the client ID and client secret. When connected, displays: - Connected account email @@ -70,10 +70,11 @@ When connected, displays: ### Security -- OAuth credentials are stored locally in Obsidian's data folder +- OAuth client credentials and account tokens are stored in Obsidian Secret Storage and encrypted at rest +- OAuth credentials and account tokens are not written to TaskNotes' `data.json` - Access tokens refresh automatically - Calendar data syncs directly between Obsidian and the calendar provider (no intermediary servers) -- Disconnect at any time to revoke access +- Disconnect at any time to revoke account access; use **Forget saved credentials** to remove the OAuth app credentials ## Calendar subscriptions (ICS) diff --git a/src/bootstrap/pluginBootstrap.ts b/src/bootstrap/pluginBootstrap.ts index 28747311a..173c5d2f0 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -217,7 +217,7 @@ export function registerRibbonIcons(plugin: TaskNotesPlugin): void { } export function initializeCalendarProviders(plugin: TaskNotesPlugin): void { - plugin.oauthService = new OAuthService(plugin); + plugin.oauthService = new OAuthService(plugin, plugin.oauthSecretStore); plugin.googleCalendarService = new GoogleCalendarService(plugin, plugin.oauthService); plugin.microsoftCalendarService = new MicrosoftCalendarService(plugin, plugin.oauthService); plugin.calendarProviderRegistry = new CalendarProviderRegistry(); @@ -457,7 +457,7 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { (data: { path?: string; updatedTask?: TaskInfo }) => { plugin.app.workspace.iterateRootLeaves((leaf) => { if (leaf.view && leaf.view.getViewType() === "markdown") { - const editor = (leaf.view as MarkdownView).editor; + const editor = (leaf.view as MarkdownView).editor; const cm = getCodeMirrorEditor(editor); if (cm) { const taskPath = data?.path || data?.updatedTask?.path; @@ -472,7 +472,7 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { plugin.app.workspace.on("active-leaf-change", (leaf) => { window.setTimeout(() => { if (leaf && leaf.view && leaf.view.getViewType() === "markdown") { - const editor = (leaf.view as MarkdownView).editor; + const editor = (leaf.view as MarkdownView).editor; const cm = getCodeMirrorEditor(editor); if (cm) { dispatchTaskUpdate(cm as EditorView); diff --git a/src/main.ts b/src/main.ts index d10796aa9..995cc1c14 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,6 +61,8 @@ import { AutoExportService } from "./services/AutoExportService"; import type { HTTPAPIService } from "./services/HTTPAPIService"; import { createI18nService, I18nService } from "./i18n"; import { OAuthService } from "./services/OAuthService"; +import { OAuthSecretStore } from "./services/OAuthSecretStore"; +import { migrateLegacyOAuthData, stripLegacyOAuthData } from "./services/oauthSecretMigration"; import { GoogleCalendarService } from "./services/GoogleCalendarService"; import { MicrosoftCalendarService } from "./services/MicrosoftCalendarService"; import { CalendarProviderRegistry } from "./services/CalendarProvider"; @@ -197,8 +199,10 @@ export default class TaskNotesPlugin extends Plugin { // Public JavaScript API for in-vault scripts api: import("./api/TaskNotesAPI").TaskNotesPublicAPI; - // OAuth service + // OAuth services oauthService: OAuthService; + oauthSecretStore: OAuthSecretStore; + private oauthSecretStorageReady = false; // Google Calendar service googleCalendarService: GoogleCalendarService; @@ -695,7 +699,19 @@ export default class TaskNotesPlugin extends Plugin { } async loadSettings() { - const loadedData = await this.loadSettingsData(); + let loadedData = await this.loadSettingsData(); + this.oauthSecretStore ??= new OAuthSecretStore(this.app.secretStorage); + this.oauthSecretStorageReady = false; + + if (!this.settingsLoadCompromised) { + const migration = migrateLegacyOAuthData(loadedData, this.oauthSecretStore); + if (migration.changed && migration.data) { + await super.saveData(migration.data); + } + loadedData = migration.data; + this.oauthSecretStorageReady = true; + } + const { settings, shouldPersistMigratedSettings } = buildSettingsFromLoadedData(loadedData); this.settings = settings; this.shouldCreateStarterNoteOnStartup = !settings.lastSeenVersion; @@ -720,6 +736,17 @@ export default class TaskNotesPlugin extends Plugin { // Cache setting migration is no longer needed (native cache only) } + async saveData(data: unknown): Promise { + const sanitizedData = + this.oauthSecretStorageReady && + typeof data === "object" && + data !== null && + !Array.isArray(data) + ? stripLegacyOAuthData(data as Record) + : data; + await super.saveData(sanitizedData); + } + async saveSettings() { await this.settingsLifecycleService.saveSettings(); } diff --git a/src/services/OAuthSecretStore.ts b/src/services/OAuthSecretStore.ts new file mode 100644 index 000000000..4efd7f274 --- /dev/null +++ b/src/services/OAuthSecretStore.ts @@ -0,0 +1,261 @@ +import type { SecretStorage } from "obsidian"; +import type { OAuthConnection, OAuthProvider } from "../types"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; + +const tasknotesLogger = createTaskNotesLogger({ tag: "Services/OAuthSecretStore" }); + +export type OAuthCredentials = { + clientId: string; + clientSecret?: string; +}; + +type CredentialsEnvelope = + | { + version: 1; + state: "configured"; + credentials: OAuthCredentials; + } + | { + version: 1; + state: "cleared"; + }; + +type ConnectionEnvelope = + | { + version: 1; + state: "connected"; + connection: OAuthConnection; + } + | { + version: 1; + state: "cleared"; + }; + +export type OAuthCredentialsState = + | { status: "missing" } + | { status: "configured"; credentials: OAuthCredentials } + | { status: "cleared" } + | { status: "invalid" }; + +export type OAuthConnectionState = + | { status: "missing" } + | { status: "connected"; connection: OAuthConnection } + | { status: "cleared" } + | { status: "invalid" }; + +type SecretStorageAccess = Pick; + +type ProviderSecretIds = { + credentials: string; + connection: string; +}; + +const OAUTH_SECRET_IDS: Record = { + google: { + credentials: "tasknotes-oauth-google-credentials", + connection: "tasknotes-oauth-google-connection", + }, + microsoft: { + credentials: "tasknotes-oauth-microsoft-credentials", + connection: "tasknotes-oauth-microsoft-connection", + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function parseOAuthConnection( + value: unknown, + provider: OAuthProvider +): OAuthConnection | null { + if (!isRecord(value) || value.provider !== provider || !isRecord(value.tokens)) { + return null; + } + + const accessToken = value.tokens.accessToken; + const expiresAt = value.tokens.expiresAt; + if ( + typeof accessToken !== "string" || + accessToken.length === 0 || + typeof expiresAt !== "number" || + !Number.isFinite(expiresAt) + ) { + return null; + } + + const connectedAt = optionalString(value.connectedAt) ?? ""; + return { + provider, + tokens: { + accessToken, + refreshToken: optionalString(value.tokens.refreshToken) ?? "", + expiresAt, + scope: optionalString(value.tokens.scope) ?? "", + tokenType: optionalString(value.tokens.tokenType) ?? "Bearer", + }, + ...(optionalString(value.userEmail) !== undefined && { + userEmail: optionalString(value.userEmail), + }), + connectedAt, + ...(optionalString(value.lastRefreshed) !== undefined && { + lastRefreshed: optionalString(value.lastRefreshed), + }), + }; +} + +function parseCredentialsEnvelope(raw: string): OAuthCredentialsState { + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || parsed.version !== 1) { + return { status: "invalid" }; + } + if (parsed.state === "cleared") { + return { status: "cleared" }; + } + if (parsed.state !== "configured" || !isRecord(parsed.credentials)) { + return { status: "invalid" }; + } + + const clientId = parsed.credentials.clientId; + const clientSecret = parsed.credentials.clientSecret; + if ( + typeof clientId !== "string" || + (clientSecret !== undefined && typeof clientSecret !== "string") + ) { + return { status: "invalid" }; + } + + return { + status: "configured", + credentials: { + clientId, + ...(typeof clientSecret === "string" && clientSecret.length > 0 + ? { clientSecret } + : {}), + }, + }; + } catch { + return { status: "invalid" }; + } +} + +function parseConnectionEnvelope(raw: string, provider: OAuthProvider): OAuthConnectionState { + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || parsed.version !== 1) { + return { status: "invalid" }; + } + if (parsed.state === "cleared") { + return { status: "cleared" }; + } + if (parsed.state !== "connected") { + return { status: "invalid" }; + } + + const connection = parseOAuthConnection(parsed.connection, provider); + return connection ? { status: "connected", connection } : { status: "invalid" }; + } catch { + return { status: "invalid" }; + } +} + +export class OAuthSecretStore { + constructor(private readonly secretStorage: SecretStorageAccess) {} + + getCredentialsState(provider: OAuthProvider): OAuthCredentialsState { + const secretId = OAUTH_SECRET_IDS[provider].credentials; + const raw = this.secretStorage.getSecret(secretId); + if (raw === null) { + return { status: "missing" }; + } + + const state = parseCredentialsEnvelope(raw); + if (state.status === "invalid") { + tasknotesLogger.warn("Stored OAuth credentials could not be read", { + category: "configuration", + operation: "read-oauth-credentials", + details: { provider }, + }); + } + return state; + } + + getCredentials(provider: OAuthProvider): OAuthCredentials | null { + const state = this.getCredentialsState(provider); + return state.status === "configured" ? state.credentials : null; + } + + setCredentials(provider: OAuthProvider, credentials: OAuthCredentials): void { + const envelope: CredentialsEnvelope = { + version: 1, + state: "configured", + credentials: { + clientId: credentials.clientId.trim(), + ...(credentials.clientSecret?.trim() + ? { clientSecret: credentials.clientSecret.trim() } + : {}), + }, + }; + this.writeVerified(OAUTH_SECRET_IDS[provider].credentials, envelope); + } + + clearCredentials(provider: OAuthProvider): void { + const envelope: CredentialsEnvelope = { version: 1, state: "cleared" }; + this.writeVerified(OAUTH_SECRET_IDS[provider].credentials, envelope); + } + + getConnectionState(provider: OAuthProvider): OAuthConnectionState { + const secretId = OAUTH_SECRET_IDS[provider].connection; + const raw = this.secretStorage.getSecret(secretId); + if (raw === null) { + return { status: "missing" }; + } + + const state = parseConnectionEnvelope(raw, provider); + if (state.status === "invalid") { + tasknotesLogger.warn("Stored OAuth connection could not be read", { + category: "configuration", + operation: "read-oauth-connection", + details: { provider }, + }); + } + return state; + } + + getConnection(provider: OAuthProvider): OAuthConnection | null { + const state = this.getConnectionState(provider); + return state.status === "connected" ? state.connection : null; + } + + setConnection(provider: OAuthProvider, connection: OAuthConnection): void { + const normalized = parseOAuthConnection(connection, provider); + if (!normalized) { + throw new Error(`Cannot store an invalid ${provider} OAuth connection`); + } + + const envelope: ConnectionEnvelope = { + version: 1, + state: "connected", + connection: normalized, + }; + this.writeVerified(OAUTH_SECRET_IDS[provider].connection, envelope); + } + + clearConnection(provider: OAuthProvider): void { + const envelope: ConnectionEnvelope = { version: 1, state: "cleared" }; + this.writeVerified(OAUTH_SECRET_IDS[provider].connection, envelope); + } + + private writeVerified(secretId: string, value: CredentialsEnvelope | ConnectionEnvelope): void { + const serialized = JSON.stringify(value); + this.secretStorage.setSecret(secretId, serialized); + if (this.secretStorage.getSecret(secretId) !== serialized) { + throw new Error(`Obsidian SecretStorage did not persist ${secretId}`); + } + } +} diff --git a/src/services/OAuthService.ts b/src/services/OAuthService.ts index 3bf26f576..df78580fa 100644 --- a/src/services/OAuthService.ts +++ b/src/services/OAuthService.ts @@ -6,6 +6,7 @@ import { OAuthNotConfiguredError, TokenExpiredError, TokenRefreshError } from ". import type { HTTPRequestLike, HTTPResponseLike, HTTPServerLike } from "../api/httpTypes"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; +import { OAuthSecretStore, type OAuthCredentials } from "./OAuthSecretStore"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/OAuthService" }); @@ -43,6 +44,7 @@ function ensureHttpModule(): HttpModuleLike { */ export class OAuthService { private plugin: TaskNotesPlugin; + private readonly secretStore: OAuthSecretStore; private callbackServer: HTTPServerLike | null = null; private pendingOAuthState: Map< string, @@ -57,6 +59,7 @@ export class OAuthService { // Token refresh mutex to prevent race conditions // Maps provider to pending refresh promise private tokenRefreshPromises: Map> = new Map(); + private connectionGenerations: Map = new Map(); // OAuth configurations for different providers private configs: Record = { @@ -85,23 +88,30 @@ export class OAuthService { }, }; - constructor(plugin: TaskNotesPlugin) { + constructor(plugin: TaskNotesPlugin, secretStore: OAuthSecretStore) { this.plugin = plugin; - void this.loadClientIds(); + this.secretStore = secretStore; } - /** - * Loads OAuth client IDs and secrets - * from user settings. - */ - async loadClientIds(): Promise { - // Google Calendar - this.configs.google.clientId = this.plugin.settings.googleOAuthClientId || ""; - this.configs.google.clientSecret = this.plugin.settings.googleOAuthClientSecret || ""; - - // Microsoft Calendar - this.configs.microsoft.clientId = this.plugin.settings.microsoftOAuthClientId || ""; - this.configs.microsoft.clientSecret = this.plugin.settings.microsoftOAuthClientSecret || ""; + getCredentials(provider: OAuthProvider): OAuthCredentials | null { + return this.secretStore.getCredentials(provider); + } + + setCredentials(provider: OAuthProvider, credentials: OAuthCredentials): void { + this.secretStore.setCredentials(provider, credentials); + } + + clearCredentials(provider: OAuthProvider): void { + this.secretStore.clearCredentials(provider); + } + + private getConfig(provider: OAuthProvider): OAuthConfig { + const credentials = this.secretStore.getCredentials(provider); + return { + ...this.configs[provider], + clientId: credentials?.clientId ?? "", + clientSecret: credentials?.clientSecret, + }; } /** @@ -109,20 +119,11 @@ export class OAuthService { * Uses standard loopback redirect flow with user-provided credentials. */ async authenticate(provider: OAuthProvider): Promise { - const config = this.configs[provider]; - + const config = this.getConfig(provider); if (!config.clientId) { throw new OAuthNotConfiguredError(provider); } - const hasCredentials = - (provider === "google" && this.plugin.settings.googleOAuthClientId) || - (provider === "microsoft" && this.plugin.settings.microsoftOAuthClientId); - - if (!hasCredentials) { - throw new OAuthNotConfiguredError(provider); - } - return await this.authenticateStandard(provider); } @@ -132,10 +133,13 @@ export class OAuthService { */ private async authenticateStandard(provider: OAuthProvider): Promise { try { - const config = this.configs[provider]; + const config = this.getConfig(provider); if (!Platform.isDesktopApp) { - publishUserNotice(this.plugin.emitter, "OAUTH authentication requires the desktop app."); + publishUserNotice( + this.plugin.emitter, + "OAUTH authentication requires the desktop app." + ); throw new Error("OAuth authentication requires the desktop app."); } @@ -170,7 +174,10 @@ export class OAuthService { reject: () => {}, }); - publishUserNotice(this.plugin.emitter, `Opening browser for ${provider} authorization...`); + publishUserNotice( + this.plugin.emitter, + `Opening browser for ${provider} authorization...` + ); // Open browser to authorization URL window.open(authUrl, "_blank"); @@ -184,7 +191,10 @@ export class OAuthService { // Store connection await this.storeConnection(provider, tokens); - publishUserNotice(this.plugin.emitter, `Successfully connected to ${provider} Calendar!`); + publishUserNotice( + this.plugin.emitter, + `Successfully connected to ${provider} Calendar!` + ); } finally { // Restore original redirect URI config.redirectUri = originalRedirectUri; @@ -195,7 +205,10 @@ export class OAuthService { operation: "oauth-authentication", error: error, }); - publishUserNotice(this.plugin.emitter, `Failed to connect to ${provider}: ${error.message}`); + publishUserNotice( + this.plugin.emitter, + `Failed to connect to ${provider}: ${error.message}` + ); throw error; } finally { await this.stopCallbackServer(); @@ -546,7 +559,8 @@ export class OAuthService { throw new Error(`No refresh token available for ${provider}`); } - const config = this.configs[provider]; + const connectionGeneration = this.connectionGenerations.get(provider) ?? 0; + const config = this.getConfig(provider); const params: Record = { client_id: config.clientId, refresh_token: connection.tokens.refreshToken, @@ -603,11 +617,16 @@ export class OAuthService { (oauthError === "invalid_grant" || oauthError === "invalid_client")); if (isIrrecoverableError) { - // Auto-disconnect to prevent repeated failures - // This clears local tokens but doesn't revoke on provider (token is already invalid) - await this.clearConnection(provider); - - publishUserNotice(this.plugin.emitter, + // Clear only if this response still belongs to the active connection. + // The user may have reconnected while the request was in flight. + if (!this.clearConnection(provider, connectionGeneration)) { + throw new Error( + `${provider} OAuth connection changed during token refresh` + ); + } + + publishUserNotice( + this.plugin.emitter, `${provider} connection expired. Please reconnect in Settings > Integrations.` ); throw new TokenRefreshError(provider, oauthError, oauthErrorDescription); @@ -637,8 +656,13 @@ export class OAuthService { tokenType: data.token_type || "Bearer", }; - // Update stored connection - await this.storeConnection(provider, newTokens, connection.userEmail); + // Update stored connection unless it was disconnected while refresh was in flight. + await this.storeConnection( + provider, + newTokens, + connection.userEmail, + connectionGeneration + ); return newTokens; } catch (error) { @@ -660,12 +684,15 @@ export class OAuthService { * Clears a stored OAuth connection without revoking tokens on the provider. * Used when tokens are already invalid (e.g., after refresh failure with invalid_grant). */ - private async clearConnection(provider: OAuthProvider): Promise { - const data = (await this.plugin.loadData()) || {}; - if (data.oauthConnections) { - delete data.oauthConnections[provider]; - await this.plugin.saveData(data); + private clearConnection(provider: OAuthProvider, expectedGeneration?: number): boolean { + const currentGeneration = this.connectionGenerations.get(provider) ?? 0; + if (expectedGeneration !== undefined && expectedGeneration !== currentGeneration) { + return false; } + + this.secretStore.clearConnection(provider); + this.connectionGenerations.set(provider, currentGeneration + 1); + return true; } /** @@ -707,13 +734,21 @@ export class OAuthService { } /** - * Stores OAuth connection (encrypted) + * Stores an OAuth connection in Obsidian SecretStorage. */ private async storeConnection( provider: OAuthProvider, tokens: OAuthTokens, - userEmail?: string + userEmail?: string, + expectedGeneration?: number ): Promise { + if ( + expectedGeneration !== undefined && + expectedGeneration !== (this.connectionGenerations.get(provider) ?? 0) + ) { + throw new Error(`${provider} OAuth connection changed during token refresh`); + } + const connection: OAuthConnection = { provider, tokens, @@ -721,22 +756,18 @@ export class OAuthService { connectedAt: new Date().toISOString(), lastRefreshed: new Date().toISOString(), }; - - // Store in plugin data (Obsidian handles encryption) - const data = (await this.plugin.loadData()) || {}; - if (!data.oauthConnections) { - data.oauthConnections = {}; + this.secretStore.setConnection(provider, connection); + if (expectedGeneration === undefined) { + const currentGeneration = this.connectionGenerations.get(provider) ?? 0; + this.connectionGenerations.set(provider, currentGeneration + 1); } - data.oauthConnections[provider] = connection; - await this.plugin.saveData(data); } /** - * Retrieves stored OAuth connection + * Retrieves a connection from Obsidian SecretStorage. */ async getConnection(provider: OAuthProvider): Promise { - const data = await this.plugin.loadData(); - return data?.oauthConnections?.[provider] || null; + return this.secretStore.getConnection(provider); } /** @@ -756,21 +787,17 @@ export class OAuthService { return; } - // Revoke tokens on the OAuth provider's server + // Clear local tokens first so an interrupted or concurrent refresh cannot reconnect. + this.clearConnection(provider); + + // Revoke tokens on the OAuth provider's server. await this.revokeToken(provider, connection.tokens.accessToken); - // Also revoke refresh token if present (best practice) + // Also revoke refresh token if present (best practice). if (connection.tokens.refreshToken) { await this.revokeToken(provider, connection.tokens.refreshToken); } - // Remove from local storage - const data = (await this.plugin.loadData()) || {}; - if (data.oauthConnections) { - delete data.oauthConnections[provider]; - await this.plugin.saveData(data); - } - publishUserNotice(this.plugin.emitter, `Disconnected from ${provider} Calendar`); } @@ -779,7 +806,7 @@ export class OAuthService { * Note: Revocation failures are logged but don't prevent local disconnection */ private async revokeToken(provider: OAuthProvider, token: string): Promise { - const config = this.configs[provider]; + const config = this.getConfig(provider); if (!config.revocationEndpoint) { tasknotesLogger.warn(`No revocation endpoint configured for ${provider}`, { @@ -825,7 +852,8 @@ export class OAuthService { // Clear pending OAuth state this.pendingOAuthState.clear(); - // Clear token refresh mutex to prevent orphaned promises + // Clear token refresh state to prevent orphaned promises. this.tokenRefreshPromises.clear(); + this.connectionGenerations.clear(); } } diff --git a/src/services/oauthSecretMigration.ts b/src/services/oauthSecretMigration.ts new file mode 100644 index 000000000..8cfbd6a2e --- /dev/null +++ b/src/services/oauthSecretMigration.ts @@ -0,0 +1,153 @@ +import type { OAuthProvider } from "../types"; +import { OAuthSecretStore, parseOAuthConnection, type OAuthCredentials } from "./OAuthSecretStore"; + +const PROVIDERS: OAuthProvider[] = ["google", "microsoft"]; + +const LEGACY_CREDENTIAL_KEYS: Record = { + google: { + clientId: "googleOAuthClientId", + clientSecret: "googleOAuthClientSecret", + }, + microsoft: { + clientId: "microsoftOAuthClientId", + clientSecret: "microsoftOAuthClientSecret", + }, +}; + +export type OAuthSecretMigrationResult = { + data: Record | null; + changed: boolean; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(data: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(data, key); +} + +function readLegacyCredentials( + data: Record, + provider: OAuthProvider +): OAuthCredentials | null { + const keys = LEGACY_CREDENTIAL_KEYS[provider]; + const clientId = data[keys.clientId]; + const clientSecret = data[keys.clientSecret]; + if (typeof clientId !== "string" && typeof clientSecret !== "string") { + return null; + } + + const credentials: OAuthCredentials = { + clientId: typeof clientId === "string" ? clientId : "", + }; + if (typeof clientSecret === "string" && clientSecret.length > 0) { + credentials.clientSecret = clientSecret; + } + return credentials; +} + +/** + * Removes OAuth values that were historically persisted in data.json. + * Callers must migrate those values to SecretStorage before invoking this helper. + */ +export function stripLegacyOAuthData(data: Record): Record { + let changed = false; + const sanitized = { ...data }; + + for (const provider of PROVIDERS) { + const keys = LEGACY_CREDENTIAL_KEYS[provider]; + for (const key of [keys.clientId, keys.clientSecret]) { + if (hasOwn(sanitized, key)) { + delete sanitized[key]; + changed = true; + } + } + } + + if (hasOwn(sanitized, "oauthConnections")) { + const connections = sanitized.oauthConnections; + if (isRecord(connections)) { + const remainingConnections = { ...connections }; + for (const provider of PROVIDERS) { + if (hasOwn(remainingConnections, provider)) { + delete remainingConnections[provider]; + changed = true; + } + } + + if (Object.keys(remainingConnections).length === 0) { + delete sanitized.oauthConnections; + changed = true; + } else if (changed) { + sanitized.oauthConnections = remainingConnections; + } + } else { + delete sanitized.oauthConnections; + changed = true; + } + } + + return changed ? sanitized : data; +} + +/** + * Copies legacy OAuth credentials and connections into Obsidian SecretStorage. + * Secure values are written and verified before the returned data is sanitized, + * making retries safe if the eventual data.json write is interrupted. + */ +export function migrateLegacyOAuthData( + data: Record | null, + secretStore: OAuthSecretStore +): OAuthSecretMigrationResult { + if (data === null) { + return { data, changed: false }; + } + + for (const provider of PROVIDERS) { + const keys = LEGACY_CREDENTIAL_KEYS[provider]; + const hasLegacyCredentials = hasOwn(data, keys.clientId) || hasOwn(data, keys.clientSecret); + if (hasLegacyCredentials) { + const legacyCredentials = readLegacyCredentials(data, provider); + const currentState = secretStore.getCredentialsState(provider); + if ( + legacyCredentials && + (currentState.status === "missing" || currentState.status === "invalid") + ) { + secretStore.setCredentials(provider, legacyCredentials); + } + } + } + + if (hasOwn(data, "oauthConnections")) { + const connections = data.oauthConnections; + if (connections !== null && !isRecord(connections)) { + throw new Error("Cannot migrate malformed OAuth connection storage"); + } + + if (isRecord(connections)) { + for (const provider of PROVIDERS) { + if (!hasOwn(connections, provider) || connections[provider] === null) { + continue; + } + + const currentState = secretStore.getConnectionState(provider); + if (currentState.status === "connected" || currentState.status === "cleared") { + continue; + } + + const connection = parseOAuthConnection(connections[provider], provider); + if (!connection) { + throw new Error(`Cannot migrate malformed ${provider} OAuth connection`); + } + secretStore.setConnection(provider, connection); + } + } + } + + const sanitized = stripLegacyOAuthData(data); + return { + data: sanitized, + changed: sanitized !== data, + }; +} diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5d..14e7a235d 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -398,10 +398,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Frontmatter link format defaults useFrontmatterMarkdownLinks: false, // Default to wikilinks for compatibility // OAuth Calendar Integration defaults - googleOAuthClientId: "", - googleOAuthClientSecret: "", - microsoftOAuthClientId: "", - microsoftOAuthClientSecret: "", + // OAuth client credentials and account tokens are stored in Obsidian SecretStorage. enableGoogleCalendar: false, enableMicrosoftCalendar: false, disableCalendarOnMobile: false, diff --git a/src/settings/tabs/integrationsTab.ts b/src/settings/tabs/integrationsTab.ts index 4fde3b504..30dd9bc4d 100644 --- a/src/settings/tabs/integrationsTab.ts +++ b/src/settings/tabs/integrationsTab.ts @@ -1,6 +1,6 @@ import { Notice, Platform, Modal, Setting, setIcon, App } from "obsidian"; import TaskNotesPlugin from "../../main"; -import type { WebhookConfig, WebhookEvent } from "../../types"; +import type { OAuthProvider, WebhookConfig, WebhookEvent } from "../../types"; import type { ICSIntegrationSettings } from "../../types/settings"; import { TranslationKey } from "../../i18n"; import { loadAPIEndpoints } from "../../api/loadAPIEndpoints"; @@ -102,9 +102,7 @@ function isICSNoteFilenameFormat(value: string): value is ICSNoteFilenameFormat return ICS_NOTE_FILENAME_FORMATS.some((format) => format === value); } -function isRecurringEventRelatedNotesMode( - value: string -): value is RecurringEventRelatedNotesMode { +function isRecurringEventRelatedNotesMode(value: string): value is RecurringEventRelatedNotesMode { return RECURRING_EVENT_RELATED_NOTES_MODES.some((mode) => mode === value); } @@ -132,6 +130,72 @@ function parseDefaultReminderMinutes(value: string): number | number[] | null { return uniqueMinutes; } +type OAuthCredentialControls = { + clientIdInput: HTMLInputElement; + clientSecretInput: HTMLInputElement; + persistPendingValues(): void; + hasStoredCredentials: boolean; +}; + +function createOAuthCredentialControls( + plugin: TaskNotesPlugin, + provider: OAuthProvider, + clientIdPlaceholder: string, + clientSecretPlaceholder: string +): OAuthCredentialControls { + const oauthService = plugin.oauthService; + const storedCredentials = oauthService?.getCredentials(provider); + const clientIdInput = createCardInput( + "text", + clientIdPlaceholder, + storedCredentials?.clientId ?? "" + ); + const clientSecretInput = createCardInput( + "text", + storedCredentials?.clientSecret + ? "Stored securely - enter a new value to replace" + : clientSecretPlaceholder, + "" + ); + clientSecretInput.setAttribute("type", "password"); + + const persistClientId = () => { + if (!oauthService) return; + const existing = oauthService.getCredentials(provider); + const clientId = clientIdInput.value.trim(); + if (!clientId && !existing?.clientSecret) return; + oauthService.setCredentials(provider, { + clientId, + ...(existing?.clientSecret ? { clientSecret: existing.clientSecret } : {}), + }); + }; + + const persistClientSecret = () => { + if (!oauthService) return; + const clientSecret = clientSecretInput.value.trim(); + if (!clientSecret) return; + oauthService.setCredentials(provider, { + clientId: clientIdInput.value.trim(), + clientSecret, + }); + clientSecretInput.value = ""; + clientSecretInput.placeholder = "Stored securely - enter a new value to replace"; + }; + + clientIdInput.addEventListener("blur", persistClientId); + clientSecretInput.addEventListener("blur", persistClientSecret); + + return { + clientIdInput, + clientSecretInput, + persistPendingValues: () => { + persistClientSecret(); + persistClientId(); + }, + hasStoredCredentials: storedCredentials !== null, + }; +} + const WEBHOOK_EVENT_OPTIONS: ReadonlyArray<{ id: WebhookEvent; label: string; @@ -381,46 +445,22 @@ export function renderIntegrationsTab( }, ]; - const clientIdInput = createCardInput( - "text", + const credentialControls = createOAuthCredentialControls( + plugin, + "google", "your-client-id.apps.googleusercontent.com", - plugin.settings.googleOAuthClientId - ); - clientIdInput.addEventListener("blur", () => { - runAsyncSettingCallback(async () => { - plugin.settings.googleOAuthClientId = clientIdInput.value.trim(); - save(); - if (plugin.oauthService) { - await plugin.oauthService.loadClientIds(); - } - }); - }); - - const clientSecretInput = createCardInput( - "text", - "your-client-secret", - plugin.settings.googleOAuthClientSecret + "your-client-secret" ); - clientSecretInput.setAttribute("type", "password"); - clientSecretInput.addEventListener("blur", () => { - runAsyncSettingCallback(async () => { - plugin.settings.googleOAuthClientSecret = clientSecretInput.value.trim(); - save(); - if (plugin.oauthService) { - await plugin.oauthService.loadClientIds(); - } - }); - }); const credentialNote = activeDocument.createElement("div"); credentialNote.className = "tasknotes-credential-note"; credentialNote.textContent = - "Enter your OAUTH app credentials from Google cloud console."; + "Enter your OAUTH app credentials from Google cloud console. Credentials are encrypted at rest in Obsidian secret storage."; sections.push({ rows: [ - { label: "Client ID:", input: clientIdInput }, - { label: "Client Secret:", input: clientSecretInput }, + { label: "Client ID:", input: credentialControls.clientIdInput }, + { label: "Client Secret:", input: credentialControls.clientSecretInput }, { label: "", input: credentialNote, fullWidth: true }, ], }); @@ -449,6 +489,7 @@ export function renderIntegrationsTab( try { const oauthService = plugin.oauthService; if (!oauthService) return; + credentialControls.persistPendingValues(); await oauthService.authenticate("google"); new Notice("Google calendar connected successfully!"); void renderGoogleCalendarCard(); // Re-render to show connected state @@ -462,6 +503,31 @@ export function renderIntegrationsTab( } }, }, + ...(credentialControls.hasStoredCredentials + ? [ + { + text: "Forget saved credentials", + icon: "trash-2", + variant: "warning" as const, + onClick: async () => { + const confirmed = await showConfirmationModal( + plugin.app, + { + title: "Forget Google OAuth credentials?", + message: + "This removes the saved client ID and client secret from Obsidian Secret Storage.", + confirmText: "Forget credentials", + isDestructive: true, + } + ); + if (!confirmed) return; + plugin.oauthService.clearCredentials("google"); + new Notice("Forgot Google OAUTH credentials"); + void renderGoogleCalendarCard(); + }, + }, + ] + : []), ], }, }); @@ -648,45 +714,22 @@ export function renderIntegrationsTab( }, ]; - const clientIdInput = createCardInput( - "text", + const credentialControls = createOAuthCredentialControls( + plugin, + "microsoft", "your-microsoft-client-id", - plugin.settings.microsoftOAuthClientId - ); - clientIdInput.addEventListener("blur", () => { - runAsyncSettingCallback(async () => { - plugin.settings.microsoftOAuthClientId = clientIdInput.value.trim(); - save(); - if (plugin.oauthService) { - await plugin.oauthService.loadClientIds(); - } - }); - }); - - const clientSecretInput = createCardInput( - "text", - "your-microsoft-client-secret", - plugin.settings.microsoftOAuthClientSecret + "your-microsoft-client-secret" ); - clientSecretInput.setAttribute("type", "password"); - clientSecretInput.addEventListener("blur", () => { - runAsyncSettingCallback(async () => { - plugin.settings.microsoftOAuthClientSecret = clientSecretInput.value.trim(); - save(); - if (plugin.oauthService) { - await plugin.oauthService.loadClientIds(); - } - }); - }); const credentialNote = activeDocument.createElement("div"); credentialNote.className = "tasknotes-credential-note"; - credentialNote.textContent = "Enter your OAUTH app credentials from azure portal."; + credentialNote.textContent = + "Enter your OAUTH app credentials from azure portal. Credentials are encrypted at rest in Obsidian secret storage."; sections.push({ rows: [ - { label: "Client ID:", input: clientIdInput }, - { label: "Client Secret:", input: clientSecretInput }, + { label: "Client ID:", input: credentialControls.clientIdInput }, + { label: "Client Secret:", input: credentialControls.clientSecretInput }, { label: "", input: credentialNote, fullWidth: true }, ], }); @@ -715,6 +758,7 @@ export function renderIntegrationsTab( try { const oauthService = plugin.oauthService; if (!oauthService) return; + credentialControls.persistPendingValues(); await oauthService.authenticate("microsoft"); new Notice("Microsoft calendar connected successfully!"); void renderMicrosoftCalendarCard(); @@ -728,6 +772,31 @@ export function renderIntegrationsTab( } }, }, + ...(credentialControls.hasStoredCredentials + ? [ + { + text: "Forget saved credentials", + icon: "trash-2", + variant: "warning" as const, + onClick: async () => { + const confirmed = await showConfirmationModal( + plugin.app, + { + title: "Forget Microsoft OAuth credentials?", + message: + "This removes the saved client ID and client secret from Obsidian Secret Storage.", + confirmText: "Forget credentials", + isDestructive: true, + } + ); + if (!confirmed) return; + plugin.oauthService.clearCredentials("microsoft"); + new Notice("Forgot Microsoft OAUTH credentials"); + void renderMicrosoftCalendarCard(); + }, + }, + ] + : []), ], }, }); @@ -1366,8 +1435,7 @@ export function renderIntegrationsTab( if (!isRecurringEventRelatedNotesMode(value)) { return; } - plugin.settings.icsIntegration.recurringEventRelatedNotesMode = - value; + plugin.settings.icsIntegration.recurringEventRelatedNotesMode = value; save(); }, }) diff --git a/src/types/settings.ts b/src/types/settings.ts index 960391ce5..faee0e303 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -239,10 +239,7 @@ export interface TaskNotesSettings { // Frontmatter link format settings useFrontmatterMarkdownLinks: boolean; // Use markdown links in frontmatter (requires obsidian-frontmatter-markdown-links plugin) // OAuth Calendar Integration settings - googleOAuthClientId: string; - googleOAuthClientSecret: string; - microsoftOAuthClientId: string; - microsoftOAuthClientSecret: string; + // OAuth client credentials and account tokens are stored in Obsidian SecretStorage. enableGoogleCalendar: boolean; enableMicrosoftCalendar: boolean; disableCalendarOnMobile: boolean; diff --git a/tests/helpers/obsidian-bridge.ts b/tests/helpers/obsidian-bridge.ts index 690a215fc..4bd3128dd 100644 --- a/tests/helpers/obsidian-bridge.ts +++ b/tests/helpers/obsidian-bridge.ts @@ -5,8 +5,12 @@ export * from "obsidian-test-mocks/obsidian"; let fallbackApp = stabilizeApp(Runtime.App.createConfigured__()); function stabilizeApp(app: Runtime.App): Runtime.App { - const appRecord = app as unknown as { renderContext?: Record }; + const appRecord = app as unknown as { + renderContext?: Record; + secretStorage?: ReturnType; + }; appRecord.renderContext = {}; + appRecord.secretStorage = Runtime.SecretStorage.create__(app); return app; } @@ -120,9 +124,11 @@ Object.defineProperty(TAbstractFileFacade, Symbol.hasInstance, { }); export { TAbstractFileFacade as TAbstractFile }; -export const Notice = jest.fn().mockImplementation((message: string | DocumentFragment, timeout?: number) => { - return new Runtime.Notice(message, timeout); -}); +export const Notice = jest + .fn() + .mockImplementation((message: string | DocumentFragment, timeout?: number) => { + return new Runtime.Notice(message, timeout); + }); export const requestUrl = jest.fn((request: Parameters[0]) => Runtime.requestUrl(request) @@ -144,7 +150,7 @@ export const MarkdownRenderer = { } as unknown as typeof Runtime.MarkdownRenderer; export const Menu = jest.fn().mockImplementation(() => { - return wrapMenu(new Runtime.Menu()); + return wrapMenu(Runtime.Menu.create2__()); }); (Menu as unknown as { forEvent: jest.Mock }).forEvent = jest.fn((event) => wrapMenu(Runtime.Menu.forEvent(event)) @@ -245,7 +251,9 @@ function wrapMenuItem(item: RuntimeMenuItem): TestMenuItem { } if (!jest.isMockFunction(record.setDisabled)) { const original = record.setDisabled.bind(record); - record.setDisabled = jest.fn((disabled) => original(disabled)) as TestMenuItem["setDisabled"]; + record.setDisabled = jest.fn((disabled) => + original(disabled) + ) as TestMenuItem["setDisabled"]; } if (!jest.isMockFunction(record.setIsLabel)) { const original = record.setIsLabel.bind(record); @@ -265,7 +273,9 @@ function wrapMenuItem(item: RuntimeMenuItem): TestMenuItem { } if (!jest.isMockFunction(record.setWarning)) { const original = record.setWarning.bind(record); - record.setWarning = jest.fn((isWarning) => original(isWarning)) as TestMenuItem["setWarning"]; + record.setWarning = jest.fn((isWarning) => + original(isWarning) + ) as TestMenuItem["setWarning"]; } return record; diff --git a/tests/services/OAuthService.revocation.test.ts b/tests/services/OAuthService.revocation.test.ts index 38cc153c3..4c8b105aa 100644 --- a/tests/services/OAuthService.revocation.test.ts +++ b/tests/services/OAuthService.revocation.test.ts @@ -1,275 +1,162 @@ -import { OAuthService } from '../../src/services/OAuthService'; -import { requestUrl } from 'obsidian'; -import type TaskNotesPlugin from '../../src/main'; -import { EVENT_USER_NOTICE } from '../../src/core/userNotices'; - -// Mock Obsidian APIs -jest.mock('obsidian', () => ({ - Notice: jest.fn(), +import { requestUrl } from "obsidian"; +import type TaskNotesPlugin from "../../src/main"; +import { EVENT_USER_NOTICE } from "../../src/core/userNotices"; +import { OAuthSecretStore } from "../../src/services/OAuthSecretStore"; +import { OAuthService } from "../../src/services/OAuthService"; +import type { OAuthConnection, OAuthProvider } from "../../src/types"; + +jest.mock("obsidian", () => ({ + Platform: { isDesktopApp: true }, requestUrl: jest.fn(), - Modal: class MockModal { - constructor() {} - open() {} - close() {} - } })); -// Mock DeviceCodeModal -jest.mock('../../src/modals/DeviceCodeModal', () => ({ - DeviceCodeModal: jest.fn().mockImplementation(() => ({ - open: jest.fn(), - close: jest.fn() - })) -})); +class InMemorySecretStorage { + private readonly values = new Map(); -describe('OAuthService - Token Revocation', () => { - let oauthService: OAuthService; + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + setSecret(id: string, value: string): void { + this.values.set(id, value); + } +} + +function getRequestCall( + mockRequestUrl: jest.MockedFunction, + index: number +): Exclude[0], string> { + const request = mockRequestUrl.mock.calls[index][0]; + if (typeof request === "string") { + throw new Error("Expected requestUrl to receive request parameters"); + } + return request; +} + +function createConnection( + provider: OAuthProvider, + options: { refreshToken?: string } = { refreshToken: `${provider}-refresh-token` } +): OAuthConnection { + return { + provider, + tokens: { + accessToken: `${provider}-access-token`, + refreshToken: options.refreshToken ?? "", + expiresAt: Date.now() + 3_600_000, + scope: "calendar.read calendar.write", + tokenType: "Bearer", + }, + connectedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("OAuthService token revocation", () => { + let sut: OAuthService; + let secretStore: OAuthSecretStore; let mockPlugin: Partial; let mockRequestUrl: jest.MockedFunction; - let mockConnectionData: any; beforeEach(() => { jest.clearAllMocks(); - - // Setup mock connection data - mockConnectionData = { - oauthConnections: { - google: { - provider: 'google', - tokens: { - accessToken: 'test-access-token', - refreshToken: 'test-refresh-token', - expiresAt: Date.now() + 3600000, - scope: 'calendar.readonly', - tokenType: 'Bearer' - }, - connectedAt: new Date().toISOString() - } - } - }; - - // Setup mock plugin + secretStore = new OAuthSecretStore(new InMemorySecretStorage()); + secretStore.setCredentials("google", { + clientId: "google-client-id", + clientSecret: "google-client-secret", + }); + secretStore.setConnection("google", createConnection("google")); mockPlugin = { - app: {} as any, - settings: { - googleOAuthClientId: 'test-client-id', - googleOAuthClientSecret: 'test-client-secret', - microsoftOAuthClientId: '', - microsoftOAuthClientSecret: '' - } as any, - emitter: { - trigger: jest.fn() - } as any, - loadData: jest.fn().mockResolvedValue(mockConnectionData), - saveData: jest.fn().mockResolvedValue(undefined) + emitter: { trigger: jest.fn() } as never, }; - - // Create service instance - oauthService = new OAuthService(mockPlugin as TaskNotesPlugin); - - // Setup requestUrl mock + sut = new OAuthService(mockPlugin as TaskNotesPlugin, secretStore); mockRequestUrl = requestUrl as jest.MockedFunction; }); - describe('Token Revocation on Disconnect', () => { - test('should revoke both access and refresh tokens on disconnect', async () => { - // Mock successful revocation responses - mockRequestUrl - .mockResolvedValueOnce({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }) - .mockResolvedValueOnce({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); - - await oauthService.disconnect('google'); - - // Verify both tokens were revoked - expect(mockRequestUrl).toHaveBeenCalledTimes(2); - - // Check first call (access token) - const firstCall = mockRequestUrl.mock.calls[0][0]; - expect(firstCall.url).toBe('https://oauth2.googleapis.com/revoke'); - expect(firstCall.method).toBe('POST'); - expect(firstCall.body).toContain('token=test-access-token'); - - // Check second call (refresh token) - const secondCall = mockRequestUrl.mock.calls[1][0]; - expect(secondCall.url).toBe('https://oauth2.googleapis.com/revoke'); - expect(secondCall.method).toBe('POST'); - expect(secondCall.body).toContain('token=test-refresh-token'); - }); - - test('should remove connection from storage after revocation', async () => { - mockRequestUrl.mockResolvedValue({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); - - await oauthService.disconnect('google'); - - // Verify saveData was called to remove connection - expect(mockPlugin.saveData).toHaveBeenCalledWith({ - oauthConnections: {} - }); - }); - - test('should show disconnect notice to user', async () => { - mockRequestUrl.mockResolvedValue({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); - - await oauthService.disconnect('google'); - - expect(mockPlugin.emitter?.trigger).toHaveBeenCalledWith( - EVENT_USER_NOTICE, - expect.objectContaining({ message: 'Disconnected from google Calendar' }) - ); + it("revokes both tokens, clears the connection, and preserves app credentials", async () => { + mockRequestUrl.mockResolvedValue({ + status: 200, + json: {}, + text: "OK", + arrayBuffer: new ArrayBuffer(0), + headers: {}, }); - test('should handle revocation failure gracefully', async () => { - // Mock failed revocation (e.g., network error) - mockRequestUrl.mockRejectedValue(new Error('Network error')); - - // Should not throw - disconnection should complete - await expect(oauthService.disconnect('google')).resolves.not.toThrow(); - - // Should still remove from storage - expect(mockPlugin.saveData).toHaveBeenCalled(); - - // Should still show disconnect notice - expect(mockPlugin.emitter?.trigger).toHaveBeenCalledWith( - EVENT_USER_NOTICE, - expect.objectContaining({ message: 'Disconnected from google Calendar' }) - ); + await sut.disconnect("google"); + + expect(mockRequestUrl).toHaveBeenCalledTimes(2); + const firstRequest = getRequestCall(mockRequestUrl, 0); + const secondRequest = getRequestCall(mockRequestUrl, 1); + expect(firstRequest).toEqual( + expect.objectContaining({ + url: "https://oauth2.googleapis.com/revoke", + method: "POST", + body: expect.stringContaining("token=google-access-token"), + }) + ); + expect(secondRequest.body).toContain("token=google-refresh-token"); + expect(firstRequest.body).toContain("client_id=google-client-id"); + expect(secretStore.getConnection("google")).toBeNull(); + expect(secretStore.getCredentials("google")).toEqual({ + clientId: "google-client-id", + clientSecret: "google-client-secret", }); + expect(mockPlugin.emitter?.trigger).toHaveBeenCalledWith( + EVENT_USER_NOTICE, + expect.objectContaining({ message: "Disconnected from google Calendar" }) + ); + }); - test('should handle already-revoked tokens (400 error)', async () => { - mockRequestUrl.mockResolvedValue({ - status: 400, - json: { error: 'invalid_token' }, - text: 'Bad Request', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); - - // Should not throw - await expect(oauthService.disconnect('google')).resolves.not.toThrow(); - - // Should still complete disconnection - expect(mockPlugin.saveData).toHaveBeenCalled(); - }); - - test('should handle provider without revocation endpoint', async () => { - // Create connection for a provider without revocation endpoint configured - // (This test ensures backwards compatibility if endpoint is missing) - - // Setup connection without revocation endpoint - mockConnectionData.oauthConnections.test = { - provider: 'test', - tokens: { - accessToken: 'test-token', - expiresAt: Date.now() + 3600000 - } - }; + it("keeps the local connection cleared when provider revocation fails", async () => { + mockRequestUrl.mockRejectedValue(new Error("Network error")); - await oauthService.disconnect('google'); + await expect(sut.disconnect("google")).resolves.toBeUndefined(); - // Should still work even if revocation endpoint is undefined - expect(mockPlugin.saveData).toHaveBeenCalled(); - }); + expect(secretStore.getConnection("google")).toBeNull(); + expect(mockPlugin.emitter?.trigger).toHaveBeenCalledWith( + EVENT_USER_NOTICE, + expect.objectContaining({ message: "Disconnected from google Calendar" }) + ); }); - describe('Revocation for Microsoft', () => { - beforeEach(() => { - // Add Microsoft connection - mockConnectionData.oauthConnections.microsoft = { - provider: 'microsoft', - tokens: { - accessToken: 'ms-access-token', - refreshToken: 'ms-refresh-token', - expiresAt: Date.now() + 3600000 - }, - connectedAt: new Date().toISOString() - }; - }); - - test('should use Microsoft revocation endpoint', async () => { - mockRequestUrl.mockResolvedValue({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); + it("does nothing when the provider is already disconnected", async () => { + secretStore.clearConnection("google"); - await oauthService.disconnect('microsoft'); + await sut.disconnect("google"); - // Verify Microsoft endpoint was called - const calls = mockRequestUrl.mock.calls; - expect(calls[0][0].url).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/logout'); - }); + expect(mockRequestUrl).not.toHaveBeenCalled(); + expect(mockPlugin.emitter?.trigger).not.toHaveBeenCalled(); }); - describe('Edge Cases', () => { - test('should handle disconnect when already disconnected', async () => { - // Remove connection - mockConnectionData.oauthConnections = {}; - - await expect(oauthService.disconnect('google')).resolves.not.toThrow(); - - // Should not attempt revocation or storage update - expect(mockRequestUrl).not.toHaveBeenCalled(); - expect(mockPlugin.saveData).not.toHaveBeenCalled(); + it("revokes only the access token when no refresh token is available", async () => { + secretStore.setConnection("google", createConnection("google", { refreshToken: "" })); + mockRequestUrl.mockResolvedValue({ + status: 200, + json: {}, + text: "OK", + arrayBuffer: new ArrayBuffer(0), + headers: {}, }); - test('should handle connection with only access token (no refresh token)', async () => { - // Remove refresh token - delete mockConnectionData.oauthConnections.google.tokens.refreshToken; - - mockRequestUrl.mockResolvedValue({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); + await sut.disconnect("google"); - await oauthService.disconnect('google'); + expect(mockRequestUrl).toHaveBeenCalledTimes(1); + expect(getRequestCall(mockRequestUrl, 0).body).toContain("token=google-access-token"); + }); - // Should only revoke access token (1 call) - expect(mockRequestUrl).toHaveBeenCalledTimes(1); - expect(mockRequestUrl.mock.calls[0][0].body).toContain('token=test-access-token'); + it("uses the Microsoft revocation endpoint", async () => { + secretStore.setCredentials("microsoft", { clientId: "microsoft-client-id" }); + secretStore.setConnection("microsoft", createConnection("microsoft")); + mockRequestUrl.mockResolvedValue({ + status: 200, + json: {}, + text: "OK", + arrayBuffer: new ArrayBuffer(0), + headers: {}, }); - test('should include client_id in revocation request', async () => { - mockRequestUrl.mockResolvedValue({ - status: 200, - json: {}, - text: 'OK', - arrayBuffer: new ArrayBuffer(0), - headers: {} - }); + await sut.disconnect("microsoft"); - await oauthService.disconnect('google'); - - const firstCall = mockRequestUrl.mock.calls[0][0]; - expect(firstCall.body).toContain('client_id=test-client-id'); - }); + expect(getRequestCall(mockRequestUrl, 0).url).toBe( + "https://login.microsoftonline.com/common/oauth2/v2.0/logout" + ); }); }); diff --git a/tests/services/OAuthService.secret-storage.test.ts b/tests/services/OAuthService.secret-storage.test.ts new file mode 100644 index 000000000..1137421a1 --- /dev/null +++ b/tests/services/OAuthService.secret-storage.test.ts @@ -0,0 +1,171 @@ +import { requestUrl } from "obsidian"; +import type TaskNotesPlugin from "../../src/main"; +import { EVENT_USER_NOTICE } from "../../src/core/userNotices"; +import { TokenRefreshError } from "../../src/services/errors"; +import { OAuthSecretStore } from "../../src/services/OAuthSecretStore"; +import { OAuthService } from "../../src/services/OAuthService"; +import type { OAuthConnection } from "../../src/types"; + +jest.mock("obsidian", () => ({ + Platform: { isDesktopApp: true }, + requestUrl: jest.fn(), +})); + +class InMemorySecretStorage { + private readonly values = new Map(); + + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + setSecret(id: string, value: string): void { + this.values.set(id, value); + } +} + +function createExpiredConnection(): OAuthConnection { + return { + provider: "google", + tokens: { + accessToken: "expired-access-token", + refreshToken: "current-refresh-token", + expiresAt: 1, + scope: "calendar.read", + tokenType: "Bearer", + }, + userEmail: "person@example.com", + connectedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function getRequestCall( + mockRequestUrl: jest.MockedFunction +): Exclude[0], string> { + const request = mockRequestUrl.mock.calls[0][0]; + if (typeof request === "string") { + throw new Error("Expected requestUrl to receive request parameters"); + } + return request; +} + +describe("OAuthService SecretStorage persistence", () => { + let sut: OAuthService; + let secretStore: OAuthSecretStore; + let mockPlugin: Partial; + let mockRequestUrl: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + secretStore = new OAuthSecretStore(new InMemorySecretStorage()); + secretStore.setCredentials("google", { + clientId: "current-client-id", + clientSecret: "current-client-secret", + }); + secretStore.setConnection("google", createExpiredConnection()); + mockPlugin = { + emitter: { trigger: jest.fn() } as never, + saveData: jest.fn(), + }; + sut = new OAuthService(mockPlugin as TaskNotesPlugin, secretStore); + mockRequestUrl = requestUrl as jest.MockedFunction; + }); + + it("refreshes and persists tokens without writing plugin data", async () => { + mockRequestUrl.mockResolvedValue({ + status: 200, + json: { + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }, + text: "OK", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }); + + const result = await sut.refreshToken("google"); + + expect(result).toEqual( + expect.objectContaining({ + accessToken: "new-access-token", + refreshToken: "current-refresh-token", + }) + ); + expect(secretStore.getConnection("google")?.tokens).toEqual(result); + expect(mockPlugin.saveData).not.toHaveBeenCalled(); + const request = getRequestCall(mockRequestUrl); + expect(request.body).toContain("client_id=current-client-id"); + expect(request.body).toContain("client_secret=current-client-secret"); + expect(request.body).toContain("refresh_token=current-refresh-token"); + }); + + it("clears invalid tokens without deleting the saved app credentials", async () => { + mockRequestUrl.mockResolvedValue({ + status: 400, + json: { + error: "invalid_grant", + error_description: "Refresh token was revoked", + }, + text: "Bad Request", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }); + + await expect(sut.refreshToken("google")).rejects.toBeInstanceOf(TokenRefreshError); + + expect(secretStore.getConnection("google")).toBeNull(); + expect(secretStore.getCredentials("google")).toEqual({ + clientId: "current-client-id", + clientSecret: "current-client-secret", + }); + expect(mockPlugin.saveData).not.toHaveBeenCalled(); + expect(mockPlugin.emitter?.trigger).toHaveBeenCalledWith( + EVENT_USER_NOTICE, + expect.objectContaining({ + message: expect.stringContaining("connection expired"), + }) + ); + }); + + it("does not let a stale refresh failure clear a reconnected account", async () => { + type RequestResponse = Awaited>; + let resolveRefresh: (response: RequestResponse) => void = () => undefined; + const refreshResponse = new Promise((resolve) => { + resolveRefresh = resolve; + }); + mockRequestUrl.mockResolvedValue({ + status: 200, + json: {}, + text: "OK", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }); + mockRequestUrl.mockReturnValueOnce( + refreshResponse as unknown as ReturnType + ); + const staleRefresh = sut.refreshToken("google"); + const staleRefreshResult = expect(staleRefresh).rejects.toThrow( + "connection changed during token refresh" + ); + + await sut.disconnect("google"); + const reconnected = createExpiredConnection(); + reconnected.tokens.accessToken = "reconnected-access-token"; + secretStore.setConnection("google", reconnected); + resolveRefresh({ + status: 400, + json: { + error: "invalid_grant", + error_description: "The old refresh token was revoked", + }, + text: "Bad Request", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }); + + await staleRefreshResult; + expect(secretStore.getConnection("google")?.tokens.accessToken).toBe( + "reconnected-access-token" + ); + }); +}); diff --git a/tests/unit/issues/issue-1964-microsoft-oauth-consent.test.ts b/tests/unit/issues/issue-1964-microsoft-oauth-consent.test.ts index 5c1e11a5f..563e861eb 100644 --- a/tests/unit/issues/issue-1964-microsoft-oauth-consent.test.ts +++ b/tests/unit/issues/issue-1964-microsoft-oauth-consent.test.ts @@ -1,5 +1,6 @@ import { OAuthService } from "../../../src/services/OAuthService"; import type TaskNotesPlugin from "../../../src/main"; +import { OAuthSecretStore } from "../../../src/services/OAuthSecretStore"; import type { OAuthConfig } from "../../../src/types"; jest.mock("obsidian", () => ({ @@ -7,24 +8,30 @@ jest.mock("obsidian", () => ({ requestUrl: jest.fn(), })); -type BuildAuthorizationUrl = ( - config: OAuthConfig, - codeChallenge: string, - state: string -) => string; +type BuildAuthorizationUrl = (config: OAuthConfig, codeChallenge: string, state: string) => string; + +class InMemorySecretStorage { + private readonly values = new Map(); + + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + setSecret(id: string, value: string): void { + this.values.set(id, value); + } +} function createOAuthService(): OAuthService { - return new OAuthService({ - settings: { - googleOAuthClientId: "", - googleOAuthClientSecret: "", - microsoftOAuthClientId: "", - microsoftOAuthClientSecret: "", - }, - emitter: { - trigger: jest.fn(), - }, - } as unknown as TaskNotesPlugin); + const secretStore = new OAuthSecretStore(new InMemorySecretStorage()); + return new OAuthService( + { + emitter: { + trigger: jest.fn(), + }, + } as unknown as TaskNotesPlugin, + secretStore + ); } function buildUrl(config: OAuthConfig): URL { @@ -41,8 +48,7 @@ describe("Issue #1964: Microsoft OAuth consent prompt", () => { clientId: "microsoft-client-id", redirectUri: "http://127.0.0.1:8080", scope: ["Calendars.Read", "Calendars.ReadWrite", "offline_access"], - authorizationEndpoint: - "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + authorizationEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", }); diff --git a/tests/unit/services/OAuthSecretStore.test.ts b/tests/unit/services/OAuthSecretStore.test.ts new file mode 100644 index 000000000..f11b9035d --- /dev/null +++ b/tests/unit/services/OAuthSecretStore.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "@jest/globals"; +import { OAuthSecretStore, type OAuthCredentials } from "../../../src/services/OAuthSecretStore"; +import { + migrateLegacyOAuthData, + stripLegacyOAuthData, +} from "../../../src/services/oauthSecretMigration"; +import type { OAuthConnection, OAuthProvider } from "../../../src/types"; + +class InMemorySecretStorage { + private readonly values = new Map(); + + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + setSecret(id: string, value: string): void { + this.values.set(id, value); + } +} + +type SecretStorageStub = { + getSecret(id: string): string | null; + setSecret(id: string, value: string): void; +}; + +function createStore(storage: SecretStorageStub = new InMemorySecretStorage()): OAuthSecretStore { + return new OAuthSecretStore(storage); +} + +function createCredentials(clientId: string): OAuthCredentials { + return { clientId, clientSecret: `${clientId}-secret` }; +} + +function createConnection(provider: OAuthProvider, accessToken: string): OAuthConnection { + return { + provider, + tokens: { + accessToken, + refreshToken: `${accessToken}-refresh`, + expiresAt: 2_000_000_000_000, + scope: "calendar.read calendar.write", + tokenType: "Bearer", + }, + userEmail: `${provider}@example.com`, + connectedAt: "2026-01-01T00:00:00.000Z", + lastRefreshed: "2026-01-02T00:00:00.000Z", + }; +} + +describe("OAuth secret migration", () => { + it("moves OAuth credentials and account tokens out of plugin data", () => { + const sut = createStore(); + const legacyGoogleConnection = createConnection("google", "google-access"); + const legacyMicrosoftConnection = createConnection("microsoft", "microsoft-access"); + const legacyData = { + tasksFolder: "Projects/Tasks", + googleOAuthClientId: "google-client", + googleOAuthClientSecret: "google-secret", + microsoftOAuthClientId: "microsoft-client", + microsoftOAuthClientSecret: "microsoft-secret", + oauthConnections: { + google: legacyGoogleConnection, + microsoft: legacyMicrosoftConnection, + }, + }; + + const result = migrateLegacyOAuthData(legacyData, sut); + + expect(result).toEqual({ + changed: true, + data: { tasksFolder: "Projects/Tasks" }, + }); + expect(sut.getCredentials("google")).toEqual({ + clientId: "google-client", + clientSecret: "google-secret", + }); + expect(sut.getCredentials("microsoft")).toEqual({ + clientId: "microsoft-client", + clientSecret: "microsoft-secret", + }); + expect(sut.getConnection("google")).toEqual(legacyGoogleConnection); + expect(sut.getConnection("microsoft")).toEqual(legacyMicrosoftConnection); + }); + + it("keeps newer secure values when stale plugin data reappears", () => { + const sut = createStore(); + sut.setCredentials("google", createCredentials("current-client")); + sut.setConnection("google", createConnection("google", "current-access")); + + const result = migrateLegacyOAuthData( + { + googleOAuthClientId: "stale-client", + googleOAuthClientSecret: "stale-secret", + oauthConnections: { + google: createConnection("google", "stale-access"), + }, + }, + sut + ); + + expect(result.data).toEqual({}); + expect(sut.getCredentials("google")).toEqual(createCredentials("current-client")); + expect(sut.getConnection("google")?.tokens.accessToken).toBe("current-access"); + }); + + it("does not resurrect credentials or connections after they were cleared", () => { + const sut = createStore(); + sut.clearCredentials("google"); + sut.clearConnection("google"); + + migrateLegacyOAuthData( + { + googleOAuthClientId: "restored-client", + googleOAuthClientSecret: "restored-secret", + oauthConnections: { + google: createConnection("google", "restored-access"), + }, + }, + sut + ); + + expect(sut.getCredentials("google")).toBeNull(); + expect(sut.getConnection("google")).toBeNull(); + }); + + it("leaves the source data untouched when SecretStorage cannot verify a write", () => { + const storage = { + getSecret: () => null, + setSecret: () => undefined, + }; + const sut = createStore(storage); + const legacyData = { + googleOAuthClientId: "google-client", + googleOAuthClientSecret: "google-secret", + }; + + expect(() => migrateLegacyOAuthData(legacyData, sut)).toThrow( + "SecretStorage did not persist" + ); + expect(legacyData).toEqual({ + googleOAuthClientId: "google-client", + googleOAuthClientSecret: "google-secret", + }); + }); + + it("redacts stale OAuth values while preserving unrelated plugin data", () => { + const staleSnapshot = { + tasksFolder: "Projects/Tasks", + googleOAuthClientId: "client-id", + googleOAuthClientSecret: "client-secret", + oauthConnections: { + google: createConnection("google", "access-token"), + legacyProvider: { keep: true }, + }, + }; + + expect(stripLegacyOAuthData(staleSnapshot)).toEqual({ + tasksFolder: "Projects/Tasks", + oauthConnections: { + legacyProvider: { keep: true }, + }, + }); + }); +}); From 3811efc2dfae61e0153604603c7d060cabb8bbf1 Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Fri, 17 Jul 2026 06:09:45 -0300 Subject: [PATCH 02/18] fix: record real completion date on materialized occurrences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completing a materialized occurrence wrote the occurrence date (occurrence_date) into completedDate, losing the actual completion date. The parent's complete_instances already tracks which occurrence was fulfilled, so completedDate can record when the user actually completed it — matching non-recurring task behavior. Fixes #2125 (callumalpass/tasknotes) Co-Authored-By: Claude Fable 5 --- src/services/TaskService.ts | 5 ++++- tests/unit/services/task-occurrence-materialization.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 533b325c2..ef36ff494 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -204,7 +204,10 @@ export class TaskService { } private getCompletionDateForTask(task: TaskInfo): string { - return task.occurrence_date || getCurrentDateString(); + // Record the real completion date, matching non-recurring task behavior. + // Which occurrence was fulfilled is tracked separately on the parent's + // complete_instances (see buildMaterializedOccurrenceCompletePlan). #2125 + return getCurrentDateString(); } /** diff --git a/tests/unit/services/task-occurrence-materialization.test.ts b/tests/unit/services/task-occurrence-materialization.test.ts index 36010db8c..31ece205c 100644 --- a/tests/unit/services/task-occurrence-materialization.test.ts +++ b/tests/unit/services/task-occurrence-materialization.test.ts @@ -437,9 +437,12 @@ describe("TaskService materialized occurrences", () => { await taskService.updateProperty(occurrence, "status", "done"); + // completedDate records when the user actually completed the occurrence + // (the mocked "today", 2025-01-01), while the parent's complete_instances + // keeps tracking WHICH occurrence was fulfilled (the occurrence date). expect(frontmatterByPath.get(occurrence.path)).toMatchObject({ status: "done", - completedDate: "2026-06-01", + completedDate: "2025-01-01", }); expect(frontmatterByPath.get(parent.path)).toMatchObject({ complete_instances: ["2026-06-01"], From 354a56861cbf9807c4d21a6aae004e1c51f1f30d Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Fri, 17 Jul 2026 08:24:59 -0300 Subject: [PATCH 03/18] feat: add occurrence filename template variables (#2126) --- src/utils/filenameGenerator.ts | 79 +++++++++++++++++ tests/__mocks__/date-fns.ts | 19 +++++ ...-2126-occurrence-filename-template.test.ts | 85 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 tests/unit/issues/issue-2126-occurrence-filename-template.test.ts diff --git a/src/utils/filenameGenerator.ts b/src/utils/filenameGenerator.ts index d17074884..f10206b8a 100644 --- a/src/utils/filenameGenerator.ts +++ b/src/utils/filenameGenerator.ts @@ -3,6 +3,7 @@ import type { Vault } from "obsidian"; import { TaskNotesSettings } from "../types/settings"; import { getProjectDisplayName } from "./linkUtils"; import { createTaskNotesLogger } from "./tasknotesLogger"; +import { parseDateToLocal } from "./dateUtils"; const tasknotesLogger = createTaskNotesLogger({ tag: "Utils/FilenameGenerator" }); @@ -624,3 +625,81 @@ export async function generateUniqueFilename( return `task-${timestamp}`; } } + +type ParentRecurrence = string | { frequency?: string } | undefined; + +function getOccurrencePeriodKey( + parentRecurrence: ParentRecurrence +): "occurrenceDate" | "occurrenceWeek" | "occurrenceMonth" | "occurrenceYear" { + let freq: string | undefined; + if (typeof parentRecurrence === "string") { + freq = parentRecurrence.match(/FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i)?.[1]; + } else if (parentRecurrence && typeof parentRecurrence === "object") { + freq = parentRecurrence.frequency; + } + switch (freq?.toUpperCase()) { + case "WEEKLY": + return "occurrenceWeek"; + case "MONTHLY": + return "occurrenceMonth"; + case "YEARLY": + return "occurrenceYear"; + default: + return "occurrenceDate"; + } +} + +/** + * Builds the template variables derived from a materialized occurrence's date. + * `occurrencePeriod` auto-selects the granularity matching the parent's recurrence FREQ. + */ +export function buildOccurrenceFilenameVariables( + occurrenceDate: string, + parentRecurrence?: ParentRecurrence +): Record { + const date = parseDateToLocal(occurrenceDate); + if (!(date instanceof Date) || isNaN(date.getTime())) { + throw new Error(`Invalid occurrence date: ${occurrenceDate}`); + } + const variables: Record = { + occurrenceDate: format(date, "yyyy-MM-dd"), + occurrenceWeek: format(date, "RRRR-'W'II"), + occurrenceMonth: format(date, "yyyy-MM"), + occurrenceYear: format(date, "yyyy"), + occurrenceMonthName: format(date, "MMMM"), + }; + variables.occurrencePeriod = variables[getOccurrencePeriodKey(parentRecurrence)]; + return variables; +} + +/** + * Generates the filename for a materialized occurrence from a template. + * Falls back to the sanitized parent title on any error (a bad template must + * never break materialization). + */ +export function generateOccurrenceFilename( + context: FilenameContext, + template: string, + occurrenceDate: string, + parentRecurrence?: ParentRecurrence +): string { + try { + const occurrenceVariables = buildOccurrenceFilenameVariables( + occurrenceDate, + parentRecurrence + ); + return generateCustomFilename( + context, + template, + context.date || new Date(), + occurrenceVariables + ); + } catch (error) { + tasknotesLogger.error("Error generating occurrence filename:", { + category: "persistence", + operation: "generating-occurrence-filename", + error: error, + }); + return sanitizeForFilename(context.title); + } +} diff --git a/tests/__mocks__/date-fns.ts b/tests/__mocks__/date-fns.ts index 3f3b39e5e..05866c835 100644 --- a/tests/__mocks__/date-fns.ts +++ b/tests/__mocks__/date-fns.ts @@ -20,6 +20,7 @@ export const format = jest.fn((date: Date, formatStr: string) => { if (formatStr === 'MM') return String(month + 1).padStart(2, '0'); if (formatStr === 'dd') return String(day).padStart(2, '0'); if (formatStr === 'yyyy-MM-dd') return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (formatStr === 'yyyy-MM') return `${year}-${String(month + 1).padStart(2, '0')}`; // Time formats if (formatStr === 'HH') return String(hours).padStart(2, '0'); @@ -114,6 +115,24 @@ export const format = jest.fn((date: Date, formatStr: string) => { return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } + // ISO week-numbering year + week (e.g. "2026-W31") + if (formatStr === "RRRR-'W'II") { + const target = new Date(date.getTime()); + target.setHours(0, 0, 0, 0); + // Thursday of the current ISO week decides the ISO week-numbering year + target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7)); + const firstThursday = new Date(target.getFullYear(), 0, 4); + const week = + 1 + + Math.round( + ((target.getTime() - firstThursday.getTime()) / 86400000 - + 3 + + ((firstThursday.getDay() + 6) % 7)) / + 7 + ); + return `${target.getFullYear()}-W${String(week).padStart(2, '0')}`; + } + // Default fallback return date.toISOString(); }); diff --git a/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts b/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts new file mode 100644 index 000000000..217b2c082 --- /dev/null +++ b/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts @@ -0,0 +1,85 @@ +import { + buildOccurrenceFilenameVariables, + generateOccurrenceFilename, + FilenameContext, +} from "../../../src/utils/filenameGenerator"; + +const context: FilenameContext = { + title: "Pay rent", + priority: "normal", + status: "open", + date: new Date(2026, 0, 15), // creation date, distinct from occurrence date +}; + +describe("issue #2126 — occurrence filename variables", () => { + it("exposes explicit granularity variables", () => { + const vars = buildOccurrenceFilenameVariables("2026-08-01"); + expect(vars.occurrenceDate).toBe("2026-08-01"); + expect(vars.occurrenceMonth).toBe("2026-08"); + expect(vars.occurrenceYear).toBe("2026"); + expect(vars.occurrenceMonthName).toBe("August"); + expect(vars.occurrenceWeek).toBe("2026-W31"); + }); + + it("occurrencePeriod picks granularity from rrule FREQ", () => { + expect( + buildOccurrenceFilenameVariables("2026-08-01", "FREQ=MONTHLY;INTERVAL=1").occurrencePeriod + ).toBe("2026-08"); + expect( + buildOccurrenceFilenameVariables("2026-08-01", "FREQ=WEEKLY;BYDAY=MO").occurrencePeriod + ).toBe("2026-W31"); + expect( + buildOccurrenceFilenameVariables("2026-08-01", "FREQ=DAILY").occurrencePeriod + ).toBe("2026-08-01"); + expect( + buildOccurrenceFilenameVariables("2026-08-01", "FREQ=YEARLY").occurrencePeriod + ).toBe("2026"); + }); + + it("occurrencePeriod falls back to full date without a clear FREQ", () => { + expect(buildOccurrenceFilenameVariables("2026-08-01").occurrencePeriod).toBe("2026-08-01"); + expect( + buildOccurrenceFilenameVariables("2026-08-01", "every other tuesday").occurrencePeriod + ).toBe("2026-08-01"); + }); + + it("occurrencePeriod understands legacy recurrence objects", () => { + expect( + buildOccurrenceFilenameVariables("2026-08-01", { frequency: "monthly" }).occurrencePeriod + ).toBe("2026-08"); + }); + + it("generateOccurrenceFilename renders the default template", () => { + const name = generateOccurrenceFilename( + context, + "{{title}} — {{occurrencePeriod}}", + "2026-08-01", + "FREQ=MONTHLY" + ); + expect(name).toBe("Pay rent — 2026-08"); + }); + + it("supports regular filename variables alongside occurrence ones", () => { + const name = generateOccurrenceFilename( + context, + "{{titleKebab}}-{{occurrenceMonth}}", + "2026-08-01", + "FREQ=MONTHLY" + ); + expect(name).toBe("pay-rent-2026-08"); + }); + + it("falls back to sanitized title when the template resolves empty", () => { + const name = generateOccurrenceFilename(context, "{{nonexistent}}", "2026-08-01"); + expect(name).toBe("Pay rent"); + }); + + it("falls back to sanitized title on invalid occurrence date", () => { + const name = generateOccurrenceFilename( + context, + "{{title}} — {{occurrencePeriod}}", + "not-a-date" + ); + expect(name).toBe("Pay rent"); + }); +}); From 3365081a30bda0b6c68a9407dd8e6c65543d8a5c Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Fri, 17 Jul 2026 08:30:01 -0300 Subject: [PATCH 04/18] feat: settings and UI for occurrence filename template (#2126) --- src/i18n/resources/en.ts | 6 +++ src/i18n/resources/pt.ts | 8 +++- src/settings/defaults.ts | 2 + .../tabs/taskProperties/titlePropertyCard.ts | 43 +++++++++++++++++++ src/types.ts | 2 + src/types/settings.ts | 2 + 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..32ad3bbac 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1195,6 +1195,12 @@ export const en: TranslationTree = { customTemplate: "Custom template:", legacySyntaxWarning: "Single-brace syntax like {title} is deprecated. Please use double-brace syntax {{title}} instead for consistency with body templates.", + occurrenceFilenameTemplate: "Occurrence filename template", + occurrenceFilenameTemplateHelp: + "Filename template for materialized occurrences of recurring tasks. Leave empty to keep the default name (parent title plus a numeric suffix). Variables: {{occurrencePeriod}} (matches the recurrence frequency: 2026-08-01 / 2026-W32 / 2026-08 / 2026), {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, {{occurrenceMonthName}}, plus all regular filename variables. A parent task can override this template via the frontmatter property configured below.", + occurrenceFilenameProperty: "Occurrence template override property", + occurrenceFilenamePropertyHelp: + "Name of the frontmatter property on a recurring (parent) task that overrides the occurrence filename template for that task's occurrences.", }, tagsCard: { nativeObsidianTags: "Uses native Obsidian tags", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 7d377b4c7..a567ffb08 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1114,7 +1114,13 @@ export const pt: TranslationTree = { filenameUpdatesWithTitle: "O nome do arquivo será atualizado automaticamente quando o título da tarefa mudar.", filenameFormat: "Formato do nome do arquivo:", customTemplate: "Modelo personalizado:", - legacySyntaxWarning: "A sintaxe de chaves simples como {title} está obsoleta. Por favor, use a sintaxe de chaves duplas {{title}} para consistência com os modelos de corpo." + legacySyntaxWarning: "A sintaxe de chaves simples como {title} está obsoleta. Por favor, use a sintaxe de chaves duplas {{title}} para consistência com os modelos de corpo.", + occurrenceFilenameTemplate: "Modelo de nome de arquivo das ocorrências", + occurrenceFilenameTemplateHelp: + "Modelo do nome de arquivo das ocorrências materializadas de tarefas recorrentes. Deixe vazio para manter o nome padrão (título da tarefa-mãe com sufixo numérico). Variáveis: {{occurrencePeriod}} (acompanha a frequência da recorrência: 2026-08-01 / 2026-W32 / 2026-08 / 2026), {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, {{occurrenceMonthName}}, além de todas as variáveis normais de nome de arquivo. Uma tarefa-mãe pode sobrescrever este modelo pela propriedade de frontmatter configurada abaixo.", + occurrenceFilenameProperty: "Propriedade de sobrescrita do modelo", + occurrenceFilenamePropertyHelp: + "Nome da propriedade de frontmatter na tarefa recorrente (mãe) que sobrescreve o modelo de nome de arquivo para as ocorrências daquela tarefa." }, tagsCard: { nativeObsidianTags: "Usa tags nativas do Obsidian" diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5d..1aff36411 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -265,6 +265,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { taskFilenameFormat: "zettel", // Keep existing behavior as default storeTitleInFilename: true, customFilenameTemplate: "{{title}}", // Simple title template + occurrenceFilenameTemplate: "{{title}} — {{occurrencePeriod}}", + occurrenceFilenameTemplateProperty: "occurrenceFilenameTemplate", // Task creation defaults taskCreationDefaults: DEFAULT_TASK_CREATION_DEFAULTS, openTaskAfterCreation: "none", diff --git a/src/settings/tabs/taskProperties/titlePropertyCard.ts b/src/settings/tabs/taskProperties/titlePropertyCard.ts index 3c736cf27..479376721 100644 --- a/src/settings/tabs/taskProperties/titlePropertyCard.ts +++ b/src/settings/tabs/taskProperties/titlePropertyCard.ts @@ -106,6 +106,49 @@ function renderFilenameSettingsContent( ): void { container.empty(); + // Occurrence filename template — applies to materialized occurrences of + // recurring tasks regardless of the filename format below (#2126) + const occurrenceTemplateContainer = container.createDiv("tasknotes-settings__card-config-row"); + occurrenceTemplateContainer.createSpan({ + text: translate("settings.taskProperties.titleCard.occurrenceFilenameTemplate"), + cls: "tasknotes-settings__card-config-label", + }); + const occurrenceTemplateInput = createCardInput( + "text", + "{{title}} — {{occurrencePeriod}}", + plugin.settings.occurrenceFilenameTemplate + ); + occurrenceTemplateInput.addEventListener("change", () => { + plugin.settings.occurrenceFilenameTemplate = occurrenceTemplateInput.value; + save(); + }); + occurrenceTemplateContainer.appendChild(occurrenceTemplateInput); + container.createDiv({ + text: translate("settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp"), + cls: "setting-item-description", + }); + + const occurrencePropertyContainer = container.createDiv("tasknotes-settings__card-config-row"); + occurrencePropertyContainer.createSpan({ + text: translate("settings.taskProperties.titleCard.occurrenceFilenameProperty"), + cls: "tasknotes-settings__card-config-label", + }); + const occurrencePropertyInput = createCardInput( + "text", + "occurrenceFilenameTemplate", + plugin.settings.occurrenceFilenameTemplateProperty + ); + occurrencePropertyInput.addEventListener("change", () => { + plugin.settings.occurrenceFilenameTemplateProperty = + occurrencePropertyInput.value.trim() || "occurrenceFilenameTemplate"; + save(); + }); + occurrencePropertyContainer.appendChild(occurrencePropertyInput); + container.createDiv({ + text: translate("settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp"), + cls: "setting-item-description", + }); + // Only show filename format settings when storeTitleInFilename is off if (plugin.settings.storeTitleInFilename) { container.createDiv({ diff --git a/src/types.ts b/src/types.ts index c968e6bea..e4ab5b25c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -502,6 +502,8 @@ export interface TaskCreationData extends Partial { parentNote?: string; // Optional parent note name/path for template variable creationContext?: "inline-conversion" | "manual-creation" | "modal-inline-creation" | "api" | "import" | "ics-event"; // Context for folder determination customFrontmatter?: Record; // Custom frontmatter properties (including user fields) + occurrenceFilenameTemplate?: string; // Resolved filename template for a materialized occurrence (creation-only) + occurrenceParentRecurrence?: string | { frequency?: string }; // Parent's recurrence, for {{occurrencePeriod}} (creation-only) } export interface TimeEntry { diff --git a/src/types/settings.ts b/src/types/settings.ts index 960391ce5..73a1ac713 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -109,6 +109,8 @@ export interface TaskNotesSettings { taskFilenameFormat: "title" | "zettel" | "timestamp" | "uuid" | "custom"; storeTitleInFilename: boolean; customFilenameTemplate: string; // Template for custom format + occurrenceFilenameTemplate: string; // Template for materialized occurrence filenames ("" = legacy dedup suffix) + occurrenceFilenameTemplateProperty: string; // Frontmatter property on the parent that overrides the template // Task creation defaults taskCreationDefaults: TaskCreationDefaults; openTaskAfterCreation: "none" | "same-tab" | "new-tab"; From 55c336a34fdca230d4d4c35a5782301d908a139b Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Fri, 17 Jul 2026 08:35:43 -0300 Subject: [PATCH 05/18] feat: name materialized occurrences from configurable template (#2126) --- src/services/TaskService.ts | 32 ++++++++ .../task-service/TaskCreationService.ts | 12 ++- .../task-occurrence-materialization.test.ts | 78 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 533b325c2..2e682d71f 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -755,6 +755,8 @@ export class TaskService { ...(plan.occurrenceTask as Partial), creationContext: "api", customFrontmatter: occurrenceTemplate.customFrontmatter, + occurrenceFilenameTemplate: this.resolveOccurrenceFilenameTemplate(freshParent), + occurrenceParentRecurrence: freshParent.recurrence, }; const { taskInfo } = await this.createTask(taskData, { applyDefaults: false, @@ -763,6 +765,36 @@ export class TaskService { return taskInfo; } + /** + * Resolves the filename template for a materialized occurrence (#2126): + * the parent's frontmatter override property wins over the global setting. + * Returns undefined when no non-empty template applies (legacy naming). + */ + private resolveOccurrenceFilenameTemplate(parentTask: TaskInfo): string | undefined { + try { + const propertyName = + this.plugin.settings.occurrenceFilenameTemplateProperty?.trim() || + "occurrenceFilenameTemplate"; + const parentFile = this.plugin.app.vault.getAbstractFileByPath(parentTask.path); + const frontmatter = parentFile + ? this.plugin.app.metadataCache.getFileCache(parentFile as TFile)?.frontmatter + : undefined; + const override = frontmatter?.[propertyName]; + const template = + typeof override === "string" && override.trim() + ? override + : this.plugin.settings.occurrenceFilenameTemplate; + return typeof template === "string" && template.trim() ? template : undefined; + } catch (error) { + tasknotesLogger.warn("Failed to resolve occurrence filename template:", { + category: "persistence", + operation: "resolving-occurrence-filename-template", + error, + }); + return this.plugin.settings.occurrenceFilenameTemplate?.trim() || undefined; + } + } + async findMaterializedOccurrence( parentTask: TaskInfo, targetDate: string | Date diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index a253737c5..c112d165e 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -13,6 +13,7 @@ import { type TaskFilenameSettings, generateTaskFilename, generateUniqueFilename, + generateOccurrenceFilename, } from "../../utils/filenameGenerator"; import { ensureFolderExists } from "../../utils/helpers"; import { getCurrentTimestamp } from "../../utils/dateUtils"; @@ -167,7 +168,16 @@ export class TaskCreationService { parentNote: taskData.parentNote, }; - const baseFilename = generateTaskFilename(filenameContext, runtime.settings); + const occurrenceFilenameTemplate = taskData.occurrenceFilenameTemplate?.trim(); + const baseFilename = + occurrenceFilenameTemplate && taskData.occurrence_date + ? generateOccurrenceFilename( + filenameContext, + occurrenceFilenameTemplate, + taskData.occurrence_date, + taskData.occurrenceParentRecurrence + ) + : generateTaskFilename(filenameContext, runtime.settings); const folder = await this.resolveTargetFolder(taskData); if (folder) { diff --git a/tests/unit/services/task-occurrence-materialization.test.ts b/tests/unit/services/task-occurrence-materialization.test.ts index 36010db8c..4bdde779c 100644 --- a/tests/unit/services/task-occurrence-materialization.test.ts +++ b/tests/unit/services/task-occurrence-materialization.test.ts @@ -65,6 +65,9 @@ jest.mock("../../../src/utils/dateUtils", () => { jest.mock("../../../src/utils/filenameGenerator", () => ({ generateTaskFilename: jest.fn((context) => context.title.toLowerCase().replace(/\s+/g, "-")), generateUniqueFilename: jest.fn((base) => base), + generateOccurrenceFilename: jest.fn( + (context, template, occurrenceDate) => `${context.title} OCC ${occurrenceDate}` + ), })); describe("TaskService materialized occurrences", () => { @@ -447,4 +450,79 @@ describe("TaskService materialized occurrences", () => { }); expect(frontmatterByPath.get(parent.path)?.skipped_instances).toBeUndefined(); }); + + describe("issue #2126 — occurrence filename template wiring", () => { + const filenameGenerator = jest.requireMock("../../../src/utils/filenameGenerator"); + + beforeEach(() => { + filenameGenerator.generateOccurrenceFilename.mockClear(); + filenameGenerator.generateTaskFilename.mockClear(); + }); + + it("uses the global template and passes the parent recurrence", async () => { + const parent = TaskFactory.createTask({ + title: "Pay rent", + path: "Tasks/Pay rent.md", + recurrence: "FREQ=MONTHLY", + scheduled: "2026-08-01", + }); + const { taskService, plugin } = createService({ [parent.path]: parent }); + plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrencePeriod}}"; + + const occurrence = await taskService.materializeOccurrence(parent, "2026-08-01"); + + expect(filenameGenerator.generateOccurrenceFilename).toHaveBeenCalledWith( + expect.objectContaining({ title: "Pay rent" }), + "{{title}} — {{occurrencePeriod}}", + "2026-08-01", + "FREQ=MONTHLY" + ); + expect(filenameGenerator.generateTaskFilename).not.toHaveBeenCalled(); + expect(occurrence.path).toBe("Tasks/Pay rent OCC 2026-08-01.md"); + }); + + it("parent frontmatter property overrides the global template", async () => { + const parent = TaskFactory.createTask({ + title: "Weekly review", + path: "Tasks/Weekly review.md", + recurrence: "FREQ=WEEKLY", + scheduled: "2026-08-03", + }); + const { taskService, plugin } = createService({ [parent.path]: parent }); + plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrencePeriod}}"; + plugin.settings.occurrenceFilenameTemplateProperty = "occurrenceFilenameTemplate"; + // the harness resolves the parent frontmatter via metadataCache.getFileCache — + // override it so the parent note carries the per-task template override + plugin.app.metadataCache.getFileCache = jest.fn((file: { path: string }) => + file.path === "Tasks/Weekly review.md" + ? { frontmatter: { occurrenceFilenameTemplate: "{{title}} ({{occurrenceWeek}})" } } + : { frontmatter: {} } + ); + + await taskService.materializeOccurrence(parent, "2026-08-03"); + + expect(filenameGenerator.generateOccurrenceFilename).toHaveBeenCalledWith( + expect.anything(), + "{{title}} ({{occurrenceWeek}})", + "2026-08-03", + "FREQ=WEEKLY" + ); + }); + + it("empty template preserves legacy filename behavior", async () => { + const parent = TaskFactory.createTask({ + title: "Daily task", + path: "Tasks/Daily task.md", + recurrence: "FREQ=DAILY", + scheduled: "2026-08-01", + }); + const { taskService, plugin } = createService({ [parent.path]: parent }); + plugin.settings.occurrenceFilenameTemplate = ""; + + await taskService.materializeOccurrence(parent, "2026-08-01"); + + expect(filenameGenerator.generateOccurrenceFilename).not.toHaveBeenCalled(); + expect(filenameGenerator.generateTaskFilename).toHaveBeenCalled(); + }); + }); }); From bf3a12d6fe976156cbe253c8263b384e4ddab4e3 Mon Sep 17 00:00:00 2001 From: renatomen Date: Wed, 29 Jul 2026 11:59:01 +1200 Subject: [PATCH 06/18] fix(bases): keep Task List groups collapsed on first render A Task List view configured with defaultCollapsedState "Collapsed" seeds its collapsed-group sets during render. The debounced data-update render in BasesViewBase captures ephemeral state before that render and restores it in a finally block, so the pre-render (empty) snapshot overwrote the seed. Nothing re-seeded afterwards, because the group keys were already marked initialized. The result: the DOM still showed every group collapsed while the view's state said none were, so the first chevron click collapsed the clicked group and expanded all the others. Restore collapse state from ephemeral state only before the view has built its first grouping snapshot; afterwards the live sets are authoritative. --- src/bases/TaskListView.ts | 25 ++++++++---- .../ui/TaskListView.groupCollapse.test.ts | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 5919804fc..54483270c 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2153,14 +2153,11 @@ export class TaskListView extends BasesViewBase { }; } - /** - * Restore ephemeral state after view reload. - * Restores scroll position, collapsed groups, and collapsed sub-groups. - */ - setEphemeralState(state: unknown): void { - if (!isTaskListEphemeralState(state)) return; - super.setEphemeralState(state); + private hasInitializedCollapseState(): boolean { + return this.initializedPrimaryGroupKeys.size > 0 || this.initializedSubGroupKeys.size > 0; + } + private restoreCollapsedStateFromEphemeral(state: TaskListEphemeralState): void { let restoredCollapsedState = false; // Restore collapsed groups immediately @@ -2181,6 +2178,20 @@ export class TaskListView extends BasesViewBase { restoredCollapsedState = restoredCollapsedState || filtered.length > 0; } this.deferCollapseDefaultForNextSnapshot = restoredCollapsedState; + } + + /** + * Restore ephemeral state after view reload. Collapse state is applied only before the + * view builds its first grouping snapshot, so a stale snapshot captured before a render + * cannot undo the collapse default that render seeded. + */ + setEphemeralState(state: unknown): void { + if (!isTaskListEphemeralState(state)) return; + super.setEphemeralState(state); + + if (!this.hasInitializedCollapseState()) { + this.restoreCollapsedStateFromEphemeral(state); + } // Restore scroll position after render completes if (typeof state.scrollTop === "number" && this.rootElement) { diff --git a/tests/unit/ui/TaskListView.groupCollapse.test.ts b/tests/unit/ui/TaskListView.groupCollapse.test.ts index 731e2056d..f6fa9de63 100644 --- a/tests/unit/ui/TaskListView.groupCollapse.test.ts +++ b/tests/unit/ui/TaskListView.groupCollapse.test.ts @@ -102,6 +102,45 @@ describe("TaskListView group collapse controls", () => { expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true); }); + it("keeps the collapsed default when a pre-render ephemeral snapshot is restored after seeding", () => { + const view = createView(); + (view as any).defaultCollapsedState = "Collapsed"; + + const savedState = view.getEphemeralState(); + (view as any).initializeCollapseStateForSnapshot( + ["Open", "Done"], + new Map([ + ["Open", ["Open:Urgent"]], + ["Done", ["Done:Later"]], + ]) + ); + view.setEphemeralState(savedState); + + expect((view as any).collapsedGroups.has("Open")).toBe(true); + expect((view as any).collapsedGroups.has("Done")).toBe(true); + expect((view as any).collapsedSubGroups.has("Open:Urgent")).toBe(true); + }); + + it("expands only the toggled group after a pre-render ephemeral snapshot is restored", async () => { + const view = createView(); + (view as any).defaultCollapsedState = "Collapsed"; + + const savedState = view.getEphemeralState(); + (view as any).initializeCollapseStateForSnapshot( + ["Open", "Done"], + new Map([ + ["Open", ["Open:Urgent"]], + ["Done", ["Done:Later"]], + ]) + ); + view.setEphemeralState(savedState); + + await (view as any).handleGroupToggle("Open"); + + expect((view as any).collapsedGroups.has("Open")).toBe(false); + expect((view as any).collapsedGroups.has("Done")).toBe(true); + }); + it("reads defaultCollapsedState correctly from config.get", () => { const view = createView(); (view as any).config = { From 84513f6aef2613897f4b8ca6578ab34f2e0ca59b Mon Sep 17 00:00:00 2001 From: AbbieFawlZ <136364092+abbiefalls90@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:26:06 -0400 Subject: [PATCH 07/18] Fix recurring all-day ICS event end dates (#1958) * Fix recurring all-day ICS event ends * Clean up ICS recurrence PR notes --------- Co-authored-by: callumalpass --- docs/releases/unreleased.md | 6 ++ src/services/ICSSubscriptionService.ts | 80 +++++++++++++--- ...sue-ics-recurring-all-day-end-date.test.ts | 94 +++++++++++++++++++ 3 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 tests/unit/issues/issue-ics-recurring-all-day-end-date.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index f4584701d..468b865d4 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -44,3 +44,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l guides for adopting TaskNotes in an existing vault, mobile use, custom Bases, and backup and recovery. See the [TaskNotes documentation](https://tasknotes.dev/). + +## Fixed + +- Fixed recurring all-day ICS subscription events keeping the original end date + on later instances, which could cause calendar list views to show events under + the wrong day. Thanks to @abbiefalls90. diff --git a/src/services/ICSSubscriptionService.ts b/src/services/ICSSubscriptionService.ts index bf6c19f1a..16d7717c9 100644 --- a/src/services/ICSSubscriptionService.ts +++ b/src/services/ICSSubscriptionService.ts @@ -15,6 +15,8 @@ const tasknotesLogger = createTaskNotesLogger({ tag: "Services/ICSSubscriptionSe const ICS_RECURRENCE_EXPANSION_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; const MAX_RECURRING_ICS_VISIBLE_INSTANCES = 3000; const MAX_RECURRING_ICS_ITERATIONS = 10000; +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const MS_PER_DAY = 24 * 60 * 60 * 1000; type VaultAdapterWithBasePath = { getBasePath?: () => string; @@ -75,6 +77,43 @@ function registerCalendarVTimezones(calendar: ICAL.Component): void { }); } +function dateOnlyToUtcMs(dateString: string): number | null { + if (!DATE_ONLY_PATTERN.test(dateString)) { + return null; + } + + const [year, month, day] = dateString.split("-").map(Number); + return Date.UTC(year, month - 1, day); +} + +function formatUtcDateOnly(date: Date): string { + const year = date.getUTCFullYear().toString().padStart(4, "0"); + const month = (date.getUTCMonth() + 1).toString().padStart(2, "0"); + const day = date.getUTCDate().toString().padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getDateOnlyDurationDays(startDate: string, endDate: string): number | null { + const startMs = dateOnlyToUtcMs(startDate); + const endMs = dateOnlyToUtcMs(endDate); + + if (startMs === null || endMs === null || endMs <= startMs) { + return null; + } + + return Math.round((endMs - startMs) / MS_PER_DAY); +} + +function addDaysToDateOnly(dateString: string, days: number): string { + const dateMs = dateOnlyToUtcMs(dateString); + + if (dateMs === null) { + return dateString; + } + + return formatUtcDateOnly(new Date(dateMs + days * MS_PER_DAY)); +} + export class ICSSubscriptionService extends EventEmitter { private plugin: TaskNotesPlugin; private subscriptions: ICSSubscription[] = []; @@ -154,6 +193,25 @@ export class ICSSubscriptionService extends EventEmitter { return typeof tzid === "string" ? tzid : null; } + private getRecurringInstanceEnd( + instanceStart: string, + startISO: string, + endISO: string | undefined, + isAllDay: boolean + ): string | undefined { + if (!endISO) { + return undefined; + } + + if (isAllDay) { + const durationDays = getDateOnlyDurationDays(startISO, endISO); + return durationDays === null ? endISO : addDaysToDateOnly(instanceStart, durationDays); + } + + const durationMs = new Date(endISO).getTime() - new Date(startISO).getTime(); + return new Date(new Date(instanceStart).getTime() + durationMs).toISOString(); + } + constructor(plugin: TaskNotesPlugin) { super(); this.plugin = plugin; @@ -629,19 +687,15 @@ export class ICSSubscriptionService extends EventEmitter { occurrence, startTzidRaw ); - let instanceEnd = endISO; - - if (endISO && startISO && !isAllDay) { - // Derive duration from the already-resolved ISO - // strings so the fallback path stays consistent - // across the start and end of an instance. - const durationMs = - new Date(endISO).getTime() - - new Date(startISO).getTime(); - instanceEnd = new Date( - new Date(instanceStart).getTime() + durationMs - ).toISOString(); - } + // Derive duration from the already-resolved ISO + // strings so the fallback path stays consistent + // across the start and end of an instance. + const instanceEnd = this.getRecurringInstanceEnd( + instanceStart, + startISO, + endISO, + isAllDay + ); events.push({ ...icsEvent, diff --git a/tests/unit/issues/issue-ics-recurring-all-day-end-date.test.ts b/tests/unit/issues/issue-ics-recurring-all-day-end-date.test.ts new file mode 100644 index 000000000..780a614a1 --- /dev/null +++ b/tests/unit/issues/issue-ics-recurring-all-day-end-date.test.ts @@ -0,0 +1,94 @@ +import { ICSSubscriptionService } from "../../../src/services/ICSSubscriptionService"; +import { ICSEvent } from "../../../src/types"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + requestUrl: jest.fn(), + TFile: jest.fn(), +})); + +jest.mock("ical.js", () => jest.requireActual("../../../node_modules/ical.js/dist/ical.es5.cjs")); + +type TestableICSSubscriptionService = { + parseICS(icsData: string, subscriptionId: string): ICSEvent[]; +}; + +function makeService(): TestableICSSubscriptionService { + return new ICSSubscriptionService({ + loadData: jest.fn().mockResolvedValue({ icsSubscriptions: [] }), + saveData: jest.fn().mockResolvedValue(undefined), + i18n: { + translate: jest.fn((key: string) => key), + }, + app: { + vault: { + getAbstractFileByPath: jest.fn(), + cachedRead: jest.fn(), + getFiles: jest.fn().mockReturnValue([]), + on: jest.fn(), + offref: jest.fn(), + }, + }, + } as unknown as ConstructorParameters< + typeof ICSSubscriptionService + >[0]) as unknown as TestableICSSubscriptionService; +} + +describe("ICS recurring all-day event end dates", () => { + beforeEach(() => { + jest.spyOn(Date, "now").mockReturnValue(new Date("2026-05-27T12:00:00.000Z").getTime()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("preserves one-day all-day duration for each recurrence instance", () => { + const icsData = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Test//TaskNotes Regression//EN", + "BEGIN:VEVENT", + "DTSTART;VALUE=DATE:20260528", + "DTEND;VALUE=DATE:20260529", + "RRULE:FREQ=DAILY;COUNT=4", + "UID:recurring-all-day-single@test", + "SUMMARY:One-day hold", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + const events = makeService().parseICS(icsData, "test-subscription"); + + expect(events.map((event) => ({ start: event.start, end: event.end }))).toEqual([ + { start: "2026-05-28", end: "2026-05-29" }, + { start: "2026-05-29", end: "2026-05-30" }, + { start: "2026-05-30", end: "2026-05-31" }, + { start: "2026-05-31", end: "2026-06-01" }, + ]); + expect(events.every((event) => event.allDay)).toBe(true); + }); + + it("preserves multi-day all-day duration for each recurrence instance", () => { + const icsData = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Test//TaskNotes Regression//EN", + "BEGIN:VEVENT", + "DTSTART;VALUE=DATE:20260528", + "DTEND;VALUE=DATE:20260530", + "RRULE:FREQ=WEEKLY;COUNT=2", + "UID:recurring-all-day-multiday@test", + "SUMMARY:Two-day hold", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + const events = makeService().parseICS(icsData, "test-subscription"); + + expect(events.map((event) => ({ start: event.start, end: event.end }))).toEqual([ + { start: "2026-05-28", end: "2026-05-30" }, + { start: "2026-06-04", end: "2026-06-06" }, + ]); + }); +}); From a166b3c728dc0eba44a463f0c2f379b1e7f621fd Mon Sep 17 00:00:00 2001 From: Scott Tomaszewski Date: Fri, 31 Jul 2026 19:09:12 -0400 Subject: [PATCH 08/18] Fix Kanban hide-empty columns with swimlanes (#2019) * fix(kanban): add helper to hide empty columns across swimlanes * fix(kanban): hide empty columns in swimlane mode * test(kanban): guard hide-empty-columns swimlane behavior * Add release note for Kanban swimlane fix --------- Co-authored-by: callumalpass --- docs/releases/unreleased.md | 3 + src/bases/KanbanView.ts | 12 ++- src/bases/kanbanGrouping.ts | 16 ++++ tests/unit/bases/kanbanGrouping.test.ts | 75 +++++++++++++++++++ ...anban-hide-empty-columns-swimlanes.test.ts | 54 +++++++++++++ 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-kanban-hide-empty-columns-swimlanes.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 468b865d4..6d600d4df 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -47,6 +47,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2018) Fixed **Hide empty columns** in Kanban views with swimlanes so columns + with no visible tasks are hidden while pinned columns remain. Thanks to + @scottTomaszewski for reporting and fixing this. - Fixed recurring all-day ICS subscription events keeping the original end date on later instances, which could cause calendar list views to show events under the wrong day. Thanks to @abbiefalls90. diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 20e05d39c..82fc7fcf2 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -66,6 +66,7 @@ import { findKanbanStatusConfigForGroupKey, formatKanbanColumnCount, getKanbanColumnTaskCounts, + getVisibleKanbanSwimLaneColumnKeys, getKanbanListPropertyValue, getKanbanStatusGroupKeyAliases, getKanbanSwimLaneKeys, @@ -1492,6 +1493,15 @@ export class KanbanView extends BasesViewBase { // Apply column ordering const columnKeys = Array.from(groups.keys()); const orderedKeys = this.applyColumnOrder(groupByPropertyId, columnKeys); + // Hide columns that are empty across every swimlane (matches flat mode's + // shouldRenderKanbanColumn behavior). Uses the filtered `swimLanes` map so + // counts reflect the active filter. + const visibleColumnKeys = getVisibleKanbanSwimLaneColumnKeys( + orderedKeys, + swimLanes, + this.hideEmptyColumns, + this.pinnedColumns + ); const orderedSwimLanes = this.applySwimLaneOrderToMap( this.swimLanePropertyId, swimLanes, @@ -1501,7 +1511,7 @@ export class KanbanView extends BasesViewBase { // Render swimlane table await this.renderSwimLaneTable( orderedSwimLanes, - orderedKeys, + visibleColumnKeys, pathToProps, groupByPropertyId ); diff --git a/src/bases/kanbanGrouping.ts b/src/bases/kanbanGrouping.ts index 59c0ffd4f..5f63fc91b 100644 --- a/src/bases/kanbanGrouping.ts +++ b/src/bases/kanbanGrouping.ts @@ -681,6 +681,22 @@ export function getKanbanColumnTaskCounts( return counts; } +export function getVisibleKanbanSwimLaneColumnKeys( + columnKeys: readonly string[], + swimLanes: ReadonlyMap>, + hideEmptyColumns: boolean, + pinnedColumns: readonly string[] +): string[] { + if (!hideEmptyColumns) { + return [...columnKeys]; + } + + const counts = getKanbanColumnTaskCounts(swimLanes, columnKeys); + return columnKeys.filter( + (columnKey) => (counts.get(columnKey) ?? 0) > 0 || pinnedColumns.includes(columnKey) + ); +} + export function compareKanbanSpecialColumnKeys(a: string, b: string): number { if (a === "None" && b !== "None") return 1; if (b === "None" && a !== "None") return -1; diff --git a/tests/unit/bases/kanbanGrouping.test.ts b/tests/unit/bases/kanbanGrouping.test.ts index ff1a34ed3..f1bab7a3c 100644 --- a/tests/unit/bases/kanbanGrouping.test.ts +++ b/tests/unit/bases/kanbanGrouping.test.ts @@ -11,6 +11,7 @@ import { findKanbanStatusConfigForGroupKey, formatKanbanColumnCount, getKanbanColumnTaskCounts, + getVisibleKanbanSwimLaneColumnKeys, getConfiguredKanbanOrder, getKanbanListPropertyValue, getKanbanStatusGroupKeyAliases, @@ -374,6 +375,80 @@ describe("Kanban grouping helpers", () => { ); }); + it("hides columns that are empty across all swimlanes when hideEmptyColumns is enabled", () => { + const swimlanes = new Map>([ + [ + "Research", + new Map([ + ["todo", [task("a.md")]], + ["done", []], + ["blocked", []], + ]), + ], + [ + "Review", + new Map([ + ["todo", [task("b.md")]], + ["done", []], + ["blocked", []], + ]), + ], + ]); + + expect( + getVisibleKanbanSwimLaneColumnKeys( + ["todo", "done", "blocked"], + swimlanes, + true, + [] + ) + ).toEqual(["todo"]); + }); + + it("keeps empty pinned columns even when hideEmptyColumns is enabled", () => { + const swimlanes = new Map>([ + [ + "Research", + new Map([ + ["todo", [task("a.md")]], + ["done", []], + ["blocked", []], + ]), + ], + ]); + + expect( + getVisibleKanbanSwimLaneColumnKeys( + ["todo", "done", "blocked"], + swimlanes, + true, + ["done"] + ) + ).toEqual(["todo", "done"]); + }); + + it("keeps all columns when hideEmptyColumns is disabled", () => { + const swimlanes = new Map>([ + [ + "Research", + new Map([ + ["todo", [task("a.md")]], + ["done", []], + ["blocked", []], + ]), + ], + ]); + + expect( + getVisibleKanbanSwimLaneColumnKeys( + ["todo", "done", "blocked"], + swimlanes, + false, + [] + ) + ).toEqual(["todo", "done", "blocked"]); + }); + it("orders columns from saved order and appends default-ordered new columns", () => { const order = applyKanbanColumnOrder({ groupBy: "task.status", diff --git a/tests/unit/issues/issue-kanban-hide-empty-columns-swimlanes.test.ts b/tests/unit/issues/issue-kanban-hide-empty-columns-swimlanes.test.ts new file mode 100644 index 000000000..d545aa389 --- /dev/null +++ b/tests/unit/issues/issue-kanban-hide-empty-columns-swimlanes.test.ts @@ -0,0 +1,54 @@ +import { + buildKanbanSwimlaneColumns, + getVisibleKanbanSwimLaneColumnKeys, +} from "../../../src/bases/kanbanGrouping"; + +type TestTask = { + path: string; + swimlanes: string[]; +}; + +function task(path: string, swimlanes: string[]): TestTask { + return { path, swimlanes }; +} + +describe("Kanban: hide empty columns with swimlanes enabled", () => { + it("drops a column that is empty across all swimlanes after filtering", () => { + // Simulates a board grouped by status (columns) with swimlanes by priority, + // where the "done" column has been filtered down to zero tasks. + const todoA = task("todo-a.md", ["High"]); + const todoB = task("todo-b.md", ["Low"]); + const groups = new Map([ + ["todo", [todoA, todoB]], + ["done", []], + ]); + + const swimLanes = buildKanbanSwimlaneColumns([todoA, todoB], groups, (item) => item.swimlanes); + + const orderedKeys = ["todo", "done"]; + + expect(getVisibleKanbanSwimLaneColumnKeys(orderedKeys, swimLanes, true, [])).toEqual(["todo"]); + + // With the option off, both columns remain visible. + expect(getVisibleKanbanSwimLaneColumnKeys(orderedKeys, swimLanes, false, [])).toEqual([ + "todo", + "done", + ]); + }); + + it("keeps a column that has tasks in at least one swimlane", () => { + const todoA = task("todo-a.md", ["High"]); + const doneA = task("done-a.md", ["Low"]); + const groups = new Map([ + ["todo", [todoA]], + ["done", [doneA]], + ]); + + const swimLanes = buildKanbanSwimlaneColumns([todoA, doneA], groups, (item) => item.swimlanes); + + expect(getVisibleKanbanSwimLaneColumnKeys(["todo", "done"], swimLanes, true, [])).toEqual([ + "todo", + "done", + ]); + }); +}); From 4215f2f5706ae042262109f244e422013d5e0624 Mon Sep 17 00:00:00 2001 From: AbbieFawlZ <136364092+abbiefalls90@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:36:50 -0400 Subject: [PATCH 09/18] Fix project folder badge subtask toggle (#2033) Respect the expandable-subtasks setting, keep the project badge non-interactive when expansion is disabled, synchronize translated tooltips, and document the fix in unreleased notes. --- docs/releases/unreleased.md | 3 + i18n.manifest.json | 3 +- i18n.state.json | 64 +++++-- src/bases/TaskListView.ts | 16 -- src/i18n/resources/de.ts | 3 +- src/i18n/resources/en.ts | 3 +- src/i18n/resources/es.ts | 3 +- src/i18n/resources/fr.ts | 3 +- src/i18n/resources/ja.ts | 3 +- src/i18n/resources/ko.ts | 3 +- src/i18n/resources/pt.ts | 3 +- src/i18n/resources/ru.ts | 3 +- src/i18n/resources/zh.ts | 3 +- src/main.ts | 24 --- src/ui/taskCardActions.ts | 23 --- src/ui/taskCardSecondaryBadges.ts | 163 +++++++++++++----- tests/unit/ui/taskCardActions.test.ts | 12 -- tests/unit/ui/taskCardSecondaryBadges.test.ts | 162 +++++++++++++++++ 18 files changed, 352 insertions(+), 145 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 6d600d4df..1281e52a8 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -53,3 +53,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - Fixed recurring all-day ICS subscription events keeping the original end date on later instances, which could cause calendar list views to show events under the wrong day. Thanks to @abbiefalls90. +- (#2033) Fixed the project folder badge on task cards so, when expandable + subtasks are enabled, it expands or collapses inline subtasks instead of + showing an unavailable-action notice. Thanks to @abbiefalls90. diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbec..076b95d8a 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1995,7 +1995,8 @@ "ui.taskCard.reminderTooltipOne": "77575376a67811906b16a84a69f482d3e71c8fc3", "ui.taskCard.reminderTooltipMany": "b92ef7c3a154324b61a517693ae0e34f79237d9c", "ui.taskCard.detailsTooltip": "76535db67448f9c312e90031c889cdc58381ae73", - "ui.taskCard.projectTooltip": "0236231ede6436c6aa38eb74d5b4467c9680945a", + "ui.taskCard.projectTooltip": "cd359c35a908c00e4e72047651ece2063270ec4b", + "ui.taskCard.projectIndicatorTooltip": "2b40a2d7b3604874a55787a593ac354c91de54a8", "ui.taskCard.expandSubtasks": "071739c23c0cef8acf5b704018981d0eaf267f39", "ui.taskCard.collapseSubtasks": "83f3a3fde26f5b42a42b89bcbde737e2472781a9", "ui.taskCard.dueToday": "08297268e2e02748afb5a2434c8f1aaec58e1d6b", diff --git a/i18n.state.json b/i18n.state.json index 01d90a504..b3f6aace1 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7985,8 +7985,12 @@ "translation": "d1f345b54af40186bd657b142ffbd41770ffbb27" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "e42ce79ea403ebdb8a93bb5b03feeeb2c3b35ae6" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "05b54725a5fac92c44fb9f2c58af44a34a4b2de4" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "867c254282ca123145a2c86db1fc90ec92ebe53d" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -16851,8 +16855,12 @@ "translation": "5e38c0c2d684c7530d3f5312764253d8cf2c6659" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "c77ab77dc6e210d44bdd3d4f08df478cb4d83861" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "da4ae49b2f564e3708b9f9327b7ab3c373b35cd7" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "806ada5f2b7e2f450ce558fb7749059d2a2c893c" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -25717,8 +25725,12 @@ "translation": "af56aa2bad75074186837e4de8f4363ad2216a89" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "b4c12df8c8d83436cefc32e0cb56b5f638af40c0" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "e689716754262c098755a4ce8c19ca6636503e77" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "f9e6aa72cdbc58f23331ebdc63452c8e4a5bf738" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -34583,8 +34595,12 @@ "translation": "80e801ffbff761d536ccf02abc4f7f1784400d9c" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "b128b465d8930a5917d73784717d418fe9250dd0" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "fcbb9a6c7bfac9f4a068d5b85da05fc133ad3d49" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "2ee509f3fe1dca7720ac1683b7ee3817b941b5fb" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -43449,8 +43465,12 @@ "translation": "c527b769cadbaa5c2e3fe77f9cee106e9e53dc17" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "3647e2aafd41da512e803d1a4b52626ad3414a65" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "26fca1dfc772091a2edc919c0d193ebea649c64d" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "05893616fca0c1c858a808124aa7114d7214fceb" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -52315,8 +52335,12 @@ "translation": "3335ca0907d119f3c58d71e9ad7ea2aa48d613f7" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "a3e5ec764e5e1100a3245c9619e94ab97199de6b" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "f251fd424a9070903530d6f354c64034c126bd94" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "5a0c3575d7c24252117f18e5d7eec51e4ec4f4ef" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -61181,8 +61205,12 @@ "translation": "9d6d487adefac8e39a9c6c7984750cc244be3182" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "05ddbdec4eea528c618ccbdff1a879f164bc211f" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "05e348afabe4481c16a59c81493441630e4c08fa" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "7416ab7c30413864f82817f3768b95d96060e27c" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", @@ -70047,8 +70075,12 @@ "translation": "4c8fe35a23fe0e461f32d6471a1588ade7b4b692" }, "ui.taskCard.projectTooltip": { - "source": "0236231ede6436c6aa38eb74d5b4467c9680945a", - "translation": "7e61085063036737cb33893a0a3530ff572e2f22" + "source": "cd359c35a908c00e4e72047651ece2063270ec4b", + "translation": "94d45d4d32df97bce05d43695b356bd58f6db19b" + }, + "ui.taskCard.projectIndicatorTooltip": { + "source": "2b40a2d7b3604874a55787a593ac354c91de54a8", + "translation": "ac5b519e46635d48f9d13abdf07722141dd1d22c" }, "ui.taskCard.expandSubtasks": { "source": "071739c23c0cef8acf5b704018981d0eaf267f39", diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 5919804fc..fb8049274 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2515,9 +2515,6 @@ export class TaskListView extends BasesViewBase { event ); return; - case "filter-project-subtasks": - await this.filterProjectSubtasks(task); - return; case "toggle-subtasks": await this.toggleSubtasks(task, target); return; @@ -2753,19 +2750,6 @@ export class TaskListView extends BasesViewBase { } } - private async filterProjectSubtasks(task: TaskInfo): Promise { - try { - await this.plugin.applyProjectSubtaskFilter(task); - } catch (error) { - tasknotesLogger.error("[TaskNotes][TaskListView] Failed to filter project subtasks", { - category: "persistence", - operation: "filter-project-subtasks", - error: error, - }); - new Notice("Failed to filter project subtasks"); - } - } - private async toggleSubtasks(task: TaskInfo, target: HTMLElement): Promise { try { if (!this.plugin.expandedProjectsService) { diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 46a27f84c..9e85c572e 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -3100,7 +3100,8 @@ export const de: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "1 Erinnerung gesetzt (zum Verwalten klicken)", reminderTooltipMany: "{count} Erinnerungen gesetzt (zum Verwalten klicken)", - projectTooltip: "Diese Aufgabe wird als Projekt verwendet (zum Filtern von Unteraufgaben klicken)", + projectTooltip: "Diese Aufgabe wird als Projekt verwendet (zum Ein-/Ausblenden von Unteraufgaben klicken)", + projectIndicatorTooltip: "Diese Aufgabe wird als Projekt verwendet", expandSubtasks: "Unteraufgaben ausklappen", collapseSubtasks: "Unteraufgaben einklappen", dueToday: "{label}: Heute", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..7d6a602be 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3277,7 +3277,8 @@ export const en: TranslationTree = { reminderTooltipOne: "1 reminder set (click to manage)", reminderTooltipMany: "{count} reminders set (click to manage)", detailsTooltip: "Task has details", - projectTooltip: "This task is used as a project (click to filter subtasks)", + projectTooltip: "This task is used as a project (click to show/hide subtasks)", + projectIndicatorTooltip: "This task is used as a project", expandSubtasks: "Expand subtasks", collapseSubtasks: "Collapse subtasks", dueToday: "{label}: Today", diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 07d2caeaf..9fa8f5c41 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -3100,7 +3100,8 @@ export const es: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "1 recordatorio configurado (clic para gestionar)", reminderTooltipMany: "{count} recordatorios configurados (clic para gestionar)", - projectTooltip: "Esta tarea se usa como proyecto (clic para filtrar subtareas)", + projectTooltip: "Esta tarea se usa como proyecto (clic para mostrar/ocultar subtareas)", + projectIndicatorTooltip: "Esta tarea se utiliza como proyecto", expandSubtasks: "Expandir subtareas", collapseSubtasks: "Contraer subtareas", dueToday: "{label}: Hoy", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 9d16e2657..e89b9383c 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -3100,7 +3100,8 @@ export const fr: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "1 rappel défini (cliquer pour gérer)", reminderTooltipMany: "{count} rappels définis (cliquer pour gérer)", - projectTooltip: "Cette tâche est utilisée comme projet (cliquer pour filtrer les sous-tâches)", + projectTooltip: "Cette tâche est utilisée comme projet (cliquer pour afficher/masquer les sous-tâches)", + projectIndicatorTooltip: "Cette tâche est utilisée comme projet", expandSubtasks: "Déplier les sous-tâches", collapseSubtasks: "Replier les sous-tâches", dueToday: "{label}: Aujourd'hui", diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 1e3f4df6e..98bce89ff 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -3100,7 +3100,8 @@ export const ja: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "リマインダーが1件設定されています (クリックして管理)", reminderTooltipMany: "{count}件のリマインダーが設定されています (クリックして管理)", - projectTooltip: "このタスクはプロジェクトとして使用されています(サブタスクをフィルタするにはクリック)", + projectTooltip: "このタスクはプロジェクトとして使用されています(クリックでサブタスクを表示/非表示)", + projectIndicatorTooltip: "このタスクはプロジェクトとして使用されています", expandSubtasks: "サブタスクを展開", collapseSubtasks: "サブタスクを折りたたむ", dueToday: "{label}: 今日", diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index ee54da2c2..ad48aeac0 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -3084,7 +3084,8 @@ export const ko: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "알림 1개 설정됨 (관리하려면 클릭)", reminderTooltipMany: "{count}개 알림 설정됨 (관리하려면 클릭)", - projectTooltip: "이 작업은 프로젝트로 사용됩니다 (하위 작업을 필터링하려면 클릭)", + projectTooltip: "이 작업은 프로젝트로 사용됩니다 (클릭하여 하위 작업 표시/숨기기)", + projectIndicatorTooltip: "이 작업은 프로젝트로 사용됩니다", expandSubtasks: "하위 작업 펼치기", collapseSubtasks: "하위 작업 접기", dueToday: "{label}: 오늘", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 7d377b4c7..96cad132b 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -3105,7 +3105,8 @@ export const pt: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "1 lembrete definido (clique para gerenciar)", reminderTooltipMany: "{count} lembretes definidos (clique para gerenciar)", - projectTooltip: "Esta tarefa é usada como projeto (clique para filtrar subtarefas)", + projectTooltip: "Esta tarefa é usada como projeto (clique para mostrar/ocultar subtarefas)", + projectIndicatorTooltip: "Esta tarefa é usada como projeto", expandSubtasks: "Expandir subtarefas", collapseSubtasks: "Recolher subtarefas", dueToday: "{label}: Hoje", diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index d0f75346a..b4379cf36 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -3100,7 +3100,8 @@ export const ru: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "1 напоминание установлено (нажмите, чтобы управлять)", reminderTooltipMany: "{count} напоминаний установлено (нажмите, чтобы управлять)", - projectTooltip: "Эта задача используется как проект (нажмите, чтобы отфильтровать подзадачи)", + projectTooltip: "Эта задача используется как проект (нажмите, чтобы показать/скрыть подзадачи)", + projectIndicatorTooltip: "Эта задача используется как проект", expandSubtasks: "Развернуть подзадачи", collapseSubtasks: "Свернуть подзадачи", dueToday: "{label}: Сегодня", diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 0665e8e0c..3b6e39d83 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -3099,7 +3099,8 @@ export const zh: TranslationTree = { recurrenceTooltip: "{label}: {value}", reminderTooltipOne: "已设置 1 个提醒(点击管理)", reminderTooltipMany: "已设置 {count} 个提醒(点击管理)", - projectTooltip: "此任务用作项目(点击可筛选子任务)", + projectTooltip: "此任务用作项目(点击可显示/隐藏子任务)", + projectIndicatorTooltip: "此任务用作项目", expandSubtasks: "展开子任务", collapseSubtasks: "折叠子任务", dueToday: "{label}: 今天", diff --git a/src/main.ts b/src/main.ts index d10796aa9..169c220ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1267,30 +1267,6 @@ export default class TaskNotesPlugin extends Plugin { await this.taskActionCoordinator.rolloverOverdueScheduledTasks(); } - /** - * Apply a filter to show subtasks of a project - */ - async applyProjectSubtaskFilter(projectTask: TaskInfo): Promise { - try { - const file = this.app.vault.getAbstractFileByPath(projectTask.path); - if (!file) { - new Notice("Project file not found"); - return; - } - - // Note: This feature was part of the old view system (deprecated in v4) - // TODO: Re-implement for Bases views if needed - new Notice("Project subtask filtering not available"); - } catch (error) { - tasknotesLogger.error("Error applying project subtask filter:", { - category: "persistence", - operation: "applying-project-subtask-filter", - error: error, - }); - new Notice("Failed to apply project filter"); - } - } - /** * Starts a time tracking session for a task */ diff --git a/src/ui/taskCardActions.ts b/src/ui/taskCardActions.ts index 35c0d0e50..e0c893e65 100644 --- a/src/ui/taskCardActions.ts +++ b/src/ui/taskCardActions.ts @@ -19,7 +19,6 @@ export interface StatusCycleHandlerOptions { targetDate: Date; updateStatusVisuals: TaskCardStatusVisualUpdater; } - function getTaskCardActionLogger(plugin: TaskNotesPlugin) { return createTaskNotesLogger({ tag: "TaskCard/Actions", @@ -183,25 +182,3 @@ export function createReminderClickHandler(task: TaskInfo, plugin: TaskNotesPlug modal.open(); }; } - -/** - * Creates a click handler for project indicators. - */ -export function createProjectClickHandler(task: TaskInfo, plugin: TaskNotesPlugin): () => void { - return () => { - void (async () => { - const logger = getTaskCardActionLogger(plugin); - try { - await plugin.applyProjectSubtaskFilter(task); - } catch (error) { - logger.error("Error filtering project subtasks", { - category: "internal", - operation: "filter-project-subtasks", - details: { taskPath: task.path }, - error, - }); - new Notice("Failed to filter project subtasks"); - } - })(); - }; -} diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index b84035263..dedb877f1 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -3,7 +3,6 @@ import type TaskNotesPlugin from "../main"; import type { TaskInfo } from "../types"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { - createProjectClickHandler, createRecurrenceClickHandler, createReminderClickHandler, } from "./taskCardActions"; @@ -79,11 +78,23 @@ function tTaskCard( return plugin.i18n.translate(`ui.taskCard.${key}`, vars); } +function canToggleProjectSubtasks(plugin: TaskNotesPlugin): boolean { + return plugin.settings?.showExpandableSubtasks === true; +} + function shouldExpandSubtasksByDefault(plugin: TaskNotesPlugin): boolean { - return plugin.settings?.expandSubtasksByDefault === true; + // Only auto-expand by default when expandable subtasks are enabled. + return ( + plugin.settings?.expandSubtasksByDefault === true && + canToggleProjectSubtasks(plugin) + ); } export function isTaskCardSubtasksExpanded(task: TaskInfo, plugin: TaskNotesPlugin): boolean { + if (!canToggleProjectSubtasks(plugin)) { + return false; + } + return ( plugin.expandedProjectsService?.isExpanded( task.path, @@ -153,6 +164,51 @@ function createChevronClickHandler( }; } +/** + * Toggles the inline subtask list for a project task card. Used by the folder + * (project) badge so it works whether or not the expand chevron is rendered, + * keeping the chevron visual in sync when it is present. Shares expansion state + * with the chevron via expandedProjectsService, so the two never diverge. + */ +function createProjectSubtasksToggleHandler( + task: TaskInfo, + plugin: TaskNotesPlugin, + card: HTMLElement, + handlers: TaskCardSecondaryBadgeHandlers +): () => void { + return () => { + void (async () => { + const logger = getTaskCardBadgeLogger(plugin); + try { + if (!plugin.expandedProjectsService) { + new Notice("Service not available. Please try reloading the plugin."); + return; + } + + const newExpanded = plugin.expandedProjectsService.toggle( + task.path, + shouldExpandSubtasksByDefault(plugin) + ); + + const chevron = card.querySelector(".task-card__chevron"); + if (chevron) { + updateChevronElement(chevron, plugin, newExpanded); + } + + await handlers.toggleSubtasks(card, task, newExpanded); + } catch (error) { + logger.error("Error toggling project subtasks", { + category: "internal", + operation: "toggle-project-subtasks", + details: { taskPath: task.path }, + error, + }); + new Notice("Failed to toggle subtasks"); + } + })(); + }; +} + function createBlockingToggleClickHandler( task: TaskInfo, card: HTMLElement, @@ -217,27 +273,36 @@ function renderProjectBadges( return; } + const isExpanded = isTaskCardSubtasksExpanded(task, plugin); + + const canToggleSubtasks = canToggleProjectSubtasks(plugin); + + // The folder badge always identifies project tasks. It only acts as an + // expansion control when expandable subtasks are enabled. createBadgeIndicator({ container: badgesContainer, className: "task-card__project-indicator", icon: "folder", - tooltip: tTaskCard(plugin, "projectTooltip"), - onClick: createProjectClickHandler(task, plugin), + tooltip: tTaskCard( + plugin, + canToggleSubtasks ? "projectTooltip" : "projectIndicatorTooltip" + ), + onClick: canToggleSubtasks + ? createProjectSubtasksToggleHandler(task, plugin, card, handlers) + : undefined, }); - if (!plugin.settings?.showExpandableSubtasks) { - return; + if (canToggleSubtasks) { + createBadgeIndicator({ + container: badgesContainer, + className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, + icon: "chevron-right", + tooltip: getChevronTooltip(plugin, isExpanded), + onClick: createChevronClickHandler(task, plugin, card, handlers), + }); } - const isExpanded = isTaskCardSubtasksExpanded(task, plugin); - createBadgeIndicator({ - container: badgesContainer, - className: `task-card__chevron${isExpanded ? " task-card__chevron--expanded" : ""}`, - icon: "chevron-right", - tooltip: getChevronTooltip(plugin, isExpanded), - onClick: createChevronClickHandler(task, plugin, card, handlers), - }); - + // Render the subtask list immediately when expanded. if (isExpanded) { handlers.toggleSubtasks(card, task, true).catch((error: unknown) => { getTaskCardBadgeLogger(plugin).error("Error showing initial subtasks", { @@ -340,20 +405,42 @@ function updateProjectBadges(options: UpdateTaskCardSecondaryBadgesOptions): voi .then((isProject: boolean) => { card.querySelector(".task-card__project-indicator-placeholder")?.remove(); card.querySelector(".task-card__chevron-placeholder")?.remove(); + const canToggleSubtasks = canToggleProjectSubtasks(plugin); + const existingProjectBadge = card.querySelector( + ".task-card__project-indicator" + ); + const projectBadgeIsInteractive = + existingProjectBadge?.getAttribute("role") === "button"; + + // updateBadgeIndicator cannot remove existing interaction listeners, so + // recreate the badge if this setting changed while the card was mounted. + if ( + isProject && + existingProjectBadge && + projectBadgeIsInteractive !== canToggleSubtasks + ) { + existingProjectBadge.remove(); + } updateBadgeIndicator(card, ".task-card__project-indicator", { shouldExist: isProject, className: "task-card__project-indicator", icon: "folder", - tooltip: tTaskCard(plugin, "projectTooltip"), - onClick: createProjectClickHandler(task, plugin), + tooltip: tTaskCard( + plugin, + canToggleSubtasks ? "projectTooltip" : "projectIndicatorTooltip" + ), + onClick: canToggleSubtasks + ? createProjectSubtasksToggleHandler(task, plugin, card, handlers) + : undefined, }); - const showChevron = isProject && plugin.settings?.showExpandableSubtasks; + const showChevron = isProject && canToggleSubtasks; const existingChevron = card.querySelector(".task-card__chevron"); + const isExpanded = isProject && isTaskCardSubtasksExpanded(task, plugin); + // Keep the chevron in sync with the setting (create / update / remove). if (showChevron && !existingChevron) { - const isExpanded = isTaskCardSubtasksExpanded(task, plugin); createBadgeIndicator({ container: card.querySelector(".task-card__badges") ?? mainRow ?? card, @@ -362,35 +449,23 @@ function updateProjectBadges(options: UpdateTaskCardSecondaryBadgesOptions): voi tooltip: getChevronTooltip(plugin, isExpanded), onClick: createChevronClickHandler(task, plugin, card, handlers), }); - - if (isExpanded) { - handlers.toggleSubtasks(card, task, true).catch((error: unknown) => { - logger.error("Error showing initial subtasks in update", { - category: "internal", - operation: "show-initial-subtasks-update", - details: { taskPath: task.path }, - error, - }); - }); - } } else if (showChevron && existingChevron) { - const isExpanded = isTaskCardSubtasksExpanded(task, plugin); updateChevronElement(existingChevron, plugin, isExpanded); - - if (isExpanded) { - handlers.toggleSubtasks(card, task, true).catch((error: unknown) => { - logger.error("Error refreshing default-expanded subtasks", { - category: "internal", - operation: "refresh-default-expanded-subtasks", - details: { taskPath: task.path }, - error, - }); - }); - } else { - removeRelationshipContainer(card, ".task-card__subtasks"); - } } else if (!showChevron && existingChevron) { existingChevron.remove(); + } + + // Sync the inline subtask list with the saved expansion state. + if (isExpanded) { + handlers.toggleSubtasks(card, task, true).catch((error: unknown) => { + logger.error("Error showing subtasks in update", { + category: "internal", + operation: "show-subtasks-update", + details: { taskPath: task.path }, + error, + }); + }); + } else { removeRelationshipContainer(card, ".task-card__subtasks"); } }) diff --git a/tests/unit/ui/taskCardActions.test.ts b/tests/unit/ui/taskCardActions.test.ts index 6675138ae..b4eb7b994 100644 --- a/tests/unit/ui/taskCardActions.test.ts +++ b/tests/unit/ui/taskCardActions.test.ts @@ -1,7 +1,6 @@ import { Notice } from "obsidian"; import { createPriorityClickHandler, - createProjectClickHandler, createRecurrenceClickHandler, createReminderClickHandler, createStatusCycleHandler, @@ -67,7 +66,6 @@ function createPlugin(overrides: Partial = {}): TaskNotesPlugin ...updatedTask, status: "done", })), - applyProjectSubtaskFilter: jest.fn(async () => undefined), ...overrides, } as unknown as TaskNotesPlugin; } @@ -193,14 +191,4 @@ describe("taskCardActions", () => { expect(modalInstance.open).toHaveBeenCalled(); expect(plugin.updateTaskProperty).toHaveBeenCalledWith(task, "reminders", reminders); }); - - it("wires project indicators to the project-subtask filter", async () => { - const plugin = createPlugin(); - const handler = createProjectClickHandler(task, plugin); - - handler(); - await flushAsyncHandlers(); - - expect(plugin.applyProjectSubtaskFilter).toHaveBeenCalledWith(task); - }); }); diff --git a/tests/unit/ui/taskCardSecondaryBadges.test.ts b/tests/unit/ui/taskCardSecondaryBadges.test.ts index 66c9c7e31..c3298de6f 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.test.ts @@ -43,6 +43,7 @@ function createPlugin(overrides: Partial = {}): TaskNotesPlugin "ui.taskCard.reminderTooltipMany": `${vars?.count ?? 0} reminders`, "ui.taskCard.detailsTooltip": "Has details", "ui.taskCard.projectTooltip": "Project", + "ui.taskCard.projectIndicatorTooltip": "Used as a project", "ui.taskCard.expandSubtasks": "Expand subtasks", "ui.taskCard.collapseSubtasks": "Collapse subtasks", "ui.taskCard.blockingToggle": `Blocking ${vars?.count ?? 0} tasks`, @@ -129,6 +130,167 @@ describe("taskCardSecondaryBadges", () => { expect(handlers.toggleSubtasks).toHaveBeenCalledWith(card, task, true); }); + it("toggles the inline subtask list when the project folder badge is clicked", async () => { + const plugin = createPlugin(); + (plugin.projectSubtasksService.isTaskUsedAsProjectSync as jest.Mock).mockReturnValue(true); + (plugin.expandedProjectsService?.isExpanded as jest.Mock).mockReturnValue(false); + (plugin.expandedProjectsService?.toggle as jest.Mock).mockReturnValue(true); + const handlers = createHandlers(); + const { card, badgesContainer } = createCard(); + const task = createTask(); + + renderTaskCardSecondaryBadges({ + card, + badgesContainer, + task, + plugin, + hasDetails: false, + propertyOptions: {}, + handlers, + }); + + const folder = card.querySelector(".task-card__project-indicator"); + expect(folder).not.toBeNull(); + + folder?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(plugin.expandedProjectsService?.toggle).toHaveBeenCalledWith(task.path, false); + expect(handlers.toggleSubtasks).toHaveBeenCalledWith(card, task, true); + }); + + it("keeps the project badge inert when expandable subtasks are disabled", async () => { + const plugin = createPlugin({ + settings: { + showExpandableSubtasks: false, + expandSubtasksByDefault: false, + enableDebugLogging: false, + }, + } as unknown as Partial); + (plugin.projectSubtasksService.isTaskUsedAsProjectSync as jest.Mock).mockReturnValue(true); + (plugin.expandedProjectsService?.isExpanded as jest.Mock).mockReturnValue(true); + const handlers = createHandlers(); + const { card, badgesContainer } = createCard(); + const task = createTask(); + + renderTaskCardSecondaryBadges({ + card, + badgesContainer, + task, + plugin, + hasDetails: false, + propertyOptions: {}, + handlers, + }); + + const folder = card.querySelector(".task-card__project-indicator"); + expect(folder).not.toBeNull(); + expect(folder?.getAttribute("role")).toBeNull(); + expect(folder?.getAttribute("aria-label")).toBe("Used as a project"); + expect(card.querySelector(".task-card__chevron")).toBeNull(); + expect(plugin.expandedProjectsService?.isExpanded).not.toHaveBeenCalled(); + expect(handlers.toggleSubtasks).not.toHaveBeenCalled(); + + folder?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(plugin.expandedProjectsService?.toggle).not.toHaveBeenCalled(); + expect(handlers.toggleSubtasks).not.toHaveBeenCalled(); + }); + + it("does not auto-expand subtasks when expandable subtasks are disabled", () => { + const plugin = createPlugin({ + settings: { + // The user disabled expandable subtasks but left expand-by-default on. + showExpandableSubtasks: false, + expandSubtasksByDefault: true, + enableDebugLogging: false, + }, + } as unknown as Partial); + (plugin.projectSubtasksService.isTaskUsedAsProjectSync as jest.Mock).mockReturnValue(true); + // Reflect the real service: an untoggled project falls back to the passed default. + (plugin.expandedProjectsService?.isExpanded as jest.Mock).mockImplementation( + (_path: string, def: boolean) => def + ); + const handlers = createHandlers(); + const { card, badgesContainer } = createCard(); + const task = createTask(); + + renderTaskCardSecondaryBadges({ + card, + badgesContainer, + task, + plugin, + hasDetails: false, + propertyOptions: {}, + handlers, + }); + + // The disabled feature must not consult saved expansion state or render subtasks. + expect(plugin.expandedProjectsService?.isExpanded).not.toHaveBeenCalled(); + expect(handlers.toggleSubtasks).not.toHaveBeenCalled(); + }); + + it("update path removes project-badge interaction when expandable subtasks are disabled", async () => { + const plugin = createPlugin({ + settings: { + showExpandableSubtasks: true, + expandSubtasksByDefault: false, + enableDebugLogging: false, + }, + } as unknown as Partial); + (plugin.projectSubtasksService.isTaskUsedAsProjectSync as jest.Mock).mockReturnValue(true); + (plugin.projectSubtasksService.isTaskUsedAsProject as jest.Mock).mockResolvedValue(true); + (plugin.expandedProjectsService?.isExpanded as jest.Mock).mockReturnValue(false); + const handlers = createHandlers(); + const { card, mainRow, badgesContainer } = createCard(); + const task = createTask(); + + // Initial render: both project controls are interactive. + renderTaskCardSecondaryBadges({ + card, + badgesContainer, + task, + plugin, + hasDetails: false, + propertyOptions: {}, + handlers, + }); + expect(handlers.toggleSubtasks).not.toHaveBeenCalled(); + + const originalFolder = card.querySelector(".task-card__project-indicator"); + expect(originalFolder?.getAttribute("role")).toBe("button"); + expect(card.querySelector(".task-card__chevron")).not.toBeNull(); + + // Disable expandable subtasks while the card is mounted. The update must + // recreate the folder as an inert indicator and remove the chevron. + plugin.settings.showExpandableSubtasks = false; + (plugin.expandedProjectsService?.isExpanded as jest.Mock).mockReturnValue(true); + updateTaskCardSecondaryBadges({ + card, + mainRow, + task, + plugin, + hasDetails: false, + propertyOptions: {}, + handlers, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const updatedFolder = card.querySelector(".task-card__project-indicator"); + expect(updatedFolder).not.toBe(originalFolder); + expect(updatedFolder?.getAttribute("role")).toBeNull(); + expect(updatedFolder?.getAttribute("aria-label")).toBe("Used as a project"); + expect(card.querySelector(".task-card__chevron")).toBeNull(); + expect(plugin.expandedProjectsService?.isExpanded).toHaveBeenCalledTimes(1); + expect(handlers.toggleSubtasks).not.toHaveBeenCalled(); + + updatedFolder?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + expect(plugin.expandedProjectsService?.toggle).not.toHaveBeenCalled(); + }); + it("keeps secondary badges omitted when the option is disabled", () => { const plugin = createPlugin(); const handlers = createHandlers(); From 39c42b4b45209462dd91c3301b0a9bd9bd028675 Mon Sep 17 00:00:00 2001 From: Callum Macdonald Date: Sat, 1 Aug 2026 01:57:07 +0200 Subject: [PATCH 10/18] Avoid Google Calendar retry queue when sync is disabled (#2041) Gate task-creation sync on the master Google Calendar export setting and prevent defensive sync calls from persisting retry work while sync is intentionally disabled. Add regression coverage for issue #2040 and document the fix in unreleased notes. --- docs/releases/unreleased.md | 3 + src/services/TaskCalendarSyncService.ts | 5 +- .../task-service/TaskCreationService.ts | 1 + ...2040-google-calendar-sync-disabled.test.ts | 106 ++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-2040-google-calendar-sync-disabled.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 1281e52a8..aa15215bf 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -56,3 +56,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2033) Fixed the project folder badge on task cards so, when expandable subtasks are enabled, it expands or collapses inline subtasks instead of showing an unavailable-action notice. Thanks to @abbiefalls90. +- (#2040, #2041) Stopped newly created tasks from filling the Google Calendar + retry queue when calendar export is disabled. Thanks to @chmac for reporting + and fixing this. diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index cc4076bb5..0f46a74ae 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -2430,7 +2430,10 @@ export class TaskCalendarSyncService { try { if (!this.isEnabled()) { - if (queueOnFailure) { + if (queueOnFailure && settings.enabled) { + // Only queue if sync is enabled but temporarily not ready + // (e.g. no calendar selected yet, or OAuth not connected). + // When sync is intentionally disabled, don't populate the queue. await this.queueTaskSync( task.path, new Error("Google Calendar sync is not ready") diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index a253737c5..ecc91d36e 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -360,6 +360,7 @@ export class TaskCreationService { if ( runtime.taskCalendarSyncService && + runtime.settings.googleCalendarExport.enabled && runtime.settings.googleCalendarExport.syncOnTaskCreate ) { runtime.taskCalendarSyncService.syncTaskToCalendar(taskInfo).catch((error) => { diff --git a/tests/unit/issues/issue-2040-google-calendar-sync-disabled.test.ts b/tests/unit/issues/issue-2040-google-calendar-sync-disabled.test.ts new file mode 100644 index 000000000..d5b2f3199 --- /dev/null +++ b/tests/unit/issues/issue-2040-google-calendar-sync-disabled.test.ts @@ -0,0 +1,106 @@ +/** + * Regression coverage for Issue #2040: the Google Calendar retry queue must + * stay empty when calendar export is disabled. + * + * @see https://github.com/callumalpass/tasknotes/issues/2040 + */ + +import { describe, expect, it, jest } from "@jest/globals"; +import { TaskCalendarSyncService } from "../../../src/services/TaskCalendarSyncService"; +import { TaskCreationService } from "../../../src/services/task-service/TaskCreationService"; +import { DEFAULT_GOOGLE_CALENDAR_EXPORT } from "../../../src/settings/defaults"; +import { PluginFactory, TaskFactory } from "../../helpers/mock-factories"; + +jest.mock("../../../src/utils/dateUtils", () => ({ + getCurrentTimestamp: jest.fn(() => "2026-08-01T00:00:00+10:00"), +})); + +jest.mock("../../../src/utils/filenameGenerator", () => ({ + generateTaskFilename: jest.fn(() => "calendar-disabled-task"), + generateUniqueFilename: jest.fn(() => "calendar-disabled-task"), +})); + +jest.mock("../../../src/utils/helpers", () => ({ + ensureFolderExists: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../../src/utils/templateProcessor", () => ({ + mergeTemplateFrontmatter: jest.fn((base, template) => ({ ...base, ...template })), +})); + +function createTaskCreationService(enabled: boolean) { + const plugin = PluginFactory.createMockPlugin(); + plugin.settings.googleCalendarExport = { + ...DEFAULT_GOOGLE_CALENDAR_EXPORT, + enabled, + syncOnTaskCreate: true, + }; + plugin.taskCalendarSyncService = { + syncTaskToCalendar: jest.fn().mockResolvedValue(true), + } as any; + + const service = new TaskCreationService({ + runtime: plugin, + applyTaskCreationDefaults: jest.fn(async (taskData) => taskData), + applyTemplate: jest.fn(async () => ({ frontmatter: {}, body: "" })), + processFolderTemplate: jest.fn((folderTemplate) => folderTemplate), + sanitizeTitleForFilename: jest.fn((input) => input), + sanitizeTitleForStorage: jest.fn((input) => input), + }); + + return { plugin, service }; +} + +describe("Issue #2040: Google Calendar sync disabled", () => { + it("does not request calendar sync when creating a task", async () => { + const { plugin, service } = createTaskCreationService(false); + + await service.createTask( + { title: "Calendar disabled task", scheduled: "2026-08-02" }, + { applyDefaults: false } + ); + + expect(plugin.taskCalendarSyncService?.syncTaskToCalendar).not.toHaveBeenCalled(); + }); + + it("still requests calendar sync on creation when export is enabled", async () => { + const { plugin, service } = createTaskCreationService(true); + + await service.createTask( + { title: "Calendar enabled task", scheduled: "2026-08-02" }, + { applyDefaults: false } + ); + + expect(plugin.taskCalendarSyncService?.syncTaskToCalendar).toHaveBeenCalledTimes(1); + }); + + it("does not persist retry work when sync is called defensively", async () => { + const pluginData: Record = {}; + const plugin = PluginFactory.createMockPlugin(); + plugin.settings.googleCalendarExport = { + ...DEFAULT_GOOGLE_CALENDAR_EXPORT, + enabled: false, + syncOnTaskCreate: true, + syncTrigger: "scheduled", + }; + plugin.loadData = jest.fn().mockResolvedValue(pluginData); + plugin.saveData = jest.fn().mockResolvedValue(undefined); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([]), + createEvent: jest.fn(), + }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: "Tasks/calendar-disabled-task.md", + scheduled: "2026-08-02", + }) + ); + + expect(synced).toBe(false); + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(plugin.saveData).not.toHaveBeenCalled(); + expect(pluginData).not.toHaveProperty("googleCalendarSyncQueue"); + }); +}); From c545c68101354718804eecff9138f3849a13f56f Mon Sep 17 00:00:00 2001 From: Rizky Zulkarnaen Date: Sat, 1 Aug 2026 07:10:42 +0700 Subject: [PATCH 11/18] Focus natural-language date input when enabled (#2051) Make keyboard-driven date and time quick actions focus the natural-language field when it is available, retain the native date-input fallback, add regression coverage for issue #2046, and document the fix in unreleased notes. --- docs/releases/unreleased.md | 3 + src/modals/DateTimePickerModal.ts | 9 +- .../issue-2046-nlp-input-autofocus.test.ts | 117 ++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-2046-nlp-input-autofocus.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa15215bf..3150a5a9a 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -59,3 +59,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2040, #2041) Stopped newly created tasks from filling the Google Calendar retry queue when calendar export is disabled. Thanks to @chmac for reporting and fixing this. +- (#2046, #2051) The date and time picker now focuses the natural-language input + when it is enabled, making keyboard-driven quick actions ready for immediate + typing. Thanks to @DevOps-Toast for reporting this and @ther12k for fixing it. diff --git a/src/modals/DateTimePickerModal.ts b/src/modals/DateTimePickerModal.ts index cea524b83..a14f90a55 100644 --- a/src/modals/DateTimePickerModal.ts +++ b/src/modals/DateTimePickerModal.ts @@ -122,7 +122,14 @@ export class DateTimePickerModal extends Modal { this.updateSelectButtonState(); window.setTimeout(() => { - this.dateInput?.focus(); + // Prefer the natural language input when it is enabled and rendered, + // so keyboard users land on the NLP box first (matches user expectation + // from Quick Actions). Fall back to the date input otherwise. + if (this.canUseNaturalLanguageInput() && this.naturalLanguageInput) { + this.naturalLanguageInput.focus(); + } else { + this.dateInput?.focus(); + } }, 100); } diff --git a/tests/unit/issues/issue-2046-nlp-input-autofocus.test.ts b/tests/unit/issues/issue-2046-nlp-input-autofocus.test.ts new file mode 100644 index 000000000..94f399bf7 --- /dev/null +++ b/tests/unit/issues/issue-2046-nlp-input-autofocus.test.ts @@ -0,0 +1,117 @@ +import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal"; +import type TaskNotesPlugin from "../../../src/main"; + +/** + * Regression test for issue #2046: + * "Quick Actions for Task Under Cursor 'Set due date' autofocuses incorrectly" + * + * Expected behavior: + * When the user opens the date/time picker from Quick Actions (or any + * keyboard-driven path) and natural language input is enabled, the + * natural language input should receive initial focus — NOT the date + * input. When NLP is disabled, fall back to the date input. + */ + +function makeNLPEnabledPlugin(): TaskNotesPlugin { + return { + settings: { enableNaturalLanguageInput: true }, + } as unknown as TaskNotesPlugin; +} + +describe("Issue #2046: DateTimePickerModal focuses natural language input first when enabled", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("focuses the natural language input when NLP is enabled and rendered", () => { + const onSelect = jest.fn(); + const plugin = makeNLPEnabledPlugin(); + + const modal = new DateTimePickerModal({} as any, { + currentDate: null, + onSelect, + plugin, + }); + + modal.open(); + + const nlpInput = modal.contentEl.querySelector( + "input.date-time-picker-modal__nlp-input" + ); + const dateInput = modal.contentEl.querySelector( + 'input[type="date"].date-time-picker-modal__date-input' + ); + expect(nlpInput).toBeTruthy(); + expect(dateInput).toBeTruthy(); + + const nlpFocus = jest.spyOn(nlpInput!, "focus"); + const dateFocus = jest.spyOn(dateInput!, "focus"); + + jest.advanceTimersByTime(150); + + expect(nlpFocus).toHaveBeenCalled(); + expect(dateFocus).not.toHaveBeenCalled(); + }); + + it("falls back to the date input when NLP is disabled", () => { + const onSelect = jest.fn(); + const plugin = { + settings: { enableNaturalLanguageInput: false }, + } as unknown as TaskNotesPlugin; + + const modal = new DateTimePickerModal({} as any, { + currentDate: null, + onSelect, + plugin, + }); + + modal.open(); + + const nlpInput = modal.contentEl.querySelector( + "input.date-time-picker-modal__nlp-input" + ); + const dateInput = modal.contentEl.querySelector( + 'input[type="date"].date-time-picker-modal__date-input' + ); + expect(nlpInput).toBeFalsy(); + expect(dateInput).toBeTruthy(); + + const dateFocus = jest.spyOn(dateInput!, "focus"); + + jest.advanceTimersByTime(150); + + expect(dateFocus).toHaveBeenCalled(); + }); + + it("does not throw if NLP is enabled but the input ref is not assigned", () => { + // Defensive: if the render pipeline ever changes and the ref is not + // assigned, focus should still fall back to the date input rather than + // crashing the modal. + const onSelect = jest.fn(); + const plugin = makeNLPEnabledPlugin(); + + const modal = new DateTimePickerModal({} as any, { + currentDate: null, + onSelect, + plugin, + }); + + modal.open(); + + ( + modal as unknown as { naturalLanguageInput: HTMLInputElement | null } + ).naturalLanguageInput = null; + + const dateInput = modal.contentEl.querySelector( + 'input[type="date"].date-time-picker-modal__date-input' + ); + const dateFocus = jest.spyOn(dateInput!, "focus"); + + expect(() => jest.advanceTimersByTime(150)).not.toThrow(); + expect(dateFocus).toHaveBeenCalled(); + }); +}); From 1e7c8f8be8265e5dd10dd531f6dcac3360dfea22 Mon Sep 17 00:00:00 2001 From: Callum Macdonald Date: Sat, 1 Aug 2026 02:46:09 +0200 Subject: [PATCH 12/18] Fix dynamic recurring schedule advancement (#2066) Keep manually adjusted recurring tasks tied to their underlying occurrence, preserve due-date offsets and scheduled times, handle future and long-interval rules and moved Google Calendar exceptions, add regression coverage, and document the fix for #2064. --- docs/releases/unreleased.md | 7 + src/core/recurrence.ts | 22 +- .../task-service/taskRecurringPlanning.ts | 128 ++++++++- ...dynamic-scheduled-dates-completion.test.ts | 269 ++++++++++++++++++ 4 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 3150a5a9a..d776f1a79 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,6 +32,13 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> +## Fixed + +- (#2064, #2066) Recurring tasks now advance correctly after their next + scheduled date is manually adjusted, preserving due-date offsets and times + without jumping back to an earlier occurrence. Thanks to @chmac for reporting + and fixing this. + ## Changed - Generated TaskNotes type contracts now include configured natural-language diff --git a/src/core/recurrence.ts b/src/core/recurrence.ts index 2bce38c01..3437785ba 100644 --- a/src/core/recurrence.ts +++ b/src/core/recurrence.ts @@ -17,6 +17,10 @@ type RecurringTaskInput = Parameters[0 type RecurrenceUpdateTaskInput = Parameters[0]; type RecurrenceUpdateResult = ReturnType; +export interface RecurrenceOccurrenceOptions { + minOccurrenceDate?: string; +} + export function addDTSTARTToRecurrenceRule(task: AddDTSTARTTaskInput): string | null { return addDTSTARTToRecurrenceRuleModel(normalizeMinutePrecisionDateInputs(task)); } @@ -33,16 +37,22 @@ export function addDTSTARTToRecurrenceRuleWithDraggedTime( ); } -export function getNextUncompletedOccurrence(task: RecurringTaskInput): Date | null { - return getNextUncompletedOccurrenceModel(task, { today: getTodayString() }); +export function getNextUncompletedOccurrence( + task: RecurringTaskInput, + options?: RecurrenceOccurrenceOptions +): Date | null { + return getNextUncompletedOccurrenceModel(task, { + today: options?.minOccurrenceDate ?? getTodayString(), + }); } export function updateToNextScheduledOccurrence( task: RecurrenceUpdateTaskInput, - maintainDueOffset = true + maintainDueOffset = true, + options?: RecurrenceOccurrenceOptions ): RecurrenceUpdateResult { return updateToNextScheduledOccurrenceModel(task, maintainDueOffset, { - today: getTodayString(), + today: options?.minOccurrenceDate ?? getTodayString(), }); } @@ -66,9 +76,7 @@ function normalizeMinutePrecisionDateInputs typeof entry === "string") : []; + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function getRecurrenceSearchDays(recurrence: string): number { + const intervalMatch = recurrence.match(/(?:^|;)INTERVAL=(\d+)(?:;|$)/); + const interval = intervalMatch ? Number.parseInt(intervalMatch[1], 10) : 1; + + if (recurrence.includes("FREQ=DAILY")) return Math.max(30, interval * 2); + if (recurrence.includes("FREQ=WEEKLY")) return Math.max(90, interval * 7 * 2); + if (recurrence.includes("FREQ=MONTHLY")) return Math.max(400, interval * 31 * 2); + if (recurrence.includes("FREQ=YEARLY")) return Math.max(800, interval * 366 * 2); + return 365; +} + +function findOwningRecurrenceDate(task: TaskInfo, targetDate: Date): Date | null { + if (!task.recurrence) return null; + + // If targetDate is itself a recurrence occurrence, return it as-is. + // This keeps toggle-off (un-complete) working correctly when an explicit + // recurrence date is passed. + const exactOccurrences = generateRecurringInstances(task, targetDate, targetDate); + const targetDateStr = formatDateForStorage(targetDate); + if (exactOccurrences.some((occ) => formatDateForStorage(occ) === targetDateStr)) { + return targetDate; + } + + // targetDate is a shifted scheduled date — find the recurrence occurrence it belongs to. + const processedInstances = new Set([ + ...(task.complete_instances || []), + ...(task.skipped_instances || []), + ]); + + // Prefer the most recent unprocessed occurrence at or before the target date. + // Size the search window from the RRULE frequency and interval so multi-year + // rules are covered without scanning the series' complete history. + const searchDays = getRecurrenceSearchDays(task.recurrence); + const lookBackStart = new Date(targetDate.getTime() - searchDays * 24 * 60 * 60 * 1000); + const pastOccurrences = generateRecurringInstances(task, lookBackStart, targetDate); + for (let i = pastOccurrences.length - 1; i >= 0; i--) { + if (!processedInstances.has(formatDateForStorage(pastOccurrences[i]))) { + return pastOccurrences[i]; + } + } + + // If every earlier occurrence is processed, let the recurrence model perform + // its frequency-aware forward search from the shifted date. + return getNextUncompletedOccurrence(task, { minOccurrenceDate: targetDateStr }); +} + +function getOwningRecurrenceDate(task: TaskInfo, targetDate: Date): Date | null { + const recurrenceAnchor = task.recurrence_anchor || "scheduled"; + if (recurrenceAnchor === "completion" || task.googleCalendarExceptionOriginalScheduled) { + return null; + } + + return findOwningRecurrenceDate(task, targetDate); +} + +function moveScheduledDateToOccurrence( + scheduled: string | undefined, + occurrenceDate: Date +): string { + const occurrenceDateStr = formatDateForStorage(occurrenceDate); + if (!scheduled) { + return occurrenceDateStr; + } + + const scheduledDatePart = getDatePart(scheduled); + return `${occurrenceDateStr}${scheduled.slice(scheduledDatePart.length)}`; } export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date { @@ -99,7 +172,16 @@ export function buildRecurringTaskCompletePlan({ throw new Error("Task is not recurring"); } - const dateStr = formatDateForStorage(targetDate); + const recurrenceAnchor = freshTask.recurrence_anchor || "scheduled"; + // For scheduled-anchor tasks, find the recurrence date that owns the target date. + // The scheduled field may have been manually shifted forward from the actual recurrence + // date; storing the recurrence date in complete_instances ensures the model's exact-match + // check correctly marks this instance as done. + // Skip this for Google Calendar moved exceptions: the exception's scheduled date is + // distinct from the original series date and must be tracked as-is. + const owningRecurrenceDate = getOwningRecurrenceDate(freshTask, targetDate); + const instanceDate = owningRecurrenceDate ?? targetDate; + const dateStr = formatDateForStorage(instanceDate); const completeInstances = getStringArray(freshTask.complete_instances); const currentComplete = completeInstances.includes(dateStr); const newComplete = !currentComplete; @@ -123,8 +205,6 @@ export function buildRecurringTaskCompletePlan({ } if (newComplete && typeof updatedTask.recurrence === "string") { - const recurrenceAnchor = updatedTask.recurrence_anchor || "scheduled"; - if (recurrenceAnchor === "completion") { const updatedRecurrence = updateDTSTARTInRecurrenceRule( updatedTask.recurrence, @@ -141,9 +221,25 @@ export function buildRecurringTaskCompletePlan({ } } + // Use the recurrence date (not the shifted scheduled date) as the reference for + // due-date offset calculation and next-occurrence search. + // Pass max(today, instanceDate) as the floor so future-dated tasks advance past + // the current cycle rather than jumping back to today's nearest occurrence. + const taskForNextOccurrence = owningRecurrenceDate + ? { + ...updatedTask, + scheduled: moveScheduledDateToOccurrence( + updatedTask.scheduled, + owningRecurrenceDate + ), + } + : updatedTask; + const todayStr = getTodayString(); + const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( - updatedTask, - maintainDueDateOffsetInRecurring + taskForNextOccurrence, + maintainDueDateOffsetInRecurring, + { minOccurrenceDate: floorDate } ); if (nextDates.scheduled) { updatedTask.scheduled = nextDates.scheduled; @@ -235,7 +331,9 @@ export function buildRecurringTaskSkippedPlan({ throw new Error("Task is not recurring"); } - const dateStr = formatDateForStorage(targetDate); + const owningRecurrenceDate = getOwningRecurrenceDate(freshTask, targetDate); + const instanceDate = owningRecurrenceDate ?? targetDate; + const dateStr = formatDateForStorage(instanceDate); const skippedInstances = getStringArray(freshTask.skipped_instances); const currentlySkipped = skippedInstances.includes(dateStr); const newSkipped = !currentlySkipped; @@ -255,9 +353,21 @@ export function buildRecurringTaskSkippedPlan({ updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr); } + const taskForNextOccurrence = owningRecurrenceDate + ? { + ...updatedTask, + scheduled: moveScheduledDateToOccurrence( + updatedTask.scheduled, + owningRecurrenceDate + ), + } + : updatedTask; + const todayStr = getTodayString(); + const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( - updatedTask, - maintainDueDateOffsetInRecurring + taskForNextOccurrence, + maintainDueDateOffsetInRecurring, + { minOccurrenceDate: floorDate } ); if (nextDates.scheduled) { updatedTask.scheduled = nextDates.scheduled; diff --git a/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts b/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts new file mode 100644 index 000000000..931ed7c97 --- /dev/null +++ b/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts @@ -0,0 +1,269 @@ +/** + * Tests for the "Dynamic Scheduled Dates" bug fix. + * + * When a recurring task's scheduled date is manually shifted forward from the + * actual recurrence date, completing the task previously stored the shifted + * date in complete_instances and calculated the due-date offset from the shifted + * date. This caused: + * 1. The task to jump backward (or to today) instead of advancing to the next cycle. + * 2. The due-date offset to drift each cycle. + * 3. Future-dated tasks to jump to the first occurrence after today, not after + * their own scheduled date. + */ + +import { + buildRecurringTaskCompletePlan, + buildRecurringTaskSkippedPlan, +} from "../../../src/services/task-service/taskRecurringPlanning"; +import type { TaskInfo } from "../../../src/types"; +import { getTodayString } from "../../../src/utils/dateUtils"; + +jest.mock("../../../src/utils/dateUtils", () => ({ + ...jest.requireActual("../../../src/utils/dateUtils"), + getTodayString: jest.fn(), +})); + +const mockGetTodayString = getTodayString as jest.MockedFunction; + +function task(overrides: Partial = {}): TaskInfo { + return { + title: "Recurring task", + status: "open", + priority: "normal", + path: "TaskNotes/test.md", + archived: false, + complete_instances: [], + skipped_instances: [], + ...overrides, + } as TaskInfo; +} + +describe("dynamic scheduled dates — shifted scheduled date on completion", () => { + beforeEach(() => { + mockGetTodayString.mockReturnValue("2026-07-01"); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("stores the recurrence date in complete_instances when scheduled is shifted forward", () => { + // Recurrence: quarterly on the 1st, DTSTART 2026-04-01. + // User shifted scheduled 2 days to 2026-07-03. Completing should mark 2026-07-01. + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-03", + due: "2026-08-07", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-03T00:00:00.000Z"), + currentTimestamp: "2026-07-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + // Instance key should be the recurrence date, not the shifted scheduled date. + expect(plan.dateStr).toBe("2026-07-01"); + expect(plan.updatedTask.complete_instances).toContain("2026-07-01"); + expect(plan.updatedTask.complete_instances).not.toContain("2026-07-03"); + }); + + it("advances to the next quarterly cycle, not backward, when scheduled is shifted", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-03", + due: "2026-08-07", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-03T00:00:00.000Z"), + currentTimestamp: "2026-07-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.scheduled).toBe("2026-10-01"); + }); + + it("calculates due-date offset from the recurrence date, not the shifted scheduled date", () => { + // due (2026-08-07) is 37 days after the recurrence date (2026-07-01), not 35. + // Next due should be 2026-10-01 + 37 days = 2026-11-07. + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-03", + due: "2026-08-07", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-03T00:00:00.000Z"), + currentTimestamp: "2026-07-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.due).toBe("2026-11-07"); + }); + + it("works the same when scheduled date matches the recurrence date (regression guard)", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-01", + due: "2026-08-07", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-01T00:00:00.000Z"), + currentTimestamp: "2026-07-01T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.dateStr).toBe("2026-07-01"); + expect(plan.updatedTask.scheduled).toBe("2026-10-01"); + // 37-day offset: 2026-10-01 + 37 = 2026-11-07 + expect(plan.updatedTask.due).toBe("2026-11-07"); + }); +}); + +describe("dynamic scheduled dates — future-dated task completes to correct cycle", () => { + beforeEach(() => { + // Today is mid-2026, but the task is scheduled in 2027. + mockGetTodayString.mockReturnValue("2026-06-22"); + }); + + it("advances to the occurrence after the 2027 scheduled date, not to 2026", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2027-04-03", + due: "2027-05-07", + complete_instances: [], + }), + targetDate: new Date("2027-04-03T00:00:00.000Z"), + currentTimestamp: "2027-04-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + // Should advance to 2027-07-01, not jump back to 2026-07-01. + expect(plan.updatedTask.scheduled).toBe("2027-07-01"); + expect(plan.updatedTask.complete_instances).toContain("2027-04-01"); + }); +}); + +describe("dynamic scheduled dates — all past occurrences already completed", () => { + beforeEach(() => { + mockGetTodayString.mockReturnValue("2026-06-22"); + }); + + it("does not toggle off an already-completed recurrence when scheduled is shifted past it", () => { + // Scenario from bug report: + // The 2028-01-01 recurrence was already completed. + // The next cycle (2028-04-01) has not started yet. + // User shifted scheduled from 2028-04-01 to 2028-03-01 as an early reminder. + // Clicking "done" should mark 2028-04-01 as complete, NOT toggle 2028-01-01 off. + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2028-03-01", + due: "2028-05-07", + complete_instances: ["2027-04-01", "2027-07-01", "2027-10-01", "2028-01-01"], + }), + targetDate: new Date("2028-03-01T00:00:00.000Z"), + currentTimestamp: "2028-03-01T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.newComplete).toBe(true); + // All previously-completed instances must still be present. + expect(plan.updatedTask.complete_instances).toContain("2027-04-01"); + expect(plan.updatedTask.complete_instances).toContain("2027-07-01"); + expect(plan.updatedTask.complete_instances).toContain("2027-10-01"); + expect(plan.updatedTask.complete_instances).toContain("2028-01-01"); + // The next recurrence (2028-04-01) should be the newly completed instance. + expect(plan.dateStr).toBe("2028-04-01"); + expect(plan.updatedTask.complete_instances).toContain("2028-04-01"); + // And the task should advance past 2028-04-01. + expect(plan.updatedTask.scheduled).toBe("2028-07-01"); + }); +}); + +describe("dynamic scheduled dates — skipped instance uses recurrence date", () => { + beforeEach(() => { + mockGetTodayString.mockReturnValue("2026-07-01"); + }); + + it("stores the recurrence date in skipped_instances when scheduled is shifted", () => { + const plan = buildRecurringTaskSkippedPlan({ + freshTask: task({ + recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-03", + due: "2026-08-07", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-03T00:00:00.000Z"), + currentTimestamp: "2026-07-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.dateStr).toBe("2026-07-01"); + expect(plan.updatedTask.skipped_instances).toContain("2026-07-01"); + expect(plan.updatedTask.skipped_instances).not.toContain("2026-07-03"); + expect(plan.updatedTask.scheduled).toBe("2026-10-01"); + }); + + it("keeps a moved Google Calendar exception keyed to its moved date", () => { + mockGetTodayString.mockReturnValue("2026-04-15"); + + const plan = buildRecurringTaskSkippedPlan({ + freshTask: task({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + scheduled: "2026-04-15", + googleCalendarEventId: "master-event-id", + googleCalendarExceptionEventId: "detached-exception-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + }), + targetDate: new Date("2026-04-15T00:00:00.000Z"), + currentTimestamp: "2026-04-15T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.dateStr).toBe("2026-04-15"); + expect(plan.updatedTask.skipped_instances).toContain("2026-04-15"); + expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); +}); + +describe("dynamic scheduled dates — recurrence identity edge cases", () => { + it("preserves the scheduled time while advancing a shifted timed occurrence", () => { + mockGetTodayString.mockReturnValue("2026-07-01"); + + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20260401T100000Z;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", + scheduled: "2026-07-03T10:00:00", + complete_instances: ["2026-04-01"], + }), + targetDate: new Date("2026-07-03T00:00:00.000Z"), + currentTimestamp: "2026-07-03T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.scheduled).toBe("2026-10-01T10:00:00"); + }); + + it("finds the owning occurrence beyond the previous fixed search window", () => { + mockGetTodayString.mockReturnValue("2026-06-01"); + + const plan = buildRecurringTaskCompletePlan({ + freshTask: task({ + recurrence: "DTSTART:20250101;FREQ=MONTHLY;INTERVAL=24;BYMONTHDAY=1", + scheduled: "2026-06-01", + }), + targetDate: new Date("2026-06-01T00:00:00.000Z"), + currentTimestamp: "2026-06-01T00:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.dateStr).toBe("2025-01-01"); + expect(plan.updatedTask.complete_instances).toContain("2025-01-01"); + expect(plan.updatedTask.scheduled).toBe("2027-01-01"); + }); +}); From a02ce2b2106fe5acbdc16ddb3c6847c6dd059e57 Mon Sep 17 00:00:00 2001 From: Martin Ball <228563004+martin-forge@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:29:05 +0100 Subject: [PATCH 13/18] Fix reading mode note widget stability (#2103) Keep task-card and relationship widgets mounted through reading-mode DOM rebuilds, refresh reused leaves and visible metadata changes, distinguish transient cache misses from confirmed non-task notes, add regression coverage, and document the fix for #2081. --- docs/releases/unreleased.md | 4 + src/editor/ReadingModeWidgetObserver.ts | 105 +++++ src/editor/RelationshipsDecorations.ts | 103 ++++- src/editor/TaskCardNoteDecorations.ts | 96 ++++- ...2081-reading-mode-widget-stability.test.ts | 369 ++++++++++++++++++ 5 files changed, 661 insertions(+), 16 deletions(-) create mode 100644 src/editor/ReadingModeWidgetObserver.ts create mode 100644 tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index d776f1a79..329b67a34 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -38,6 +38,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l scheduled date is manually adjusted, preserving due-date offsets and times without jumping back to an earlier occurrence. Thanks to @chmac for reporting and fixing this. +- (#2081, #2103) Kept task card and relationships widgets visible in reading mode + when Obsidian scroll or preview updates remove their DOM, and refreshed visible + widgets promptly after metadata changes. Thanks to @mukhozhuk for reporting and + debugging this and @martin-forge for fixing it. ## Changed diff --git a/src/editor/ReadingModeWidgetObserver.ts b/src/editor/ReadingModeWidgetObserver.ts new file mode 100644 index 000000000..4579ae31c --- /dev/null +++ b/src/editor/ReadingModeWidgetObserver.ts @@ -0,0 +1,105 @@ +import { MarkdownView, WorkspaceLeaf } from "obsidian"; +import { shouldSkipMarkdownWidgetLeaf } from "./MarkdownWidgetContext"; + +type FrameHandle = { + id: number; + cancel: () => void; +}; + +function scheduleBeforePaint(win: Window, callback: () => void): FrameHandle { + if (typeof win.requestAnimationFrame === "function") { + const id = win.requestAnimationFrame(callback); + return { + id, + cancel: () => win.cancelAnimationFrame(id), + }; + } + + const id = win.setTimeout(callback, 0); + return { + id, + cancel: () => win.clearTimeout(id), + }; +} + +function nodeContainsSelector(node: Node, selector: string): boolean { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const element = node as Element; + return element.matches(selector) || Boolean(element.querySelector(selector)); +} + +function mutationTouchesPreviewWidget(mutation: MutationRecord, widgetSelector: string): boolean { + const changedNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)]; + return changedNodes.some( + (node) => + nodeContainsSelector(node, widgetSelector) || + nodeContainsSelector(node, ".markdown-preview-sizer") + ); +} + +function getReadingModeContainer(leaf: WorkspaceLeaf): HTMLElement | null { + const view = leaf.view; + if (!(view instanceof MarkdownView) || view.getMode() !== "preview") { + return null; + } + + if (shouldSkipMarkdownWidgetLeaf(leaf)) { + return null; + } + + return view.previewMode.containerEl; +} + +export function observeReadingModeWidgetMutations( + leaf: WorkspaceLeaf, + widgetSelector: string, + scheduleInjection: (leaf: WorkspaceLeaf) => void, + observedContainers: WeakSet, + cleanupCallbacks: Array<() => void>, + shouldRefresh: (leaf: WorkspaceLeaf) => boolean +): void { + const containerEl = getReadingModeContainer(leaf); + if (!containerEl || observedContainers.has(containerEl)) { + return; + } + + let pendingFrame: FrameHandle | null = null; + const requestRefresh = () => { + if (pendingFrame) { + return; + } + + const win = containerEl.ownerDocument.defaultView ?? window; + pendingFrame = scheduleBeforePaint(win, () => { + pendingFrame = null; + if (!containerEl.isConnected || !shouldRefresh(leaf)) { + return; + } + + const sizer = containerEl.querySelector(".markdown-preview-sizer"); + if (sizer && !sizer.querySelector(widgetSelector)) { + scheduleInjection(leaf); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + if (mutations.some((mutation) => mutationTouchesPreviewWidget(mutation, widgetSelector))) { + requestRefresh(); + } + }); + + observer.observe(containerEl, { + childList: true, + subtree: true, + }); + + observedContainers.add(containerEl); + cleanupCallbacks.push(() => { + pendingFrame?.cancel(); + observer.disconnect(); + }); +} diff --git a/src/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 7f53682db..c1081d21e 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -57,6 +57,7 @@ import { ReadingModeInjectionContext, ReadingModeInjectionScheduler, } from "./ReadingModeInjectionScheduler"; +import { observeReadingModeWidgetMutations } from "./ReadingModeWidgetObserver"; import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, @@ -211,10 +212,7 @@ export function applyRelationshipsBottomOffset(container: HTMLElement, widget: H const spacerGap = Math.max( 0, - Math.round( - contentContainer.getBoundingClientRect().bottom - - contentBottom - ) + Math.round(contentContainer.getBoundingClientRect().bottom - contentBottom) ); if (spacerGap > 0) { const defaultMarginTop = getRelationshipsWidgetDefaultMarginTop(widget); @@ -249,6 +247,7 @@ async function createRelationshipsWidget( container.setAttribute("contenteditable", "false"); container.setAttribute("spellcheck", "false"); container.setAttribute("data-widget-type", "relationships"); + container.setAttribute("data-note-path", notePath); // Create container for embedded Bases view const basesContainer = activeDocument.createElement("div"); @@ -709,15 +708,25 @@ async function injectReadingModeWidget( } if (!isTaskNote && !isProjectNote) { - // Remove any existing widgets if conditions no longer met + // Preserve same-file widgets while Obsidian is rebuilding metadata. try { const previewView = view.previewMode; const containerEl = previewView.containerEl; - containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`).forEach((el) => { - const holder = el as HTMLElementWithComponent; - holder.component?.unload(); - el.remove(); - }); + const existingWidgets = Array.from( + containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`) + ); + const hasWidgetForDifferentFile = existingWidgets.some( + (widget) => widget.dataset.notePath !== file.path + ); + const isConfirmedNonRelationshipsNote = metadata !== null; + + if (hasWidgetForDifferentFile || isConfirmedNonRelationshipsNote) { + existingWidgets.forEach((el) => { + const holder = el as HTMLElementWithComponent; + holder.component?.unload(); + el.remove(); + }); + } } catch (error) { tasknotesLogger.debug( "[TaskNotes] Error cleaning up relationships widget in reading mode:", @@ -797,10 +806,49 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const workspaceRefs: EventRef[] = []; const metadataCacheRefs: EventRef[] = []; const dependencyCacheRefs: EventRef[] = []; + const markdownWidgetObserverCleanups: Array<() => void> = []; + const observedMarkdownContainers = new WeakSet(); const scheduler = new ReadingModeInjectionScheduler(); const scheduleInjection = (leaf: WorkspaceLeaf) => { scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context)); }; + const shouldRefreshMarkdownLeaf = (leaf: WorkspaceLeaf) => { + const view = leaf.view; + return ( + plugin.settings.showRelationships && + view instanceof MarkdownView && + view.getMode() === "preview" && + Boolean(view.file) + ); + }; + const observeMarkdownLeaf = (leaf: WorkspaceLeaf) => { + observeReadingModeWidgetMutations( + leaf, + `.${CSS_RELATIONSHIPS_WIDGET}`, + scheduleInjection, + observedMarkdownContainers, + markdownWidgetObserverCleanups, + shouldRefreshMarkdownLeaf + ); + }; + const observeMarkdownLeaves = () => { + plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + observeMarkdownLeaf(leaf); + }); + }; + const leafMatchesFile = (leaf: WorkspaceLeaf, file: { path: string } | null) => { + const view = leaf.view; + return view instanceof MarkdownView && (!file || view.file?.path === file.path); + }; + const refreshLeavesForFile = (file: { path: string } | null) => { + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + if (leafMatchesFile(leaf, file)) { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + } + }); + }; // Debounce to prevent excessive re-renders let debounceTimer: number | null = null; @@ -811,6 +859,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); }, 100); }; @@ -822,10 +871,17 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const activeLeafChangeRef = plugin.app.workspace.on("active-leaf-change", (leaf) => { if (leaf) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); workspaceRefs.push(activeLeafChangeRef); + // Inject widget when a new file is opened in the same leaf + const fileOpenRef = plugin.app.workspace.on("file-open", (file) => { + refreshLeavesForFile(file instanceof TFile ? file : null); + }); + workspaceRefs.push(fileOpenRef); + // Inject widget when file is modified (metadata changes) - debounced per file const metadataDebounceTimers = new Map(); const metadataChangeRef = plugin.app.metadataCache.on("changed", (file) => { @@ -833,14 +889,33 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const existingTimer = metadataDebounceTimers.get(file.path); if (existingTimer) window.clearTimeout(existingTimer); + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const visibleReadingLeaves = leaves.filter((leaf) => { + const view = leaf.view; + return ( + view instanceof MarkdownView && + view.file?.path === file.path && + view.getMode() === "preview" + ); + }); + + if (visibleReadingLeaves.length > 0) { + metadataDebounceTimers.delete(file.path); + visibleReadingLeaves.forEach((leaf) => { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + }); + return; + } + // Debounce per file to avoid freezing during typing const timer = window.setTimeout(() => { metadataDebounceTimers.delete(file.path); - const leaves = plugin.app.workspace.getLeavesOfType("markdown"); leaves.forEach((leaf) => { const view = leaf.view; - if (view instanceof MarkdownView && view.file === file) { + if (view instanceof MarkdownView && view.file?.path === file.path) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); }, 500); @@ -861,10 +936,14 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); // Return cleanup function return () => { if (debounceTimer) window.clearTimeout(debounceTimer); + metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); + metadataDebounceTimers.clear(); + markdownWidgetObserverCleanups.forEach((cleanup) => cleanup()); // Clean up each type of event ref with the correct method workspaceRefs.forEach((ref) => plugin.app.workspace.offref(ref)); diff --git a/src/editor/TaskCardNoteDecorations.ts b/src/editor/TaskCardNoteDecorations.ts index 6d73762d4..204637f85 100644 --- a/src/editor/TaskCardNoteDecorations.ts +++ b/src/editor/TaskCardNoteDecorations.ts @@ -55,6 +55,7 @@ import { ReadingModeInjectionContext, ReadingModeInjectionScheduler, } from "./ReadingModeInjectionScheduler"; +import { observeReadingModeWidgetMutations } from "./ReadingModeWidgetObserver"; import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, @@ -585,11 +586,25 @@ async function injectReadingModeWidget( // Get task info for this file const task = plugin.cacheManager.getCachedTaskInfoSync(file.path); if (!task) { - // Not a task note - remove any existing widgets + // A same-file cache miss can happen while Obsidian is rebuilding metadata. + // Keep the existing card until metadata confirms this is no longer a task note. try { const previewView = view.previewMode; const containerEl = previewView.containerEl; - removeTaskCardWidgets(containerEl); + const existingWidgets = Array.from( + containerEl.querySelectorAll(`.${CSS_TASK_CARD_WIDGET}`) + ); + const hasWidgetForDifferentFile = existingWidgets.some( + (widget) => widget.dataset.taskPath !== file.path + ); + const metadata = plugin.app.metadataCache.getFileCache(file); + const isConfirmedNonTask = + metadata !== null && + (!metadata.frontmatter || !plugin.cacheManager.isTaskFile(metadata.frontmatter)); + + if (hasWidgetForDifferentFile || isConfirmedNonTask) { + removeTaskCardWidgets(containerEl); + } } catch (error) { tasknotesLogger.debug("[TaskNotes] Error cleaning up task card in reading mode:", { category: "persistence", @@ -700,10 +715,49 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const canvasObservers: MutationObserver[] = []; const canvasInteractionCleanups: Array<() => void> = []; const observedCanvasContainers = new WeakSet(); + const markdownWidgetObserverCleanups: Array<() => void> = []; + const observedMarkdownContainers = new WeakSet(); const scheduler = new ReadingModeInjectionScheduler(); const scheduleInjection = (leaf: WorkspaceLeaf) => { scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context)); }; + const shouldRefreshMarkdownLeaf = (leaf: WorkspaceLeaf) => { + const view = leaf.view; + return ( + plugin.settings.showTaskCardInNote && + view instanceof MarkdownView && + view.getMode() === "preview" && + Boolean(view.file) + ); + }; + const observeMarkdownLeaf = (leaf: WorkspaceLeaf) => { + observeReadingModeWidgetMutations( + leaf, + `.${CSS_TASK_CARD_WIDGET}`, + scheduleInjection, + observedMarkdownContainers, + markdownWidgetObserverCleanups, + shouldRefreshMarkdownLeaf + ); + }; + const observeMarkdownLeaves = () => { + plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + observeMarkdownLeaf(leaf); + }); + }; + const leafMatchesFile = (leaf: WorkspaceLeaf, file: { path: string } | null) => { + const view = leaf.view; + return view instanceof MarkdownView && (!file || view.file?.path === file.path); + }; + const refreshLeavesForFile = (file: { path: string } | null) => { + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + if (leafMatchesFile(leaf, file)) { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + } + }); + }; // Debounce to prevent excessive re-renders let debounceTimer: number | null = null; @@ -753,6 +807,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); observeCanvasLeaves(); debouncedCanvasRefresh({ force: true }); }, 100); @@ -766,12 +821,21 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const activeLeafChangeRef = plugin.app.workspace.on("active-leaf-change", (leaf) => { if (leaf) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); observeCanvasLeaves(); debouncedCanvasRefresh(); } }); workspaceRefs.push(activeLeafChangeRef); + // Inject widget when a new file is opened in the same leaf + const fileOpenRef = plugin.app.workspace.on("file-open", (file) => { + refreshLeavesForFile(file instanceof TFile ? file : null); + observeCanvasLeaves(); + debouncedCanvasRefresh({ force: true }); + }); + workspaceRefs.push(fileOpenRef); + // Inject widget when file is modified (metadata changes) - debounced per file const metadataDebounceTimers = new Map(); const metadataChangeRef = plugin.app.metadataCache.on("changed", (file) => { @@ -779,14 +843,34 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const existingTimer = metadataDebounceTimers.get(file.path); if (existingTimer) window.clearTimeout(existingTimer); + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const visibleReadingLeaves = leaves.filter((leaf) => { + const view = leaf.view; + return ( + view instanceof MarkdownView && + view.file?.path === file.path && + view.getMode() === "preview" + ); + }); + + if (visibleReadingLeaves.length > 0) { + metadataDebounceTimers.delete(file.path); + visibleReadingLeaves.forEach((leaf) => { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + }); + debouncedCanvasRefresh({ force: true }); + return; + } + // Debounce per file to avoid freezing during typing const timer = window.setTimeout(() => { metadataDebounceTimers.delete(file.path); - const leaves = plugin.app.workspace.getLeavesOfType("markdown"); leaves.forEach((leaf) => { const view = leaf.view; - if (view instanceof MarkdownView && view.file === file) { + if (view instanceof MarkdownView && view.file?.path === file.path) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); debouncedCanvasRefresh({ force: true }); @@ -807,6 +891,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); observeCanvasLeaves(); debouncedCanvasRefresh({ force: true }); @@ -814,6 +899,9 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { return () => { if (debounceTimer) window.clearTimeout(debounceTimer); if (canvasDebounceTimer) window.clearTimeout(canvasDebounceTimer); + metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); + metadataDebounceTimers.clear(); + markdownWidgetObserverCleanups.forEach((cleanup) => cleanup()); canvasObservers.forEach((observer) => observer.disconnect()); canvasInteractionCleanups.forEach((cleanup) => cleanup()); diff --git a/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts new file mode 100644 index 000000000..ee585114f --- /dev/null +++ b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts @@ -0,0 +1,369 @@ +jest.mock("../../../src/ui/TaskCard", () => ({ + createTaskCard: jest.fn((task) => { + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.taskPath = task.path; + return card; + }), +})); + +import { Component, MarkdownView, TFile } from "obsidian"; +import { createTaskCard } from "../../../src/ui/TaskCard"; +import { setupReadingModeHandlers as setupTaskCardReadingModeHandlers } from "../../../src/editor/TaskCardNoteDecorations"; +import { setupReadingModeHandlers as setupRelationshipsReadingModeHandlers } from "../../../src/editor/RelationshipsDecorations"; + +type EventCallback = (...args: unknown[]) => void; + +function createEventSource() { + const handlers = new Map(); + + return { + on: jest.fn((event: string, callback: EventCallback) => { + const callbacks = handlers.get(event) ?? []; + callbacks.push(callback); + handlers.set(event, callbacks); + return { event, callback }; + }), + offref: jest.fn(), + trigger: (event: string, ...args: unknown[]) => { + for (const callback of handlers.get(event) ?? []) { + callback(...args); + } + }, + }; +} + +function createPreviewDom(): HTMLElement { + const containerEl = document.createElement("div"); + containerEl.innerHTML = ` +
+
+
+
+
Task
+ +
+

Body

+
+
+ `; + document.body.appendChild(containerEl); + return containerEl; +} + +function createMarkdownLeaf(path = "Tasks/test.md") { + const file = new TFile(path); + const containerEl = createPreviewDom(); + const view = Object.assign(Object.create(MarkdownView.prototype), { + containerEl, + file, + previewMode: { + containerEl, + }, + getMode: jest.fn(() => "preview"), + }) as MarkdownView; + + return { + containerEl, + file, + leaf: { + parent: {}, + view, + } as any, + view, + }; +} + +function createPluginMock(leaf: any) { + const workspaceEvents = createEventSource(); + const metadataEvents = createEventSource(); + const emitterEvents = createEventSource(); + let metadata: { frontmatter?: Record } | null = { + frontmatter: { tags: ["task"] }, + }; + let task: any = { + title: "Task", + status: "open", + priority: "normal", + path: leaf.file.path, + }; + + return { + workspaceEvents, + metadataEvents, + emitterEvents, + setMetadata: (nextMetadata: { frontmatter?: Record } | null) => { + metadata = nextMetadata; + }, + setTask: (nextTask: any) => { + task = nextTask; + }, + plugin: { + settings: { + showTaskCardInNote: true, + showRelationships: true, + defaultVisibleProperties: ["status", "priority"], + relationshipsPosition: "top", + commandFileMapping: { + relationships: "TaskNotes/Views/Relationships.base", + }, + }, + app: { + workspace: { + getLeavesOfType: jest.fn((type: string) => + type === "markdown" ? [leaf.leaf] : [] + ), + on: workspaceEvents.on, + offref: workspaceEvents.offref, + }, + metadataCache: { + getFileCache: jest.fn(() => metadata), + on: metadataEvents.on, + offref: metadataEvents.offref, + }, + }, + cacheManager: { + getCachedTaskInfoSync: jest.fn(() => task), + isTaskFile: jest.fn((frontmatter: Record | undefined) => + Array.isArray(frontmatter?.tags) + ? frontmatter.tags.includes("task") + : frontmatter?.type === "task" + ), + }, + dependencyCache: { + isFileUsedAsProject: jest.fn(() => false), + on: jest.fn(() => ({})), + offref: jest.fn(), + }, + fieldMapper: { + getMapping: jest.fn(() => ({})), + toUserField: jest.fn((field: string) => field), + }, + emitter: { + on: emitterEvents.on, + offref: emitterEvents.offref, + trigger: emitterEvents.trigger, + }, + } as any, + }; +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +async function flushMutationObserver(): Promise { + await Promise.resolve(); + jest.advanceTimersByTime(20); + await flushMicrotasks(); +} + +describe("Issue #2081: reading mode note widgets stay mounted", () => { + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + const componentPrototype = Component.prototype as unknown as { + load: jest.Mock; + unload: jest.Mock; + }; + componentPrototype.load = jest.fn(); + componentPrototype.unload = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("refreshes a task card when file-open reuses the current leaf", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + const newFile = new TFile("Tasks/next.md"); + leaf.view.file = newFile; + pluginMock.setTask({ + title: "Next task", + status: "open", + priority: "normal", + path: newFile.path, + }); + + pluginMock.workspaceEvents.trigger("file-open", newFile); + + expect( + leaf.containerEl.querySelector( + `.tasknotes-task-card-note-widget[data-task-path="${newFile.path}"]` + ) + ).not.toBeNull(); + } finally { + cleanup(); + } + }); + + it("re-injects a task card removed by Obsidian reading-mode DOM updates", async () => { + jest.useFakeTimers(); + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + const widget = leaf.containerEl.querySelector(".tasknotes-task-card-note-widget"); + expect(widget).not.toBeNull(); + + widget?.remove(); + await flushMutationObserver(); + + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + expect(createTaskCard).toHaveBeenCalledTimes(2); + } finally { + cleanup(); + } + }); + + it("refreshes visible reading-mode task cards immediately on metadata changes", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + (createTaskCard as jest.Mock).mockClear(); + pluginMock.setTask({ + title: "Updated task", + status: "done", + priority: "normal", + path: leaf.file.path, + }); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect(createTaskCard).toHaveBeenCalledTimes(1); + expect(createTaskCard).toHaveBeenCalledWith( + expect.objectContaining({ title: "Updated task", status: "done" }), + pluginMock.plugin, + expect.any(Array) + ); + } finally { + cleanup(); + } + }); + + it("keeps an existing task card during a same-file transient cache miss", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata(null); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + } finally { + cleanup(); + } + }); + + it("removes a stale task card when metadata confirms the file is not a task", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata({ frontmatter: { tags: ["note"] } }); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect(leaf.containerEl.querySelector(".tasknotes-task-card-note-widget")).toBeNull(); + } finally { + cleanup(); + } + }); + + it("removes a stale task card when confirmed metadata has no frontmatter", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata({}); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + await flushMicrotasks(); + + expect(leaf.containerEl.querySelector(".tasknotes-task-card-note-widget")).toBeNull(); + } finally { + cleanup(); + } + }); + + it("removes stale relationships when confirmed metadata has no frontmatter", async () => { + jest.useFakeTimers(); + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin); + + try { + jest.runOnlyPendingTimers(); + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-relationships-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata({}); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + await flushMicrotasks(); + + expect(leaf.containerEl.querySelector(".tasknotes-relationships-widget")).toBeNull(); + } finally { + cleanup(); + } + }); + + it("re-injects a relationships widget removed by Obsidian reading-mode DOM updates", async () => { + jest.useFakeTimers(); + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin); + + try { + jest.runOnlyPendingTimers(); + await flushMicrotasks(); + const widget = leaf.containerEl.querySelector(".tasknotes-relationships-widget"); + expect(widget).not.toBeNull(); + + widget?.remove(); + await flushMutationObserver(); + + expect( + leaf.containerEl.querySelector(".tasknotes-relationships-widget") + ).not.toBeNull(); + } finally { + cleanup(); + } + }); +}); From b5a626314f8fbad01c7bdb7ab0f656507168ae13 Mon Sep 17 00:00:00 2001 From: Cary Pruitt Date: Fri, 31 Jul 2026 18:52:55 -0700 Subject: [PATCH 14/18] Add project folder template variables (#2105) Add {{projectFolder}} and {{projectFolders}} folder-template variables for placing tasks alongside linked project notes, document their use, add focused tests, and include the feature in unreleased notes with contributor credit. --- docs/features/template-variables.md | 2 + docs/releases/unreleased.md | 7 +++ docs/settings/task-defaults.md | 2 + src/utils/folderTemplateProcessor.ts | 33 +++++++++++++ .../utils/folderTemplateProcessor.test.ts | 49 +++++++++++++++++++ 5 files changed, 93 insertions(+) diff --git a/docs/features/template-variables.md b/docs/features/template-variables.md index d2e80ae27..2bd7f1224 100644 --- a/docs/features/template-variables.md +++ b/docs/features/template-variables.md @@ -35,6 +35,8 @@ Variables for task-specific data. | `{{projects}}` | All projects joined by `/` | No | Yes | `ProjectA/ProjectB` | | `{{projectFilePath}}` | Full path of the first project, without `.md` | No | Yes | `Work/Projects/ProjectA` | | `{{projectFilePaths}}` | Full paths of all projects, joined by `/` | No | Yes | `Work/Alpha/Personal/Beta` | +| `{{projectFolder}}` | Folder containing the first project note (its file path without the note's own name) | No | Yes | `Work/Projects` | +| `{{projectFolders}}` | Folders of all projects, joined by `/` | No | Yes | `Work/Personal` | | `{{dueDate}}` | Task due date | Yes | Yes | `2025-01-15` | | `{{scheduledDate}}` | Task scheduled date | Yes | Yes | `2025-01-10` | diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 329b67a34..80ba6e031 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,6 +32,13 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> +## Added + +- (#2105) Added `{{projectFolder}}` and `{{projectFolders}}` folder template + variables for storing tasks alongside linked project notes. See + [Template Variables](https://tasknotes.dev/features/template-variables/). + Thanks to @carypruitt for this contribution. + ## Fixed - (#2064, #2066) Recurring tasks now advance correctly after their next diff --git a/docs/settings/task-defaults.md b/docs/settings/task-defaults.md index 89ea0ebe9..767bc02d1 100644 --- a/docs/settings/task-defaults.md +++ b/docs/settings/task-defaults.md @@ -26,6 +26,8 @@ The **Default Tasks Folder** setting supports dynamic folder creation using temp - `{{projects}}` - All projects from the task's projects array, joined by `/` - `{{projectFilePath}}` - Full path of the first project, without `.md` - `{{projectFilePaths}}` - Full paths of all projects, without `.md`, joined by `/` +- `{{projectFolder}}` - Folder containing the first project note (the project file path without the note's own name); empty when the project note is at the top level. Use this to store tasks beside their project note, e.g. `{{projectFolder}}` stores a task for `[[Areas/EFC/EFC]]` in `Areas/EFC/` +- `{{projectFolders}}` - Folders of all projects, joined by `/` - `{{priority}}` - Task priority (e.g., "high", "medium", "low") - `{{status}}` - Task status (e.g., "todo", "in-progress", "done") - `{{title}}` - Task title (sanitized for folder names) diff --git a/src/utils/folderTemplateProcessor.ts b/src/utils/folderTemplateProcessor.ts index bacb9cf21..bb3089b8f 100644 --- a/src/utils/folderTemplateProcessor.ts +++ b/src/utils/folderTemplateProcessor.ts @@ -164,6 +164,22 @@ function getProjectFilePath( return sanitizeProjectFilePath(rawPath); } +/** + * Get the folder that contains a project note, i.e. the project file path with + * its final (basename) segment removed. Returns an empty string when the + * project note lives at the top level with no containing folder. + */ +function getProjectFolder( + project: string, + extractProjectFilePath?: (project: string) => string +): string { + const filePath = getProjectFilePath(project, extractProjectFilePath); + if (!filePath) { + return ""; + } + return filePath.split("/").slice(0, -1).join("/"); +} + /** * Process a folder path template by replacing template variables with actual values * @@ -188,6 +204,7 @@ function getProjectFilePath( * - {{context}}, {{contexts}} - First context or all contexts joined with / * - {{project}}, {{projects}} - First project or all projects joined with / * - {{projectFilePath}}, {{projectFilePaths}} - First project path or all project paths joined with / + * - {{projectFolder}}, {{projectFolders}} - Folder containing the first project note or all project folders joined with / * - {{priority}}, {{priorityShort}} * - {{status}}, {{statusShort}} * - {{title}}, {{titleLower}}, {{titleUpper}}, {{titleSnake}}, {{titleKebab}}, {{titleCamel}}, {{titlePascal}} @@ -288,6 +305,22 @@ export function processFolderTemplate( : ""; processedPath = processedPath.replace(/\{\{projectFilePaths\}\}/g, projectFilePaths); + // Handle project folder (folder containing the first project note) + const projectFolder = + Array.isArray(taskData.projects) && taskData.projects.length > 0 + ? getProjectFolder(taskData.projects[0], extractProjectFilePath) + : ""; + processedPath = processedPath.replace(/\{\{projectFolder\}\}/g, projectFolder); + + const projectFolders = + Array.isArray(taskData.projects) && taskData.projects.length > 0 + ? taskData.projects + .map((proj) => getProjectFolder(proj, extractProjectFilePath)) + .filter((folder) => folder.length > 0) + .join("/") + : ""; + processedPath = processedPath.replace(/\{\{projectFolders\}\}/g, projectFolders); + // Handle multiple contexts const contexts = Array.isArray(taskData.contexts) && taskData.contexts.length > 0 diff --git a/tests/unit/utils/folderTemplateProcessor.test.ts b/tests/unit/utils/folderTemplateProcessor.test.ts index acc756964..8ac2e0f5d 100644 --- a/tests/unit/utils/folderTemplateProcessor.test.ts +++ b/tests/unit/utils/folderTemplateProcessor.test.ts @@ -143,6 +143,55 @@ describe('processFolderTemplate', () => { expect(result).toBe('MyProject/OtherProject'); }); + describe('{{projectFolder}} and {{projectFolders}}', () => { + // Resolve a wikilink to its full file path, mirroring how the plugin + // wires extractProjectFilePath in production. + const extractFilePath = (project: string) => { + const match = project.match(/^\[\[([^\]]+)\]\]$/); + return match ? match[1] : project; + }; + + it('should return the folder containing a nested project note', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: ['[[Areas/EFC/EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('Areas/EFC'); + }); + + it('should return an empty string for a top-level project note', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: ['[[EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe(''); + }); + + it('should place a task beside its project note', () => { + const result = processFolderTemplate('{{projectFolder}}/{{title}}', { + taskData: { title: 'Practice plan', projects: ['[[EFC/EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('EFC/Practice plan'); + }); + + it('should join folders and drop empties for {{projectFolders}}', () => { + const result = processFolderTemplate('{{projectFolders}}', { + taskData: { projects: ['[[EFC/EFC]]', '[[TopLevel]]', '[[a/b/c]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('EFC/a/b'); + }); + + it('should return an empty string when no projects are set', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: [] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe(''); + }); + }); + it('should handle empty task arrays gracefully', () => { const emptyTaskData: TaskTemplateData = { contexts: [], From ca64515ceabc5d5492ddb974c22ac4498b46af37 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 1 Aug 2026 12:07:39 +1000 Subject: [PATCH 15/18] Address OAuth secret storage review findings --- PRIVACY.md | 2 +- docs/calendar-setup.md | 2 +- docs/features/calendar-integration.md | 2 +- docs/privacy.md | 2 +- docs/releases/unreleased.md | 5 +++++ docs/settings/integrations.md | 2 +- src/settings/tabs/integrationsTab.ts | 4 ++-- tests/unit/issues/issue-1591-settings-lost-on-update.test.ts | 1 + 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 57cffb3ab..a0d2b52dd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -31,7 +31,7 @@ Optional network features: ## OAuth Credentials and Tokens - OAuth client IDs and client secrets are configured by you in TaskNotes settings. -- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage and encrypted at rest. +- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage, which is encrypted at rest when supported by the operating system. - These values are not written to TaskNotes' `data.json`. - Secret Storage is local to the Obsidian installation and vault, so another device may require its own credentials and calendar connection. - You can disconnect providers at any time to revoke TaskNotes access. Disconnecting retains the OAuth app credentials for reconnection; **Forget saved credentials** removes them from Secret Storage. diff --git a/docs/calendar-setup.md b/docs/calendar-setup.md index e34d12528..2cb2835d3 100644 --- a/docs/calendar-setup.md +++ b/docs/calendar-setup.md @@ -43,7 +43,7 @@ Next, add delegated Microsoft Graph API permissions (`Calendars.Read`, `Calendar ## Security Notes -OAuth client IDs, client secrets, and account tokens are stored locally in Obsidian Secret Storage and encrypted at rest. They are not written to TaskNotes' `data.json`. Tokens refresh automatically, calendar data syncs directly between your vault and provider, and you can disconnect at any time to revoke account access. +OAuth client IDs, client secrets, and account tokens are stored locally in Obsidian Secret Storage, which is encrypted at rest when supported by the operating system. They are not written to TaskNotes' `data.json`. Tokens refresh automatically, calendar data syncs directly between your vault and provider, and you can disconnect at any time to revoke account access. Secret Storage is local to the Obsidian installation and vault. When setting up TaskNotes on another device, enter the OAuth app credentials and connect the calendar again. After disconnecting, use **Forget saved credentials** if you also want to remove the client ID and client secret. diff --git a/docs/features/calendar-integration.md b/docs/features/calendar-integration.md index 4386e4b71..4f78507c6 100644 --- a/docs/features/calendar-integration.md +++ b/docs/features/calendar-integration.md @@ -30,7 +30,7 @@ OAuth calendar integration requires creating an OAuth application with your cale ### Token Management -TaskNotes stores OAuth client credentials, access tokens, and refresh tokens in Obsidian Secret Storage, encrypted at rest and separate from TaskNotes' `data.json`. Tokens are refreshed automatically before expiration. You can revoke account access or forget the saved OAuth app credentials through the integrations settings. +TaskNotes stores OAuth client credentials, access tokens, and refresh tokens in Obsidian Secret Storage, which is encrypted at rest when supported by the operating system and kept separate from TaskNotes' `data.json`. Tokens are refreshed automatically before expiration. You can revoke account access or forget the saved OAuth app credentials through the integrations settings. ## Calendar Views diff --git a/docs/privacy.md b/docs/privacy.md index 57cffb3ab..a0d2b52dd 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -31,7 +31,7 @@ Optional network features: ## OAuth Credentials and Tokens - OAuth client IDs and client secrets are configured by you in TaskNotes settings. -- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage and encrypted at rest. +- OAuth client credentials, access tokens, and refresh tokens are stored locally in Obsidian Secret Storage, which is encrypted at rest when supported by the operating system. - These values are not written to TaskNotes' `data.json`. - Secret Storage is local to the Obsidian installation and vault, so another device may require its own credentials and calendar connection. - You can disconnect providers at any time to revoke TaskNotes access. Disconnecting retains the OAuth app credentials for reconnection; **Forget saved credentials** removes them from Secret Storage. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..34da532a5 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Security + +- Calendar OAuth credentials and account tokens are now stored in Obsidian Secret Storage instead of TaskNotes' `data.json`, with automatic migration for existing connections. See [Calendar Integration](https://tasknotes.dev/features/calendar-integration/). + - Thanks to @mcuste for the contribution diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index 504738df8..35231667a 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -70,7 +70,7 @@ When connected, displays: ### Security -- OAuth client credentials and account tokens are stored in Obsidian Secret Storage and encrypted at rest +- OAuth client credentials and account tokens are stored in Obsidian Secret Storage, which is encrypted at rest when supported by the operating system - OAuth credentials and account tokens are not written to TaskNotes' `data.json` - Access tokens refresh automatically - Calendar data syncs directly between Obsidian and the calendar provider (no intermediary servers) diff --git a/src/settings/tabs/integrationsTab.ts b/src/settings/tabs/integrationsTab.ts index 30dd9bc4d..d4a440db0 100644 --- a/src/settings/tabs/integrationsTab.ts +++ b/src/settings/tabs/integrationsTab.ts @@ -455,7 +455,7 @@ export function renderIntegrationsTab( const credentialNote = activeDocument.createElement("div"); credentialNote.className = "tasknotes-credential-note"; credentialNote.textContent = - "Enter your OAUTH app credentials from Google cloud console. Credentials are encrypted at rest in Obsidian secret storage."; + "Enter your OAUTH app credentials from Google cloud console. Obsidian secret storage encrypts credentials at rest when supported by the operating system."; sections.push({ rows: [ @@ -724,7 +724,7 @@ export function renderIntegrationsTab( const credentialNote = activeDocument.createElement("div"); credentialNote.className = "tasknotes-credential-note"; credentialNote.textContent = - "Enter your OAUTH app credentials from azure portal. Credentials are encrypted at rest in Obsidian secret storage."; + "Enter your OAUTH app credentials from azure portal. Obsidian secret storage encrypts credentials at rest when supported by the operating system."; sections.push({ rows: [ diff --git a/tests/unit/issues/issue-1591-settings-lost-on-update.test.ts b/tests/unit/issues/issue-1591-settings-lost-on-update.test.ts index dca543569..bcd377f6e 100644 --- a/tests/unit/issues/issue-1591-settings-lost-on-update.test.ts +++ b/tests/unit/issues/issue-1591-settings-lost-on-update.test.ts @@ -20,6 +20,7 @@ function createPlugin(options: { dataFileExists: boolean; version?: string }) { (plugin as any).settingsLifecycleService = { saveSettings: jest.fn(() => plugin.saveSettingsDataOnly()), }; + (plugin as any).oauthSecretStore = {}; plugin.saveData = jest.fn().mockResolvedValue(undefined); return plugin; From 8dc8b8613161e728c24de3c66d9404c7fdcc9ec6 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 1 Aug 2026 13:52:10 +1000 Subject: [PATCH 16/18] docs: add completion date fix to unreleased --- docs/releases/unreleased.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index cb0614a93..c82fec1d4 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -85,3 +85,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2046, #2051) The date and time picker now focuses the natural-language input when it is enabled, making keyboard-driven quick actions ready for immediate typing. Thanks to @DevOps-Toast for reporting this and @ther12k for fixing it. +- (#2125, #2129) Materialized recurring occurrence notes now record the date + they were actually completed, while the parent recurring task continues to + track the occurrence date. Thanks to @raphaelfaouakhiri for reporting and + fixing this. From 14998187692cda7997fc369d6415d3a1c6f17161 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 1 Aug 2026 16:11:44 +1000 Subject: [PATCH 17/18] fix: make occurrence filename templates explicit --- docs/features/recurring-tasks.md | 20 +++ docs/releases/unreleased.md | 5 + docs/settings/task-defaults.md | 4 + i18n.manifest.json | 4 + i18n.state.json | 128 ++++++++++++++++++ src/i18n/resources/de.ts | 8 +- src/i18n/resources/en.ts | 2 +- src/i18n/resources/es.ts | 8 +- src/i18n/resources/fr.ts | 8 +- src/i18n/resources/ja.ts | 8 +- src/i18n/resources/ko.ts | 8 +- src/i18n/resources/pt.ts | 2 +- src/i18n/resources/ru.ts | 8 +- src/i18n/resources/zh.ts | 8 +- src/services/TaskService.ts | 5 +- .../task-service/TaskCreationService.ts | 3 +- src/settings/defaults.ts | 2 +- .../tabs/taskProperties/titlePropertyCard.ts | 2 +- src/types.ts | 1 - src/utils/filenameGenerator.ts | 38 +----- ...-2126-occurrence-filename-template.test.ts | 42 +----- .../task-occurrence-materialization.test.ts | 14 +- tests/unit/settings/SettingsDefaults.test.ts | 5 +- 23 files changed, 236 insertions(+), 97 deletions(-) diff --git a/docs/features/recurring-tasks.md b/docs/features/recurring-tasks.md index eba9c2231..63316c84a 100644 --- a/docs/features/recurring-tasks.md +++ b/docs/features/recurring-tasks.md @@ -121,6 +121,26 @@ Occurrence notes can use a separate template from regular new tasks. Set `occurr The parent task remains the source of the recurrence rule and series history. Occurrence notes do not copy the parent's `recurrence`, `complete_instances`, `skipped_instances`, `completedDate`, calendar provider IDs, or `timeEntries`. New time entries belong to the occurrence note once you track time there. +### Occurrence Filenames + +By default, materialized occurrence notes use the existing filename behavior: the parent title followed by a numeric suffix. To opt in to custom filenames, set **Occurrence filename template** under **Settings → Task Properties → Title**. For example, `{{title}} — {{occurrenceDate}}` produces `Weekly review — 2026-08-01.md`. + +Occurrence filename templates support all regular filename variables plus these occurrence-specific variables: + +- `{{occurrenceDate}}` - occurrence date, such as `2026-08-01` +- `{{occurrenceWeek}}` - ISO week, such as `2026-W31` +- `{{occurrenceMonth}}` - month, such as `2026-08` +- `{{occurrenceYear}}` - year, such as `2026` +- `{{occurrenceMonthName}}` - full month name, such as `August` + +Choose the date granularity explicitly in the template; TaskNotes does not infer it from the recurrence rule. A recurring parent can override the global template with the frontmatter property configured by **Occurrence template override property** (by default, `occurrenceFilenameTemplate`): + +```yaml +occurrenceFilenameTemplate: "{{title}} ({{occurrenceWeek}})" +``` + +Leaving the global template empty preserves the existing filename behavior. TaskNotes never renames existing occurrence notes, and filename collisions continue to receive a numeric suffix. + ### Occurrence Note Policies Each recurring parent has an **Occurrence notes** submenu under its recurrence menu: diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index c82fec1d4..155659897 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,6 +39,11 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added +- (#2126, #2134) Added optional filename templates for materialized recurring + occurrence notes, with explicit date, ISO week, month, year, and month-name + variables plus per-parent overrides. Existing naming remains unchanged until a + template is configured. See [Recurring Tasks](https://tasknotes.dev/features/recurring-tasks/#occurrence-filenames). + Thanks to @raphaelfaouakhiri for reporting and implementing this. - (#2105) Added `{{projectFolder}}` and `{{projectFolders}}` folder template variables for storing tasks alongside linked project notes. See [Template Variables](https://tasknotes.dev/features/template-variables/). diff --git a/docs/settings/task-defaults.md b/docs/settings/task-defaults.md index 767bc02d1..7be410c0b 100644 --- a/docs/settings/task-defaults.md +++ b/docs/settings/task-defaults.md @@ -211,6 +211,10 @@ Date/time components provide uniqueness without relying only on title text. - `{{uuid}}` - UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000") - `{{nano}}` - Nano ID with timestamp and random string +### Materialized Occurrence Filename Variables + +Materialized recurring occurrence notes can use a separate, optional filename template. It supports all variables above plus explicit occurrence date, ISO week, month, year, and month-name variables. See [Recurring Tasks](../features/recurring-tasks.md#occurrence-filenames) for configuration, per-parent overrides, and examples. + ### Filename Template Examples **Date-based with title:** diff --git a/i18n.manifest.json b/i18n.manifest.json index 076b95d8a..b22da7307 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -695,6 +695,10 @@ "settings.taskProperties.titleCard.filenameFormat": "cf0cb55cf575b23cf5f0d5872fad7e82f6c1d028", "settings.taskProperties.titleCard.customTemplate": "39448a2ba1e6f74f53c856ae2b7c9ee2fd3029c9", "settings.taskProperties.titleCard.legacySyntaxWarning": "1d18778142343b6959fb00f6acb0dbf4f575b40e", + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "settings.taskProperties.titleCard.occurrenceFilenameProperty": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": "0a3f6bb8068175d9d613b581013699bc157614a4", "settings.taskProperties.tagsCard.nativeObsidianTags": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "settings.taskProperties.remindersCard.defaultReminders": "87ff17b932d9c3612442d485ae6dd415e628bdc0", "settings.taskProperties.taskStatuses.header": "8db340a4c47c03e7bae3e0030f9ef2356b9dd5de", diff --git a/i18n.state.json b/i18n.state.json index b3f6aace1..aea59c7fa 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2784,6 +2784,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "dda04673dd51a18e235907498a5c995f3107066a" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "9946590df9ba74665b1c7513353d8e8e28ec4034" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "b24d171e78460c00cca712076b979d0524f7378c" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "26b1b6c24221777f8e57a31eaee05e45fa1ab7c9" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "2cf2ee267db3595a6cee9859030a255c385a2d68" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "ab3be06d574b88103cdbf39183e94d9ffc7e9f53" @@ -11654,6 +11670,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "bb6b941aabc41058be31f8dfd83591934d389e64" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "0d6456726428caf502c41ee6d921e812138fae82" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "c7fbd2b3d99d4325cf97e6fa3b905dbcefaed123" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "c25630d862ddb373a7f4c13320a6460298cf5340" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "6a941d659a4a13bfd827f093d2f5289e7cad2189" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "b3a9646483d1d8da951934d38d01a4fa36991c9b" @@ -20524,6 +20556,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "e7e9dd41d4981d01631e55e4898cf7f028b78a86" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "b70a89b1ecf4834fd72aba58bb88932333510001" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "0c0281de3384649f19f7da01daa3ee9b6848c51d" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "5b8f974ccc1fb3b4b356af7dd507246515426f31" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "c0ec68321554ae581aec50bab714287f6a94d480" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "4798205e0784697c88ad582e92e23c929f8b4c3d" @@ -29394,6 +29442,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "36989c6e80c6f1a92afa5f396d196f7f843ca155" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "3de22ec9cf17e2b5ed2ee061edecc97fe042146e" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "f90e199c3d8c96667b579caa586be83bd1431b60" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "e4fee801f9801871b010d97f7cd4382c4c2ad796" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "167760edb715bae4c3f9fd381d6b7d8c20637168" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "0b406fa8c0ba75b47acf7a433b7e50d66c0ade13" @@ -38264,6 +38328,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "5cecc78bb43b6e321ce9fc0d8562f57bfbc489d3" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "7034934bcab82860531ce09ef55ce9f8885858cf" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "85e23a18daab6d12a081c845473013c084dbd092" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "d8d9e66606b2ac0a2fa20b6809175ce31fb21cac" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "8df62c7191afc0e43fe41f1a6e4af3b980c0b5c8" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "70b84dd5e8cad86c892f3d0be03b119a4fdac69b" @@ -47134,6 +47214,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "6b5c83092e60546eef383fd252a89503f628c26a" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "efba05bd6ba399112c1200391b8df67ac0e9e7f5" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "39433dc5d505c97d137c53d0d1c31ffae2293aa7" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "f10e4356dc3defac458decb56b547cf228024bf5" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "97d629c952ac71f0cf2ae7ad53b4ea76ecd78db4" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "dd3534cec3d201d4da70dea73123c094285734bd" @@ -56004,6 +56100,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "59ec2cba1c9f5fedd4f1dee9d21abca60ccb68a3" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "2c63431d4247a2a419e2ce4e6958a02c0ace3297" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "9ec4caa158f9a9e411d6e1aa81840d50df1b7639" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "880649125081b95df20cc3952af554c57b8358fa" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "fbe357ccbdc40ecf62addd3fb12a81fd1a3e706d" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "41e43d9282de7af6f5acc815614810ec0f3dad31" @@ -64874,6 +64986,22 @@ "source": "1d18778142343b6959fb00f6acb0dbf4f575b40e", "translation": "f374be067bbdd23cebdd7376918f479d0a72430e" }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplate": { + "source": "57abe4824766d21a8afefcce7c29295b80ff8eeb", + "translation": "6d55e92f94d5123630eaede588cf5a935bd7c803" + }, + "settings.taskProperties.titleCard.occurrenceFilenameTemplateHelp": { + "source": "09d58a64109a84e279ea82ef2aebefd00b548cb8", + "translation": "1b1b7dae79b40267ac769ae08a67d485b804a6be" + }, + "settings.taskProperties.titleCard.occurrenceFilenameProperty": { + "source": "1f6abc08c12cb021b16e974bd80bf40590c75e61", + "translation": "0d468b740b9d84c0ad63626c1ec71569bd997db6" + }, + "settings.taskProperties.titleCard.occurrenceFilenamePropertyHelp": { + "source": "0a3f6bb8068175d9d613b581013699bc157614a4", + "translation": "045038a845b9b3f277ca63beab601424431a4f42" + }, "settings.taskProperties.tagsCard.nativeObsidianTags": { "source": "c062ef944dbbfbde38a1c80dee244e9f0e38c6bf", "translation": "525f5b80d3cc32aca9e618a998b998dcf4d41ea0" diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 9e85c572e..7d6af1f6c 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1112,7 +1112,13 @@ export const de: TranslationTree = { filenameUpdatesWithTitle: "Der Dateiname wird automatisch aktualisiert, wenn sich der Aufgabentitel ändert.", filenameFormat: "Dateinamenformat:", customTemplate: "Benutzerdefinierte Vorlage:", - legacySyntaxWarning: "Die Syntax mit einfachen Klammern wie {title} ist veraltet. Bitte verwenden Sie stattdessen die Syntax mit doppelten Klammern {{title}} für Konsistenz mit Body-Vorlagen." + legacySyntaxWarning: "Die Syntax mit einfachen Klammern wie {title} ist veraltet. Bitte verwenden Sie stattdessen die Syntax mit doppelten Klammern {{title}} für Konsistenz mit Body-Vorlagen.", + occurrenceFilenameTemplate: "Dateinamenvorlage für Vorkommen", + occurrenceFilenameTemplateHelp: + "Dateinamenvorlage für materialisierte Vorkommen wiederkehrender Aufgaben. Leer lassen, um den bisherigen Namen beizubehalten (Titel der übergeordneten Aufgabe plus Zahlensuffix). Wähle die Granularität ausdrücklich mit {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}} oder {{occurrenceMonthName}}. Alle regulären Dateinamensvariablen funktionieren ebenfalls. Eine übergeordnete Aufgabe kann diese Vorlage über die unten konfigurierte Frontmatter-Eigenschaft überschreiben.", + occurrenceFilenameProperty: "Override-Eigenschaft für Vorkommensvorlagen", + occurrenceFilenamePropertyHelp: + "Name der Frontmatter-Eigenschaft einer wiederkehrenden übergeordneten Aufgabe, die die Dateinamenvorlage ihrer Vorkommen überschreibt." }, tagsCard: { nativeObsidianTags: "Verwendet native Obsidian-Tags" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 1f289f6da..4d592f521 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1197,7 +1197,7 @@ export const en: TranslationTree = { "Single-brace syntax like {title} is deprecated. Please use double-brace syntax {{title}} instead for consistency with body templates.", occurrenceFilenameTemplate: "Occurrence filename template", occurrenceFilenameTemplateHelp: - "Filename template for materialized occurrences of recurring tasks. Leave empty to keep the default name (parent title plus a numeric suffix). Variables: {{occurrencePeriod}} (matches the recurrence frequency: 2026-08-01 / 2026-W32 / 2026-08 / 2026), {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, {{occurrenceMonthName}}, plus all regular filename variables. A parent task can override this template via the frontmatter property configured below.", + "Filename template for materialized occurrences of recurring tasks. Leave empty to keep the existing name (parent title plus a numeric suffix). Choose the filename granularity explicitly with {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, or {{occurrenceMonthName}}. All regular filename variables also work. A parent task can override this template via the frontmatter property configured below.", occurrenceFilenameProperty: "Occurrence template override property", occurrenceFilenamePropertyHelp: "Name of the frontmatter property on a recurring (parent) task that overrides the occurrence filename template for that task's occurrences.", diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 9fa8f5c41..50e2e053e 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1112,7 +1112,13 @@ export const es: TranslationTree = { filenameUpdatesWithTitle: "El nombre del archivo se actualizará automáticamente cuando cambie el título de la tarea.", filenameFormat: "Formato de nombre de archivo:", customTemplate: "Plantilla personalizada:", - legacySyntaxWarning: "La sintaxis de llaves simples como {title} está obsoleta. Por favor, use la sintaxis de llaves dobles {{title}} para consistencia con las plantillas de cuerpo." + legacySyntaxWarning: "La sintaxis de llaves simples como {title} está obsoleta. Por favor, use la sintaxis de llaves dobles {{title}} para consistencia con las plantillas de cuerpo.", + occurrenceFilenameTemplate: "Plantilla de nombre de archivo para ocurrencias", + occurrenceFilenameTemplateHelp: + "Plantilla de nombre de archivo para ocurrencias materializadas de tareas recurrentes. Déjala vacía para conservar el nombre existente (título de la tarea principal más un sufijo numérico). Elige explícitamente la granularidad con {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}} o {{occurrenceMonthName}}. También funcionan todas las variables normales de nombres de archivo. Una tarea principal puede sobrescribir esta plantilla mediante la propiedad de frontmatter configurada abajo.", + occurrenceFilenameProperty: "Propiedad para sobrescribir la plantilla de ocurrencias", + occurrenceFilenamePropertyHelp: + "Nombre de la propiedad de frontmatter de una tarea recurrente principal que sobrescribe la plantilla de nombre de archivo de sus ocurrencias." }, tagsCard: { nativeObsidianTags: "Usa etiquetas nativas de Obsidian" diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index e89b9383c..976346135 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1112,7 +1112,13 @@ export const fr: TranslationTree = { filenameUpdatesWithTitle: "Le nom du fichier sera automatiquement mis à jour quand le titre de la tâche change.", filenameFormat: "Format du nom de fichier :", customTemplate: "Modèle personnalisé :", - legacySyntaxWarning: "La syntaxe à accolades simples comme {title} est obsolète. Veuillez utiliser la syntaxe à accolades doubles {{title}} pour la cohérence avec les modèles de corps." + legacySyntaxWarning: "La syntaxe à accolades simples comme {title} est obsolète. Veuillez utiliser la syntaxe à accolades doubles {{title}} pour la cohérence avec les modèles de corps.", + occurrenceFilenameTemplate: "Modèle de nom de fichier des occurrences", + occurrenceFilenameTemplateHelp: + "Modèle de nom de fichier pour les occurrences matérialisées des tâches récurrentes. Laissez-le vide pour conserver le nom existant (titre de la tâche parente suivi d’un suffixe numérique). Choisissez explicitement la granularité avec {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}} ou {{occurrenceMonthName}}. Toutes les variables de nom de fichier habituelles fonctionnent également. Une tâche parente peut remplacer ce modèle via la propriété de frontmatter configurée ci-dessous.", + occurrenceFilenameProperty: "Propriété de remplacement du modèle d’occurrence", + occurrenceFilenamePropertyHelp: + "Nom de la propriété de frontmatter d’une tâche récurrente parente qui remplace le modèle de nom de fichier de ses occurrences." }, tagsCard: { nativeObsidianTags: "Utilise les étiquettes Obsidian natives" diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 98bce89ff..9eb2f74ec 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1112,7 +1112,13 @@ export const ja: TranslationTree = { filenameUpdatesWithTitle: "タスクタイトルが変更されると、ファイル名は自動的に更新されます。", filenameFormat: "ファイル名形式:", customTemplate: "カスタムテンプレート:", - legacySyntaxWarning: "{title}のような単一波括弧構文は非推奨です。本文テンプレートとの一貫性のために、{{title}}のような二重波括弧構文を使用してください。" + legacySyntaxWarning: "{title}のような単一波括弧構文は非推奨です。本文テンプレートとの一貫性のために、{{title}}のような二重波括弧構文を使用してください。", + occurrenceFilenameTemplate: "オカレンスのファイル名テンプレート", + occurrenceFilenameTemplateHelp: + "繰り返しタスクから実体化されたオカレンス用のファイル名テンプレートです。空欄にすると、既存の名前(親タスクのタイトルと連番のサフィックス)が維持されます。{{occurrenceDate}}、{{occurrenceWeek}}、{{occurrenceMonth}}、{{occurrenceYear}}、{{occurrenceMonthName}}のいずれかを使って粒度を明示的に選択してください。通常のファイル名変数もすべて使用できます。親タスクは、下で設定するフロントマターのプロパティを使ってこのテンプレートを上書きできます。", + occurrenceFilenameProperty: "オカレンステンプレートの上書きプロパティ", + occurrenceFilenamePropertyHelp: + "繰り返し親タスクで、そのオカレンスのファイル名テンプレートを上書きするフロントマタープロパティの名前です。" }, tagsCard: { nativeObsidianTags: "ネイティブObsidianタグを使用" diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index ad48aeac0..6d51d313b 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -1112,7 +1112,13 @@ export const ko: TranslationTree = { filenameUpdatesWithTitle: "작업 제목이 변경되면 파일명이 자동으로 업데이트됩니다.", filenameFormat: "파일명 형식:", customTemplate: "사용자 지정 템플릿:", - legacySyntaxWarning: "{title}과 같은 단일 중괄호 구문은 더 이상 사용되지 않습니다. 본문 템플릿과의 일관성을 위해 {{title}}과 같은 이중 중괄호 구문을 사용하세요." + legacySyntaxWarning: "{title}과 같은 단일 중괄호 구문은 더 이상 사용되지 않습니다. 본문 템플릿과의 일관성을 위해 {{title}}과 같은 이중 중괄호 구문을 사용하세요.", + occurrenceFilenameTemplate: "발생 항목 파일명 템플릿", + occurrenceFilenameTemplateHelp: + "반복 작업에서 구체화된 발생 항목의 파일명 템플릿입니다. 비워 두면 기존 이름(상위 작업 제목과 숫자 접미사)을 유지합니다. {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, {{occurrenceMonthName}} 중 하나로 파일명 단위를 명시적으로 선택하세요. 일반 파일명 변수도 모두 사용할 수 있습니다. 상위 작업은 아래에서 설정한 프론트매터 속성으로 이 템플릿을 재정의할 수 있습니다.", + occurrenceFilenameProperty: "발생 항목 템플릿 재정의 속성", + occurrenceFilenamePropertyHelp: + "반복 상위 작업에서 발생 항목 파일명 템플릿을 재정의하는 프론트매터 속성의 이름입니다." }, tagsCard: { nativeObsidianTags: "기본 Obsidian 태그 사용" diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index dded43d02..212771a33 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1117,7 +1117,7 @@ export const pt: TranslationTree = { legacySyntaxWarning: "A sintaxe de chaves simples como {title} está obsoleta. Por favor, use a sintaxe de chaves duplas {{title}} para consistência com os modelos de corpo.", occurrenceFilenameTemplate: "Modelo de nome de arquivo das ocorrências", occurrenceFilenameTemplateHelp: - "Modelo do nome de arquivo das ocorrências materializadas de tarefas recorrentes. Deixe vazio para manter o nome padrão (título da tarefa-mãe com sufixo numérico). Variáveis: {{occurrencePeriod}} (acompanha a frequência da recorrência: 2026-08-01 / 2026-W32 / 2026-08 / 2026), {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}}, {{occurrenceMonthName}}, além de todas as variáveis normais de nome de arquivo. Uma tarefa-mãe pode sobrescrever este modelo pela propriedade de frontmatter configurada abaixo.", + "Modelo do nome de arquivo das ocorrências materializadas de tarefas recorrentes. Deixe vazio para manter o nome existente (título da tarefa-mãe com sufixo numérico). Escolha explicitamente a granularidade com {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}} ou {{occurrenceMonthName}}. Todas as variáveis normais de nome de arquivo também funcionam. Uma tarefa-mãe pode sobrescrever este modelo pela propriedade de frontmatter configurada abaixo.", occurrenceFilenameProperty: "Propriedade de sobrescrita do modelo", occurrenceFilenamePropertyHelp: "Nome da propriedade de frontmatter na tarefa recorrente (mãe) que sobrescreve o modelo de nome de arquivo para as ocorrências daquela tarefa." diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index b4379cf36..eff034d4e 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1112,7 +1112,13 @@ export const ru: TranslationTree = { filenameUpdatesWithTitle: "Имя файла будет автоматически обновляться при изменении заголовка задачи.", filenameFormat: "Формат имени файла:", customTemplate: "Пользовательский шаблон:", - legacySyntaxWarning: "Синтаксис с одинарными фигурными скобками, такой как {title}, устарел. Пожалуйста, используйте синтаксис с двойными фигурными скобками {{title}} для согласованности с шаблонами тела." + legacySyntaxWarning: "Синтаксис с одинарными фигурными скобками, такой как {title}, устарел. Пожалуйста, используйте синтаксис с двойными фигурными скобками {{title}} для согласованности с шаблонами тела.", + occurrenceFilenameTemplate: "Шаблон имени файла экземпляра", + occurrenceFilenameTemplateHelp: + "Шаблон имени файла для материализованных экземпляров повторяющихся задач. Оставьте поле пустым, чтобы сохранить существующее имя (заголовок родительской задачи с числовым суффиксом). Явно выберите детализацию с помощью {{occurrenceDate}}, {{occurrenceWeek}}, {{occurrenceMonth}}, {{occurrenceYear}} или {{occurrenceMonthName}}. Также доступны все обычные переменные имени файла. Родительская задача может переопределить шаблон через указанное ниже свойство frontmatter.", + occurrenceFilenameProperty: "Свойство переопределения шаблона экземпляра", + occurrenceFilenamePropertyHelp: + "Имя свойства frontmatter родительской повторяющейся задачи, которое переопределяет шаблон имени файла её экземпляров." }, tagsCard: { nativeObsidianTags: "Использует нативные теги Obsidian" diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index 3b6e39d83..d7d1f72f3 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1112,7 +1112,13 @@ export const zh: TranslationTree = { filenameUpdatesWithTitle: "文件名将在任务标题更改时自动更新。", filenameFormat: "文件名格式:", customTemplate: "自定义模板:", - legacySyntaxWarning: "像 {title} 这样的单花括号语法已弃用。请使用双花括号语法 {{title}} 以与正文模板保持一致。" + legacySyntaxWarning: "像 {title} 这样的单花括号语法已弃用。请使用双花括号语法 {{title}} 以与正文模板保持一致。", + occurrenceFilenameTemplate: "实例文件名模板", + occurrenceFilenameTemplateHelp: + "用于周期性任务实体化实例的文件名模板。留空可保留现有命名方式(父任务标题加数字后缀)。请使用 {{occurrenceDate}}、{{occurrenceWeek}}、{{occurrenceMonth}}、{{occurrenceYear}} 或 {{occurrenceMonthName}} 明确选择文件名粒度。所有常规文件名变量也可使用。父任务可以通过下方配置的 frontmatter 属性覆盖此模板。", + occurrenceFilenameProperty: "实例模板覆盖属性", + occurrenceFilenamePropertyHelp: + "周期性父任务中用于覆盖其实例文件名模板的 frontmatter 属性名称。" }, tagsCard: { nativeObsidianTags: "使用原生Obsidian标签" diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 58d34259f..e020d421f 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -759,7 +759,6 @@ export class TaskService { creationContext: "api", customFrontmatter: occurrenceTemplate.customFrontmatter, occurrenceFilenameTemplate: this.resolveOccurrenceFilenameTemplate(freshParent), - occurrenceParentRecurrence: freshParent.recurrence, }; const { taskInfo } = await this.createTask(taskData, { applyDefaults: false, @@ -779,8 +778,8 @@ export class TaskService { this.plugin.settings.occurrenceFilenameTemplateProperty?.trim() || "occurrenceFilenameTemplate"; const parentFile = this.plugin.app.vault.getAbstractFileByPath(parentTask.path); - const frontmatter = parentFile - ? this.plugin.app.metadataCache.getFileCache(parentFile as TFile)?.frontmatter + const frontmatter = parentFile instanceof TFile + ? this.plugin.app.metadataCache.getFileCache(parentFile)?.frontmatter : undefined; const override = frontmatter?.[propertyName]; const template = diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index aa9a61b5d..ea259c0c7 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -174,8 +174,7 @@ export class TaskCreationService { ? generateOccurrenceFilename( filenameContext, occurrenceFilenameTemplate, - taskData.occurrence_date, - taskData.occurrenceParentRecurrence + taskData.occurrence_date ) : generateTaskFilename(filenameContext, runtime.settings); const folder = await this.resolveTargetFolder(taskData); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 2c0742003..0e77749a2 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -265,7 +265,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { taskFilenameFormat: "zettel", // Keep existing behavior as default storeTitleInFilename: true, customFilenameTemplate: "{{title}}", // Simple title template - occurrenceFilenameTemplate: "{{title}} — {{occurrencePeriod}}", + occurrenceFilenameTemplate: "", occurrenceFilenameTemplateProperty: "occurrenceFilenameTemplate", // Task creation defaults taskCreationDefaults: DEFAULT_TASK_CREATION_DEFAULTS, diff --git a/src/settings/tabs/taskProperties/titlePropertyCard.ts b/src/settings/tabs/taskProperties/titlePropertyCard.ts index 479376721..27605a66f 100644 --- a/src/settings/tabs/taskProperties/titlePropertyCard.ts +++ b/src/settings/tabs/taskProperties/titlePropertyCard.ts @@ -115,7 +115,7 @@ function renderFilenameSettingsContent( }); const occurrenceTemplateInput = createCardInput( "text", - "{{title}} — {{occurrencePeriod}}", + "{{title}} — {{occurrenceDate}}", plugin.settings.occurrenceFilenameTemplate ); occurrenceTemplateInput.addEventListener("change", () => { diff --git a/src/types.ts b/src/types.ts index e4ab5b25c..911bd13c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -503,7 +503,6 @@ export interface TaskCreationData extends Partial { creationContext?: "inline-conversion" | "manual-creation" | "modal-inline-creation" | "api" | "import" | "ics-event"; // Context for folder determination customFrontmatter?: Record; // Custom frontmatter properties (including user fields) occurrenceFilenameTemplate?: string; // Resolved filename template for a materialized occurrence (creation-only) - occurrenceParentRecurrence?: string | { frequency?: string }; // Parent's recurrence, for {{occurrencePeriod}} (creation-only) } export interface TimeEntry { diff --git a/src/utils/filenameGenerator.ts b/src/utils/filenameGenerator.ts index f10206b8a..fed77e176 100644 --- a/src/utils/filenameGenerator.ts +++ b/src/utils/filenameGenerator.ts @@ -626,37 +626,10 @@ export async function generateUniqueFilename( } } -type ParentRecurrence = string | { frequency?: string } | undefined; - -function getOccurrencePeriodKey( - parentRecurrence: ParentRecurrence -): "occurrenceDate" | "occurrenceWeek" | "occurrenceMonth" | "occurrenceYear" { - let freq: string | undefined; - if (typeof parentRecurrence === "string") { - freq = parentRecurrence.match(/FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i)?.[1]; - } else if (parentRecurrence && typeof parentRecurrence === "object") { - freq = parentRecurrence.frequency; - } - switch (freq?.toUpperCase()) { - case "WEEKLY": - return "occurrenceWeek"; - case "MONTHLY": - return "occurrenceMonth"; - case "YEARLY": - return "occurrenceYear"; - default: - return "occurrenceDate"; - } -} - /** * Builds the template variables derived from a materialized occurrence's date. - * `occurrencePeriod` auto-selects the granularity matching the parent's recurrence FREQ. */ -export function buildOccurrenceFilenameVariables( - occurrenceDate: string, - parentRecurrence?: ParentRecurrence -): Record { +export function buildOccurrenceFilenameVariables(occurrenceDate: string): Record { const date = parseDateToLocal(occurrenceDate); if (!(date instanceof Date) || isNaN(date.getTime())) { throw new Error(`Invalid occurrence date: ${occurrenceDate}`); @@ -668,7 +641,6 @@ export function buildOccurrenceFilenameVariables( occurrenceYear: format(date, "yyyy"), occurrenceMonthName: format(date, "MMMM"), }; - variables.occurrencePeriod = variables[getOccurrencePeriodKey(parentRecurrence)]; return variables; } @@ -680,14 +652,10 @@ export function buildOccurrenceFilenameVariables( export function generateOccurrenceFilename( context: FilenameContext, template: string, - occurrenceDate: string, - parentRecurrence?: ParentRecurrence + occurrenceDate: string ): string { try { - const occurrenceVariables = buildOccurrenceFilenameVariables( - occurrenceDate, - parentRecurrence - ); + const occurrenceVariables = buildOccurrenceFilenameVariables(occurrenceDate); return generateCustomFilename( context, template, diff --git a/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts b/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts index 217b2c082..b56db6cae 100644 --- a/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts +++ b/tests/unit/issues/issue-2126-occurrence-filename-template.test.ts @@ -21,50 +21,20 @@ describe("issue #2126 — occurrence filename variables", () => { expect(vars.occurrenceWeek).toBe("2026-W31"); }); - it("occurrencePeriod picks granularity from rrule FREQ", () => { - expect( - buildOccurrenceFilenameVariables("2026-08-01", "FREQ=MONTHLY;INTERVAL=1").occurrencePeriod - ).toBe("2026-08"); - expect( - buildOccurrenceFilenameVariables("2026-08-01", "FREQ=WEEKLY;BYDAY=MO").occurrencePeriod - ).toBe("2026-W31"); - expect( - buildOccurrenceFilenameVariables("2026-08-01", "FREQ=DAILY").occurrencePeriod - ).toBe("2026-08-01"); - expect( - buildOccurrenceFilenameVariables("2026-08-01", "FREQ=YEARLY").occurrencePeriod - ).toBe("2026"); - }); - - it("occurrencePeriod falls back to full date without a clear FREQ", () => { - expect(buildOccurrenceFilenameVariables("2026-08-01").occurrencePeriod).toBe("2026-08-01"); - expect( - buildOccurrenceFilenameVariables("2026-08-01", "every other tuesday").occurrencePeriod - ).toBe("2026-08-01"); - }); - - it("occurrencePeriod understands legacy recurrence objects", () => { - expect( - buildOccurrenceFilenameVariables("2026-08-01", { frequency: "monthly" }).occurrencePeriod - ).toBe("2026-08"); - }); - - it("generateOccurrenceFilename renders the default template", () => { + it("generateOccurrenceFilename renders an occurrence-date template", () => { const name = generateOccurrenceFilename( context, - "{{title}} — {{occurrencePeriod}}", - "2026-08-01", - "FREQ=MONTHLY" + "{{title}} — {{occurrenceDate}}", + "2026-08-01" ); - expect(name).toBe("Pay rent — 2026-08"); + expect(name).toBe("Pay rent — 2026-08-01"); }); it("supports regular filename variables alongside occurrence ones", () => { const name = generateOccurrenceFilename( context, "{{titleKebab}}-{{occurrenceMonth}}", - "2026-08-01", - "FREQ=MONTHLY" + "2026-08-01" ); expect(name).toBe("pay-rent-2026-08"); }); @@ -77,7 +47,7 @@ describe("issue #2126 — occurrence filename variables", () => { it("falls back to sanitized title on invalid occurrence date", () => { const name = generateOccurrenceFilename( context, - "{{title}} — {{occurrencePeriod}}", + "{{title}} — {{occurrenceDate}}", "not-a-date" ); expect(name).toBe("Pay rent"); diff --git a/tests/unit/services/task-occurrence-materialization.test.ts b/tests/unit/services/task-occurrence-materialization.test.ts index 566f4a761..b7b5d5d49 100644 --- a/tests/unit/services/task-occurrence-materialization.test.ts +++ b/tests/unit/services/task-occurrence-materialization.test.ts @@ -462,7 +462,7 @@ describe("TaskService materialized occurrences", () => { filenameGenerator.generateTaskFilename.mockClear(); }); - it("uses the global template and passes the parent recurrence", async () => { + it("uses the global template", async () => { const parent = TaskFactory.createTask({ title: "Pay rent", path: "Tasks/Pay rent.md", @@ -470,15 +470,14 @@ describe("TaskService materialized occurrences", () => { scheduled: "2026-08-01", }); const { taskService, plugin } = createService({ [parent.path]: parent }); - plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrencePeriod}}"; + plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrenceDate}}"; const occurrence = await taskService.materializeOccurrence(parent, "2026-08-01"); expect(filenameGenerator.generateOccurrenceFilename).toHaveBeenCalledWith( expect.objectContaining({ title: "Pay rent" }), - "{{title}} — {{occurrencePeriod}}", - "2026-08-01", - "FREQ=MONTHLY" + "{{title}} — {{occurrenceDate}}", + "2026-08-01" ); expect(filenameGenerator.generateTaskFilename).not.toHaveBeenCalled(); expect(occurrence.path).toBe("Tasks/Pay rent OCC 2026-08-01.md"); @@ -492,7 +491,7 @@ describe("TaskService materialized occurrences", () => { scheduled: "2026-08-03", }); const { taskService, plugin } = createService({ [parent.path]: parent }); - plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrencePeriod}}"; + plugin.settings.occurrenceFilenameTemplate = "{{title}} — {{occurrenceDate}}"; plugin.settings.occurrenceFilenameTemplateProperty = "occurrenceFilenameTemplate"; // the harness resolves the parent frontmatter via metadataCache.getFileCache — // override it so the parent note carries the per-task template override @@ -507,8 +506,7 @@ describe("TaskService materialized occurrences", () => { expect(filenameGenerator.generateOccurrenceFilename).toHaveBeenCalledWith( expect.anything(), "{{title}} ({{occurrenceWeek}})", - "2026-08-03", - "FREQ=WEEKLY" + "2026-08-03" ); }); diff --git a/tests/unit/settings/SettingsDefaults.test.ts b/tests/unit/settings/SettingsDefaults.test.ts index cac13e0f6..45eb353e2 100644 --- a/tests/unit/settings/SettingsDefaults.test.ts +++ b/tests/unit/settings/SettingsDefaults.test.ts @@ -4,5 +4,8 @@ describe('Settings defaults', () => { test('viewsButtonAlignment defaults to right', () => { expect(DEFAULT_SETTINGS.viewsButtonAlignment).toBe('right'); }); -}); + test('occurrence filename templates are opt-in', () => { + expect(DEFAULT_SETTINGS.occurrenceFilenameTemplate).toBe(''); + }); +}); From 03ebc57cfbd3500339973125ed8e3b0a86b97da7 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 1 Aug 2026 17:06:00 +1000 Subject: [PATCH 18/18] docs: add task list collapse fix to unreleased --- docs/releases/unreleased.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 155659897..be90ad6a6 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -51,6 +51,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2166) Fixed Task List views configured to start collapsed so the first + chevron click expands only the selected group instead of expanding every + other group. Thanks to @renatomen for reporting and fixing this. - (#2064, #2066) Recurring tasks now advance correctly after their next scheduled date is manually adjusted, preserving due-date offsets and times without jumping back to an earlier occurrence. Thanks to @chmac for reporting