diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index c29cde0dd..fd2b4b744 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -36,9 +36,18 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#1849) Fixed context menus stacking on top of each other. Only one menu stays open at a time, and clicking the same indicator again closes its menu. This previously applied to date fields only, and now covers priority, status, recurrence, reminders, task, ICS event, and batch menus. - Thanks to @3zra47 for reporting and @YBKF for the contribution. +- Rebase task edits on current frontmatter so an older edit window preserves externally completed recurrence occurrences and unrelated field changes. +- Stop plugin loading when an existing settings file cannot be read safely, and keep saved Pomodoro sessions paused at mobile startup. ## Added - (#2147) Added context-menu actions for recording task completion today, on the scheduled date, on the due date, or on a chosen date. The actions can be grouped in a submenu from Appearance settings. See [Completing Tasks](https://tasknotes.dev/features/task-management/#completing-tasks). - Rescheduling a recurring task can reactivate affected completed or skipped instances after confirmation. See [Recurring Tasks](https://tasknotes.dev/features/recurring-tasks/). - Thanks to @renatomen for the contribution. +- An optional **Rebuild private task projections** Google export setting gives projected tasks a durable `tasknotesUid`. At startup and every 15 minutes it repairs provider drift, duplicates, missing events and renamed source links from task files, even after the local event index is lost. It removes only marked, attendee-free projections and refuses ambiguous task identities. Unchanged projections do not write to Calendar. + +## Security + +- Generate a 256-bit token before starting API/MCP listeners and reject empty-token authentication. +- Bind OAuth callbacks to an OS-assigned loopback port, consume valid state before responding, reject replay, and return static pages with restrictive security headers. +- Re-read managed task events before deletion and use their ETags for conditional writes, preserving a competing provider edit for a subsequent reconciliation. diff --git a/src/api/httpTypes.ts b/src/api/httpTypes.ts index 9774ab23b..297eb88c7 100644 --- a/src/api/httpTypes.ts +++ b/src/api/httpTypes.ts @@ -16,6 +16,7 @@ export interface HTTPResponseLike { } export interface HTTPServerLike { + address?(): { port: number } | string | null; listening?: boolean; listen(port: number, callback?: () => void): void; listen(port: number, hostname: string, callback?: () => void): void; @@ -25,8 +26,6 @@ export interface HTTPServerLike { once(event: "listening", listener: () => void): void; } -export function parseRequestUrl( - req: Pick -): URL { +export function parseRequestUrl(req: Pick): URL { return new URL(req.url ?? "", "http://localhost"); } diff --git a/src/main.ts b/src/main.ts index 4a967ff72..f35db06e1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -684,9 +684,7 @@ export default class TaskNotesPlugin extends Plugin { return pluginDataFileExists(this); } - async loadPluginDataForSafeWrite( - operation: string - ): Promise | null> { + async loadPluginDataForSafeWrite(operation: string): Promise | null> { const loadedData = (await this.loadData()) as Record | null | undefined; if ( (loadedData === null || loadedData === undefined) && @@ -744,6 +742,10 @@ export default class TaskNotesPlugin extends Plugin { }, }); } + if (result.compromised) + throw new Error( + "TaskNotes settings are unreadable; restore settings before loading the plugin." + ); return result.data; } diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 8d85747d6..0c62113f9 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -58,6 +58,58 @@ type GoogleCalendarEventPayload = Record & { end?: GoogleCalendarDateTime; }; +export type TaskProjectionEvent = { + id: string; + etag?: string; + status?: string; + recurringEventId?: string; + attendees?: unknown[]; + summary?: string; + description?: string; + location?: string; + start?: GoogleCalendarDateTime; + end?: GoogleCalendarDateTime; + recurrence?: string[]; + reminders?: { useDefault: boolean; overrides?: Array<{ method: string; minutes: number }> }; + colorId?: string; + transparency?: string; + visibility?: string; + extendedProperties?: { private?: Record }; +}; + +/** Compare the owned projection fields, allowing provider date normalisation. */ +export function taskProjectionMatches( + actual: Omit, + expected: Omit +): boolean { + const date = (value?: GoogleCalendarDateTime) => + value?.date || + (value?.dateTime ? `${Date.parse(value.dateTime)}:${value.timeZone || ""}` : ""); + const normalise = (value: Omit) => ({ + summary: value.summary || "", + description: value.description || "", + location: value.location || "", + start: date(value.start), + end: date(value.end), + recurrence: [...(value.recurrence || [])].sort(), + reminders: { + useDefault: value.reminders?.useDefault ?? true, + overrides: [...(value.reminders?.overrides || [])].sort( + (a, b) => a.method.localeCompare(b.method) || a.minutes - b.minutes + ), + }, + colorId: value.colorId || "", + transparency: value.transparency || "opaque", + visibility: value.visibility || "default", + }); + return ( + JSON.stringify(normalise(actual)) === JSON.stringify(normalise(expected)) && + Object.entries(expected.extendedProperties?.private || {}).every( + ([key, value]) => actual.extendedProperties?.private?.[key] === value + ) + ); +} + /** * GoogleCalendarService handles Google Calendar API interactions. * Uses OAuth for authentication and provides calendar event access. @@ -352,21 +404,13 @@ export class GoogleCalendarService extends CalendarProvider { timeMin || new Date( now.getTime() - - GOOGLE_CALENDAR_CONSTANTS.VIEW_RANGE.DAYS_BEFORE * - 24 * - 60 * - 60 * - 1000 + GOOGLE_CALENDAR_CONSTANTS.VIEW_RANGE.DAYS_BEFORE * 24 * 60 * 60 * 1000 ); fullSyncTimeMax = timeMax || new Date( now.getTime() + - GOOGLE_CALENDAR_CONSTANTS.VIEW_RANGE.DAYS_AFTER * - 24 * - 60 * - 60 * - 1000 + GOOGLE_CALENDAR_CONSTANTS.VIEW_RANGE.DAYS_AFTER * 24 * 60 * 60 * 1000 ); } @@ -666,7 +710,10 @@ export class GoogleCalendarService extends CalendarProvider { if (timeSinceLastRefresh < minInterval) { const remainingMs = minInterval - timeSinceLastRefresh; - publishUserNotice(this.plugin.emitter, `Please wait ${Math.ceil(remainingMs / 1000)}s before refreshing again`); + publishUserNotice( + this.plugin.emitter, + `Please wait ${Math.ceil(remainingMs / 1000)}s before refreshing again` + ); return; } @@ -674,6 +721,46 @@ export class GoogleCalendarService extends CalendarProvider { this.lastManualRefresh = Date.now(); } + /** List only this vault's explicitly marked task projections, with all pages. */ + async listTaskProjections( + calendarId: string, + vaultName: string + ): Promise { + validateCalendarId(calendarId); + const token = await this.oauthService.getValidToken("google"); + const events: TaskProjectionEvent[] = []; + let pageToken: string | undefined; + do { + const params = new URLSearchParams({ + maxResults: "2500", + singleEvents: "false", + showDeleted: "false", + }); + params.append("privateExtendedProperty", "tasknotesProjection=1"); + params.append("privateExtendedProperty", `tasknotesVault=${vaultName}`); + if (pageToken) params.set("pageToken", pageToken); + const response = await this.withRetry( + () => + requestUrl({ + url: `${this.baseUrl}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + method: "GET", + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }), + "List owned task projections" + ); + const page = response.json as { items?: TaskProjectionEvent[]; nextPageToken?: string }; + if (!page || (page.items !== undefined && !Array.isArray(page.items))) + throw new Error("Incomplete task projection listing"); + events.push( + ...(page.items || []).filter( + (event) => event.status !== "cancelled" && !event.recurringEventId + ) + ); + pageToken = page.nextPageToken; + } while (pageToken); + return events; + } + /** * Clears the cache */ @@ -702,6 +789,9 @@ export class GoogleCalendarService extends CalendarProvider { }; colorId?: string; recurrence?: string[]; + transparency?: "transparent"; + visibility?: "private"; + extendedProperties?: { private: Record }; }, expectedConnectionGeneration?: number ): Promise { @@ -730,8 +820,28 @@ export class GoogleCalendarService extends CalendarProvider { const currentEvent = getResponse.json as GoogleCalendarEventPayload; + // Task projections are private and attendee-free; never overwrite an invitation. + if ( + updates.extendedProperties && + Array.isArray(currentEvent.attendees) && + currentEvent.attendees.length + ) { + throw new Error("A task projection has attendees; refusing to overwrite it"); + } // Build update payload const payload: GoogleCalendarEventPayload = { ...currentEvent }; + if (updates.extendedProperties && typeof currentEvent.etag !== "string") + throw new Error("Task projection has no concurrency token"); + const currentOwner = (currentEvent as TaskProjectionEvent).extendedProperties?.private; + if ( + updates.extendedProperties && + currentOwner?.tasknotesUid && + currentOwner.tasknotesUid !== updates.extendedProperties.private.tasknotesUid + ) + throw new Error("Task projection belongs to another task"); + if (updates.extendedProperties) payload.extendedProperties = updates.extendedProperties; + if (updates.transparency) payload.transparency = updates.transparency; + if (updates.visibility) payload.visibility = updates.visibility; if (payload.status === "cancelled") { payload.status = "confirmed"; } @@ -809,6 +919,9 @@ export class GoogleCalendarService extends CalendarProvider { method: "PUT", headers: { Authorization: `Bearer ${token}`, + ...(updates.extendedProperties + ? { "If-Match": currentEvent.etag as string } + : {}), "Content-Type": "application/json", Accept: "application/json", }, @@ -818,6 +931,23 @@ export class GoogleCalendarService extends CalendarProvider { const updatedEvent = updateResponse.json; + if (updates.extendedProperties) { + const readback = await this.withRetry( + () => + requestUrl({ + url: `${this.baseUrl}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }), + "Verify task projection update" + ); + if ( + (readback.json as TaskProjectionEvent).attendees?.length || + !taskProjectionMatches(readback.json as TaskProjectionEvent, payload) + ) + throw new Error("Task projection update did not read back"); + } + // Convert to ICSEvent for return const icsEvent = this.convertToICSEvent(updatedEvent, calendarId); @@ -865,6 +995,9 @@ export class GoogleCalendarService extends CalendarProvider { }; colorId?: string; recurrence?: string[]; + transparency?: "transparent"; + visibility?: "private"; + extendedProperties?: { private: Record }; }, expectedConnectionGeneration?: number ): Promise { @@ -889,6 +1022,9 @@ export class GoogleCalendarService extends CalendarProvider { summary: summary, description: event.description, location: event.location, + ...(event.extendedProperties && { extendedProperties: event.extendedProperties }), + ...(event.transparency && { transparency: event.transparency }), + ...(event.visibility && { visibility: event.visibility }), }; // Add reminders if provided @@ -940,6 +1076,23 @@ export class GoogleCalendarService extends CalendarProvider { const createdEvent = response.json; + if (event.extendedProperties) { + const readback = await this.withRetry( + () => + requestUrl({ + url: `${this.baseUrl}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(createdEvent.id)}`, + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }), + "Verify task projection creation" + ); + if ( + (readback.json as TaskProjectionEvent).attendees?.length || + !taskProjectionMatches(readback.json as TaskProjectionEvent, payload) + ) + throw new Error("Task projection creation did not read back"); + } + // Convert to ICSEvent for return const icsEvent = this.convertToICSEvent(createdEvent, calendarId); @@ -984,12 +1137,42 @@ export class GoogleCalendarService extends CalendarProvider { expectedConnectionGeneration ); + let etag: string | undefined; + if ( + this.plugin.settings.googleCalendarExport.reconcileFromTasks && + calendarId === this.plugin.settings.googleCalendarExport.targetCalendarId + ) { + const response = await this.withRetry( + () => + requestUrl({ + url: `${this.baseUrl}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }), + "Read task projection before deletion" + ); + const event = response.json as TaskProjectionEvent; + const owner = event.extendedProperties?.private; + if ( + event.attendees?.length || + owner?.tasknotesProjection !== "1" || + owner.tasknotesVault !== this.plugin.app.vault.getName() || + !owner.tasknotesUid || + typeof event.etag !== "string" + ) + throw new Error( + "Refusing to delete an unowned, invited or unversioned task projection" + ); + etag = event.etag; + } + await this.withRetry(async () => { return await requestUrl({ url: `${this.baseUrl}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, method: "DELETE", headers: { Authorization: `Bearer ${token}`, + ...(etag ? { "If-Match": etag } : {}), }, }); }, `Delete event ${eventId}`); diff --git a/src/services/HTTPAPIService.ts b/src/services/HTTPAPIService.ts index 7d8252072..d0f565638 100644 --- a/src/services/HTTPAPIService.ts +++ b/src/services/HTTPAPIService.ts @@ -188,9 +188,9 @@ export class HTTPAPIService implements IWebhookNotifier { private authenticate(req: HTTPRequestLike): boolean { const authToken = this.plugin.settings.apiAuthToken; - // Skip auth if no token is configured + // A missing credential never opens an unauthenticated listener. if (!authToken) { - return true; + return false; } const authHeader = req.headers.authorization; @@ -292,6 +292,17 @@ export class HTTPAPIService implements IWebhookNotifier { } async start(): Promise { + if (!Platform.isDesktop || !Platform.isDesktopApp) + throw new Error("The HTTP API is only available in the desktop app."); + if (!this.plugin.settings.apiAuthToken) { + this.plugin.settings.apiAuthToken = btoa( + String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))) + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + await this.plugin.saveSettings(); + } return new Promise((resolve, reject) => { if (!Platform.isDesktop || !Platform.isDesktopApp) { reject(new Error("The HTTP API is only available in the desktop app.")); diff --git a/src/services/OAuthService.ts b/src/services/OAuthService.ts index 9fed0b255..cf6b596fe 100644 --- a/src/services/OAuthService.ts +++ b/src/services/OAuthService.ts @@ -157,12 +157,7 @@ export class OAuthService { const codeChallenge = await this.generateCodeChallenge(codeVerifier); const state = this.generateState(); - // Find available port - const port = await this.findAvailablePort( - OAUTH_CONSTANTS.CALLBACK_PORT_START, - OAUTH_CONSTANTS.CALLBACK_PORT_END - ); - await this.startCallbackServer(port); + const port = await this.startCallbackServer(0); // Update redirect URI for this session const originalRedirectUri = config.redirectUri; @@ -185,11 +180,11 @@ export class OAuthService { `Opening browser for ${provider} authorization...` ); - // Open browser to authorization URL + // Register the resolver before the browser can return a fast callback. + const callback = this.waitForCallback(state, 300000); + void callback.catch(() => undefined); await this.openAuthorizationUrl(authUrl); - - // Wait for callback with timeout - const code = await this.waitForCallback(state, 300000); // 5 minute timeout + const code = await callback; // Exchange code for tokens const tokens = await this.exchangeCodeForTokens(config, code, codeVerifier); @@ -202,6 +197,10 @@ export class OAuthService { `Successfully connected to ${provider} Calendar!` ); } finally { + // Clear a pending resolver even if opening the browser failed. + const pending = this.pendingOAuthState.get(state); + this.pendingOAuthState.delete(state); + pending?.reject(new Error("OAuth flow ended before authorization")); // Restore original redirect URI config.redirectUri = originalRedirectUri; } @@ -231,43 +230,19 @@ export class OAuthService { return; } } catch (error) { - tasknotesLogger.warn("Failed to open OAuth URL in system browser; falling back to window.open.", { - category: "provider", - operation: "oauth-open-external", - error, - }); + tasknotesLogger.warn( + "Failed to open OAuth URL in system browser; falling back to window.open.", + { + category: "provider", + operation: "oauth-open-external", + error, + } + ); } window.open(authUrl, "_blank"); } - /** - * Finds an available port in the given range - */ - private async findAvailablePort(startPort: number, endPort: number): Promise { - const http = ensureHttpModule(); - - for (let port = startPort; port <= endPort; port++) { - try { - await new Promise((resolve, reject) => { - const server = http.createServer(); - server.once("error", reject); - server.once("listening", () => { - server.close(); - resolve(); - }); - server.listen(port, "127.0.0.1"); - }); - return port; - } catch { - // Port in use, try next one - continue; - } - } - - throw new Error(`No available ports found between ${startPort} and ${endPort}`); - } - /** * Generates a random code verifier for PKCE */ @@ -330,10 +305,10 @@ export class OAuthService { /** * Starts a temporary HTTP server to receive the OAuth callback */ - private async startCallbackServer(port: number): Promise { + private async startCallbackServer(port: number): Promise { return new Promise((resolve, reject) => { if (this.callbackServer) { - resolve(); // Already running + reject(new Error("An OAuth callback is already pending")); return; } @@ -363,7 +338,12 @@ export class OAuthService { }); this.callbackServer.listen(port, "127.0.0.1", () => { - resolve(); + const address = this.callbackServer?.address?.(); + if (!address || typeof address === "string") { + reject(new Error("OAuth listener did not expose its bound port")); + return; + } + resolve(address.port); }); }); } @@ -389,70 +369,48 @@ export class OAuthService { * Handles incoming HTTP requests to the callback server */ private handleCallback(req: HTTPRequestLike, res: HTTPResponseLike): void { - const hostHeader = req.headers.host; - const host = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? "localhost"); - const url = new URL(req.url || "", `http://${host}`); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - // Send response to browser - res.writeHead(200, { "Content-Type": "text/html" }); - - if (error) { - res.end(` - - - OAuth Error - -

Authorization Failed

-

Error: ${error}

-

You can close this window.

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

Invalid Callback

-

Missing required parameters.

-

You can close this window.

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

No pending authorization matches this callback.

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

Authorization Successful!

-

You can close this window and return to Obsidian.

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

Authorization was not completed. Return to Obsidian.

" + : "Authorization complete

You can close this window and return to Obsidian.

" + ); + if (error) pending.reject(new Error("OAuth authorization failed")); + else pending.resolve(code as string); } /** @@ -726,10 +684,7 @@ export class OAuthService { * Uses mutex pattern to prevent race conditions when multiple API calls * happen simultaneously with an expired token. */ - async getValidToken( - provider: OAuthProvider, - expectedGeneration?: number - ): Promise { + async getValidToken(provider: OAuthProvider, expectedGeneration?: number): Promise { this.assertExpectedConnectionGeneration(provider, expectedGeneration); const connection = await this.getConnection(provider); this.assertExpectedConnectionGeneration(provider, expectedGeneration); diff --git a/src/services/PomodoroService.ts b/src/services/PomodoroService.ts index bcf5cdaaf..38956ee1e 100644 --- a/src/services/PomodoroService.ts +++ b/src/services/PomodoroService.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Pomodoro state transitions guard active sessions before dereferencing. */ -import { TFile, moment as obsidianMoment } from "obsidian"; +import { TFile, Platform, moment as obsidianMoment } from "obsidian"; import TaskNotesPlugin from "../main"; import { createDailyNote, @@ -92,6 +92,8 @@ export class PomodoroService { async initialize() { await this.loadState(); + // Opening a phone presenter must not resume an old task writer. + if (Platform.isMobile) this.state.isRunning = false; this.setupTicker(); this.subscribeToTaskFileRenames(); diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 43f371315..5344567c4 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -1,7 +1,11 @@ -import { TFile, stringifyYaml } from "obsidian"; +import { TFile, stringifyYaml, parseYaml } from "obsidian"; import { format } from "date-fns"; import TaskNotesPlugin from "../main"; -import { GoogleCalendarService } from "./GoogleCalendarService"; +import { + GoogleCalendarService, + taskProjectionMatches, + type TaskProjectionEvent, +} from "./GoogleCalendarService"; import { GoogleCalendarEventIndexEntry, PendingGoogleCalendarDeletion, @@ -55,6 +59,10 @@ const EVENT_INDEX_RECOVERY_INTERVAL_MS = 15 * 60 * 1000; type CalendarEventPayload = { summary: string; description?: string; + location?: string; + transparency?: "transparent"; + visibility?: "private"; + extendedProperties?: { private: Record }; start: { date?: string; dateTime?: string; timeZone?: string }; end: { date?: string; dateTime?: string; timeZone?: string }; colorId?: string; @@ -184,10 +192,7 @@ export class TaskCalendarSyncService { ); } - private static clearTaskExceptionEventIdCache( - taskPath: string, - calendarId?: string - ): void { + private static clearTaskExceptionEventIdCache(taskPath: string, calendarId?: string): void { if (calendarId) { TaskCalendarSyncService.taskExceptionEventIdCache.delete( TaskCalendarSyncService.getTaskCalendarCacheKey(taskPath, calendarId) @@ -228,19 +233,11 @@ export class TaskCalendarSyncService { ); } - private profileIncrement( - name: string, - amount = 1, - details?: PerformanceProfilerDetails - ): void { + private profileIncrement(name: string, amount = 1, details?: PerformanceProfilerDetails): void { this.plugin.performanceProfiler?.increment(`calendarSync.${name}`, amount, details); } - private profileGauge( - name: string, - value: number, - details?: PerformanceProfilerDetails - ): void { + private profileGauge(name: string, value: number, details?: PerformanceProfilerDetails): void { this.plugin.performanceProfiler?.recordGauge(`calendarSync.${name}`, value, details); } @@ -439,22 +436,23 @@ export class TaskCalendarSyncService { } private async mutateDeletionQueue( - mutation: ( - queue: PendingGoogleCalendarDeletion[] - ) => PendingGoogleCalendarDeletion[] | null + mutation: (queue: PendingGoogleCalendarDeletion[]) => PendingGoogleCalendarDeletion[] | null ): Promise { const previousMutation = TaskCalendarSyncService.googleCalendarDeletionQueueWrite; - const currentMutation = previousMutation.catch(() => undefined).then(async () => { - const data = await this.plugin.loadPluginDataForSafeWrite( - "save-google-calendar-deletion-queue" - ); - if (!data) return; - const queue = (data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] || []) as PendingGoogleCalendarDeletion[]; - const updatedQueue = mutation(queue); - if (updatedQueue === null) return; - data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] = updatedQueue; - await this.plugin.saveData(data); - }); + const currentMutation = previousMutation + .catch(() => undefined) + .then(async () => { + const data = await this.plugin.loadPluginDataForSafeWrite( + "save-google-calendar-deletion-queue" + ); + if (!data) return; + const queue = (data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] || + []) as PendingGoogleCalendarDeletion[]; + const updatedQueue = mutation(queue); + if (updatedQueue === null) return; + data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] = updatedQueue; + await this.plugin.saveData(data); + }); TaskCalendarSyncService.googleCalendarDeletionQueueWrite = currentMutation; await currentMutation; } @@ -465,7 +463,9 @@ export class TaskCalendarSyncService { } private async saveEventIndex(index: GoogleCalendarEventIndexEntry[]): Promise { - const data = await this.plugin.loadPluginDataForSafeWrite("save-google-calendar-event-index"); + const data = await this.plugin.loadPluginDataForSafeWrite( + "save-google-calendar-event-index" + ); if (!data) return; data[GOOGLE_CALENDAR_EVENT_INDEX_KEY] = index; await this.plugin.saveData(data); @@ -477,7 +477,9 @@ export class TaskCalendarSyncService { } private async saveSyncQueue(queue: PendingGoogleCalendarSync[]): Promise { - const data = await this.plugin.loadPluginDataForSafeWrite("save-google-calendar-sync-queue"); + const data = await this.plugin.loadPluginDataForSafeWrite( + "save-google-calendar-sync-queue" + ); if (!data) return; data[GOOGLE_CALENDAR_SYNC_QUEUE_KEY] = queue; await this.plugin.saveData(data); @@ -516,6 +518,7 @@ export class TaskCalendarSyncService { private getCalendarRelevantFingerprint(task: TaskInfo): string { return JSON.stringify({ + path: task.path, title: task.title || "", status: task.status || "", priority: task.priority || "", @@ -840,8 +843,7 @@ export class TaskCalendarSyncService { return false; } - const connectionGeneration = - expectedConnectionGeneration ?? this.getConnectionGeneration(); + const connectionGeneration = expectedConnectionGeneration ?? this.getConnectionGeneration(); try { await this.withGoogleRateLimit(async () => { await this.assertConnectionGenerationCurrent(connectionGeneration); @@ -909,8 +911,208 @@ export class TaskCalendarSyncService { return !this.isTaskCalendarEligible(task); } + /** A task's durable identity survives a rename, archival and provider cache loss. */ + private async projectionIdentity(task: TaskInfo): Promise { + const file = this.plugin.app.vault.getAbstractFileByPath(task.path); + if (!(file instanceof TFile)) throw new Error("Projection source disappeared"); + let uid = ""; + await withVaultFileMutation(file, () => + processVaultFrontMatterWithinMutation(this.plugin.app, file, (fm) => { + if ( + fm.tasknotesUid !== undefined && + (typeof fm.tasknotesUid !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + fm.tasknotesUid + )) + ) { + throw new Error("Invalid stable task identity"); + } + uid = fm.tasknotesUid || crypto.randomUUID(); + fm.tasknotesUid = uid; + }) + ); + return uid; + } + + private ownedProjectionReconciliation: Promise | null = null; + + async reconcileOwnedTaskProjections(): Promise { + if (this.ownedProjectionReconciliation) return this.ownedProjectionReconciliation; + const work = this.reconcileOwnedTaskProjectionsOnce(); + this.ownedProjectionReconciliation = work; + try { + await work; + } finally { + this.ownedProjectionReconciliation = null; + } + } + + private ownedProjectionPayload( + task: TaskInfo, + uid: string, + role: "series" | "exception" + ): CalendarEventPayload | null { + const payload = + role === "series" + ? this.taskToCalendarEvent(task, true) + : this.buildRecurringExceptionEvent(task); + if (!payload) return null; + payload.extendedProperties = { + private: { + tasknotesProjection: "1", + tasknotesRole: role, + tasknotesVault: this.plugin.app.vault.getName(), + tasknotesUid: uid, + ...(role === "exception" + ? { tasknotesOccurrence: task.googleCalendarExceptionOriginalScheduled || "" } + : {}), + }, + }; + payload.transparency = "transparent"; + payload.visibility = "private"; + payload.location = ""; + payload.description ??= ""; + if (!payload.reminders) payload.reminders = { useDefault: false }; + return payload; + } + + private ownedProjectionIsCurrent( + task: TaskInfo, + uid: string, + series: TaskProjectionEvent | undefined, + exception: TaskProjectionEvent | undefined + ): boolean { + const expected = this.ownedProjectionPayload(task, uid, "series"); + if (!series || !expected || !taskProjectionMatches(series, expected)) return false; + if (!this.shouldCreateDetachedRecurringException(task)) + return ( + !exception && + !this.getTaskExceptionEventId(task) && + !task.googleCalendarExceptionOriginalScheduled + ); + const detached = this.ownedProjectionPayload(task, uid, "exception"); + return !!(exception && detached && taskProjectionMatches(exception, detached)); + } + + /** Rebuild provider state from a complete, readable Markdown cohort. */ + private async reconcileOwnedTaskProjectionsOnce(): Promise { + if (!this.plugin.settings.googleCalendarExport.reconcileFromTasks || !this.isEnabled()) + return; + const calendarId = this.plugin.settings.googleCalendarExport.targetCalendarId; + const vaultName = this.plugin.app.vault.getName(); + const generation = this.getConnectionGeneration(); + const tasks = await this.plugin.cacheManager.getAllTasks(); + const byPath = new Map(tasks.map((task) => [task.path, task])); + const byUid = new Map(); + const uidByPath = new Map(); + // Read every candidate before any remote mutation. Missing metadata or a + // duplicated identity must never look like permission to delete an event. + for (const file of this.plugin.app.vault.getMarkdownFiles()) { + const content = await this.plugin.app.vault.read(file); + const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content); + if (!header || !header[1].includes("tasknotesUid")) continue; + const fm = parseYaml(header[1]) as Record | null; + const uid = fm?.tasknotesUid; + if (uid === undefined) continue; + if ( + typeof uid !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uid) || + byUid.has(uid) + ) { + throw new Error(`Invalid or duplicated task identity: ${file.path}`); + } + const indexedTask = byPath.get(file.path); + if (!indexedTask) throw new Error(`Task identity is not indexed yet: ${file.path}`); + const task = { + path: file.path, + title: indexedTask.title, + status: indexedTask.status, + priority: indexedTask.priority, + archived: false, + ...this.plugin.fieldMapper.mapFromFrontmatter( + fm, + file.path, + this.plugin.settings.storeTitleInFilename + ), + }; + byPath.set(file.path, task); + byUid.set(uid, task); + uidByPath.set(file.path, uid); + } + const events = await this.googleCalendarService.listTaskProjections(calendarId, vaultName); + const byTask = new Map(); + const exceptions = new Map(); + for (const event of events) { + const owner = event.extendedProperties?.private; + if ( + owner?.tasknotesProjection !== "1" || + owner.tasknotesVault !== vaultName || + !owner.tasknotesUid + ) + continue; + if (event.attendees?.length) throw new Error("An owned task projection has attendees"); + const groups = owner.tasknotesRole === "exception" ? exceptions : byTask; + const group = groups.get(owner.tasknotesUid) || []; + group.push(event); + groups.set(owner.tasknotesUid, group); + } + await this.assertConnectionGenerationCurrent(generation); + for (const uid of new Set([...byTask.keys(), ...exceptions.keys()])) { + const group = byTask.get(uid) || []; + const detached = exceptions.get(uid) || []; + const task = byUid.get(uid); + if (!task || !this.isTaskCalendarEligible(task)) { + for (const event of [...group, ...detached]) + await this.deleteOrQueueCalendarEvent(task?.path || "", calendarId, event.id); + continue; + } + const matchingExceptions = detached.filter( + (event) => + event.extendedProperties?.private?.tasknotesOccurrence === + task.googleCalendarExceptionOriginalScheduled + ); + const exception = + matchingExceptions.find( + (event) => event.id === task.googleCalendarExceptionEventId + ) || matchingExceptions[0]; + if (exception && task.googleCalendarExceptionEventId !== exception.id) { + await this.saveTaskExceptionMetadata( + task.path, + { googleCalendarExceptionEventId: exception.id }, + calendarId, + generation + ); + task.googleCalendarExceptionEventId = exception.id; + } + const existing = + group.find((event) => event.id === this.getTaskEventId(task)) || group[0]; + if (existing && task.googleCalendarEventId !== existing.id) { + await this.saveTaskEventId(task.path, existing.id, calendarId, generation); + task.googleCalendarEventId = existing.id; + } + // First prove one correct survivor, then remove duplicate derived events. + if ( + this.ownedProjectionIsCurrent(task, uid, existing, exception) || + (await this.syncTaskToCalendar(task)) + ) { + for (const event of group) + if (event.id !== existing?.id) + await this.deleteOrQueueCalendarEvent(task.path, calendarId, event.id); + for (const event of detached) + if (event.id !== exception?.id) + await this.deleteOrQueueCalendarEvent(task.path, calendarId, event.id); + } + } + for (const task of byPath.values()) { + if (!this.isTaskCalendarEligible(task)) continue; + const uid = uidByPath.get(task.path) || (await this.projectionIdentity(task)); + if (!byTask.has(uid) && !exceptions.has(uid)) await this.syncTaskToCalendar(task); + } + } + async processStartupRecovery(): Promise { await this.profileAsync("processStartupRecovery", async () => { + await this.reconcileOwnedTaskProjections(); await this.recoverDeletedTaskEventsFromIndex(); await this.processDeletionQueue(); await this.processPendingSyncQueue(); @@ -935,13 +1137,17 @@ export class TaskCalendarSyncService { return; } + await this.reconcileOwnedTaskProjections(); await this.recoverDeletedTaskEventsFromIndex(); } async initializeExternalFileReconciliation(): Promise { await this.profileAsync("initializeExternalFileReconciliation", async () => { const settings = this.plugin.settings.googleCalendarExport; - this.profileGauge("initializeExternalFileReconciliation.enabled", settings.enabled ? 1 : 0); + this.profileGauge( + "initializeExternalFileReconciliation.enabled", + settings.enabled ? 1 : 0 + ); if (!settings.enabled) { return; } @@ -1121,7 +1327,10 @@ export class TaskCalendarSyncService { const eventKey = this.getDeletionQueueKey(item); const taskCalendarKey = this.getEventIndexTaskCalendarKey(item); - if (eventIndexByEvent.has(eventKey) || nextIndexByTaskCalendar.has(taskCalendarKey)) { + if ( + eventIndexByEvent.has(eventKey) || + nextIndexByTaskCalendar.has(taskCalendarKey) + ) { indexChanged = true; continue; } @@ -1170,7 +1379,8 @@ export class TaskCalendarSyncService { taskPath: task.path, calendarId: targetCalendarId, eventId, - updatedAt: existingEventEntry?.updatedAt || existingTaskEntry?.updatedAt || Date.now(), + updatedAt: + existingEventEntry?.updatedAt || existingTaskEntry?.updatedAt || Date.now(), }; nextIndexByTaskCalendar.set(taskCalendarKey, nextEntry); eventIndexByEvent.set(key, nextEntry); @@ -1368,12 +1578,15 @@ export class TaskCalendarSyncService { lastAttemptAt: Date.now(), lastError: getErrorMessage(error), }); - tasknotesLogger.error("[TaskCalendarSync] Failed to retry queued event deletion:", { - category: "provider", - operation: "retry-queued-event-deletion", - details: { value: item }, - error: error, - }); + tasknotesLogger.error( + "[TaskCalendarSync] Failed to retry queued event deletion:", + { + category: "provider", + operation: "retry-queued-event-deletion", + details: { value: item }, + error: error, + } + ); } } @@ -1388,7 +1601,9 @@ export class TaskCalendarSyncService { }); for (const item of remainingItems) { const key = this.getDeletionQueueKey(item); - if (!nextQueue.some((candidate) => this.getDeletionQueueKey(candidate) === key)) { + if ( + !nextQueue.some((candidate) => this.getDeletionQueueKey(candidate) === key) + ) { nextQueue.push(item); } } @@ -1432,9 +1647,7 @@ export class TaskCalendarSyncService { getTaskEventId(task: TaskInfo): string | undefined { return ( task.googleCalendarEventId || - TaskCalendarSyncService.taskEventIdCache.get( - this.getTaskEventIdCacheKey(task.path) - ) + TaskCalendarSyncService.taskEventIdCache.get(this.getTaskEventIdCacheKey(task.path)) ); } @@ -1485,9 +1698,7 @@ export class TaskCalendarSyncService { } } - const pendingOriginal = getDatePart( - task.googleCalendarExceptionOriginalScheduled || "" - ); + const pendingOriginal = getDatePart(task.googleCalendarExceptionOriginalScheduled || ""); if (pendingOriginal) { excludedDates.add(pendingOriginal); } @@ -1520,10 +1731,7 @@ export class TaskCalendarSyncService { try { return await current; } finally { - if ( - TaskCalendarSyncService.googleCalendarFrontmatterWrites.get(taskPath) === - current - ) { + if (TaskCalendarSyncService.googleCalendarFrontmatterWrites.get(taskPath) === current) { TaskCalendarSyncService.googleCalendarFrontmatterWrites.delete(taskPath); } } @@ -1546,7 +1754,12 @@ export class TaskCalendarSyncService { (frontmatter) => { for (const [fieldName, value] of Object.entries(updates)) { previousValues.set(fieldName, frontmatter[fieldName]); - this.writeOptionalFrontmatterField(frontmatter, fieldName, value, true); + this.writeOptionalFrontmatterField( + frontmatter, + fieldName, + value, + true + ); } } ); @@ -1667,7 +1880,8 @@ export class TaskCalendarSyncService { continue; } - const keepField = !updatedFields.has(fieldName) && lastFieldIndexes.get(fieldName) === index; + const keepField = + !updatedFields.has(fieldName) && lastFieldIndexes.get(fieldName) === index; if (keepField) { result.push(lines[index]); } @@ -1820,8 +2034,7 @@ export class TaskCalendarSyncService { updates.googleCalendarExceptionOriginalScheduled; } if ("googleCalendarMovedOriginalDates" in updates) { - frontmatterUpdates[movedOriginalDatesField] = - updates.googleCalendarMovedOriginalDates; + frontmatterUpdates[movedOriginalDatesField] = updates.googleCalendarMovedOriginalDates; } if (Object.keys(frontmatterUpdates).length > 0) { @@ -2492,9 +2705,7 @@ export class TaskCalendarSyncService { } const movedScheduled = getDatePart(task.scheduled || ""); - const originalScheduled = getDatePart( - task.googleCalendarExceptionOriginalScheduled || "" - ); + const originalScheduled = getDatePart(task.googleCalendarExceptionOriginalScheduled || ""); return Boolean(movedScheduled && originalScheduled && movedScheduled !== originalScheduled); } @@ -2590,7 +2801,9 @@ export class TaskCalendarSyncService { return; } - const eventData = this.buildRecurringExceptionEvent(task); + const eventData = this.plugin.settings.googleCalendarExport.reconcileFromTasks + ? this.ownedProjectionPayload(task, await this.projectionIdentity(task), "exception") + : this.buildRecurringExceptionEvent(task); if (!eventData) { return; } @@ -2673,10 +2886,7 @@ export class TaskCalendarSyncService { } return eventId; }); - TaskCalendarSyncService.pendingExceptionEventCreates.set( - createCacheKey, - createPromise - ); + TaskCalendarSyncService.pendingExceptionEventCreates.set(createCacheKey, createPromise); try { await createPromise; } finally { @@ -2698,8 +2908,7 @@ export class TaskCalendarSyncService { options: { queueOnFailure?: boolean; connectionGeneration?: number } = {} ): Promise { const queueOnFailure = options.queueOnFailure ?? true; - const connectionGeneration = - options.connectionGeneration ?? this.getConnectionGeneration(); + const connectionGeneration = options.connectionGeneration ?? this.getConnectionGeneration(); if (!this.isTaskCalendarEligible(task)) { return true; @@ -2726,7 +2935,9 @@ export class TaskCalendarSyncService { // Check if recurrence was removed (previous had recurrence, current doesn't) const clearRecurrence = !!(previous?.recurrence && !task.recurrence); - const eventData = this.taskToCalendarEvent(task, clearRecurrence); + const eventData = settings.reconcileFromTasks + ? this.ownedProjectionPayload(task, await this.projectionIdentity(task), "series") + : this.taskToCalendarEvent(task, clearRecurrence); if (!eventData) { tasknotesLogger.warn("[TaskCalendarSync] Could not convert task to event:", { category: "provider", @@ -2766,10 +2977,7 @@ export class TaskCalendarSyncService { ); }); } else { - const createCacheKey = this.getTaskEventIdCacheKey( - task.path, - targetCalendarId - ); + const createCacheKey = this.getTaskEventIdCacheKey(task.path, targetCalendarId); const pendingCreate = TaskCalendarSyncService.pendingEventCreates.get(createCacheKey); if (pendingCreate) { @@ -2790,10 +2998,7 @@ export class TaskCalendarSyncService { targetCalendarId, connectionGeneration ); - TaskCalendarSyncService.pendingEventCreates.set( - createCacheKey, - createPromise - ); + TaskCalendarSyncService.pendingEventCreates.set(createCacheKey, createPromise); try { await createPromise; } finally { @@ -2807,7 +3012,10 @@ export class TaskCalendarSyncService { } } - if (this.shouldSyncAsRecurring(task) || this.hasStoredRecurringExceptionMetadata(task)) { + if ( + this.shouldSyncAsRecurring(task) || + this.hasStoredRecurringExceptionMetadata(task) + ) { await this.syncRecurringExceptionEvent( task, targetCalendarId, @@ -2853,13 +3061,15 @@ export class TaskCalendarSyncService { // Show user-friendly message for token refresh errors // TokenRefreshError indicates the OAuth connection expired and user needs to reconnect if (error instanceof TokenRefreshError) { - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.connectionExpired" ) ); } else { - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.syncFailed", { message: getErrorMessage(error) } @@ -3274,7 +3484,8 @@ export class TaskCalendarSyncService { const results = { synced: 0, failed: 0, skipped: 0 }; if (!this.isEnabled()) { - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.notEnabledOrConfigured" ) @@ -3294,7 +3505,8 @@ export class TaskCalendarSyncService { }); const total = allTasks.length; - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.syncingTasks", { total } @@ -3320,7 +3532,8 @@ export class TaskCalendarSyncService { } }); - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.syncComplete", { @@ -3344,9 +3557,7 @@ export class TaskCalendarSyncService { let unlinkedCount = 0; for (const task of tasks) { - const connectionGeneration = deleteEvents - ? this.getConnectionGeneration() - : undefined; + const connectionGeneration = deleteEvents ? this.getConnectionGeneration() : undefined; if (!task.googleCalendarEventId && !this.hasStoredRecurringExceptionMetadata(task)) { continue; } @@ -3404,7 +3615,8 @@ export class TaskCalendarSyncService { } } - publishUserNotice(this.plugin.emitter, + publishUserNotice( + this.plugin.emitter, deleteEvents ? this.plugin.i18n.translate( "settings.integrations.googleCalendarExport.notices.eventsDeletedAndUnlinked", diff --git a/src/services/task-service/TaskUpdateService.ts b/src/services/task-service/TaskUpdateService.ts index eee4a475e..6b00fd852 100644 --- a/src/services/task-service/TaskUpdateService.ts +++ b/src/services/task-service/TaskUpdateService.ts @@ -130,20 +130,43 @@ export class TaskUpdateService { newPath = parentPath ? `${parentPath}/${newFilename}.md` : `${newFilename}.md`; } - const recurrenceUpdates = buildTaskUpdateRecurrenceUpdates({ - originalTask, - updates: taskUpdates, - maintainDueDateOffsetInRecurring: runtime.settings.maintainDueDateOffsetInRecurring, - }); + let recurrenceUpdates: Partial = {}; const normalizedDetails = normalizeTaskUpdateDetails(taskUpdates); let finalTags: string[] | undefined; const dateModified = getCurrentTimestamp(); const currentDateString = - taskUpdates.status !== undefined && !originalTask.recurrence - ? getCurrentDateString() - : ""; + taskUpdates.status !== undefined ? getCurrentDateString() : ""; await processVaultFrontMatter(runtime.app, file, (frontmatter) => { + // Rebase the caller's named changes on the bytes Obsidian is about + // to modify. A modal's TaskInfo can predate an external completion. + const current = runtime.fieldMapper.mapFromFrontmatter( + frontmatter, + originalTask.path, + runtime.settings.storeTitleInFilename + ); + originalTask = { + // Retain derived view/body context; persisted fields come from the fresh mapping. + id: originalTask.id, + basesData: originalTask.basesData, + details: originalTask.details, + blocking: originalTask.blocking, + isBlocked: originalTask.isBlocked, + isBlocking: originalTask.isBlocking, + hasSubtasks: originalTask.hasSubtasks, + path: originalTask.path, + title: originalTask.title, + status: originalTask.status, + priority: originalTask.priority, + archived: false, + ...current, + }; + recurrenceUpdates = buildTaskUpdateRecurrenceUpdates({ + originalTask, + updates: taskUpdates, + maintainDueDateOffsetInRecurring: + runtime.settings.maintainDueDateOffsetInRecurring, + }); const frontmatterResult = applyTaskUpdateFrontmatterChange({ frontmatter, originalTask, diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 460569e00..e108cf9ff 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -1,8 +1,5 @@ import type { FieldMappingKey, TaskInfo, TimeEntry } from "../../types"; -import { - addDTSTARTToRecurrenceRule, - updateToNextScheduledOccurrence, -} from "../../core/recurrence"; +import { addDTSTARTToRecurrenceRule, updateToNextScheduledOccurrence } from "../../core/recurrence"; import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, @@ -18,6 +15,11 @@ export type TaskUpdateInput = Partial & { }; export interface TaskUpdateFieldMapper { + mapFromFrontmatter: ( + frontmatter: unknown, + filePath: string, + storeTitleInFilename?: boolean + ) => Partial; mapToFrontmatter: ( taskData: Partial, taskTag?: string, @@ -134,15 +136,8 @@ export function buildTaskUpdateRecurrenceUpdates({ recurrenceUpdates.recurrence = updatedRecurrence; } } - } else if ( - updates.recurrence !== undefined && - !originalTask.recurrence && - updates.recurrence - ) { - if ( - typeof updates.recurrence === "string" && - !updates.recurrence.includes("DTSTART:") - ) { + } else if (updates.recurrence !== undefined && !originalTask.recurrence && updates.recurrence) { + if (typeof updates.recurrence === "string" && !updates.recurrence.includes("DTSTART:")) { const tempTask: TaskInfo = { ...originalTask, ...updates }; const updatedRecurrence = addDTSTARTToRecurrenceRuleFn(tempTask); if (updatedRecurrence) { @@ -196,8 +191,9 @@ export function applyTaskUpdateFrontmatterChange({ storeTitleInFilename, updateCompletedDateInFrontmatter, }: ApplyTaskUpdateFrontmatterChangeInput): ApplyTaskUpdateFrontmatterChangeResult { + // Publish only the named patch and its recurrence consequences. const completeTaskData: Partial = { - ...originalTask, + tags: getFrontmatterTags(frontmatter.tags), ...updates, ...recurrenceUpdates, dateModified, @@ -339,10 +335,7 @@ function removeUnsetMappedFields( delete frontmatter[fieldMapper.toUserField("recurrence")]; } if ( - Object.prototype.hasOwnProperty.call( - updates, - "googleCalendarExceptionOriginalScheduled" - ) && + Object.prototype.hasOwnProperty.call(updates, "googleCalendarExceptionOriginalScheduled") && updates.googleCalendarExceptionOriginalScheduled === undefined ) { delete frontmatter[fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled")]; diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 9886b3488..5211f35c0 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -190,6 +190,7 @@ export const DEFAULT_ICS_INTEGRATION_SETTINGS: ICSIntegrationSettings = { }; export const DEFAULT_GOOGLE_CALENDAR_EXPORT: GoogleCalendarExportSettings = { + reconcileFromTasks: false, enabled: false, // Disabled by default - user must opt-in targetCalendarId: "", // Empty = user must select a calendar syncOnTaskCreate: true, diff --git a/src/settings/tabs/integrationsTab.ts b/src/settings/tabs/integrationsTab.ts index 7d9083238..6f5c40c86 100644 --- a/src/settings/tabs/integrationsTab.ts +++ b/src/settings/tabs/integrationsTab.ts @@ -830,6 +830,20 @@ export function renderIntegrationsTab( }) ); + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: "Rebuild private task projections", + desc: "Tasks own a private, free-busy-transparent projection. Repair edits, duplicates and missing events at startup and every 15 minutes; remove marked events when their task is archived or deleted. Adds a stable tasknotesUid property. Use a dedicated calendar.", + getValue: () => + plugin.settings.googleCalendarExport.reconcileFromTasks ?? false, + setValue: async (value: boolean) => { + plugin.settings.googleCalendarExport.reconcileFromTasks = value; + save(); + }, + }) + ); + // Target calendar dropdown (populated dynamically) group.addSetting((setting) => { setting.setName( diff --git a/src/types/settings.ts b/src/types/settings.ts index 72926c30c..b350d13e1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -328,6 +328,8 @@ export interface ICSIntegrationSettings { * Configuration for exporting tasks to Google Calendar */ export interface GoogleCalendarExportSettings { + /** Opt-in one-way reconciliation on a dedicated projection calendar. */ + reconcileFromTasks?: boolean; enabled: boolean; // Master enable/disable for task export targetCalendarId: string; // Which calendar to create events in syncOnTaskCreate: boolean; // Auto-sync when task is created diff --git a/tests/services/GoogleCalendarService.test.ts b/tests/services/GoogleCalendarService.test.ts index 1f1b9e8d6..48423e093 100644 --- a/tests/services/GoogleCalendarService.test.ts +++ b/tests/services/GoogleCalendarService.test.ts @@ -66,6 +66,7 @@ describe('GoogleCalendarService', () => { mockPlugin = { app: {} as any, settings: { + googleCalendarExport: {reconcileFromTasks: false}, enabledGoogleCalendars: [], googleCalendarSyncTokens: {} } as any, diff --git a/tests/services/TaskReliability.test.ts b/tests/services/TaskReliability.test.ts new file mode 100644 index 000000000..1178a4bc8 --- /dev/null +++ b/tests/services/TaskReliability.test.ts @@ -0,0 +1,376 @@ +jest.mock("../../src/services/MCPService", () => ({ MCPService: jest.fn() })); +import { TFile, Platform, requestUrl } from "obsidian"; +import { + GoogleCalendarService, + taskProjectionMatches, +} from "../../src/services/GoogleCalendarService"; +import { TaskUpdateService } from "../../src/services/task-service/TaskUpdateService"; +import { TaskCalendarSyncService } from "../../src/services/TaskCalendarSyncService"; +import { OAuthService } from "../../src/services/OAuthService"; +import { OAuthSecretStore } from "../../src/services/OAuthSecretStore"; +import { HTTPAPIService } from "../../src/services/HTTPAPIService"; +import { PomodoroService } from "../../src/services/PomodoroService"; + +const UID = "61d3d239-e29c-4295-aac2-a40be5ace641"; +const OTHER = "a0a38710-9c32-485a-bd39-9a049cc7e9ee"; + +describe("task write and recovery boundaries", () => { + it("rebases a stale modal patch on current recurrence fields and returns that state", async () => { + const file = Object.assign(Object.create(TFile.prototype), { path: "Tasks/example.md" }); + const fm: any = { + status: "ready", + priority: "normal", + scheduled: "2026-09-07", + recurrence: "DTSTART:20260905;FREQ=DAILY", + complete_instances: ["2026-09-05", "2026-09-06"], + timeEstimate: 5, + custom: "keep", + }; + const runtime: any = { + app: { + vault: { getAbstractFileByPath: () => file }, + fileManager: { processFrontMatter: async (_: unknown, fn: any) => fn(fm) }, + }, + settings: { + storeTitleInFilename: false, + maintainDueDateOffsetInRecurring: false, + taskIdentificationMethod: "property", + taskPropertyName: "type", + taskPropertyValue: "task", + }, + fieldMapper: { + mapFromFrontmatter: (value: any) => ({ ...value }), + mapToFrontmatter: (value: any) => ({ ...value }), + toUserField: (key: string) => key, + }, + cacheManager: { updateTaskInfoInCache: jest.fn() }, + emitter: { trigger: jest.fn() }, + statusManager: { isCompletedStatus: () => false }, + }; + const service = new TaskUpdateService({ + runtime, + updateCompletedDateInFrontmatter: () => {}, + }); + const stale: any = { + ...fm, + path: file.path, + title: "Example", + scheduled: "2026-09-06", + due: "2026-09-01", + complete_instances: ["2026-09-05"], + }; + const result = await service.updateTask(stale, { timeEstimate: 10 }); + expect(fm.scheduled).toBe("2026-09-07"); + expect(fm.complete_instances).toEqual(["2026-09-05", "2026-09-06"]); + expect(fm.custom).toBe("keep"); + expect(fm.due).toBeUndefined(); + expect(result.due).toBeUndefined(); + expect(result.scheduled).toBe("2026-09-07"); + expect(result.complete_instances).toEqual(fm.complete_instances); + expect(result.timeEstimate).toBe(10); + expect(stale.scheduled).toBe("2026-09-06"); + }); + + function projectionFixture() { + const task: any = { + path: "Tasks/new-name.md", + title: "Example", + status: "ready", + scheduled: "2026-09-06", + archived: false, + }; + const file: any = { path: task.path }; + const plugin: any = { + app: { + vault: { + getName: () => "Example Vault", + getMarkdownFiles: () => [file], + read: async () => `---\ntasknotesUid: ${UID}\n---\n`, + }, + }, + settings: { + googleCalendarExport: { + enabled: true, + reconcileFromTasks: true, + targetCalendarId: "test-calendar", + }, + }, + fieldMapper: { mapFromFrontmatter: () => ({ ...task }) }, + cacheManager: { getAllTasks: async () => [task] }, + }; + const owner = { + tasknotesProjection: "1", + tasknotesVault: "Example Vault", + tasknotesUid: UID, + tasknotesRole: "series", + }; + const events: any[] = [{ id: "event1", extendedProperties: { private: owner } }]; + const google: any = { + listTaskProjections: jest.fn(async () => events), + getConnectionGeneration: () => 0, + }; + const service: any = new TaskCalendarSyncService(plugin, google); + service.isEnabled = () => true; + service.assertConnectionGenerationCurrent = async () => {}; + service.projectionIdentity = async () => UID; + service.ownedProjectionIsCurrent = () => false; + service.isTaskCalendarEligible = (t: any) => !t.archived; + service.getTaskEventId = (t: any) => t.googleCalendarEventId; + service.saveTaskEventId = jest.fn(async () => {}); + service.saveTaskExceptionMetadata = jest.fn(async () => {}); + service.syncTaskToCalendar = jest.fn(async () => true); + service.deleteOrQueueCalendarEvent = jest.fn(async () => true); + return { task, file, plugin, google, service, events, owner }; + } + + it("recovers a moved task's event link without a local provider index", async () => { + const { task, service } = projectionFixture(); + await service.reconcileOwnedTaskProjections(); + expect(service.saveTaskEventId).toHaveBeenCalledWith( + task.path, + "event1", + "test-calendar", + 0 + ); + expect(service.syncTaskToCalendar).toHaveBeenCalledTimes(1); + expect(service.deleteOrQueueCalendarEvent).not.toHaveBeenCalled(); + }); + + it("repairs a survivor before removing duplicate projections and cleans owned orphans", async () => { + const { service, events, owner } = projectionFixture(); + events.push( + { id: "duplicate", extendedProperties: { private: owner } }, + { id: "orphan", extendedProperties: { private: { ...owner, tasknotesUid: OTHER } } }, + { id: "unowned", extendedProperties: { private: {} } } + ); + await service.reconcileOwnedTaskProjections(); + expect(service.deleteOrQueueCalendarEvent.mock.calls.map((call: any[]) => call[2])).toEqual( + ["duplicate", "orphan"] + ); + expect(service.syncTaskToCalendar.mock.invocationCallOrder[0]).toBeLessThan( + service.deleteOrQueueCalendarEvent.mock.invocationCallOrder[0] + ); + }); + + it("keeps duplicate events if the canonical survivor cannot be repaired", async () => { + const { service, events, owner } = projectionFixture(); + events.push({ id: "duplicate", extendedProperties: { private: owner } }); + service.syncTaskToCalendar.mockResolvedValue(false); + await service.reconcileOwnedTaskProjections(); + expect(service.deleteOrQueueCalendarEvent).not.toHaveBeenCalled(); + }); + + it("deletes the projection of an archived task while retaining its identity", async () => { + const { task, service } = projectionFixture(); + task.archived = true; + await service.reconcileOwnedTaskProjections(); + expect(service.deleteOrQueueCalendarEvent).toHaveBeenCalledWith( + task.path, + "test-calendar", + "event1" + ); + expect(service.syncTaskToCalendar).not.toHaveBeenCalled(); + }); + + it.each(["duplicate identity", "unreadable source", "attendees"])( + "refuses cleanup on %s", + async (kind) => { + const { plugin, service, file, events } = projectionFixture(); + if (kind === "duplicate identity") + plugin.app.vault.getMarkdownFiles = () => [file, { path: "Tasks/copy.md" }]; + if (kind === "unreadable source") + plugin.app.vault.read = async () => { + throw Error("unavailable"); + }; + if (kind === "attendees") events[0].attendees = [{ email: "invitee@example.com" }]; + await expect(service.reconcileOwnedTaskProjections()).rejects.toThrow(); + expect(service.saveTaskEventId).not.toHaveBeenCalled(); + expect(service.deleteOrQueueCalendarEvent).not.toHaveBeenCalled(); + expect(service.syncTaskToCalendar).not.toHaveBeenCalled(); + } + ); + + it("recovers a detached recurrence event separately from its parent series", async () => { + const { task, service, events, owner } = projectionFixture(); + task.googleCalendarExceptionOriginalScheduled = "2026-09-06"; + events.push({ + id: "detached", + extendedProperties: { + private: { + ...owner, + tasknotesRole: "exception", + tasknotesOccurrence: "2026-09-06", + }, + }, + }); + await service.reconcileOwnedTaskProjections(); + expect(service.saveTaskExceptionMetadata).toHaveBeenCalledWith( + task.path, + { googleCalendarExceptionEventId: "detached" }, + "test-calendar", + 0 + ); + expect(service.deleteOrQueueCalendarEvent).not.toHaveBeenCalled(); + }); + + it("does not enable unauthenticated HTTP API access when the token is empty", () => { + const service: any = Object.create(HTTPAPIService.prototype); + service.plugin = { settings: { apiAuthToken: "" } }; + expect(service.authenticate({ headers: {} })).toBe(false); + service.plugin.settings.apiAuthToken = "fixture-token"; + expect(service.authenticate({ headers: { authorization: "Bearer wrong" } })).toBe(false); + expect(service.authenticate({ headers: { authorization: "Bearer fixture-token" } })).toBe( + true + ); + }); + + it("does not resume a persisted Pomodoro writer on mobile startup", async () => { + const previous = Platform.isMobile; + (Platform as any).isMobile = true; + try { + const service: any = new PomodoroService({ + settings: { pomodoroWorkDuration: 25 }, + } as any); + service.loadState = async () => { + service.state = { + isRunning: true, + currentSession: { id: "fixture" }, + timeRemaining: 1, + }; + }; + service.setupTicker = () => {}; + service.subscribeToTaskFileRenames = () => {}; + service.resumeTimer = jest.fn(); + await service.initialize(); + expect(service.resumeTimer).not.toHaveBeenCalled(); + expect(service.state.isRunning).toBe(false); + expect(service.state.currentSession.id).toBe("fixture"); + } finally { + (Platform as any).isMobile = previous; + } + }); + + it("validates and consumes OAuth state before writing a static response", () => { + const store = new OAuthSecretStore({ getSecret: () => null, setSecret: () => {} }); + const service: any = new OAuthService({} as any, store); + const response: any = { writeHead: jest.fn(), end: jest.fn() }; + service.handleCallback( + { method: "GET", url: "/?error=%3Cscript%3E&state=unknown", headers: {} }, + response + ); + expect(response.writeHead.mock.calls[0][0]).toBe(400); + expect(response.end.mock.calls[0][0]).not.toContain("