feat: add conflict resolution strategy with todoist-wins/obsidian-wins/manual modes

- Add conflictResolutionStrategy setting (interface, DEFAULT_SETTINGS, UI dropdown)
- lineModifiedTaskCheck: handle GetTaskById returning undefined (deleted task → mark issue)
- lineModifiedTaskCheck: apply strategy instead of always marking conflicted
  - todoist-wins: clear cached updated_at, let next pull overwrite Obsidian
  - obsidian-wins: fall through to push Obsidian content to Todoist
  - manual: disable sync until user resolves (existing behavior)
- closeTask/repoenTask: add same conflict detection + strategy logic before API calls

Co-authored-by: Sisyphus <sisyphus@ohmyopencode.ai>
This commit is contained in:
HeroBlackInk 2026-02-22 14:38:18 +08:00
parent 7bb1112098
commit 0bd4417bd4
3 changed files with 99 additions and 11 deletions

View file

@ -359,11 +359,11 @@ export class SettingsBackup {
JSON.parse(data);
} catch {
console.error('[SettingsBackup] Temp file contains invalid JSON');
await adapter.remove(tempPath);
await adapter.remove(tempPath).catch(() => {});
return false;
}
await adapter.write(StoragePathManager.SETTINGS_FILE, data);
await adapter.remove(tempPath);
await adapter.remove(tempPath).catch(() => {});
console.log('[SettingsBackup] Recovered from temp file');
new Notice('Settings recovered from temp file');
return true;

View file

@ -35,6 +35,7 @@ export interface UltimateTodoistSyncSettings {
maxBackupsPerFile: number;
storageDirectory: string;
lastStorageDirectory: string | null;
conflictResolutionStrategy: 'todoist-wins' | 'obsidian-wins' | 'manual';
}
export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
@ -62,6 +63,7 @@ export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
maxBackupsPerFile: 100,
storageDirectory: 'ultimate-todoist-sync',
lastStorageDirectory: null,
conflictResolutionStrategy: 'manual',
}
export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
@ -179,6 +181,21 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName('Conflict Resolution Strategy')
.setDesc('When a task is modified in both Obsidian and Todoist: todoist-wins overwrites Obsidian, obsidian-wins pushes Obsidian to Todoist, manual disables sync until resolved.')
.addDropdown(component =>
component
.addOption('manual', 'Manual (disable sync until resolved)')
.addOption('todoist-wins', 'Todoist wins (overwrite Obsidian)')
.addOption('obsidian-wins', 'Obsidian wins (overwrite Todoist)')
.setValue(this.plugin.settings.conflictResolutionStrategy)
.onChange(async (value: 'todoist-wins' | 'obsidian-wins' | 'manual') => {
await this.plugin.safeSettings?.update({ conflictResolutionStrategy: value }, true);
new Notice(`Conflict strategy set to: ${value}`);
})
);
// ============================================
// Sync Direction Control Section
// ============================================

View file

@ -241,15 +241,40 @@ export class ObsidianToTodoistSync {
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(lineTask_todoist_id);
// Conflict detection: if Todoist was updated since our last sync, skip push and mark conflicted
if (taskMapping.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
console.warn(`[lineModifiedTaskCheck] Conflict detected for task ${lineTask_todoist_id}: Todoist updated_at=${savedTask.updated_at}, cached=${taskMapping.updated_at}`);
await this.plugin.cacheOperation.setTaskFileMapping(lineTask_todoist_id, taskMapping.filePath, taskMapping.lineNumber, 'conflicted', false);
new Notice(`Task ${lineTask_todoist_id} has a conflict: modified in both Obsidian and Todoist. Sync disabled until resolved.`);
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on task ${lineTask_todoist_id}`, filepath, lineTask_todoist_id);
// Handle deleted task: task exists in cache but not in Todoist
if (!savedTask) {
console.warn(`[lineModifiedTaskCheck] Task ${lineTask_todoist_id} not found in Todoist (deleted?), marking as issue`);
await this.plugin.cacheOperation.setTaskFileMapping(lineTask_todoist_id, taskMapping.filePath, taskMapping.lineNumber, 'issue', false);
new Notice(`Task ${lineTask_todoist_id} no longer exists in Todoist. Sync disabled.`);
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Task ${lineTask_todoist_id} missing in Todoist`, filepath, lineTask_todoist_id);
return;
}
// Conflict detection: if Todoist was updated since our last sync
if (taskMapping.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
console.warn(`[lineModifiedTaskCheck] Conflict on task ${lineTask_todoist_id}: strategy=${strategy}`);
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on task ${lineTask_todoist_id} (strategy: ${strategy})`, filepath, lineTask_todoist_id);
if (strategy === 'todoist-wins') {
// Let toObsidian pull overwrite Obsidian on next sync — just update cached updated_at
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(lineTask_todoist_id, { updated_at: undefined });
new Notice(`Conflict on task ${lineTask_todoist_id}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'obsidian-wins') {
// Force-update Todoist with Obsidian content — fall through to normal update logic below
new Notice(`Conflict on task ${lineTask_todoist_id}: Obsidian wins — pushing to Todoist.`);
// Reset cached updated_at so toObsidian won't overwrite back
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(lineTask_todoist_id, { updated_at: savedTask.updated_at });
// fall through
} else {
// manual: disable sync until user resolves
await this.plugin.cacheOperation.setTaskFileMapping(lineTask_todoist_id, taskMapping.filePath, taskMapping.lineNumber, 'conflicted', false);
new Notice(`Task ${lineTask_todoist_id} has a conflict: modified in both Obsidian and Todoist. Sync disabled until resolved.`);
return;
}
}
const lineTaskContent = lineTask.content;
const contentModified = !this.plugin.taskParser.taskContentCompare(lineTask, savedTask);
const tagsModified = !this.plugin.taskParser.taskTagCompare(lineTask, savedTask);
@ -390,9 +415,32 @@ export class ObsidianToTodoistSync {
async closeTask(taskId: string): Promise<void> {
if (!this.plugin.cacheOperation?.isTaskSyncEnabled(taskId)) return;
try {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (!savedTask) {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', taskMapping?.lineNumber || 0, 'issue', false);
new Notice(`Task ${taskId} no longer exists in Todoist. Sync disabled.`);
return;
}
if (taskMapping?.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on closeTask ${taskId} (strategy: ${strategy})`, undefined, taskId);
if (strategy === 'todoist-wins') {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: undefined });
new Notice(`Conflict on task ${taskId}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'manual') {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping.filePath, taskMapping.lineNumber, 'conflicted', false);
new Notice(`Task ${taskId} has a conflict. Sync disabled until resolved.`);
return;
}
// obsidian-wins: fall through and close
}
await this.plugin.todoistSyncAPI.CloseTask(taskId);
await this.plugin.fileOperation.completeTaskInTheFile(taskId);
// taskFileMapping already set, no need to update
this.plugin.saveSettings();
new Notice(`Task ${taskId} is closed.`);
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Closed task via checkbox: ${taskId}`, undefined, taskId);
@ -405,11 +453,34 @@ export class ObsidianToTodoistSync {
async repoenTask(taskId: string): Promise<void> {
if (!this.plugin.cacheOperation?.isTaskSyncEnabled(taskId)) return;
try {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (!savedTask) {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', taskMapping?.lineNumber || 0, 'issue', false);
new Notice(`Task ${taskId} no longer exists in Todoist. Sync disabled.`);
return;
}
if (taskMapping?.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on repoenTask ${taskId} (strategy: ${strategy})`, undefined, taskId);
if (strategy === 'todoist-wins') {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: undefined });
new Notice(`Conflict on task ${taskId}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'manual') {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping.filePath, taskMapping.lineNumber, 'conflicted', false);
new Notice(`Task ${taskId} has a conflict. Sync disabled until resolved.`);
return;
}
// obsidian-wins: fall through and reopen
}
await this.plugin.todoistSyncAPI.OpenTask(taskId);
await this.plugin.fileOperation.uncompleteTaskInTheFile(taskId);
// taskFileMapping already set, no need to update
this.plugin.saveSettings();
new Notice(`Task ${taskId} is reopend.`);
new Notice(`Task ${taskId} is reopened.`);
this.plugin.logOperation?.log('TODOIST_TASK_REOPENED', `Reopened task via checkbox: ${taskId}`, undefined, taskId);
} catch (error) {
console.error('Error opening task:', error);