Skip to content

Commit 83b7516

Browse files
authored
Merge pull request #1769 from martin-forge/codex/issue-1765-auto-archive-calendar-cleanup
Fix auto-archive Google Calendar cleanup retries
2 parents 1e62752 + f9a4b61 commit 83b7516

2 files changed

Lines changed: 227 additions & 12 deletions

File tree

src/services/AutoArchiveService.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ export class AutoArchiveService {
1919
return !!task.googleCalendarEventId;
2020
}
2121

22+
private getCalendarCleanupState(): "ready" | "retry" | "skip" {
23+
const googleCalendarExport = this.plugin.settings.googleCalendarExport;
24+
25+
if (!googleCalendarExport?.enabled || !googleCalendarExport?.syncOnTaskDelete) {
26+
return "skip";
27+
}
28+
29+
if (!this.plugin.taskCalendarSyncService) {
30+
return "retry";
31+
}
32+
33+
return this.plugin.taskCalendarSyncService.isEnabled() ? "ready" : "retry";
34+
}
35+
2236
/**
2337
* Start the auto-archive service and begin periodic processing
2438
*/
@@ -150,10 +164,19 @@ export class AutoArchiveService {
150164
}
151165

152166
if (currentTask.archived) {
153-
if (
154-
this.plugin.taskCalendarSyncService?.isEnabled() &&
155-
this.hasGoogleCalendarLink(currentTask)
156-
) {
167+
if (this.hasGoogleCalendarLink(currentTask)) {
168+
const calendarCleanupState = this.getCalendarCleanupState();
169+
if (calendarCleanupState === "skip") {
170+
return true;
171+
}
172+
173+
if (calendarCleanupState === "retry") {
174+
console.warn(
175+
`Auto-archive Google cleanup deferred until calendar sync is ready for ${item.taskPath}`
176+
);
177+
return false;
178+
}
179+
157180
const deleted =
158181
await this.plugin.taskCalendarSyncService.deleteTaskFromCalendar(currentTask);
159182
if (!deleted) {
@@ -171,11 +194,18 @@ export class AutoArchiveService {
171194
// Archive the task
172195
try {
173196
const archivedTask = await this.plugin.taskService.toggleArchive(currentTask);
174-
if (
175-
archivedTask.archived &&
176-
this.plugin.taskCalendarSyncService?.isEnabled() &&
177-
this.hasGoogleCalendarLink(archivedTask)
178-
) {
197+
if (archivedTask.archived && this.hasGoogleCalendarLink(archivedTask)) {
198+
item.taskPath = archivedTask.path;
199+
const calendarCleanupState = this.getCalendarCleanupState();
200+
if (calendarCleanupState === "skip") {
201+
return true;
202+
}
203+
204+
if (calendarCleanupState === "retry") {
205+
console.warn(
206+
`Auto-archive Google cleanup deferred until calendar sync is ready for ${item.taskPath}`
207+
);
208+
}
179209
return false;
180210
}
181211
return true;

tests/unit/issues/issue-google-calendar-archive-reliability.test.ts

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, jest, beforeEach } from "@jest/globals";
1+
import { describe, it, expect, jest } from "@jest/globals";
22
import { TFile } from "obsidian";
33

44
import { AutoArchiveService } from "../../../src/services/AutoArchiveService";
@@ -17,6 +17,17 @@ jest.mock("obsidian", () => ({
1717
},
1818
}));
1919

20+
const createGoogleCleanupEnabledPlugin = () =>
21+
PluginFactory.createMockPlugin({
22+
settings: {
23+
...PluginFactory.createMockPlugin().settings,
24+
googleCalendarExport: {
25+
enabled: true,
26+
syncOnTaskDelete: true,
27+
},
28+
},
29+
});
30+
2031
describe("Google Calendar archive reliability", () => {
2132
it("preserves the Google Calendar event ID when deletion fails so cleanup can be retried", async () => {
2233
const frontmatter: Record<string, any> = {};
@@ -97,7 +108,7 @@ describe("Google Calendar archive reliability", () => {
97108
});
98109

99110
it("keeps an auto-archive queue item pending when Google cleanup is still incomplete after archiving", async () => {
100-
const plugin = PluginFactory.createMockPlugin();
111+
const plugin = createGoogleCleanupEnabledPlugin();
101112
plugin.cacheManager.getTaskByPath = jest.fn();
102113
plugin.taskService.toggleArchive = jest.fn();
103114
plugin.taskCalendarSyncService = {
@@ -136,7 +147,7 @@ describe("Google Calendar archive reliability", () => {
136147
});
137148

138149
it("retries Google cleanup for archived tasks that still have calendar links", async () => {
139-
const plugin = PluginFactory.createMockPlugin();
150+
const plugin = createGoogleCleanupEnabledPlugin();
140151
plugin.cacheManager.getTaskByPath = jest.fn();
141152
plugin.taskService.toggleArchive = jest.fn();
142153
plugin.taskCalendarSyncService = {
@@ -168,4 +179,178 @@ describe("Google Calendar archive reliability", () => {
168179
);
169180
expect(plugin.taskService.toggleArchive).not.toHaveBeenCalled();
170181
});
182+
183+
it("persists the archived path in the retry queue when cleanup remains pending after an archive-folder move", async () => {
184+
const plugin = createGoogleCleanupEnabledPlugin();
185+
const initialItem = {
186+
taskPath: "TaskNotes/Tasks/archive-me.md",
187+
statusChangeTimestamp: 0,
188+
archiveAfterTimestamp: 0,
189+
statusValue: "done",
190+
};
191+
const pluginData = { autoArchiveQueue: [initialItem] };
192+
plugin.loadData = jest.fn().mockResolvedValue(pluginData);
193+
plugin.saveData = jest.fn().mockResolvedValue(undefined);
194+
plugin.cacheManager.getTaskByPath = jest.fn();
195+
plugin.taskService.toggleArchive = jest.fn();
196+
plugin.taskCalendarSyncService = {
197+
isEnabled: jest.fn().mockReturnValue(true),
198+
deleteTaskFromCalendar: jest.fn().mockResolvedValue(true),
199+
};
200+
201+
const autoArchiveService = new AutoArchiveService(plugin);
202+
const currentTask: TaskInfo = TaskFactory.createTask({
203+
path: "TaskNotes/Tasks/archive-me.md",
204+
status: "done",
205+
archived: false,
206+
googleCalendarEventId: "master-event-id",
207+
});
208+
const archivedTask: TaskInfo = {
209+
...currentTask,
210+
path: "TaskNotes/Archive/archive-me.md",
211+
archived: true,
212+
tags: ["task", "archived"],
213+
};
214+
215+
plugin.cacheManager.getTaskByPath.mockResolvedValue(currentTask);
216+
plugin.taskService.toggleArchive.mockResolvedValue(archivedTask);
217+
218+
await (autoArchiveService as any).processQueue();
219+
220+
expect(plugin.saveData).toHaveBeenCalledWith({
221+
autoArchiveQueue: [{ ...initialItem, taskPath: archivedTask.path }],
222+
});
223+
});
224+
225+
it("keeps archived tasks in the retry queue until calendar sync is ready", async () => {
226+
const plugin = createGoogleCleanupEnabledPlugin();
227+
const archivedItem = {
228+
taskPath: "TaskNotes/Archive/archive-me.md",
229+
statusChangeTimestamp: 0,
230+
archiveAfterTimestamp: 0,
231+
statusValue: "done",
232+
};
233+
const pluginData = { autoArchiveQueue: [archivedItem] };
234+
plugin.loadData = jest.fn().mockResolvedValue(pluginData);
235+
plugin.saveData = jest.fn().mockResolvedValue(undefined);
236+
plugin.cacheManager.getTaskByPath = jest.fn();
237+
plugin.taskService.toggleArchive = jest.fn();
238+
plugin.taskCalendarSyncService = undefined;
239+
240+
const autoArchiveService = new AutoArchiveService(plugin);
241+
const archivedTask: TaskInfo = TaskFactory.createTask({
242+
path: archivedItem.taskPath,
243+
status: "done",
244+
archived: true,
245+
tags: ["task", "archived"],
246+
googleCalendarEventId: "master-event-id",
247+
});
248+
249+
plugin.cacheManager.getTaskByPath.mockResolvedValue(archivedTask);
250+
251+
await (autoArchiveService as any).processQueue();
252+
253+
expect(plugin.saveData).toHaveBeenCalledWith({
254+
autoArchiveQueue: [archivedItem],
255+
});
256+
expect(plugin.taskService.toggleArchive).not.toHaveBeenCalled();
257+
});
258+
259+
it("drops archived tasks from the retry queue when Google cleanup is intentionally disabled", async () => {
260+
const plugin = PluginFactory.createMockPlugin({
261+
settings: {
262+
...createGoogleCleanupEnabledPlugin().settings,
263+
googleCalendarExport: {
264+
enabled: false,
265+
syncOnTaskDelete: true,
266+
},
267+
},
268+
});
269+
const archivedItem = {
270+
taskPath: "TaskNotes/Archive/archive-me.md",
271+
statusChangeTimestamp: 0,
272+
archiveAfterTimestamp: 0,
273+
statusValue: "done",
274+
};
275+
const pluginData = { autoArchiveQueue: [archivedItem] };
276+
plugin.loadData = jest.fn().mockResolvedValue(pluginData);
277+
plugin.saveData = jest.fn().mockResolvedValue(undefined);
278+
plugin.cacheManager.getTaskByPath = jest.fn();
279+
plugin.taskService.toggleArchive = jest.fn();
280+
plugin.taskCalendarSyncService = {
281+
isEnabled: jest.fn().mockReturnValue(false),
282+
deleteTaskFromCalendar: jest.fn().mockResolvedValue(true),
283+
};
284+
285+
const autoArchiveService = new AutoArchiveService(plugin);
286+
const archivedTask: TaskInfo = TaskFactory.createTask({
287+
path: archivedItem.taskPath,
288+
status: "done",
289+
archived: true,
290+
tags: ["task", "archived"],
291+
googleCalendarEventId: "master-event-id",
292+
});
293+
294+
plugin.cacheManager.getTaskByPath.mockResolvedValue(archivedTask);
295+
296+
await (autoArchiveService as any).processQueue();
297+
298+
expect(plugin.saveData).toHaveBeenCalledWith({
299+
autoArchiveQueue: [],
300+
});
301+
expect(plugin.taskCalendarSyncService.deleteTaskFromCalendar).not.toHaveBeenCalled();
302+
expect(plugin.taskService.toggleArchive).not.toHaveBeenCalled();
303+
});
304+
305+
it("drops newly archived tasks from the retry queue when Google cleanup is intentionally disabled", async () => {
306+
const plugin = PluginFactory.createMockPlugin({
307+
settings: {
308+
...createGoogleCleanupEnabledPlugin().settings,
309+
googleCalendarExport: {
310+
enabled: false,
311+
syncOnTaskDelete: true,
312+
},
313+
},
314+
});
315+
const initialItem = {
316+
taskPath: "TaskNotes/Tasks/archive-me.md",
317+
statusChangeTimestamp: 0,
318+
archiveAfterTimestamp: 0,
319+
statusValue: "done",
320+
};
321+
const pluginData = { autoArchiveQueue: [initialItem] };
322+
plugin.loadData = jest.fn().mockResolvedValue(pluginData);
323+
plugin.saveData = jest.fn().mockResolvedValue(undefined);
324+
plugin.cacheManager.getTaskByPath = jest.fn();
325+
plugin.taskService.toggleArchive = jest.fn();
326+
plugin.taskCalendarSyncService = {
327+
isEnabled: jest.fn().mockReturnValue(false),
328+
deleteTaskFromCalendar: jest.fn().mockResolvedValue(true),
329+
};
330+
331+
const autoArchiveService = new AutoArchiveService(plugin);
332+
const currentTask: TaskInfo = TaskFactory.createTask({
333+
path: initialItem.taskPath,
334+
status: "done",
335+
archived: false,
336+
googleCalendarEventId: "master-event-id",
337+
});
338+
const archivedTask: TaskInfo = {
339+
...currentTask,
340+
path: "TaskNotes/Archive/archive-me.md",
341+
archived: true,
342+
tags: ["task", "archived"],
343+
};
344+
345+
plugin.cacheManager.getTaskByPath.mockResolvedValue(currentTask);
346+
plugin.taskService.toggleArchive.mockResolvedValue(archivedTask);
347+
348+
await (autoArchiveService as any).processQueue();
349+
350+
expect(plugin.saveData).toHaveBeenCalledWith({
351+
autoArchiveQueue: [],
352+
});
353+
expect(plugin.taskService.toggleArchive).toHaveBeenCalledWith(currentTask);
354+
expect(plugin.taskCalendarSyncService.deleteTaskFromCalendar).not.toHaveBeenCalled();
355+
});
171356
});

0 commit comments

Comments
 (0)