Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 2 additions & 3 deletions src/api/httpTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,8 +26,6 @@ export interface HTTPServerLike {
once(event: "listening", listener: () => void): void;
}

export function parseRequestUrl(
req: Pick<HTTPRequestLike, "url">
): URL {
export function parseRequestUrl(req: Pick<HTTPRequestLike, "url">): URL {
return new URL(req.url ?? "", "http://localhost");
}
8 changes: 5 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,9 +684,7 @@ export default class TaskNotesPlugin extends Plugin {
return pluginDataFileExists(this);
}

async loadPluginDataForSafeWrite(
operation: string
): Promise<Record<string, unknown> | null> {
async loadPluginDataForSafeWrite(operation: string): Promise<Record<string, unknown> | null> {
const loadedData = (await this.loadData()) as Record<string, unknown> | null | undefined;
if (
(loadedData === null || loadedData === undefined) &&
Expand Down Expand Up @@ -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;
}

Expand Down
205 changes: 194 additions & 11 deletions src/services/GoogleCalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,58 @@ type GoogleCalendarEventPayload = Record<string, unknown> & {
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<string, string> };
};

/** Compare the owned projection fields, allowing provider date normalisation. */
export function taskProjectionMatches(
actual: Omit<TaskProjectionEvent, "id">,
expected: Omit<TaskProjectionEvent, "id">
): boolean {
const date = (value?: GoogleCalendarDateTime) =>
value?.date ||
(value?.dateTime ? `${Date.parse(value.dateTime)}:${value.timeZone || ""}` : "");
const normalise = (value: Omit<TaskProjectionEvent, "id">) => ({
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.
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -666,14 +710,57 @@ 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;
}

await this.refreshAllCalendars({ propagateErrors: true });
this.lastManualRefresh = Date.now();
}

/** List only this vault's explicitly marked task projections, with all pages. */
async listTaskProjections(
calendarId: string,
vaultName: string
): Promise<TaskProjectionEvent[]> {
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
*/
Expand Down Expand Up @@ -702,6 +789,9 @@ export class GoogleCalendarService extends CalendarProvider {
};
colorId?: string;
recurrence?: string[];
transparency?: "transparent";
visibility?: "private";
extendedProperties?: { private: Record<string, string> };
},
expectedConnectionGeneration?: number
): Promise<ICSEvent> {
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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",
},
Expand All @@ -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);

Expand Down Expand Up @@ -865,6 +995,9 @@ export class GoogleCalendarService extends CalendarProvider {
};
colorId?: string;
recurrence?: string[];
transparency?: "transparent";
visibility?: "private";
extendedProperties?: { private: Record<string, string> };
},
expectedConnectionGeneration?: number
): Promise<ICSEvent> {
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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}`);
Expand Down
15 changes: 13 additions & 2 deletions src/services/HTTPAPIService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -292,6 +292,17 @@ export class HTTPAPIService implements IWebhookNotifier {
}

async start(): Promise<void> {
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."));
Expand Down
Loading
Loading