From 37e71792138d5366148990e191ea20cad5ea40d4 Mon Sep 17 00:00:00 2001 From: HeroBlackInk Date: Wed, 25 Feb 2026 15:18:53 +0800 Subject: [PATCH] feat(issues): introduce issue-driven problem task workflow Unify issue normalization and status derivation across settings, cache, sync, and Task Manager so stale links and mismatches are tracked consistently. Keep Fix Database read-only for Todoist updates while enabling guarded manual stale-link repair and stronger task-state transitions. --- src/data/cache.ts | 167 +++++++++- src/data/databaseChecker.ts | 132 ++++++-- src/data/taskIssueUtils.ts | 76 +++++ src/data/taskParser.ts | 29 +- src/settings/safeSettings.ts | 58 +++- src/settings/settings.ts | 219 +++++++++++- src/sync/toTodoist.ts | 28 +- src/ui/modals.ts | 621 ++++++++++++++++++++++++++++++----- src/vault/fileOperation.ts | 16 +- styles.css | 8 + 10 files changed, 1194 insertions(+), 160 deletions(-) create mode 100644 src/data/taskIssueUtils.ts diff --git a/src/data/cache.ts b/src/data/cache.ts index 04f7678..f65edf0 100644 --- a/src/data/cache.ts +++ b/src/data/cache.ts @@ -42,9 +42,10 @@ * ========================================================================================== */ -import { App, TFile} from 'obsidian'; +import { App, Notice, TFile} from 'obsidian'; import UltimateTodoistSyncForObsidian from "../../main"; import { TaskConflict } from '../ui/modals'; +import { deriveTaskStatusFromIssueEntries, normalizeTaskIssueTypeKey } from './taskIssueUtils'; export interface RebuildCacheResult { success: boolean; @@ -56,6 +57,22 @@ export interface RebuildCacheResult { tasksWithoutIdCount: number; } +type TaskIssueState = 'open' | 'resolved' | 'ignored'; +type TaskIssueSeverity = 'low' | 'medium' | 'high'; +type TaskIssueSource = 'database_checker' | 'runtime'; + +type TaskIssueRecord = Record; + /** * ========================================================================================== * CacheOperation 类 @@ -263,7 +280,7 @@ export class CacheOperation { * @param taskId - Todoist 任务 ID * @returns 文件路径、状态和同步开关,如果不存在则返回 null */ - getTaskFileMapping(taskId: string): { filePath: string; status?: string; syncEnabled?: boolean; updated_at?: string; note_count?: number } | null { + getTaskFileMapping(taskId: string): { filePath: string; status?: string; syncEnabled?: boolean; updated_at?: string; note_count?: number; issues?: Record } | null { return this.plugin.settings.taskFileMapping[taskId] ?? null; } @@ -283,7 +300,10 @@ export class CacheOperation { */ async setTaskFileMapping(taskId: string, filePath: string, status: 'active' | 'nonActive' | 'conflicted' | 'issue' = 'active', syncEnabled: boolean = true): Promise { const mapping = { ...this.plugin.settings.taskFileMapping }; - mapping[taskId] = { filePath, status, syncEnabled }; + const existing = mapping[taskId]; + const nextStatus = deriveTaskStatusFromIssueEntries(existing?.issues as Record | undefined, status); + const nextSyncEnabled = nextStatus === 'active' ? syncEnabled : false; + mapping[taskId] = { ...existing, filePath, status: nextStatus, syncEnabled: nextSyncEnabled }; await this.plugin.safeSettings?.update({ taskFileMapping: mapping }); } @@ -297,6 +317,63 @@ export class CacheOperation { await this.plugin.safeSettings?.update({ taskFileMapping: mapping }); } + async upsertTaskIssue( + taskId: string, + issueType: string, + issue: { + state?: TaskIssueState; + severity?: TaskIssueSeverity; + source?: TaskIssueSource; + details?: string; + expected?: string; + actual?: string; + manualAction?: string; + }, + shouldSave: boolean = true + ): Promise { + const existing = this.plugin.settings.taskFileMapping[taskId]; + if (!existing) return; + + const mapping = { ...this.plugin.settings.taskFileMapping }; + const current = mapping[taskId]; + if (!current) return; + + const now = Date.now(); + const issueRecord: TaskIssueRecord = {}; + if (current.issues && typeof current.issues === 'object' && !Array.isArray(current.issues)) { + for (const [existingIssueType, existingIssue] of Object.entries(current.issues as TaskIssueRecord)) { + issueRecord[normalizeTaskIssueTypeKey(existingIssueType)] = existingIssue; + } + } + + const normalizedIssueType = normalizeTaskIssueTypeKey(issueType); + const previous = issueRecord[normalizedIssueType]; + issueRecord[normalizedIssueType] = { + state: issue.state ?? 'open', + severity: issue.severity ?? 'medium', + source: issue.source ?? 'runtime', + detectedAt: typeof previous?.detectedAt === 'number' ? previous.detectedAt : now, + lastSeenAt: now, + details: issue.details, + expected: issue.expected, + actual: issue.actual, + manualAction: issue.manualAction, + }; + + const fallbackStatus = (current.status || 'active') as 'active' | 'nonActive' | 'conflicted' | 'issue'; + const nextStatus = deriveTaskStatusFromIssueEntries(issueRecord, fallbackStatus); + const nextSyncEnabled = nextStatus === 'active' ? (current.syncEnabled ?? true) : false; + + mapping[taskId] = { + ...current, + issues: issueRecord, + status: nextStatus, + syncEnabled: nextSyncEnabled, + }; + + await this.plugin.safeSettings?.update({ taskFileMapping: mapping }, shouldSave); + } + /** * 删除指定任务 ID 的文件映射 * @param taskId - Todoist 任务 ID @@ -363,13 +440,21 @@ export class CacheOperation { //search new filepath this.plugin.debugLog(`file ${filepath} is not exist`) const todoistId1 = tasks[0] + if (!todoistId1) { + continue; + } + if (!this.plugin.fileOperation) { + continue; + } this.plugin.debugLog(todoistId1) const searchResult = await this.plugin.fileOperation.searchFilepathsByTaskidInVault(todoistId1) this.plugin.debugLog(`new file path is`) this.plugin.debugLog(searchResult) //update metadata - await this.updateRenamedFilePath(filepath,searchResult) + if (typeof searchResult === 'string' && searchResult.length > 0) { + await this.updateRenamedFilePath(filepath, searchResult) + } const saved = await this.plugin.saveSettings(); if (!saved) { console.warn('[checkFileMetadata] saveSettings skipped or failed'); @@ -414,7 +499,7 @@ export class CacheOperation { return this.plugin.settings.defaultProjectName; } else { const defaultProjectId = metadatas[filepath].defaultProjectId; - const project = this.plugin.todoistSyncAPI.getSyncData()?.projects?.find((p: any) => p.id === defaultProjectId); + const project = this.plugin.todoistSyncAPI?.getSyncData()?.projects?.find((p: any) => p.id === defaultProjectId); return project?.name || this.plugin.settings.defaultProjectName; } } @@ -517,7 +602,7 @@ export class CacheOperation { * @returns 项目 ID,如果不存在则返回 null */ getProjectIdByNameFromCache(projectName: string): any { - return this.plugin.todoistSyncAPI.getSyncData()?.projects?.find((p: any) => p.name === projectName)?.id || null; + return this.plugin.todoistSyncAPI?.getSyncData()?.projects?.find((p: any) => p.name === projectName)?.id || null; } // ========================================================================================== @@ -532,7 +617,7 @@ export class CacheOperation { * @returns 项目名称,如果不存在则返回 null */ getProjectNameByIdFromCache(projectId: string): any { - return this.plugin.todoistSyncAPI.getSyncData()?.projects?.find((p: any) => p.id === projectId)?.name || null; + return this.plugin.todoistSyncAPI?.getSyncData()?.projects?.find((p: any) => p.id === projectId)?.name || null; } // DEPRECATED: Using syncData from Todoist API instead @@ -623,6 +708,7 @@ export class CacheOperation { * @returns Promise */ async rebuildCache(noticeCallback?: (message: string) => void): Promise { + const backupMapping = { ...this.plugin.settings.taskFileMapping }; try { if (noticeCallback) { noticeCallback('Starting cache rebuild...'); @@ -643,7 +729,6 @@ export class CacheOperation { // ========================================================================================== // Step 1: Backup old mapping, then clear (restore on failure) - const backupMapping = { ...this.plugin.settings.taskFileMapping }; await this.plugin.safeSettings?.update({ taskFileMapping: {} }, false); // ========================================================================================== @@ -664,10 +749,15 @@ export class CacheOperation { this.plugin.debugLog('Ensuring sync data is loaded...'); } - let syncData = this.plugin.todoistSyncAPI.getSyncData(); + const todoistSyncAPI = this.plugin.todoistSyncAPI; + if (!todoistSyncAPI) { + throw new Error('Todoist Sync API is not initialized'); + } + + let syncData = todoistSyncAPI.getSyncData(); if (!syncData) { - await this.plugin.todoistSyncAPI.initializeSync(); - syncData = this.plugin.todoistSyncAPI.getSyncData(); + await todoistSyncAPI.initializeSync(); + syncData = todoistSyncAPI.getSyncData(); } // ========================================================================================== @@ -699,6 +789,9 @@ export class CacheOperation { } // 使用 fileOperation 的统一扫描方法 + if (!this.plugin.fileOperation) { + throw new Error('File operation module is not initialized'); + } const { tasksWithId, tasksWithoutId } = await this.plugin.fileOperation.scanVaultTasks(); // 转换为 fileTaskMap 格式: Map @@ -762,7 +855,7 @@ export class CacheOperation { } // 获取 syncData 中所有活动的任务 ID(用于判断是否是 legacy ID) - const activeTaskIds = new Set(syncData?.items?.map(t => t.id) || []); + const activeTaskIds = new Set(syncData?.items?.map((t: { id: string }) => t.id) || []); // 找出需要转换的潜在 legacy ID(不在 syncData 中的 ID) const tasksNeedConversion: { taskId: string; content: string; filePath: string; lineNumber: number }[] = []; @@ -791,7 +884,7 @@ export class CacheOperation { // 调用 todoistSyncAPI 的 convertLegacyIds 方法 // 该方法通过任务内容匹配来找到对应的新 ID try { - idMapping = await this.plugin.todoistSyncAPI.convertLegacyIds(tasksNeedConversion); + idMapping = await todoistSyncAPI.convertLegacyIds(tasksNeedConversion); convertedCount = Object.keys(idMapping).length; this.plugin.debugLog(`[rebuildCache] Converted ${convertedCount} legacy IDs`); } catch (error) { @@ -836,11 +929,12 @@ export class CacheOperation { if (item?.id) syncItemsMap.set(item.id, item); } - const nextMapping: Record = {}; + const nextMapping: Record = {}; const conflicts: TaskConflict[] = []; let processedCount = 0; let nonActiveCount = 0; let issueCount = 0; + const now = Date.now(); const totalTasks = Array.from(fileTaskMap.values()) .reduce((sum, tasks) => sum + tasks.length, 0); const invalidTaskIds: string[] = []; @@ -853,11 +947,27 @@ export class CacheOperation { if (!task) { const status = taskInfo.isCompleted ? 'nonActive' : 'issue'; + const issueType = taskInfo.isCompleted ? 'task_nonactive' : 'task_deleted_in_todoist'; + const issueSeverity: TaskIssueSeverity = taskInfo.isCompleted ? 'low' : 'high'; if (status === 'nonActive') nonActiveCount++; else issueCount++; nextMapping[taskInfo.taskId] = { filePath, - status, syncEnabled: false + status, + syncEnabled: false, + issues: { + [issueType]: { + state: 'open', + severity: issueSeverity, + source: 'runtime', + detectedAt: now, + lastSeenAt: now, + details: taskInfo.isCompleted + ? 'Task is completed in vault but missing in Todoist.' + : 'Task no longer exists in Todoist.', + manualAction: 'Resolve in Manage Problem Tasks', + } + } }; this.plugin.logOperation?.log( taskInfo.isCompleted ? 'CACHE_TASK_NONACTIVE' : 'CACHE_TASK_ISSUE', @@ -868,7 +978,7 @@ export class CacheOperation { } if (idMapping[taskInfo.taskId]) { - await this.plugin.fileOperation.updateTaskIdInVault( + await this.plugin.fileOperation!.updateTaskIdInVault( filePath, taskInfo.taskId, taskId ); @@ -885,6 +995,27 @@ export class CacheOperation { const statusConflict = obsidianIsCompleted !== todoistIsCompleted; if (contentConflict || statusConflict) { + const conflictIssues: TaskIssueRecord = {}; + if (contentConflict) { + conflictIssues.content_mismatch = { + state: 'open', + severity: 'high', + source: 'runtime', + detectedAt: now, + lastSeenAt: now, + details: 'Task content differs between Obsidian and Todoist.', + }; + } + if (statusConflict) { + conflictIssues.status_mismatch = { + state: 'open', + severity: 'high', + source: 'runtime', + detectedAt: now, + lastSeenAt: now, + details: 'Task completion status differs between Obsidian and Todoist.', + }; + } conflicts.push({ taskId: mappingTaskId, filePath, obsidianContent, todoistContent, @@ -892,7 +1023,9 @@ export class CacheOperation { }); nextMapping[mappingTaskId] = { filePath, - status: 'conflicted', syncEnabled: false + status: 'conflicted', + syncEnabled: false, + issues: conflictIssues, }; this.plugin.logOperation?.log('CACHE_TASK_CONFLICTED', `Task ${mappingTaskId} marked as conflicted`, filePath, mappingTaskId); } else { diff --git a/src/data/databaseChecker.ts b/src/data/databaseChecker.ts index 8735c1e..98832a9 100644 --- a/src/data/databaseChecker.ts +++ b/src/data/databaseChecker.ts @@ -34,8 +34,10 @@ export interface DatabaseCheckIssue { | 'unknown_issue' // Vault 有但 mapping 和 Todoist 都没有(未知问题) | 'content_mismatch' // 内容不一致 | 'status_mismatch' // 完成状态不一致 + | 'due_date_mismatch' + | 'stale_todoist_link' | 'priority_mismatch' // 优先级不一致 - | 'label_mismatch' // 标签不一致 + | 'labels_mismatch' | 'project_mismatch' // 项目不一致 | 'duplicate_task'; // 重复任务(同一文件多行同一任务) @@ -54,8 +56,9 @@ export interface DatabaseCheckIssue { obsidianStatus?: boolean; // Obsidian 中的完成状态 todoistStatus?: boolean; // Todoist 中的完成状态 - // 日期相关 - dueDate?: string; // 截止日期 + // 日期相关 + dueDate?: string; + todoistDueDate?: string; // 优先级相关 priority?: number; // 优先级(通用) @@ -72,6 +75,9 @@ export interface DatabaseCheckIssue { labels?: string[]; // 标签(通用) obsidianLabels?: string[]; // Obsidian 中的标签 todoistLabels?: string[]; // Todoist 中的标签 + + expectedFilePath?: string; + todoistFilePath?: string; } /** @@ -85,6 +91,8 @@ export interface VaultTask { filePath: string; // 任务所在文件路径 lineNumber: number; // 任务所在行号(0-indexed) labels: string[]; // 任务标签(#tag 格式) + dueDate?: string; + priority?: number; } @@ -96,6 +104,7 @@ export interface VaultTask { export interface TodoistTask { taskId: string; // 任务 ID content: string; // 任务内容 + description?: string; checked: boolean; // 是否已完成 dueDate?: string; // 截止日期 priority: number; // 优先级 (1-4, 1 最高) @@ -124,11 +133,13 @@ export interface DatabaseCheckResult { newTaskNotSynced: number; // 新任务未同步 taskNonActive: number; // 已标记为 nonActive 的任务 taskIssue: number; // 已标记为 issue 的任务 + staleTodoistLink: number; unknownIssue: number; // 未知问题 contentMismatch: number; // 内容不一致 statusMismatch: number; // 状态不一致 + dueDateMismatch: number; priorityMismatch: number; // 优先级不一致 - labelMismatch: number; // 标签不一致 + labelsMismatch: number; projectMismatch: number; // 项目不一致 duplicateTask: number; // 重复任务 }; @@ -166,6 +177,14 @@ export class DatabaseChecker { this.plugin = plugin; } + private normalizeFilePath(path: string): string { + try { + return decodeURIComponent(path).replace(/\\/g, '/'); + } catch (_error) { + return path.replace(/\\/g, '/'); + } + } + /** * 主检查方法 - 执行完整的数据库一致性检查 * @@ -194,11 +213,13 @@ export class DatabaseChecker { newTaskNotSynced: 0, // 新任务未同步 taskNonActive: 0, // 已标记为 nonActive 的任务 taskIssue: 0, // 已标记为 issue 的任务 + staleTodoistLink: 0, unknownIssue: 0, // 未知问题 contentMismatch: 0, // 内容不一致 statusMismatch: 0, // 状态不一致 + dueDateMismatch: 0, priorityMismatch: 0, // 优先级不一致 - labelMismatch: 0, // 标签不一致 + labelsMismatch: 0, projectMismatch: 0, // 项目不一致 duplicateTask: 0 // 重复任务 }; @@ -372,11 +393,13 @@ export class DatabaseChecker { newTaskNotSynced: 0, taskNonActive: 0, taskIssue: 0, + staleTodoistLink: 0, unknownIssue: 0, contentMismatch: 0, statusMismatch: 0, + dueDateMismatch: 0, priorityMismatch: 0, - labelMismatch: 0, + labelsMismatch: 0, projectMismatch: 0, duplicateTask: 0 }; @@ -441,33 +464,43 @@ export class DatabaseChecker { summary.statusMismatch++; } - // ---- 检查优先级一致性 ---- - // Priority is stored as !! in task text, not as labels - // Skip priority check here — vault scan doesn't extract priority from text - const vaultPriority = todoistTask!.priority; - // Priority check disabled: vault scan doesn't extract priority from text - // if (vaultPriority !== todoistTask!.priority) { - // issues.push({ - // type: 'priority_mismatch', - // filePath: vaultTask!.filePath, - // taskId, - // lineNumber: vaultTask!.lineNumber, - // details: `Priority mismatch: Vault is ${vaultPriority}, Todoist is ${todoistTask!.priority}`, - // obsidianPriority: vaultPriority, - // todoistPriority: todoistTask!.priority - // }); - // summary.priorityMismatch++; - // } + const vaultDueDate = vaultTask!.dueDate || ''; + const todoistDueDate = todoistTask!.dueDate || ''; + if (vaultDueDate !== todoistDueDate) { + issues.push({ + type: 'due_date_mismatch', + filePath: vaultTask!.filePath, + taskId, + lineNumber: vaultTask!.lineNumber, + details: `Due date mismatch: Vault is ${vaultDueDate || '(none)'}, Todoist is ${todoistDueDate || '(none)'}`, + dueDate: vaultDueDate, + todoistDueDate, + }); + summary.dueDateMismatch++; + } + + const vaultPriority = vaultTask!.priority || 1; + const todoistPriority = todoistTask!.priority || 1; + if (vaultPriority !== todoistPriority) { + issues.push({ + type: 'priority_mismatch', + filePath: vaultTask!.filePath, + taskId, + lineNumber: vaultTask!.lineNumber, + details: `Priority mismatch: Vault is ${vaultPriority}, Todoist is ${todoistPriority}`, + obsidianPriority: vaultPriority, + todoistPriority, + }); + summary.priorityMismatch++; + } - // ---- 检查标签一致性 ---- - // vaultTask.labels 现在已经不带 # 前缀(由 fileOperation.extractLabelsFromLine 处理) const obsidianLabels = (vaultTask!.labels || []).filter(l => l !== 'todoist'); const todoistLabels = (todoistTask!.labels || []).filter(l => l !== 'todoist'); const labelDiff = obsidianLabels.filter(l => !todoistLabels.includes(l)).length > 0 || todoistLabels.filter(l => !obsidianLabels.includes(l)).length > 0; if (labelDiff) { issues.push({ - type: 'label_mismatch', + type: 'labels_mismatch', filePath: vaultTask!.filePath, taskId, lineNumber: vaultTask!.lineNumber, @@ -475,7 +508,7 @@ export class DatabaseChecker { obsidianLabels, todoistLabels }); - summary.labelMismatch++; + summary.labelsMismatch++; } // ---- 检查项目一致性 ---- @@ -495,6 +528,26 @@ export class DatabaseChecker { summary.projectMismatch++; } } + + const descriptionFilePath = this.plugin.taskParser?.extractFilePathFromObsidianDescription(todoistTask!.description || ''); + if (descriptionFilePath && mapping?.filePath) { + const expectedPath = this.normalizeFilePath(mapping.filePath); + const parsedPath = this.normalizeFilePath(descriptionFilePath); + + if (expectedPath !== parsedPath) { + issues.push({ + type: 'stale_todoist_link', + filePath: vaultTask!.filePath, + taskId, + lineNumber: vaultTask!.lineNumber, + details: `Todoist description link points to ${parsedPath} but mapping expects ${expectedPath}`, + expectedFilePath: expectedPath, + todoistFilePath: parsedPath, + todoistContent: todoistTask!.description || '' + }); + summary.staleTodoistLink++; + } + } } else { // 情况 2: Vault ✅ + Mapping ✅ + Todoist ❌ // 检查 mapping.status → nonActive / issue / deleted @@ -663,8 +716,9 @@ export class DatabaseChecker { // 计算第二步 8 种情况的统计 // 情况 1: Vault ✅ + Mapping ✅ + Todoist ✅ = 全部 Vault+Mapping 任务 - 有问题的任务 const totalVaultWithMappingTasks = step1.vaultWithMapping; - const issuesInVaultWithMapping = result.summary.contentMismatch + result.summary.statusMismatch + - result.summary.priorityMismatch + result.summary.labelMismatch + + const issuesInVaultWithMapping = result.summary.contentMismatch + result.summary.statusMismatch + + result.summary.dueDateMismatch + result.summary.priorityMismatch + result.summary.labelsMismatch + + result.summary.staleTodoistLink + result.summary.projectMismatch; const c1 = totalVaultWithMappingTasks - issuesInVaultWithMapping; // 一致的任务 @@ -767,8 +821,10 @@ Generated: ${new Date().toLocaleString()} 'task_not_in_vault': 'File Missing', 'content_mismatch': 'Content Mismatch', 'status_mismatch': 'Status Mismatch', + 'stale_todoist_link': 'Stale Obsidian Link in Todoist', 'priority_mismatch': 'Priority Mismatch', - 'label_mismatch': 'Label Mismatch', + 'due_date_mismatch': 'Due Date Mismatch', + 'labels_mismatch': 'Labels Mismatch', 'project_mismatch': 'Project Mismatch', 'duplicate_task': 'Duplicate Task', 'new_task_not_synced': 'New Task Not Synced' @@ -855,6 +911,20 @@ Generated: ${new Date().toLocaleString()} markdown += '\n'; } + if (type === 'due_date_mismatch') { + markdown += `#### Due Date Details\n\n`; + for (let i = 0; i < Math.min(issues.length, 10); i++) { + const issue = issues[i]; + const obsDue = issue.dueDate || 'none'; + const todoDue = issue.todoistDueDate || 'none'; + markdown += `- **\`${issue.taskId}\`**: Vault is **${obsDue}**, Todoist is **${todoDue}**\n`; + } + if (issues.length > 10) { + markdown += `*... and ${issues.length - 10} more*\n`; + } + markdown += '\n'; + } + // 为优先级不一致问题添加详细对比 if (type === 'priority_mismatch') { markdown += `#### Priority Details\n\n`; @@ -871,7 +941,7 @@ Generated: ${new Date().toLocaleString()} } // 为标签不一致问题添加详细对比 - if (type === 'label_mismatch') { + if (type === 'labels_mismatch') { markdown += `#### Label Details\n\n`; for (let i = 0; i < Math.min(issues.length, 10); i++) { const issue = issues[i]; diff --git a/src/data/taskIssueUtils.ts b/src/data/taskIssueUtils.ts new file mode 100644 index 0000000..ef3d420 --- /dev/null +++ b/src/data/taskIssueUtils.ts @@ -0,0 +1,76 @@ +export type DerivedTaskStatus = 'active' | 'nonActive' | 'conflicted' | 'issue'; + +export const CONFLICT_ISSUE_TYPE_KEYS = new Set([ + 'content_mismatch', + 'status_mismatch', + 'due_date_mismatch', + 'labels_mismatch', + 'priority_mismatch', + 'project_mismatch', + 'duplicate_task', +]); + +export const NON_ACTIVE_ISSUE_TYPE_KEYS = new Set(['task_nonactive']); + +const TASK_ISSUE_TYPE_VALUES = new Set([ + 'content_mismatch', + 'status_mismatch', + 'due_date_mismatch', + 'labels_mismatch', + 'priority_mismatch', + 'project_mismatch', + 'stale_todoist_link', + 'task_deleted_in_todoist', + 'task_not_in_vault', + 'vault_task_no_mapping', + 'mapping_file_not_found', + 'mapping_task_not_in_todoist', + 'mapping_orphan', + 'new_task_not_synced', + 'task_issue', + 'task_nonactive', + 'duplicate_task', + 'unknown_issue', +]); + +const LEGACY_TASK_ISSUE_TYPE_ALIASES: Record = { + label_mismatch: 'labels_mismatch', + labelsMismatch: 'labels_mismatch', + dueDateMismatch: 'due_date_mismatch', + duedateMismatch: 'due_date_mismatch', + priorityMismatch: 'priority_mismatch', + projectMismatch: 'project_mismatch', + contentMismatch: 'content_mismatch', + statusMismatch: 'status_mismatch', + staleTodoistLink: 'stale_todoist_link', + taskNonActive: 'task_nonactive', + taskIssue: 'task_issue', + unknownIssue: 'unknown_issue', + duplicateTask: 'duplicate_task', +}; + +export function normalizeTaskIssueTypeKey(issueType: string): string { + if (TASK_ISSUE_TYPE_VALUES.has(issueType)) { + return issueType; + } + + return LEGACY_TASK_ISSUE_TYPE_ALIASES[issueType] || 'unknown_issue'; +} + +export function deriveTaskStatusFromIssueEntries( + issues: Record | undefined, + fallbackStatus: DerivedTaskStatus = 'active' +): DerivedTaskStatus { + if (!issues || Object.keys(issues).length === 0) return fallbackStatus; + + const openIssueTypes = Object.entries(issues) + .filter(([, issue]) => issue?.state === 'open') + .map(([issueType]) => normalizeTaskIssueTypeKey(issueType)); + + const normalizedOpenIssueTypes = Array.from(new Set(openIssueTypes)); + + if (normalizedOpenIssueTypes.length === 0) return 'active'; + if (normalizedOpenIssueTypes.some(issueType => CONFLICT_ISSUE_TYPE_KEYS.has(issueType))) return 'conflicted'; + if (normalizedOpenIssueTypes.some(issueType => NON_ACTIVE_ISSUE_TYPE_KEYS.has(issueType))) return 'nonActive'; + return 'issue'; +} diff --git a/src/data/taskParser.ts b/src/data/taskParser.ts index ab2ee81..9378eae 100644 --- a/src/data/taskParser.ts +++ b/src/data/taskParser.ts @@ -79,7 +79,9 @@ const REGEX = { TAB_INDENTATION: /^(\t+)/, TASK_PRIORITY: /\s!!([1-4])\s/, BLANK_LINE: /^\s*$/, - TODOIST_EVENT_DATE: /(\d{4})-(\d{2})-(\d{2})/ + TODOIST_EVENT_DATE: /(\d{4})-(\d{2})-(\d{2})/, + OBSIDIAN_FILE_QUERY: /[?&]file=([^&]+)/, + OBSIDIAN_WIKILINK: /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/ }; export class TaskParser { @@ -498,6 +500,31 @@ export class TaskParser { return(obsidianUrl) } + extractFilePathFromObsidianDescription(description: string): string | null { + if (!description) return null; + + const markdownLinkMatch = description.match(/\((obsidian:\/\/open\?[^)]*)\)/i); + if (markdownLinkMatch) { + const url = markdownLinkMatch[1]; + const fileMatch = url.match(REGEX.OBSIDIAN_FILE_QUERY); + if (fileMatch?.[1]) { + try { + return decodeURIComponent(fileMatch[1]); + } catch (error) { + console.error(`[TaskParser] Failed to decode Obsidian description path: ${error}`); + return fileMatch[1]; + } + } + } + + const wikiLinkMatch = description.match(REGEX.OBSIDIAN_WIKILINK); + if (wikiLinkMatch?.[1]) { + return wikiLinkMatch[1]; + } + + return null; + } + addTodoistLink(linetext: string,todoistLink:string): string { const regex = new RegExp(`${keywords.TODOIST_TAG}`, "g"); diff --git a/src/settings/safeSettings.ts b/src/settings/safeSettings.ts index 3b80761..045497c 100644 --- a/src/settings/safeSettings.ts +++ b/src/settings/safeSettings.ts @@ -1,6 +1,6 @@ import { App, Notice } from 'obsidian'; -import { DEFAULT_SETTINGS } from './settings'; +import { DEFAULT_SETTINGS, deriveTaskStatusFromIssues, normalizeTaskIssueType, TaskIssueEntry } from './settings'; import { StoragePathManager } from '../storage/pathManager'; import UltimateTodoistSyncForObsidian from '../../main'; import { DeviceManager } from '../utils/deviceManager'; @@ -16,6 +16,7 @@ type TaskMappingEntry = { syncEnabled?: boolean; updated_at?: string; note_count?: number; + issues?: Record; }; // ========================================================================================== @@ -367,6 +368,9 @@ export class SafeSettings { const mapping = this.plugin.settings.taskFileMapping; if (!mapping) return; let fixed = 0; + const validStates = new Set(['open', 'resolved', 'ignored']); + const validSeverity = new Set(['low', 'medium', 'high']); + const validSource = new Set(['database_checker', 'runtime']); for (const [taskId, entry] of Object.entries(mapping)) { // Remove entries with missing or invalid filePath if (!entry || typeof entry.filePath !== 'string' || !entry.filePath.trim()) { @@ -375,14 +379,54 @@ export class SafeSettings { fixed++; continue; } - // Upgrade missing status field - if (!entry.status) { - entry.status = 'active'; + let normalizedIssues: Record | undefined; + if (entry.issues && typeof entry.issues === 'object' && !Array.isArray(entry.issues)) { + normalizedIssues = {}; + for (const [issueType, issueValue] of Object.entries(entry.issues)) { + if (!issueValue || typeof issueValue !== 'object' || Array.isArray(issueValue)) { + fixed++; + continue; + } + + const candidate = issueValue as Partial; + const state = validStates.has(candidate.state as string) ? candidate.state as TaskIssueEntry['state'] : 'open'; + const severity = validSeverity.has(candidate.severity as string) ? candidate.severity as TaskIssueEntry['severity'] : 'medium'; + const source = validSource.has(candidate.source as string) ? candidate.source as TaskIssueEntry['source'] : 'runtime'; + const detectedAt = typeof candidate.detectedAt === 'number' ? candidate.detectedAt : Date.now(); + const lastSeenAt = typeof candidate.lastSeenAt === 'number' ? candidate.lastSeenAt : detectedAt; + + const normalizedIssueType = normalizeTaskIssueType(issueType); + normalizedIssues[normalizedIssueType] = { + state, + severity, + source, + detectedAt, + lastSeenAt, + details: typeof candidate.details === 'string' ? candidate.details : undefined, + expected: typeof candidate.expected === 'string' ? candidate.expected : undefined, + actual: typeof candidate.actual === 'string' ? candidate.actual : undefined, + manualAction: typeof candidate.manualAction === 'string' ? candidate.manualAction : undefined, + }; + } + } + + if (normalizedIssues && Object.keys(normalizedIssues).length > 0) { + entry.issues = normalizedIssues; + } else if (entry.issues !== undefined) { + delete entry.issues; fixed++; } - // Upgrade missing syncEnabled field - if (typeof entry.syncEnabled !== 'boolean') { - entry.syncEnabled = true; + + const fallbackStatus = entry.status || 'active'; + const nextStatus = deriveTaskStatusFromIssues(entry.issues, fallbackStatus); + if (entry.status !== nextStatus) { + entry.status = nextStatus; + fixed++; + } + + const expectedSyncEnabled = nextStatus === 'active'; + if (entry.syncEnabled !== expectedSyncEnabled) { + entry.syncEnabled = expectedSyncEnabled; fixed++; } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 5e4b9c7..eab4a84 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -2,6 +2,60 @@ import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; import UltimateTodoistSyncForObsidian from "../../main"; import { LogViewerModal, TaskManagerModal } from '../ui/modals'; import { RebuildCacheResult } from '../data/cache'; +import { deriveTaskStatusFromIssueEntries, normalizeTaskIssueTypeKey } from '../data/taskIssueUtils'; +import type { DatabaseCheckIssue, DatabaseCheckResult } from '../data/databaseChecker'; + +export type TaskIssueType = + | 'content_mismatch' + | 'status_mismatch' + | 'due_date_mismatch' + | 'labels_mismatch' + | 'priority_mismatch' + | 'project_mismatch' + | 'stale_todoist_link' + | 'task_deleted_in_todoist' + | 'task_not_in_vault' + | 'vault_task_no_mapping' + | 'mapping_file_not_found' + | 'mapping_task_not_in_todoist' + | 'mapping_orphan' + | 'new_task_not_synced' + | 'task_issue' + | 'task_nonactive' + | 'duplicate_task' + | 'unknown_issue'; + +export interface TaskIssueEntry { + state: 'open' | 'resolved' | 'ignored'; + severity: 'low' | 'medium' | 'high'; + source: 'database_checker' | 'runtime'; + detectedAt: number; + lastSeenAt: number; + details?: string; + expected?: string; + actual?: string; + manualAction?: string; +} + +export interface TaskFileMappingEntry { + filePath: string; + status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; + syncEnabled?: boolean; + updated_at?: string; + note_count?: number; + issues?: Record; +} + +export function normalizeTaskIssueType(issueType: string): TaskIssueType { + return normalizeTaskIssueTypeKey(issueType) as TaskIssueType; +} + +export function deriveTaskStatusFromIssues( + issues: Record | undefined, + fallbackStatus: 'active' | 'nonActive' | 'conflicted' | 'issue' = 'active' +): 'active' | 'nonActive' | 'conflicted' | 'issue' { + return deriveTaskStatusFromIssueEntries(issues, fallbackStatus); +} export interface UltimateTodoistSyncSettings { initialized: boolean; @@ -12,13 +66,7 @@ export interface UltimateTodoistSyncSettings { automaticSynchronizationInterval: number; fileMetadata: Record; taskFileMapping: { - [taskId: string]: { - filePath: string; - status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; - syncEnabled?: boolean; - updated_at?: string; - note_count?: number; - }; + [taskId: string]: TaskFileMappingEntry; }; enableFullVaultSync: boolean; debugMode: boolean; @@ -81,6 +129,155 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab { this.plugin = plugin; } + private normalizeIssueType(issueType: string): TaskIssueType { + return normalizeTaskIssueType(issueType); + } + + private getIssueSeverity(issueType: TaskIssueType): TaskIssueEntry['severity'] { + if (issueType === 'stale_todoist_link' || issueType === 'content_mismatch' || issueType === 'status_mismatch') { + return 'high'; + } + if (issueType === 'due_date_mismatch' || issueType === 'labels_mismatch' || issueType === 'priority_mismatch' || issueType === 'project_mismatch') { + return 'medium'; + } + return 'low'; + } + + private getIssueManualAction(issueType: TaskIssueType): string | undefined { + if (issueType === 'stale_todoist_link') return 'Repair in Manage Problem Tasks'; + if (issueType === 'task_deleted_in_todoist') return 'Resolve in Manage Problem Tasks'; + return undefined; + } + + private buildIssueExpectedActual(issue: DatabaseCheckIssue): { expected?: string; actual?: string } { + const normalizedType = this.normalizeIssueType(issue.type); + if (normalizedType === 'stale_todoist_link') { + return { + expected: issue.expectedFilePath, + actual: issue.todoistFilePath, + }; + } + if (normalizedType === 'due_date_mismatch') { + return { + expected: issue.dueDate, + actual: issue.todoistDueDate, + }; + } + if (normalizedType === 'labels_mismatch') { + return { + expected: issue.obsidianLabels?.join(', '), + actual: issue.todoistLabels?.join(', '), + }; + } + if (normalizedType === 'priority_mismatch') { + return { + expected: issue.obsidianPriority !== undefined ? String(issue.obsidianPriority) : undefined, + actual: issue.todoistPriority !== undefined ? String(issue.todoistPriority) : undefined, + }; + } + if (normalizedType === 'status_mismatch') { + return { + expected: issue.obsidianStatus !== undefined ? String(issue.obsidianStatus) : undefined, + actual: issue.todoistStatus !== undefined ? String(issue.todoistStatus) : undefined, + }; + } + if (normalizedType === 'content_mismatch') { + return { + expected: issue.obsidianContent, + actual: issue.todoistContent, + }; + } + + return {}; + } + + private async applyDatabaseIssuesToMapping(result: DatabaseCheckResult): Promise { + const taskFileMapping = { ...this.plugin.settings.taskFileMapping }; + const now = Date.now(); + const seenIssueTypesByTask = new Map>(); + const clonedEntries = new Set(); + let changed = false; + + const ensureClonedEntry = (taskId: string): TaskFileMappingEntry | undefined => { + const originalEntry = taskFileMapping[taskId]; + if (!originalEntry) return undefined; + if (!clonedEntries.has(taskId)) { + taskFileMapping[taskId] = { + ...originalEntry, + issues: originalEntry.issues ? { ...originalEntry.issues } : undefined, + }; + clonedEntries.add(taskId); + } + return taskFileMapping[taskId]; + }; + + for (const issue of result.issues) { + if (!issue.taskId) continue; + const mappingEntry = ensureClonedEntry(issue.taskId); + if (!mappingEntry) continue; + + const issueType = this.normalizeIssueType(issue.type); + if (!mappingEntry.issues) mappingEntry.issues = {}; + + const existingIssue = mappingEntry.issues[issueType]; + const expectedActual = this.buildIssueExpectedActual(issue); + + mappingEntry.issues[issueType] = { + state: 'open', + severity: this.getIssueSeverity(issueType), + source: 'database_checker', + detectedAt: existingIssue?.detectedAt ?? now, + lastSeenAt: now, + details: issue.details, + expected: expectedActual.expected, + actual: expectedActual.actual, + manualAction: this.getIssueManualAction(issueType), + }; + + const seenIssueTypes = seenIssueTypesByTask.get(issue.taskId) || new Set(); + seenIssueTypes.add(issueType); + seenIssueTypesByTask.set(issue.taskId, seenIssueTypes); + changed = true; + } + + for (const taskId of Object.keys(taskFileMapping)) { + const mappingEntry = ensureClonedEntry(taskId); + if (!mappingEntry) continue; + if (!mappingEntry.issues) continue; + + const seenIssueTypes = seenIssueTypesByTask.get(taskId) || new Set(); + for (const [issueType, issueEntry] of Object.entries(mappingEntry.issues)) { + if (issueEntry.source !== 'database_checker') continue; + if (issueEntry.state !== 'open') continue; + if (seenIssueTypes.has(issueType)) continue; + + mappingEntry.issues[issueType] = { + ...issueEntry, + state: 'resolved', + lastSeenAt: now, + }; + changed = true; + } + + const fallbackStatus = mappingEntry.status || 'active'; + const nextStatus = deriveTaskStatusFromIssues(mappingEntry.issues, fallbackStatus); + if (mappingEntry.status !== nextStatus) { + mappingEntry.status = nextStatus; + changed = true; + } + + const nextSyncEnabled = nextStatus === 'active'; + if (mappingEntry.syncEnabled !== nextSyncEnabled) { + mappingEntry.syncEnabled = nextSyncEnabled; + changed = true; + } + } + + if (changed) { + await this.plugin.safeSettings?.update({ taskFileMapping }, true); + } + } + display(): void { const { containerEl } = this; @@ -436,6 +633,7 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab { const before = await databaseChecker.checkDatabase((msg) => { progressNotice.setMessage(`Step 1/3: ${msg}`); }); + await this.applyDatabaseIssuesToMapping(before); if (before.success) { progressNotice.hide(); await this.plugin.safeSettings?.update({ @@ -461,11 +659,11 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab { if (rebuildResult.tasksWithoutIdCount > 0) parts.push(`${rebuildResult.tasksWithoutIdCount} unsynced tasks`); new Notice(parts.join(' · '), 8000); } - // Step 3: Re-check to see what remains progressNotice.setMessage('Step 3/3: Re-checking database...'); const after = await databaseChecker.checkDatabase((msg) => { progressNotice.setMessage(`Step 3/3: ${msg}`); }); + await this.applyDatabaseIssuesToMapping(after); progressNotice.hide(); const fixedCount = Math.max(0, before.totalIssues - after.totalIssues); const remainingCount = after.totalIssues; @@ -485,6 +683,9 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab { if (after.reportPath) { new Notice(`Report: ${after.reportPath}`, 5000); } + if (after.summary.staleTodoistLink > 0) { + new Notice(`🔗 Found ${after.summary.staleTodoistLink} stale Todoist links. Please repair them manually in Manage Problem Tasks.`, 8000); + } } catch (error) { progressNotice.hide(); new Notice(`Fix Database error: ${error instanceof Error ? error.message : String(error)}`); @@ -532,7 +733,7 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Manage Problem Tasks') - .setDesc('View and resolve conflicted, issue, and inactive tasks.') + .setDesc('View and resolve conflicted, issue, inactive, and stale-link tasks.') .addButton(button => button .setButtonText('Manage') .onClick(() => { diff --git a/src/sync/toTodoist.ts b/src/sync/toTodoist.ts index 403807f..5860ad1 100644 --- a/src/sync/toTodoist.ts +++ b/src/sync/toTodoist.ts @@ -305,6 +305,13 @@ export class ObsidianToTodoistSync { } 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, 'issue', false); + await this.plugin.cacheOperation.upsertTaskIssue(lineTask_todoist_id, 'task_deleted_in_todoist', { + state: 'open', + severity: 'high', + source: 'runtime', + details: 'Task no longer exists in Todoist.', + manualAction: 'Resolve in Manage Problem Tasks', + }, 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; @@ -496,6 +503,13 @@ export class ObsidianToTodoistSync { if (!savedTask) { await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', 'issue', false); + await this.plugin.cacheOperation.upsertTaskIssue(taskId, 'task_deleted_in_todoist', { + state: 'open', + severity: 'high', + source: 'runtime', + details: 'Task no longer exists in Todoist.', + manualAction: 'Resolve in Manage Problem Tasks', + }, false); new Notice(`Task ${taskId} no longer exists in Todoist. Sync disabled.`); return; } @@ -550,6 +564,13 @@ export class ObsidianToTodoistSync { if (!savedTask) { await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', 'issue', false); + await this.plugin.cacheOperation.upsertTaskIssue(taskId, 'task_deleted_in_todoist', { + state: 'open', + severity: 'high', + source: 'runtime', + details: 'Task no longer exists in Todoist.', + manualAction: 'Resolve in Manage Problem Tasks', + }, false); new Notice(`Task ${taskId} no longer exists in Todoist. Sync disabled.`); return; } @@ -606,7 +627,12 @@ export class ObsidianToTodoistSync { const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId); if (taskMapping) { if (!this.plugin.cacheOperation.isTaskSyncEnabled(taskId)) continue; - const description = `[[${filepath}]]`; + const description = this.plugin.taskParser.getObsidianUrlFromFilepath(filepath); + const todoistTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId); + if (todoistTask?.description === description) { + continue; + } + await this.plugin.todoistSyncAPI.UpdateTask(taskId, { description }); this.plugin.logOperation?.log('TODOIST_TASK_UPDATED', `Updated task description: ${taskId}`, filepath, taskId, 'obsidian→todoist'); } diff --git a/src/ui/modals.ts b/src/ui/modals.ts index 065a16d..864b9fc 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -1,16 +1,13 @@ -import { App, Modal, Notice, Setting, TextComponent, TFile } from "obsidian"; +import { App, Modal, Notice, Setting, TFile } from "obsidian"; import UltimateTodoistSyncForObsidian from "../../main"; +import { CONFLICT_ISSUE_TYPE_KEYS, deriveTaskStatusFromIssueEntries, type DerivedTaskStatus, normalizeTaskIssueTypeKey } from '../data/taskIssueUtils'; +import type { TaskIssueEntry } from '../settings/settings'; // ========================================================================================== // SetDefalutProjectInTheFilepathModal - 设置文件默认项目 // ========================================================================================== -interface MyProject { - id: string; - name: string; -} - export class SetDefalutProjectInTheFilepathModal extends Modal { defaultProjectId: string defaultProjectName: string @@ -30,17 +27,23 @@ export class SetDefalutProjectInTheFilepathModal extends Modal { contentEl.empty(); contentEl.createEl('h5', { text: 'Set default project for todoist tasks in the current file' }); - this.defaultProjectId = await this.plugin.cacheOperation.getDefaultProjectIdForFilepath(this.filepath) - const project = await this.plugin.todoistSyncAPI.getProjectById(this.defaultProjectId) + const cacheOperation = this.plugin.cacheOperation; + const todoistSyncAPI = this.plugin.todoistSyncAPI; + if (!cacheOperation || !todoistSyncAPI) { + new Notice('Required sync modules are not initialized.'); + return; + } + + this.defaultProjectId = cacheOperation.getDefaultProjectIdForFilepath(this.filepath) || this.plugin.settings.defaultProjectId + const project = await todoistSyncAPI.getProjectById(this.defaultProjectId) this.defaultProjectName = project?.name ?? this.plugin.settings.defaultProjectName this.plugin.debugLog(this.defaultProjectId) this.plugin.debugLog(this.defaultProjectName) - const projects = this.plugin.todoistSyncAPI.getSyncData()?.projects || [] - const myProjectsOptions: MyProject | undefined = projects.reduce((obj, item) => { - obj[(item.id).toString()] = item.name; - return obj; - }, {} - ); + const projects = todoistSyncAPI.getSyncData()?.projects || [] + const myProjectsOptions: Record = {}; + for (const projectItem of projects) { + myProjectsOptions[String(projectItem.id)] = projectItem.name; + } @@ -53,7 +56,7 @@ export class SetDefalutProjectInTheFilepathModal extends Modal { .addOptions(myProjectsOptions) .onChange(async (value)=>{ this.plugin.debugLog(`project id is ${value}`) - await this.plugin.cacheOperation.setDefaultProjectIdForFilepath(this.filepath,value) + await cacheOperation.setDefaultProjectIdForFilepath(this.filepath,value) this.plugin.setStatusBarText() this.close(); @@ -338,13 +341,160 @@ export class TaskManagerModal extends Modal { private _fileCache = new Map(); private currentView: 'list' | 'detail' = 'list'; private selectedTaskId: string | null = null; - private taskData: { conflicted: string[], issue: string[], nonActive: string[] } = { conflicted: [], issue: [], nonActive: [] }; + private taskData: { conflicted: string[], issue: string[], nonActive: string[], staleLink: string[] } = { conflicted: [], issue: [], nonActive: [], staleLink: [] }; + private staleLinkIssues: Record = {}; private scrollArea: HTMLElement | null = null; private _activeOverlay: HTMLElement | null = null; + private readonly conflictIssueTypes = new Set(CONFLICT_ISSUE_TYPE_KEYS); + + private normalizeFallbackStatus(status: string): DerivedTaskStatus { + if (status === 'active' || status === 'nonActive' || status === 'conflicted' || status === 'issue') { + return status; + } + return 'active'; + } + + private getTodoistSyncAPI(showNotice = true): NonNullable | null { + const todoistSyncAPI = this.plugin.todoistSyncAPI; + if (!todoistSyncAPI) { + if (showNotice) new Notice('Todoist sync API is not initialized.'); + return null; + } + return todoistSyncAPI; + } + + private getTaskParser(showNotice = true): NonNullable | null { + const taskParser = this.plugin.taskParser; + if (!taskParser) { + if (showNotice) new Notice('Task parser is not initialized.'); + return null; + } + return taskParser; + } + + private getCacheOperation(showNotice = true): NonNullable | null { + const cacheOperation = this.plugin.cacheOperation; + if (!cacheOperation) { + if (showNotice) new Notice('Cache operation module is not initialized.'); + return null; + } + return cacheOperation; + } + + private getFileOperation(showNotice = true): NonNullable | null { + const fileOperation = this.plugin.fileOperation; + if (!fileOperation) { + if (showNotice) new Notice('File operation module is not initialized.'); + return null; + } + return fileOperation; + } + + private getStaleLinkDisplayTaskIds(): string[] { + const staleTaskIds = Array.from(new Set(this.taskData.staleLink)); + return staleTaskIds.filter(taskId => !this.taskData.conflicted.includes(taskId) && !this.taskData.nonActive.includes(taskId)); + } + + private async resolveIssuesAndRecomputeStatus( + taskId: string, + shouldResolve: (issueType: string) => boolean, + shouldSave: boolean + ): Promise { + const currentEntry = this.plugin.settings.taskFileMapping[taskId]; + if (!currentEntry) return false; + + const mapping = { ...this.plugin.settings.taskFileMapping }; + const entry = mapping[taskId]; + if (!entry) return false; + + let changed = false; + let normalizedIssues: Record | undefined; + if (entry.issues && typeof entry.issues === 'object' && !Array.isArray(entry.issues)) { + normalizedIssues = {}; + for (const [rawIssueType, issueValue] of Object.entries(entry.issues)) { + const issueType = normalizeTaskIssueTypeKey(rawIssueType); + const issueRecord: TaskIssueEntry = { ...issueValue }; + if (issueRecord.state === 'open' && shouldResolve(issueType)) { + issueRecord.state = 'resolved'; + issueRecord.lastSeenAt = Date.now(); + changed = true; + } + if (rawIssueType !== issueType) changed = true; + + const existing = normalizedIssues[issueType]; + if (!existing) { + normalizedIssues[issueType] = issueRecord; + continue; + } + if (existing.state !== 'open' && issueRecord.state === 'open') { + normalizedIssues[issueType] = issueRecord; + changed = true; + } + } + } + + if (normalizedIssues && Object.keys(normalizedIssues).length > 0) { + entry.issues = normalizedIssues; + } else if (entry.issues !== undefined) { + delete entry.issues; + changed = true; + } + + const fallbackStatus = this.normalizeFallbackStatus(entry.status || 'active'); + const nextStatus = deriveTaskStatusFromIssueEntries(entry.issues as Record | undefined, fallbackStatus); + if (entry.status !== nextStatus) { + entry.status = nextStatus; + changed = true; + } + + const nextSyncEnabled = nextStatus === 'active'; + if (entry.syncEnabled !== nextSyncEnabled) { + entry.syncEnabled = nextSyncEnabled; + changed = true; + } + + if (!changed) return false; + mapping[taskId] = { ...entry }; + await this.plugin.safeSettings?.update({ taskFileMapping: mapping }, shouldSave); + return true; + } + constructor(app: App, plugin: UltimateTodoistSyncForObsidian) { super(app); this.plugin = plugin; } + private getOpenIssueEntries(taskId: string): Array<{ issueType: string; details?: string; expected?: string; actual?: string; manualAction?: string }> { + const info = this.plugin.settings.taskFileMapping[taskId]; + const issues = info?.issues; + if (!issues) return []; + + const normalizedIssues = new Map(); + for (const [rawIssueType, issue] of Object.entries(issues)) { + if (issue?.state !== 'open') continue; + const issueType = normalizeTaskIssueTypeKey(rawIssueType); + if (!normalizedIssues.has(issueType)) { + normalizedIssues.set(issueType, { + issueType, + details: issue.details, + expected: issue.expected, + actual: issue.actual, + manualAction: issue.manualAction, + }); + } + } + + return Array.from(normalizedIssues.values()); + } + private deriveStatusFromOpenIssues(taskId: string, fallbackStatus: string): string { + const info = this.plugin.settings.taskFileMapping[taskId]; + return deriveTaskStatusFromIssueEntries( + info?.issues as Record | undefined, + this.normalizeFallbackStatus(fallbackStatus) + ); + } + private formatIssueTypeLabel(issueType: string): string { + return issueType.replace(/_/g, ' ').toUpperCase(); + } async onOpen() { const { modalEl } = this; modalEl.addClass('task-manager-modal'); @@ -352,6 +502,12 @@ export class TaskManagerModal extends Modal { } private async loadAndRender() { const { contentEl } = this; + const todoistSyncAPI = this.getTodoistSyncAPI(false); + if (!todoistSyncAPI) { + contentEl.empty(); + new Notice('Todoist sync API is not initialized.'); + return; + } contentEl.empty(); this._fileCache.clear(); // Show loading indicator @@ -359,7 +515,7 @@ export class TaskManagerModal extends Modal { // Sync with Todoist let syncFailed = false; try { - await this.plugin.todoistSyncAPI.incrementalSync(); + await todoistSyncAPI.incrementalSync(); } catch (e) { console.error('[TaskManagerModal] Failed to refresh from Todoist:', e); syncFailed = true; @@ -373,12 +529,15 @@ export class TaskManagerModal extends Modal { } // Classify tasks const mapping = this.plugin.settings.taskFileMapping; - this.taskData = { conflicted: [], issue: [], nonActive: [] }; + this.taskData = { conflicted: [], issue: [], nonActive: [], staleLink: [] }; + this.staleLinkIssues = {}; for (const [taskId, info] of Object.entries(mapping)) { - if (info.status === 'conflicted') this.taskData.conflicted.push(taskId); - else if (info.status === 'issue') this.taskData.issue.push(taskId); - else if (info.status === 'nonActive') this.taskData.nonActive.push(taskId); + const derivedStatus = this.deriveStatusFromOpenIssues(taskId, info.status || 'active'); + if (derivedStatus === 'conflicted') this.taskData.conflicted.push(taskId); + else if (derivedStatus === 'issue') this.taskData.issue.push(taskId); + else if (derivedStatus === 'nonActive') this.taskData.nonActive.push(taskId); } + this.collectStaleTodoistLinkIssues(); // Header with summary badges const header = contentEl.createDiv({ cls: 'tm-header' }); header.createEl('h3', { text: 'Task Manager' }); @@ -399,10 +558,14 @@ export class TaskManagerModal extends Modal { if (this.taskData.nonActive.length > 0) { summary.createSpan({ cls: 'tm-badge tm-badge--nonactive', text: `${this.taskData.nonActive.length} inactive` }); } + const staleLinkDisplayTaskIds = this.getStaleLinkDisplayTaskIds(); + if (staleLinkDisplayTaskIds.length > 0) { + summary.createSpan({ cls: 'tm-badge tm-badge--stale-link', text: `🔗 ${staleLinkDisplayTaskIds.length} stale link` }); + } // Create scroll area this.scrollArea = contentEl.createDiv({ cls: 'tm-scroll' }); // Empty state - if (this.taskData.conflicted.length === 0 && this.taskData.issue.length === 0 && this.taskData.nonActive.length === 0) { + if (this.taskData.conflicted.length === 0 && this.taskData.issue.length === 0 && this.taskData.nonActive.length === 0 && staleLinkDisplayTaskIds.length === 0) { const empty = this.scrollArea.createDiv({ cls: 'tm-empty' }); empty.createSpan({ cls: 'tm-empty-icon', text: '✅' }); empty.createSpan({ cls: 'tm-empty-text', text: 'No problem tasks found. Everything is in sync.' }); @@ -410,6 +573,51 @@ export class TaskManagerModal extends Modal { } await this.renderCurrentView(); } + private normalizePath(path: string): string { + try { + return decodeURIComponent(path).replace(/\\/g, '/'); + } catch (_error) { + return path.replace(/\\/g, '/'); + } + } + private collectStaleTodoistLinkIssues(): void { + const taskParser = this.getTaskParser(false); + const todoistSyncAPI = this.getTodoistSyncAPI(false); + if (!taskParser || !todoistSyncAPI) return; + + for (const [taskId, info] of Object.entries(this.plugin.settings.taskFileMapping)) { + if (!info?.filePath) continue; + + const openStaleIssue = this.getOpenIssueEntries(taskId).find(item => item.issueType === 'stale_todoist_link'); + if (openStaleIssue) { + this.staleLinkIssues[taskId] = { + currentPath: openStaleIssue.actual || '', + expectedPath: openStaleIssue.expected || info.filePath, + currentDescription: openStaleIssue.details || '', + expectedDescription: taskParser.getObsidianUrlFromFilepath(info.filePath) + }; + if (!this.taskData.staleLink.includes(taskId)) this.taskData.staleLink.push(taskId); + continue; + } + + const task = todoistSyncAPI.getTaskByIdLocal(taskId); + if (!task || !task.description) continue; + + const currentPath = taskParser.extractFilePathFromObsidianDescription(task.description); + if (!currentPath) continue; + + const expectedPath = info.filePath; + if (this.normalizePath(currentPath) === this.normalizePath(expectedPath)) continue; + + this.staleLinkIssues[taskId] = { + currentPath, + expectedPath, + currentDescription: task.description, + expectedDescription: taskParser.getObsidianUrlFromFilepath(expectedPath) + }; + if (!this.taskData.staleLink.includes(taskId)) this.taskData.staleLink.push(taskId); + } + } private async renderCurrentView() { if (!this.scrollArea) return; // Remove any existing detail header @@ -423,6 +631,15 @@ export class TaskManagerModal extends Modal { } } private async renderListView(scrollArea: HTMLElement) { + const taskParser = this.getTaskParser(false); + if (!taskParser) { + scrollArea.createDiv({ cls: 'tm-empty', text: 'Task parser is not initialized.' }); + return; + } + + type TaskListType = 'conflicted' | 'issue' | 'nonActive' | 'staleLink'; + const staleLinkDisplayTaskIds = this.getStaleLinkDisplayTaskIds(); + // Bulk operations bar const bulkBar = scrollArea.createDiv({ cls: 'tm-bulk-bar' }); let hasBulkOps = false; @@ -432,11 +649,13 @@ export class TaskManagerModal extends Modal { bulkObsBtn.addEventListener('click', async () => { bulkObsBtn.disabled = true; const ids = [...this.taskData.conflicted]; - for (let i = 0; i < ids.length; i++) { + let index = 0; + for (const conflictedTaskId of ids) { if (this._closed) return; - const fp = this.plugin.settings.taskFileMapping[ids[i]]?.filePath || ''; - bulkObsBtn.textContent = `Resolving ${i + 1}/${ids.length}...`; - await this.resolveConflict(ids[i], fp, 'obsidian', true); + index++; + const fp = this.plugin.settings.taskFileMapping[conflictedTaskId]?.filePath || ''; + bulkObsBtn.textContent = `Resolving ${index}/${ids.length}...`; + await this.resolveConflict(conflictedTaskId, fp, 'obsidian', true); } if (!this._closed) await this.loadAndRender(); }); @@ -444,11 +663,13 @@ export class TaskManagerModal extends Modal { bulkTodBtn.addEventListener('click', async () => { bulkTodBtn.disabled = true; const ids = [...this.taskData.conflicted]; - for (let i = 0; i < ids.length; i++) { + let index = 0; + for (const conflictedTaskId of ids) { if (this._closed) return; - const fp = this.plugin.settings.taskFileMapping[ids[i]]?.filePath || ''; - bulkTodBtn.textContent = `Resolving ${i + 1}/${ids.length}...`; - await this.resolveConflict(ids[i], fp, 'todoist', true); + index++; + const fp = this.plugin.settings.taskFileMapping[conflictedTaskId]?.filePath || ''; + bulkTodBtn.textContent = `Resolving ${index}/${ids.length}...`; + await this.resolveConflict(conflictedTaskId, fp, 'todoist', true); } if (!this._closed) await this.loadAndRender(); }); @@ -461,10 +682,12 @@ export class TaskManagerModal extends Modal { if (!confirmed) return; bulkDelBtn.disabled = true; const ids = [...this.taskData.issue]; - for (let i = 0; i < ids.length; i++) { + let index = 0; + for (const issueTaskId of ids) { if (this._closed) return; - bulkDelBtn.textContent = `Deleting ${i + 1}/${ids.length}...`; - await this.deleteIssueTask(ids[i], true); + index++; + bulkDelBtn.textContent = `Deleting ${index}/${ids.length}...`; + await this.deleteIssueTask(issueTaskId, true); } if (!this._closed) await this.loadAndRender(); }); @@ -475,11 +698,13 @@ export class TaskManagerModal extends Modal { bulkReBtn.addEventListener('click', async () => { bulkReBtn.disabled = true; const ids = [...this.taskData.nonActive]; - for (let i = 0; i < ids.length; i++) { + let index = 0; + for (const nonActiveTaskId of ids) { if (this._closed) return; - const fp = this.plugin.settings.taskFileMapping[ids[i]]?.filePath || ''; - bulkReBtn.textContent = `Re-enabling ${i + 1}/${ids.length}...`; - await this.reEnableTask(ids[i], fp, true); + index++; + const fp = this.plugin.settings.taskFileMapping[nonActiveTaskId]?.filePath || ''; + bulkReBtn.textContent = `Re-enabling ${index}/${ids.length}...`; + await this.reEnableTask(nonActiveTaskId, fp, true); } if (!this._closed) await this.loadAndRender(); }); @@ -489,32 +714,62 @@ export class TaskManagerModal extends Modal { if (!confirmed) return; bulkDelInBtn.disabled = true; const ids = [...this.taskData.nonActive]; - for (let i = 0; i < ids.length; i++) { + let index = 0; + for (const nonActiveTaskId of ids) { if (this._closed) return; - bulkDelInBtn.textContent = `Deleting ${i + 1}/${ids.length}...`; - await this.deleteIssueTask(ids[i], true); + index++; + bulkDelInBtn.textContent = `Deleting ${index}/${ids.length}...`; + await this.deleteIssueTask(nonActiveTaskId, true); + } + if (!this._closed) await this.loadAndRender(); + }); + } + if (staleLinkDisplayTaskIds.length > 0) { + hasBulkOps = true; + const bulkRepairBtn = bulkBar.createEl('button', { cls: 'tm-btn tm-btn--secondary', text: 'Repair All Stale Links' }); + bulkRepairBtn.addEventListener('click', async () => { + bulkRepairBtn.disabled = true; + const ids = [...staleLinkDisplayTaskIds]; + let index = 0; + for (const staleTaskId of ids) { + if (this._closed) return; + index++; + bulkRepairBtn.textContent = `Repairing ${index}/${ids.length}...`; + await this.repairStaleLink(staleTaskId, true); } if (!this._closed) await this.loadAndRender(); }); } if (!hasBulkOps) bulkBar.remove(); // Unified task list - const iconMap: Record = { conflicted: '⚠️', issue: '❗', nonActive: '📋' }; + const iconMap: Record = { conflicted: '⚠️', issue: '❗', nonActive: '📋', staleLink: '🔗' }; const badgeMap: Record = { conflicted: { cls: 'tm-list-badge tm-list-badge--conflict', text: 'CONFLICT' }, issue: { cls: 'tm-list-badge tm-list-badge--issue', text: 'ISSUE' }, nonActive: { cls: 'tm-list-badge tm-list-badge--nonactive', text: 'INACTIVE' }, + staleLink: { cls: 'tm-list-badge tm-list-badge--stale-link', text: 'STALE LINK' }, }; - const allTasks: { taskId: string, type: string }[] = []; + const staleDisplayTaskIdsSet = new Set(staleLinkDisplayTaskIds); + const allTasks: { taskId: string, type: TaskListType }[] = []; + const seenTaskIds = new Set(); for (const id of this.taskData.conflicted) allTasks.push({ taskId: id, type: 'conflicted' }); - for (const id of this.taskData.issue) allTasks.push({ taskId: id, type: 'issue' }); for (const id of this.taskData.nonActive) allTasks.push({ taskId: id, type: 'nonActive' }); + for (const id of staleLinkDisplayTaskIds) { + if (this.taskData.conflicted.includes(id) || this.taskData.nonActive.includes(id)) continue; + allTasks.push({ taskId: id, type: 'staleLink' }); + } + for (const id of this.taskData.issue) { + if (staleDisplayTaskIdsSet.has(id)) continue; + allTasks.push({ taskId: id, type: 'issue' }); + } for (const { taskId, type } of allTasks) { + if (seenTaskIds.has(taskId)) continue; + seenTaskIds.add(taskId); const info = this.plugin.settings.taskFileMapping[taskId]; const filePath = info?.filePath || ''; const line = await this.getTaskLine(taskId, filePath); const preview = line - ? (this.plugin.taskParser.getTaskContentFromLineText(line) || '(unknown)') + ? (taskParser.getTaskContentFromLineText(line) || '(unknown)') : '(unknown)'; const row = scrollArea.createDiv({ cls: 'tm-list-row' }); row.addEventListener('click', () => { @@ -545,7 +800,9 @@ export class TaskManagerModal extends Modal { return; } const filePath = info.filePath || ''; - const status = info.status || 'active'; + const status = this.deriveStatusFromOpenIssues(taskId, info.status || 'active'); + const hasStaleLinkIssue = !!this.staleLinkIssues[taskId]; + const detailType = status === 'conflicted' || status === 'nonActive' ? status : (hasStaleLinkIssue ? 'staleLink' : status); // Detail header — insert before scrollArea const detailHeader = this.contentEl.createDiv({ cls: 'tm-detail-header' }); this.contentEl.insertBefore(detailHeader, scrollArea); @@ -565,11 +822,13 @@ export class TaskManagerModal extends Modal { idRow.createSpan({ cls: 'tm-detail-info-value tm-task-id', text: taskId }); const todoistRow = infoCard.createDiv({ cls: 'tm-detail-info-row' }); todoistRow.createSpan({ cls: 'tm-detail-info-label', text: 'Todoist' }); - const todoistLink = todoistRow.createEl('a', { cls: 'tm-detail-info-value tm-todoist-link', text: 'Open in Todoist' }); + const todoistUrl = this.buildTodoistTaskUrl(taskId); + const todoistLink = todoistRow.createEl('a', { cls: 'tm-detail-info-value tm-todoist-link', text: todoistUrl }); + todoistLink.title = todoistUrl; todoistLink.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - this.openTodoistTask(taskId); + window.open(todoistUrl, '_blank', 'noopener,noreferrer'); }); const fileRow = infoCard.createDiv({ cls: 'tm-detail-info-row' }); fileRow.createSpan({ cls: 'tm-detail-info-label', text: 'File' }); @@ -581,19 +840,29 @@ export class TaskManagerModal extends Modal { conflicted: 'tm-list-badge tm-list-badge--conflict', issue: 'tm-list-badge tm-list-badge--issue', nonActive: 'tm-list-badge tm-list-badge--nonactive', + staleLink: 'tm-list-badge tm-list-badge--stale-link', }; - const badgeTextMap: Record = { conflicted: 'CONFLICT', issue: 'ISSUE', nonActive: 'INACTIVE' }; - statusRow.createSpan({ cls: badgeClsMap[status] || 'tm-list-badge', text: badgeTextMap[status] || status }); + const badgeTextMap: Record = { conflicted: 'CONFLICT', issue: 'ISSUE', nonActive: 'INACTIVE', staleLink: 'STALE LINK' }; + statusRow.createSpan({ cls: badgeClsMap[detailType] || 'tm-list-badge', text: badgeTextMap[detailType] || detailType }); // Type-specific detail - if (status === 'conflicted') { + if (detailType === 'conflicted') { await this.renderConflictDetail(body, taskId); - } else if (status === 'issue') { + } else if (detailType === 'issue') { await this.renderIssueDetail(body, taskId); - } else if (status === 'nonActive') { + } else if (detailType === 'nonActive') { await this.renderInactiveDetail(body, taskId); + } else if (detailType === 'staleLink') { + await this.renderStaleLinkDetail(body, taskId); } } private async renderConflictDetail(container: HTMLElement, taskId: string) { + const taskParser = this.getTaskParser(false); + const todoistSyncAPI = this.getTodoistSyncAPI(false); + if (!taskParser || !todoistSyncAPI) { + container.createDiv({ cls: 'tm-content-preview', text: 'Required sync modules are not initialized.' }); + return; + } + const info = this.plugin.settings.taskFileMapping[taskId]; const filePath = info?.filePath || ''; // Load data from both sides @@ -601,14 +870,14 @@ export class TaskManagerModal extends Modal { let obsContent = '', obsStatus = '', obsDue = '', obsTags = '', obsPriority = ''; let todContent = '', todStatus = '', todDue = '', todTags = '', todPriority = ''; if (line) { - obsContent = this.plugin.taskParser.getTaskContentFromLineText(line) || ''; + obsContent = taskParser.getTaskContentFromLineText(line) || ''; obsStatus = /\[(x|X)\]/.test(line) ? '\u2611' : '\u2610'; - obsDue = this.plugin.taskParser.getDueDateFromLineText(line) || ''; - obsTags = this.plugin.taskParser.getAllTagsFromLineText(line).join(', '); - obsPriority = `!!${this.plugin.taskParser.getTaskPriority(line)}`; + obsDue = taskParser.getDueDateFromLineText(line) || ''; + obsTags = taskParser.getAllTagsFromLineText(line).join(', '); + obsPriority = `!!${taskParser.getTaskPriority(line)}`; } try { - const task = this.plugin.todoistSyncAPI.getTaskByIdLocal(taskId); + const task = todoistSyncAPI.getTaskByIdLocal(taskId); if (task) { todContent = task.content || ''; todStatus = task.checked ? '\u2611' : '\u2610'; @@ -668,15 +937,43 @@ export class TaskManagerModal extends Modal { }); } private async renderIssueDetail(container: HTMLElement, taskId: string) { + const taskParser = this.getTaskParser(false); + if (!taskParser) { + container.createDiv({ cls: 'tm-content-preview', text: 'Task parser is not initialized.' }); + return; + } + const info = this.plugin.settings.taskFileMapping[taskId]; const filePath = info?.filePath || ''; const line = await this.getTaskLine(taskId, filePath); const preview = line - ? (this.plugin.taskParser.getTaskContentFromLineText(line) || line.substring(0, 60)) + ? (taskParser.getTaskContentFromLineText(line) || line.substring(0, 60)) : '(file not found)'; const previewEl = container.createDiv({ cls: 'tm-content-preview' }); previewEl.textContent = preview; + + const openIssues = this.getOpenIssueEntries(taskId); + if (openIssues.length > 0) { + const issueSummary = container.createDiv({ cls: 'tm-content-preview' }); + issueSummary.textContent = `Issue types: ${openIssues.map(issue => this.formatIssueTypeLabel(issue.issueType)).join(', ')}`; + + for (const issue of openIssues) { + const issueDetail = container.createDiv({ cls: 'tm-content-preview' }); + const expectedPart = issue.expected ? ` | expected: ${issue.expected}` : ''; + const actualPart = issue.actual ? ` | actual: ${issue.actual}` : ''; + const actionPart = issue.manualAction ? ` | action: ${issue.manualAction}` : ''; + issueDetail.textContent = `${this.formatIssueTypeLabel(issue.issueType)}${issue.details ? `: ${issue.details}` : ''}${expectedPart}${actualPart}${actionPart}`; + } + } + const actions = container.createDiv({ cls: 'tm-detail-actions' }); + if (openIssues.some(issue => issue.issueType === 'stale_todoist_link')) { + const repairBtn = actions.createEl('button', { cls: 'tm-btn tm-btn--primary', text: 'Repair Todoist Link' }); + repairBtn.addEventListener('click', async () => { + repairBtn.disabled = true; + await this.repairStaleLink(taskId); + }); + } const delBtn = actions.createEl('button', { cls: 'tm-btn tm-btn--danger', text: 'Delete' }); delBtn.addEventListener('click', async () => { delBtn.disabled = true; @@ -684,11 +981,17 @@ export class TaskManagerModal extends Modal { }); } private async renderInactiveDetail(container: HTMLElement, taskId: string) { + const taskParser = this.getTaskParser(false); + if (!taskParser) { + container.createDiv({ cls: 'tm-content-preview', text: 'Task parser is not initialized.' }); + return; + } + const info = this.plugin.settings.taskFileMapping[taskId]; const filePath = info?.filePath || ''; const line = await this.getTaskLine(taskId, filePath); const preview = line - ? (this.plugin.taskParser.getTaskContentFromLineText(line) || line.substring(0, 60)) + ? (taskParser.getTaskContentFromLineText(line) || line.substring(0, 60)) : '(file not found)'; const previewEl = container.createDiv({ cls: 'tm-content-preview' }); previewEl.textContent = preview; @@ -704,15 +1007,121 @@ export class TaskManagerModal extends Modal { await this.deleteIssueTask(taskId); }); } + private async renderStaleLinkDetail(container: HTMLElement, taskId: string) { + const issue = this.staleLinkIssues[taskId]; + if (!issue) { + const empty = container.createDiv({ cls: 'tm-content-preview' }); + empty.textContent = 'No stale-link issue details available.'; + return; + } + + const currentPathEl = container.createDiv({ cls: 'tm-content-preview' }); + currentPathEl.textContent = `Current link path: ${issue.currentPath}`; + const expectedPathEl = container.createDiv({ cls: 'tm-content-preview' }); + expectedPathEl.textContent = `Expected file path: ${issue.expectedPath}`; + + const actions = container.createDiv({ cls: 'tm-detail-actions' }); + const repairBtn = actions.createEl('button', { cls: 'tm-btn tm-btn--primary', text: 'Repair Todoist Link' }); + repairBtn.addEventListener('click', async () => { + repairBtn.disabled = true; + await this.repairStaleLink(taskId); + }); + } + private async repairStaleLink(taskId: string, skipRerender = false): Promise { + const taskParser = this.getTaskParser(); + const todoistSyncAPI = this.getTodoistSyncAPI(); + const cacheOperation = this.getCacheOperation(); + if (!taskParser || !todoistSyncAPI || !cacheOperation) { + if (!skipRerender) await this.loadAndRender(); + return; + } + + const info = this.plugin.settings.taskFileMapping[taskId]; + if (!info?.filePath) { + new Notice('Task mapping not found.'); + if (!skipRerender) await this.loadAndRender(); + return; + } + + if (!this.plugin.isPrimaryDevice()) { + new Notice('Link repair is blocked on non-primary device.'); + if (!skipRerender) await this.loadAndRender(); + return; + } + + if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) { + new Notice('Another sync is running. Please try again later.'); + if (!skipRerender) await this.loadAndRender(); + return; + } + + try { + const description = taskParser.getObsidianUrlFromFilepath(info.filePath); + const task = await todoistSyncAPI.GetTaskById(taskId); + if (!task) { + new Notice('Task not found in Todoist.'); + return; + } + + if ((task.description || '') !== description) { + await todoistSyncAPI.UpdateTask(taskId, { description }); + try { + await todoistSyncAPI.incrementalSync(); + } catch (syncErr) { + console.error('[TaskManagerModal] incrementalSync after stale-link repair failed:', syncErr); + } + const refreshed = todoistSyncAPI.getTaskByIdLocal(taskId); + if (refreshed?.updated_at) { + await cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: refreshed.updated_at }); + } + const resolved = await this.resolveIssuesAndRecomputeStatus(taskId, issueType => issueType === 'stale_todoist_link', true); + new Notice(resolved ? 'Todoist description link repaired.' : 'Todoist description link repaired (no open stale-link issue remained).'); + } else { + const resolved = await this.resolveIssuesAndRecomputeStatus(taskId, issueType => issueType === 'stale_todoist_link', true); + new Notice(resolved ? 'Link already up to date. Cleared stale-link issue.' : 'Todoist description link is already up to date.'); + } + } catch (error) { + console.error('[TaskManagerModal] repairStaleLink error:', error); + new Notice(`Failed to repair link: ${error}`); + } finally { + this.plugin.syncLockManager.release(); + } + + if (this._closed || skipRerender) return; + this.currentView = 'list'; + this.selectedTaskId = null; + await this.loadAndRender(); + } private async resolveConflict(taskId: string, filePath: string, choice: 'obsidian' | 'todoist', skipRerender = false) { + const todoistSyncAPI = this.getTodoistSyncAPI(); + const cacheOperation = this.getCacheOperation(); + const taskParser = this.getTaskParser(); + if (!todoistSyncAPI || !cacheOperation || !taskParser) { + new Notice('Required sync modules are not initialized.'); + return; + } + + let writeLockAcquired = false; + if (choice === 'obsidian') { + if (!this.plugin.isPrimaryDevice()) { + new Notice('Conflict resolution is blocked on non-primary device.'); + return; + } + writeLockAcquired = await this.plugin.syncLockManager.acquire('obsidianToTodoist'); + if (!writeLockAcquired) { + new Notice('Another sync is running. Please try again later.'); + return; + } + } + try { if (choice === 'obsidian') { const line = await this.getTaskLine(taskId, filePath); if (!line) { new Notice('Task line not found in vault'); this.navigateToList(); return; } - const content = this.plugin.taskParser.getTaskContentFromLineText(line); - const labels = this.plugin.taskParser.getAllTagsFromLineText(line); - const dueDate = this.plugin.taskParser.getDueDateFromLineText(line); - const priority = this.plugin.taskParser.getTaskPriority(line); + const content = taskParser.getTaskContentFromLineText(line); + const labels = taskParser.getAllTagsFromLineText(line); + const dueDate = taskParser.getDueDateFromLineText(line); + const priority = taskParser.getTaskPriority(line); const isCompleted = /\[(x|X)\]/.test(line); // Build batched commands for atomic resolve const commands: any[] = []; @@ -724,7 +1133,7 @@ export class TaskManagerModal extends Modal { updateArgs.priority = priority; const updateUuid = crypto.randomUUID(); commands.push({ type: 'item_update', uuid: updateUuid, args: updateArgs }); - const savedTask = this.plugin.todoistSyncAPI.getTaskByIdLocal(taskId); + const savedTask = todoistSyncAPI.getTaskByIdLocal(taskId); const todoistChecked = savedTask?.checked || false; let statusUuid: string | null = null; if (isCompleted && !todoistChecked) { @@ -735,7 +1144,7 @@ export class TaskManagerModal extends Modal { commands.push({ type: 'item_uncomplete', uuid: statusUuid, args: { id: taskId } }); } // Single batched API call - const result = await this.plugin.todoistSyncAPI.executeCommands(commands); + const result = await todoistSyncAPI.executeCommands(commands); // Check sync_status for each command if (result?.sync_status) { const updateStatus = result.sync_status[updateUuid]; @@ -752,31 +1161,38 @@ export class TaskManagerModal extends Modal { } } if (this._closed) return; - const refreshed = this.plugin.todoistSyncAPI.getTaskByIdLocal(taskId); - await this.plugin.cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); + const refreshed = todoistSyncAPI.getTaskByIdLocal(taskId); + await this.resolveIssuesAndRecomputeStatus(taskId, issueType => this.conflictIssueTypes.has(issueType), false); + await cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); if (refreshed?.updated_at) { - await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: refreshed.updated_at }); + await cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: refreshed.updated_at }); } await this.plugin.safeSettings?.update({}, true); new Notice(`Conflict resolved: kept Obsidian version`); } else { - const task = this.plugin.todoistSyncAPI.getTaskByIdLocal(taskId); + const fileOperation = this.getFileOperation(); + if (!fileOperation) { + new Notice('File operation module is not initialized.'); + return; + } + + const task = todoistSyncAPI.getTaskByIdLocal(taskId); if (!task) { new Notice('Task not found in Todoist'); this.navigateToList(); return; } // Echo protection: prevent file writes from triggering push sync back to Todoist this.plugin.isSyncingFromTodoist = true; try { if (task.content) { - await this.plugin.fileOperation.syncTaskContentToFile(taskId, task.content); + await fileOperation.syncTaskContentToFile(taskId, task.content); } const todoistDueDate = task.due?.date || ''; - await this.plugin.fileOperation.syncTaskDueDateToFile(taskId, todoistDueDate); + await fileOperation.syncTaskDueDateToFile(taskId, todoistDueDate); // Invalidate file cache after content/date writes so tag/priority sync reads fresh data this._fileCache.delete(filePath); // Sync tags (labels) from Todoist to file const todoistLabels = task.labels || []; let currentLine = await this.getTaskLine(taskId, filePath); if (currentLine && todoistLabels.length > 0) { - const existingTags = this.plugin.taskParser.getAllTagsFromLineText(currentLine); + const existingTags = taskParser.getAllTagsFromLineText(currentLine); // Remove existing tags (except #todoist and project tags) let updatedLine = currentLine; for (const tag of existingTags) { @@ -786,7 +1202,7 @@ export class TaskManagerModal extends Modal { // Add Todoist labels as tags before #todoist const todoistTagPos = updatedLine.indexOf('#todoist'); if (todoistTagPos > 0) { - const labelTags = todoistLabels.map(l => `#${l}`).join(' '); + const labelTags = todoistLabels.map((l: string) => `#${l}`).join(' '); updatedLine = updatedLine.substring(0, todoistTagPos) + labelTags + ' ' + updatedLine.substring(todoistTagPos); } // Clean up multiple spaces @@ -838,22 +1254,27 @@ export class TaskManagerModal extends Modal { const obsidianChecked = currentLine ? /\[(x|X)\]/.test(currentLine) : false; const todoistChecked = task.checked || false; if (todoistChecked && !obsidianChecked) { - await this.plugin.fileOperation.completeTaskInTheFile(taskId); + await fileOperation.completeTaskInTheFile(taskId); } else if (!todoistChecked && obsidianChecked) { - await this.plugin.fileOperation.uncompleteTaskInTheFile(taskId); + await fileOperation.uncompleteTaskInTheFile(taskId); } } finally { this.plugin.isSyncingFromTodoist = false; } if (this._closed) return; - await this.plugin.cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); - await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: task.updated_at }); + await this.resolveIssuesAndRecomputeStatus(taskId, issueType => this.conflictIssueTypes.has(issueType), false); + await cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); + await cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: task.updated_at }); await this.plugin.safeSettings?.update({}, true); new Notice(`Conflict resolved: kept Todoist version`); } } catch (e) { console.error(`[TaskManagerModal] resolveConflict error:`, e); new Notice(`Error resolving conflict: ${e}`); + } finally { + if (writeLockAcquired) { + this.plugin.syncLockManager.release(); + } } if (this._closed || skipRerender) return; this.currentView = 'list'; @@ -863,9 +1284,29 @@ export class TaskManagerModal extends Modal { private async deleteIssueTask(taskId: string, skipRerender = false) { const confirmed = await this.showConfirmDialog(`Delete task ${taskId}? This removes it from Todoist and unbinds from file.`); if (!confirmed) return; + + if (!this.plugin.isPrimaryDevice()) { + new Notice('Task deletion is blocked on non-primary device.'); + return; + } + + const todoistSyncAPI = this.plugin.todoistSyncAPI; + const fileOperation = this.plugin.fileOperation; + const cacheOperation = this.plugin.cacheOperation; + if (!todoistSyncAPI || !fileOperation || !cacheOperation) { + new Notice('Required sync modules are not initialized.'); + return; + } + + const writeLockAcquired = await this.plugin.syncLockManager.acquire('obsidianToTodoist'); + if (!writeLockAcquired) { + new Notice('Another sync is running. Please try again later.'); + return; + } + try { try { - await this.plugin.todoistSyncAPI.deleteTask(taskId); + await todoistSyncAPI.deleteTask(taskId); } catch (e: unknown) { // Only ignore 404 (task already deleted in Todoist) const err = e as Record; @@ -874,13 +1315,15 @@ export class TaskManagerModal extends Modal { if (!is404) throw e; } if (this._closed) return; - await this.plugin.fileOperation.unbindTaskInFile(taskId); - await this.plugin.cacheOperation.deleteTaskFileMapping(taskId); + await fileOperation.unbindTaskInFile(taskId); + await cacheOperation.deleteTaskFileMapping(taskId); await this.plugin.safeSettings?.update({}, true); new Notice(`Issue task deleted`); } catch (e) { console.error(`[TaskManagerModal] deleteIssueTask error:`, e); new Notice(`Error deleting task: ${e}`); + } finally { + this.plugin.syncLockManager.release(); } if (this._closed || skipRerender) return; this.currentView = 'list'; @@ -888,8 +1331,15 @@ export class TaskManagerModal extends Modal { await this.loadAndRender(); } private async reEnableTask(taskId: string, filePath: string, skipRerender = false) { + const cacheOperation = this.getCacheOperation(); + if (!cacheOperation) { + if (!skipRerender) await this.loadAndRender(); + return; + } + try { - await this.plugin.cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); + await this.resolveIssuesAndRecomputeStatus(taskId, issueType => issueType === 'task_nonactive', false); + await cacheOperation.setTaskFileMapping(taskId, filePath, 'active', true); await this.plugin.safeSettings?.update({}, true); new Notice(`Task re-enabled`); } catch (e) { @@ -931,15 +1381,6 @@ export class TaskManagerModal extends Modal { return `https://app.todoist.com/app/task/${encodedTaskId}`; } - private openTodoistTask(taskId: string): void { - if (!taskId) { - new Notice('Task ID not found.'); - return; - } - - const url = this.buildTodoistTaskUrl(taskId); - window.open(url, '_blank', 'noopener,noreferrer'); - } private navigateToList() { this.currentView = 'list'; this.selectedTaskId = null; diff --git a/src/vault/fileOperation.ts b/src/vault/fileOperation.ts index e8e15a9..7b73466 100644 --- a/src/vault/fileOperation.ts +++ b/src/vault/fileOperation.ts @@ -8,6 +8,8 @@ export interface VaultTask { filePath: string; lineNumber: number; labels: string[]; + dueDate?: string; + priority?: number; } export interface VaultTaskWithoutId { @@ -21,6 +23,7 @@ export interface VaultTaskWithoutId { export interface TodoistTask { taskId: string; content: string; + description?: string; checked: boolean; dueDate?: string; priority: number; @@ -727,9 +730,11 @@ export class FileOperation { } const match = line.match(/%%\[todoist_id::\s*([\w-]+)\]%%/); - const taskContent = this.plugin.taskParser.getTaskContentFromLineText(line); - const isCompleted = /\[x\]/i.test(line); - const labels = this.extractLabelsFromLine(line); + const taskContent = this.plugin.taskParser.getTaskContentFromLineText(line); + const isCompleted = /\[x\]/i.test(line); + const labels = this.extractLabelsFromLine(line); + const dueDate = this.plugin.taskParser.getDueDateFromLineText(line) || undefined; + const priority = this.plugin.taskParser.getTaskPriority(line); if (match && match[1]) { const taskId = match[1]; @@ -745,7 +750,9 @@ export class FileOperation { isCompleted, filePath: file.path, lineNumber: i, - labels + labels, + dueDate, + priority, }); } else { tasksWithoutId.push({ @@ -785,6 +792,7 @@ export class FileOperation { todoistTasksMap.set(task.id, { taskId: task.id, content: task.content || '', + description: taskAny.description || '', checked: !!taskAny.checked, dueDate: task.due?.date, priority: task.priority || 1, diff --git a/styles.css b/styles.css index cfdef2f..2b56afc 100644 --- a/styles.css +++ b/styles.css @@ -94,6 +94,10 @@ background: hsla(210, 15%, 55%, 0.12); color: var(--text-muted); } +.tm-badge--stale-link { + background: hsla(215, 80%, 55%, 0.12); + color: hsl(215, 80%, 45%); +} /* Scroll area */ .tm-scroll { max-height: 70vh; @@ -379,6 +383,10 @@ background: hsla(210, 15%, 55%, 0.12); color: var(--text-muted); } +.tm-list-badge--stale-link { + background: hsla(215, 80%, 55%, 0.12); + color: hsl(215, 80%, 45%); +} /* ============================================ Detail View (master-detail) ============================================ */