mirror of
https://github.com/heroblackink/ultimate-todoist-sync-for-obsidian.git
synced 2026-07-22 07:40:27 +00:00
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:
parent
7bb1112098
commit
0bd4417bd4
3 changed files with 99 additions and 11 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue