Skip to content

Commit 43c70b7

Browse files
committed
Merge pull request #1945
2 parents 4498820 + ea8c86f commit 43c70b7

6 files changed

Lines changed: 315 additions & 54 deletions

File tree

docs/releases/unreleased.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ Example:
4747
- Thanks to @joseluisgonzalezdelgado-ctrl for reporting.
4848
- (#1939) Fixed the task Details field rendering as a small nested textarea when the embedded editor falls back.
4949
- Thanks to @g-arthurvanderbilt for reporting and @cookbr for confirming the desktop impact.
50+
- Fixed tag and modal preview edge cases found during validation: double-hash tags no longer match the task tag, empty tag lists stay empty when read back, and natural-language previews remain visible when enabled.
5051
- (#1941) Fixed mobile Kanban boards scrolling the whole board vertically instead of keeping each list independently scrollable.
5152
- Thanks to @pxxush for reporting.
5253
- (#1943) Fixed MCP task-query tool instructions so filter operators match the operators TaskNotes actually accepts.
53-
- Thanks to @jordankbartos for reporting.
54+
- Thanks to @jordankbartos for reporting and proposing a fix in #1944.
55+
- (#1945) Fixed Google Calendar export so overlapping sync services do not create duplicate events for the same task while the saved event ID is still catching up.
56+
- Thanks to @martin-forge for reporting and contributing the fix.
5457
- (#1946) Fixed the bottom task in virtualized Task List Bases being partially hidden behind Obsidian's mobile bottom bar.
5558
- Thanks to @3zra47 for reporting.
5659
- (#1947) Fixed mobile Task List cards with left-positioned subtask chevrons so the chevron remains visible and project/detail badges stay on the main row.

src/core/fieldMapping.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,7 @@ export function mapTaskFromFrontmatter(
302302

303303
if (frontmatter.tags !== undefined) {
304304
const tags = getFrontmatterTags(frontmatter.tags);
305-
if (tags.length > 0) {
306-
mapped.tags = tags;
307-
}
305+
mapped.tags = tags;
308306
mapped.archived = tags.includes(normalizeTagForComparison(mapping.archiveTag));
309307
}
310308

src/services/TaskCalendarSyncService.ts

Lines changed: 170 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ function isAlreadyDeletedError(error: unknown): boolean {
9999
* Handles creating, updating, and deleting calendar events when tasks change.
100100
*/
101101
export class TaskCalendarSyncService {
102+
/** In-flight create operations keyed by calendar and task path to avoid duplicate Google events */
103+
private static pendingEventCreates: Map<string, Promise<string>> = new Map();
104+
105+
/** In-flight detached exception creates keyed by calendar and task path */
106+
private static pendingExceptionEventCreates: Map<string, Promise<string>> = new Map();
107+
108+
/** Event IDs written during this session, used while Obsidian metadata catches up */
109+
private static taskEventIdCache: Map<string, string> = new Map();
110+
111+
/** Detached exception event IDs written during this session */
112+
private static taskExceptionEventIdCache: Map<string, string> = new Map();
113+
102114
private plugin: TaskNotesPlugin;
103115
private googleCalendarService: GoogleCalendarService;
104116
private rateLimitChain: Promise<unknown> = Promise.resolve();
@@ -119,15 +131,6 @@ export class TaskCalendarSyncService {
119131
/** Store the latest explicitly passed task object during debounce to avoid cache race conditions */
120132
private pendingTasks: Map<string, TaskInfo> = new Map();
121133

122-
/** In-flight create operations keyed by task path to avoid duplicate Google events */
123-
private pendingEventCreates: Map<string, Promise<string>> = new Map();
124-
125-
/** Event IDs written during this session, used while Obsidian metadata catches up */
126-
private taskEventIdCache: Map<string, string> = new Map();
127-
128-
/** Detached recurring exception event IDs written during this session */
129-
private taskExceptionEventIdCache: Map<string, string> = new Map();
130-
131134
/** Last calendar-relevant task fingerprint persisted after successful syncs */
132135
private calendarFingerprints: Map<string, string> | null = null;
133136

@@ -136,6 +139,64 @@ export class TaskCalendarSyncService {
136139
this.googleCalendarService = googleCalendarService;
137140
}
138141

142+
private static getTaskCalendarCacheKey(taskPath: string, calendarId?: string): string {
143+
return calendarId ? `${calendarId}::${taskPath}` : taskPath;
144+
}
145+
146+
private static deleteTaskPathEntries(cache: Map<string, unknown>, taskPath: string): void {
147+
cache.delete(TaskCalendarSyncService.getTaskCalendarCacheKey(taskPath));
148+
for (const key of Array.from(cache.keys())) {
149+
if (key.endsWith(`::${taskPath}`)) {
150+
cache.delete(key);
151+
}
152+
}
153+
}
154+
155+
private static clearTaskEventIdCache(taskPath: string, calendarId?: string): void {
156+
if (calendarId) {
157+
TaskCalendarSyncService.taskEventIdCache.delete(
158+
TaskCalendarSyncService.getTaskCalendarCacheKey(taskPath, calendarId)
159+
);
160+
return;
161+
}
162+
163+
TaskCalendarSyncService.deleteTaskPathEntries(
164+
TaskCalendarSyncService.taskEventIdCache,
165+
taskPath
166+
);
167+
}
168+
169+
private static clearTaskExceptionEventIdCache(
170+
taskPath: string,
171+
calendarId?: string
172+
): void {
173+
if (calendarId) {
174+
TaskCalendarSyncService.taskExceptionEventIdCache.delete(
175+
TaskCalendarSyncService.getTaskCalendarCacheKey(taskPath, calendarId)
176+
);
177+
return;
178+
}
179+
180+
TaskCalendarSyncService.deleteTaskPathEntries(
181+
TaskCalendarSyncService.taskExceptionEventIdCache,
182+
taskPath
183+
);
184+
}
185+
186+
static clearSharedGoogleCalendarSyncStateForTests(): void {
187+
TaskCalendarSyncService.pendingEventCreates.clear();
188+
TaskCalendarSyncService.pendingExceptionEventCreates.clear();
189+
TaskCalendarSyncService.taskEventIdCache.clear();
190+
TaskCalendarSyncService.taskExceptionEventIdCache.clear();
191+
}
192+
193+
private getTaskEventIdCacheKey(taskPath: string, calendarId?: string): string {
194+
return TaskCalendarSyncService.getTaskCalendarCacheKey(
195+
taskPath,
196+
calendarId || this.plugin.settings.googleCalendarExport.targetCalendarId
197+
);
198+
}
199+
139200
private profileAsync<T>(
140201
name: string,
141202
fn: () => Promise<T>,
@@ -178,9 +239,6 @@ export class TaskCalendarSyncService {
178239
this.pendingSyncs.clear();
179240
this.previousTaskState.clear();
180241
this.pendingTasks.clear();
181-
this.pendingEventCreates.clear();
182-
this.taskEventIdCache.clear();
183-
this.taskExceptionEventIdCache.clear();
184242
this.calendarFingerprints = null;
185243
}
186244

@@ -1235,7 +1293,12 @@ export class TaskCalendarSyncService {
12351293
* Get the Google Calendar event ID from the task's frontmatter
12361294
*/
12371295
getTaskEventId(task: TaskInfo): string | undefined {
1238-
return task.googleCalendarEventId || this.taskEventIdCache.get(task.path);
1296+
return (
1297+
task.googleCalendarEventId ||
1298+
TaskCalendarSyncService.taskEventIdCache.get(
1299+
this.getTaskEventIdCacheKey(task.path)
1300+
)
1301+
);
12391302
}
12401303

12411304
/**
@@ -1244,7 +1307,9 @@ export class TaskCalendarSyncService {
12441307
getTaskExceptionEventId(task: TaskInfo): string | undefined {
12451308
return (
12461309
task.googleCalendarExceptionEventId ||
1247-
this.taskExceptionEventIdCache.get(task.path)
1310+
TaskCalendarSyncService.taskExceptionEventIdCache.get(
1311+
this.getTaskEventIdCacheKey(task.path)
1312+
)
12481313
);
12491314
}
12501315

@@ -1296,7 +1361,11 @@ export class TaskCalendarSyncService {
12961361
/**
12971362
* Save the Google Calendar event ID to the task's frontmatter
12981363
*/
1299-
private async saveTaskEventId(taskPath: string, eventId: string): Promise<void> {
1364+
private async saveTaskEventId(
1365+
taskPath: string,
1366+
eventId: string,
1367+
calendarId?: string
1368+
): Promise<void> {
13001369
const file = this.plugin.app.vault.getAbstractFileByPath(taskPath);
13011370
if (!(file instanceof TFile)) {
13021371
tasknotesLogger.warn(`Cannot save event ID: file not found at ${taskPath}`, {
@@ -1310,9 +1379,13 @@ export class TaskCalendarSyncService {
13101379
await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => {
13111380
frontmatter[fieldName] = eventId;
13121381
});
1313-
this.taskEventIdCache.set(taskPath, eventId);
1382+
TaskCalendarSyncService.taskEventIdCache.set(
1383+
this.getTaskEventIdCacheKey(taskPath, calendarId),
1384+
eventId
1385+
);
13141386

1315-
const targetCalendarId = this.plugin.settings.googleCalendarExport.targetCalendarId;
1387+
const targetCalendarId =
1388+
calendarId || this.plugin.settings.googleCalendarExport.targetCalendarId;
13161389
if (targetCalendarId) {
13171390
await this.upsertEventIndex(taskPath, targetCalendarId, eventId);
13181391
}
@@ -1328,7 +1401,7 @@ export class TaskCalendarSyncService {
13281401
category: "provider",
13291402
operation: "remove-event-id-file-not-found",
13301403
});
1331-
this.taskEventIdCache.delete(taskPath);
1404+
TaskCalendarSyncService.clearTaskEventIdCache(taskPath);
13321405
await this.removeEventIndexForTask(taskPath);
13331406
return;
13341407
}
@@ -1337,7 +1410,7 @@ export class TaskCalendarSyncService {
13371410
await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => {
13381411
delete frontmatter[fieldName];
13391412
});
1340-
this.taskEventIdCache.delete(taskPath);
1413+
TaskCalendarSyncService.clearTaskEventIdCache(taskPath);
13411414
await this.removeEventIndexForTask(taskPath);
13421415
}
13431416

@@ -1350,7 +1423,8 @@ export class TaskCalendarSyncService {
13501423
| "googleCalendarExceptionOriginalScheduled"
13511424
| "googleCalendarMovedOriginalDates"
13521425
>
1353-
>
1426+
>,
1427+
calendarId?: string
13541428
): Promise<void> {
13551429
const file = this.plugin.app.vault.getAbstractFileByPath(taskPath);
13561430
if (!(file instanceof TFile)) {
@@ -1394,12 +1468,12 @@ export class TaskCalendarSyncService {
13941468

13951469
if ("googleCalendarExceptionEventId" in updates) {
13961470
if (updates.googleCalendarExceptionEventId) {
1397-
this.taskExceptionEventIdCache.set(
1398-
taskPath,
1471+
TaskCalendarSyncService.taskExceptionEventIdCache.set(
1472+
this.getTaskEventIdCacheKey(taskPath, calendarId),
13991473
updates.googleCalendarExceptionEventId
14001474
);
14011475
} else {
1402-
this.taskExceptionEventIdCache.delete(taskPath);
1476+
TaskCalendarSyncService.clearTaskExceptionEventIdCache(taskPath, calendarId);
14031477
}
14041478
}
14051479
}
@@ -2017,7 +2091,7 @@ export class TaskCalendarSyncService {
20172091
? createdEvent.id.slice(prefix.length)
20182092
: createdEvent.id;
20192093

2020-
await this.saveTaskEventId(task.path, eventId);
2094+
await this.saveTaskEventId(task.path, eventId, calendarId);
20212095
return eventId;
20222096
}
20232097

@@ -2111,10 +2185,14 @@ export class TaskCalendarSyncService {
21112185
}
21122186
}
21132187

2114-
await this.saveTaskExceptionMetadata(task.path, {
2115-
googleCalendarExceptionEventId: undefined,
2116-
googleCalendarExceptionOriginalScheduled: undefined,
2117-
});
2188+
await this.saveTaskExceptionMetadata(
2189+
task.path,
2190+
{
2191+
googleCalendarExceptionEventId: undefined,
2192+
googleCalendarExceptionOriginalScheduled: undefined,
2193+
},
2194+
targetCalendarId
2195+
);
21182196
return;
21192197
}
21202198

@@ -2137,29 +2215,64 @@ export class TaskCalendarSyncService {
21372215
if (getErrorStatus(error) !== 404) {
21382216
throw error;
21392217
}
2140-
await this.saveTaskExceptionMetadata(task.path, {
2141-
googleCalendarExceptionEventId: undefined,
2142-
});
2218+
await this.saveTaskExceptionMetadata(
2219+
task.path,
2220+
{
2221+
googleCalendarExceptionEventId: undefined,
2222+
},
2223+
targetCalendarId
2224+
);
21432225
}
21442226
}
21452227

2146-
const createdEvent = await this.withGoogleRateLimit(() =>
2228+
const createCacheKey = this.getTaskEventIdCacheKey(task.path, targetCalendarId);
2229+
const pendingCreate =
2230+
TaskCalendarSyncService.pendingExceptionEventCreates.get(createCacheKey);
2231+
if (pendingCreate) {
2232+
const eventId = await pendingCreate;
2233+
await this.withGoogleRateLimit(() =>
2234+
this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData)
2235+
);
2236+
return;
2237+
}
2238+
2239+
const createPromise = this.withGoogleRateLimit(() =>
21472240
this.googleCalendarService.createEvent(targetCalendarId, {
21482241
...eventData,
21492242
isAllDay: !!eventData.start.date,
21502243
})
2151-
);
2152-
const prefix = `google-${targetCalendarId}-`;
2153-
const eventId = createdEvent.id.startsWith(prefix)
2154-
? createdEvent.id.slice(prefix.length)
2155-
: createdEvent.id;
2244+
).then(async (createdEvent) => {
2245+
const prefix = `google-${targetCalendarId}-`;
2246+
const eventId = createdEvent.id.startsWith(prefix)
2247+
? createdEvent.id.slice(prefix.length)
2248+
: createdEvent.id;
21562249

2157-
await this.saveTaskExceptionMetadata(task.path, {
2158-
googleCalendarExceptionEventId: eventId,
2159-
googleCalendarExceptionOriginalScheduled: getDatePart(
2160-
task.googleCalendarExceptionOriginalScheduled || ""
2161-
),
2250+
await this.saveTaskExceptionMetadata(
2251+
task.path,
2252+
{
2253+
googleCalendarExceptionEventId: eventId,
2254+
googleCalendarExceptionOriginalScheduled: getDatePart(
2255+
task.googleCalendarExceptionOriginalScheduled || ""
2256+
),
2257+
},
2258+
targetCalendarId
2259+
);
2260+
return eventId;
21622261
});
2262+
TaskCalendarSyncService.pendingExceptionEventCreates.set(
2263+
createCacheKey,
2264+
createPromise
2265+
);
2266+
try {
2267+
await createPromise;
2268+
} finally {
2269+
if (
2270+
TaskCalendarSyncService.pendingExceptionEventCreates.get(createCacheKey) ===
2271+
createPromise
2272+
) {
2273+
TaskCalendarSyncService.pendingExceptionEventCreates.delete(createCacheKey);
2274+
}
2275+
}
21632276
}
21642277

21652278
/**
@@ -2232,7 +2345,12 @@ export class TaskCalendarSyncService {
22322345
)
22332346
);
22342347
} else {
2235-
const pendingCreate = this.pendingEventCreates.get(task.path);
2348+
const createCacheKey = this.getTaskEventIdCacheKey(
2349+
task.path,
2350+
targetCalendarId
2351+
);
2352+
const pendingCreate =
2353+
TaskCalendarSyncService.pendingEventCreates.get(createCacheKey);
22362354
if (pendingCreate) {
22372355
const eventId = await pendingCreate;
22382356
await this.withGoogleRateLimit(() =>
@@ -2244,12 +2362,18 @@ export class TaskCalendarSyncService {
22442362
eventData,
22452363
targetCalendarId
22462364
);
2247-
this.pendingEventCreates.set(task.path, createPromise);
2365+
TaskCalendarSyncService.pendingEventCreates.set(
2366+
createCacheKey,
2367+
createPromise
2368+
);
22482369
try {
22492370
await createPromise;
22502371
} finally {
2251-
if (this.pendingEventCreates.get(task.path) === createPromise) {
2252-
this.pendingEventCreates.delete(task.path);
2372+
if (
2373+
TaskCalendarSyncService.pendingEventCreates.get(createCacheKey) ===
2374+
createPromise
2375+
) {
2376+
TaskCalendarSyncService.pendingEventCreates.delete(createCacheKey);
22532377
}
22542378
}
22552379
}

src/utils/taskIdentification.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ export function isTaskFrontmatter(
5353

5454
const tags = getFrontmatterTags(frontmatter.tags);
5555
return tags.some((tag) => {
56-
// Obsidian metadata cache prepends "#" to frontmatter tags.
57-
const cleanTag = tag.startsWith("#") ? tag.slice(1) : tag;
58-
return FilterUtils.matchesHierarchicalTagExact(cleanTag, settings.taskTag);
56+
return FilterUtils.matchesHierarchicalTagExact(tag, settings.taskTag);
5957
});
6058
}

styles/task-modal.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,7 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem
13321332

13331333
.tasknotes-plugin .nl-preview-container.nl-preview-container--visible {
13341334
display: block;
1335+
visibility: visible;
13351336
}
13361337

13371338
.tasknotes-plugin .nl-preview-item {

0 commit comments

Comments
 (0)