From 0eeb9c8c498cd2ff74bce0fa40aac80dc55bf2cc Mon Sep 17 00:00:00 2001 From: HeroBlackInk Date: Thu, 26 Feb 2026 21:21:57 +0800 Subject: [PATCH] fix(repair): require successful vault update before remapping legacy IDs --- src/data/cache.ts | 145 +++++++++++++++++++++++++++++++------ src/vault/fileOperation.ts | 24 ++++-- 2 files changed, 142 insertions(+), 27 deletions(-) diff --git a/src/data/cache.ts b/src/data/cache.ts index 50ab448..e43fa63 100644 --- a/src/data/cache.ts +++ b/src/data/cache.ts @@ -504,6 +504,7 @@ export class CacheOperation { async applyMatchFirstAutoRepairs(resultIssues: DatabaseCheckIssueLike[], shouldSave = true): Promise { const taskFileMapping = { ...this.plugin.settings.taskFileMapping }; const touchedTaskIds = new Set(); + const legacyConversionCandidates = new Map(); let changed = false; let mappingRepaired = 0; let nonActiveMarked = 0; @@ -546,6 +547,23 @@ export class CacheOperation { continue; } + if (normalizedIssueType === 'mapping_legacy_id') { + const mappedTaskId = (issue.details.match(/migrated to (\S+)/)?.[1] || '').trim(); + if (!mappedTaskId || mappedTaskId === taskId) { + skipped++; + continue; + } + + if (!legacyConversionCandidates.has(taskId)) { + legacyConversionCandidates.set(taskId, { + taskId, + newTaskId: mappedTaskId, + filePath, + }); + } + continue; + } + if (normalizedIssueType === 'mapping_missing_for_task' || normalizedIssueType === 'mapping_pointer_stale') { const entry = ensureEntry(taskId, filePath); @@ -615,8 +633,64 @@ export class CacheOperation { skipped++; } + if (legacyConversionCandidates.size > 0) { + const fileOperation = this.plugin.fileOperation; + if (!fileOperation) { + skipped += legacyConversionCandidates.size; + } else { + for (const [oldTaskId, candidate] of legacyConversionCandidates.entries()) { + const newTaskId = candidate.newTaskId; + + const oldEntry = taskFileMapping[oldTaskId]; + if (!oldEntry) { + skipped++; + continue; + } + + const vaultUpdated = await fileOperation.updateTaskIdInVault(candidate.filePath, oldTaskId, newTaskId); + if (!vaultUpdated) { + skipped++; + continue; + } + + const mergedEntry = { + ...(taskFileMapping[newTaskId] || {}), + ...oldEntry, + filePath: candidate.filePath, + }; + + const resolvedLegacyIssue = resolveTaskIssuesByType( + mergedEntry.issues as TaskIssueRecord | undefined, + (issueType) => issueType === 'mapping_legacy_id' + ); + + if (resolvedLegacyIssue.issues && Object.keys(resolvedLegacyIssue.issues).length > 0) { + mergedEntry.issues = resolvedLegacyIssue.issues as TaskIssueRecord; + } else if (mergedEntry.issues !== undefined) { + delete mergedEntry.issues; + } + + const reconciled = reconcileTaskEntryDerivedState( + mergedEntry as { status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; syncEnabled?: boolean; issues?: TaskIssueRecord }, + (mergedEntry.status || 'active') as 'active' | 'nonActive' | 'conflicted' | 'issue' + ); + if (reconciled.changed || resolvedLegacyIssue.changed) changed = true; + + taskFileMapping[newTaskId] = mergedEntry; + if (oldTaskId !== newTaskId) { + delete taskFileMapping[oldTaskId]; + } + + touchedTaskIds.add(newTaskId); + mappingRepaired++; + changed = true; + } + } + } + for (const taskId of touchedTaskIds) { const entry = taskFileMapping[taskId]; + if (!entry) continue; const reconciled = reconcileTaskEntryDerivedState( entry as { status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; syncEnabled?: boolean; issues?: TaskIssueRecord }, (entry.status || 'active') as 'active' | 'nonActive' | 'conflicted' | 'issue' @@ -640,17 +714,21 @@ export class CacheOperation { if (issueType === 'todoist_link_stale' || issueType === 'sync_content_mismatch' || issueType === 'sync_completion_mismatch') { return 'high'; } - if (issueType === 'sync_due_mismatch' || issueType === 'sync_labels_mismatch' || issueType === 'sync_priority_mismatch' || issueType === 'sync_project_mismatch') { - return 'medium'; - } - return 'low'; - } + if (issueType === 'sync_due_mismatch' || issueType === 'sync_labels_mismatch' || issueType === 'sync_priority_mismatch' || issueType === 'sync_project_mismatch') { + return 'medium'; + } + if (issueType === 'mapping_legacy_id') { + return 'medium'; + } + return 'low'; + } - private getIssueManualAction(issueType: string): string | undefined { - if (issueType === 'todoist_link_stale') return 'Repair in Manage Problem Tasks'; - if (issueType === 'todoist_task_missing') return 'Resolve in Manage Problem Tasks'; - return undefined; - } + private getIssueManualAction(issueType: string): string | undefined { + if (issueType === 'todoist_link_stale') return 'Repair in Manage Problem Tasks'; + if (issueType === 'todoist_task_missing') return 'Resolve in Manage Problem Tasks'; + if (issueType === 'mapping_legacy_id') return 'Run Safe Repair to migrate legacy ID'; + return undefined; + } private buildIssueExpectedActual(issue: DatabaseCheckIssueLike): { expected?: string; actual?: string } { const normalizedType = normalizeTaskIssueTypeKey(issue.type); @@ -1209,8 +1287,7 @@ export class CacheOperation { // 该方法通过任务内容匹配来找到对应的新 ID try { idMapping = await todoistSyncAPI.convertLegacyIds(tasksNeedConversion); - convertedCount = Object.keys(idMapping).length; - this.plugin.debugLog(`[rebuildCache] Converted ${convertedCount} legacy IDs`); + this.plugin.debugLog(`[rebuildCache] Found ${Object.keys(idMapping).length} legacy ID candidates to migrate`); } catch (error) { console.warn(`[rebuildCache] Legacy ID conversion failed: ${(error as Error).message}. These tasks will remain with their old IDs and may trigger conversion again on next rebuild.`); this.plugin.debugLog('[rebuildCache] Will continue without converting legacy IDs'); @@ -1266,8 +1343,9 @@ export class CacheOperation { for (const [filePath, fileTasks] of fileTaskMap.entries()) { for (const taskInfo of fileTasks) { try { - const taskId = idMapping[taskInfo.taskId] || taskInfo.taskId; - const task = syncItemsMap.get(taskId); + const convertedTaskId = idMapping[taskInfo.taskId]; + const resolvedTaskId = convertedTaskId || taskInfo.taskId; + const task = syncItemsMap.get(resolvedTaskId); if (!task) { const status = taskInfo.isCompleted ? 'nonActive' : 'issue'; @@ -1300,16 +1378,41 @@ export class CacheOperation { ); continue; } - - if (idMapping[taskInfo.taskId]) { - await this.plugin.fileOperation!.updateTaskIdInVault( + + let mappingTaskId = taskInfo.taskId; + if (convertedTaskId && convertedTaskId !== taskInfo.taskId) { + const vaultUpdated = await this.plugin.fileOperation!.updateTaskIdInVault( filePath, - taskInfo.taskId, taskId + taskInfo.taskId, + convertedTaskId ); - this.plugin.debugLog(`[rebuildCache] Updated mapping: ${taskInfo.taskId} -> ${taskId}`); + + if (!vaultUpdated) { + issueCount++; + nextMapping[taskInfo.taskId] = { + filePath, + status: 'issue', + syncEnabled: false, + issues: { + mapping_legacy_id: { + state: 'open', + severity: 'medium', + source: 'runtime', + detectedAt: now, + lastSeenAt: now, + details: `Legacy ID conversion failed for ${taskInfo.taskId} -> ${convertedTaskId}. Vault update did not succeed.`, + manualAction: 'Run Safe Repair to retry legacy ID migration', + } + } + }; + continue; + } + + mappingTaskId = convertedTaskId; + convertedCount++; + this.plugin.debugLog(`[rebuildCache] Updated mapping: ${taskInfo.taskId} -> ${convertedTaskId}`); } - - const mappingTaskId = idMapping[taskInfo.taskId] || taskInfo.taskId; + const todoistContent = task.content || ''; const obsidianContent = taskInfo.content; const todoistIsCompleted = !!(task as any).checked; diff --git a/src/vault/fileOperation.ts b/src/vault/fileOperation.ts index c2a00a8..3b429b7 100644 --- a/src/vault/fileOperation.ts +++ b/src/vault/fileOperation.ts @@ -725,13 +725,13 @@ export class FileOperation { filePath: string, oldId: string, newId: string - ): Promise { + ): Promise { try { const file = this.app.vault.getAbstractFileByPath(filePath); if (!file) { console.error(`[updateTaskIdInVault] File not found: ${filePath}`); this.plugin.debugLog(filePath) - return; + return false; } const content = await this.app.vault.read(file); @@ -748,7 +748,7 @@ export class FileOperation { if (targetLineIndex === -1) { console.warn(`[updateTaskIdInVault] Old ID ${oldId} not found in ${filePath}`); - return; + return false; } let line = lines[targetLineIndex]; @@ -782,12 +782,22 @@ export class FileOperation { if (!hasChanges) { console.warn(`[updateTaskIdInVault] No ID patterns found for ${oldId} in ${filePath}`); - return; + return false; } lines[targetLineIndex] = line; - - await this.plugin.backupOperation?.backupFile(filePath); + + if (!this.plugin.backupOperation) { + console.error(`[updateTaskIdInVault] Backup module not initialized, skipping ID update ${oldId} -> ${newId}`); + return false; + } + + const backupPath = await this.plugin.backupOperation.backupFile(filePath); + if (!backupPath) { + console.error(`[updateTaskIdInVault] Backup failed, skipping ID update ${oldId} -> ${newId}`); + return false; + } + await this.app.vault.modify(file, lines.join('\n')); this.plugin.logOperation?.log( @@ -797,8 +807,10 @@ export class FileOperation { newId ); this.plugin.debugLog(`[updateTaskIdInVault] Updated task ID ${oldId} -> ${newId} in ${filePath}`); + return true; } catch (error) { console.error(`[updateTaskIdInVault] Failed to update task ID in vault:`, error); + return false; } }