mirror of
https://github.com/heroblackink/ultimate-todoist-sync-for-obsidian.git
synced 2026-07-22 07:40:27 +00:00
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.
This commit is contained in:
parent
9594a1cc4c
commit
37e7179213
10 changed files with 1194 additions and 160 deletions
|
|
@ -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<string, {
|
||||
state: TaskIssueState;
|
||||
severity: TaskIssueSeverity;
|
||||
source: TaskIssueSource;
|
||||
detectedAt: number;
|
||||
lastSeenAt: number;
|
||||
details?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
manualAction?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* ==========================================================================================
|
||||
* 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<string, unknown> } | 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<void> {
|
||||
const mapping = { ...this.plugin.settings.taskFileMapping };
|
||||
mapping[taskId] = { filePath, status, syncEnabled };
|
||||
const existing = mapping[taskId];
|
||||
const nextStatus = deriveTaskStatusFromIssueEntries(existing?.issues as Record<string, { state?: string }> | 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<void> {
|
||||
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<RebuildCacheResult>
|
||||
*/
|
||||
async rebuildCache(noticeCallback?: (message: string) => void): Promise<RebuildCacheResult> {
|
||||
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<filePath, tasks[]>
|
||||
|
|
@ -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<string, { filePath: string; status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; syncEnabled?: boolean; updated_at?: string; note_count?: number }> = {};
|
||||
const nextMapping: Record<string, { filePath: string; status?: 'active' | 'nonActive' | 'conflicted' | 'issue'; syncEnabled?: boolean; updated_at?: string; note_count?: number; issues?: TaskIssueRecord }> = {};
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 !!<n> 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];
|
||||
|
|
|
|||
76
src/data/taskIssueUtils.ts
Normal file
76
src/data/taskIssueUtils.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export type DerivedTaskStatus = 'active' | 'nonActive' | 'conflicted' | 'issue';
|
||||
|
||||
export const CONFLICT_ISSUE_TYPE_KEYS = new Set<string>([
|
||||
'content_mismatch',
|
||||
'status_mismatch',
|
||||
'due_date_mismatch',
|
||||
'labels_mismatch',
|
||||
'priority_mismatch',
|
||||
'project_mismatch',
|
||||
'duplicate_task',
|
||||
]);
|
||||
|
||||
export const NON_ACTIVE_ISSUE_TYPE_KEYS = new Set<string>(['task_nonactive']);
|
||||
|
||||
const TASK_ISSUE_TYPE_VALUES = new Set<string>([
|
||||
'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<string, string> = {
|
||||
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<string, { state?: string }> | 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';
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<string, TaskIssueEntry>;
|
||||
};
|
||||
|
||||
// ==========================================================================================
|
||||
|
|
@ -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<string, TaskIssueEntry> | 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<TaskIssueEntry>;
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, TaskIssueEntry>;
|
||||
}
|
||||
|
||||
export function normalizeTaskIssueType(issueType: string): TaskIssueType {
|
||||
return normalizeTaskIssueTypeKey(issueType) as TaskIssueType;
|
||||
}
|
||||
|
||||
export function deriveTaskStatusFromIssues(
|
||||
issues: Record<string, TaskIssueEntry> | 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<string, { defaultProjectId?: string }>;
|
||||
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<void> {
|
||||
const taskFileMapping = { ...this.plugin.settings.taskFileMapping };
|
||||
const now = Date.now();
|
||||
const seenIssueTypesByTask = new Map<string, Set<string>>();
|
||||
const clonedEntries = new Set<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
621
src/ui/modals.ts
621
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<string, string> = {};
|
||||
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<string, string>();
|
||||
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<string, { currentPath: string; expectedPath: string; currentDescription: string; expectedDescription: string }> = {};
|
||||
private scrollArea: HTMLElement | null = null;
|
||||
private _activeOverlay: HTMLElement | null = null;
|
||||
private readonly conflictIssueTypes = new Set<string>(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<UltimateTodoistSyncForObsidian['todoistSyncAPI']> | 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<UltimateTodoistSyncForObsidian['taskParser']> | 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<UltimateTodoistSyncForObsidian['cacheOperation']> | 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<UltimateTodoistSyncForObsidian['fileOperation']> | 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<boolean> {
|
||||
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<string, TaskIssueEntry> | 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<string, { state?: string }> | 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<string, { issueType: string; details?: string; expected?: string; actual?: string; manualAction?: string }>();
|
||||
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<string, { state?: string }> | 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<string, string> = { conflicted: '⚠️', issue: '❗', nonActive: '📋' };
|
||||
const iconMap: Record<string, string> = { conflicted: '⚠️', issue: '❗', nonActive: '📋', staleLink: '🔗' };
|
||||
const badgeMap: Record<string, { cls: string, text: string }> = {
|
||||
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<string>();
|
||||
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<string, string> = { conflicted: 'CONFLICT', issue: 'ISSUE', nonActive: 'INACTIVE' };
|
||||
statusRow.createSpan({ cls: badgeClsMap[status] || 'tm-list-badge', text: badgeTextMap[status] || status });
|
||||
const badgeTextMap: Record<string, string> = { 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<void> {
|
||||
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<string, unknown>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
============================================ */
|
||||
|
|
|
|||
Loading…
Reference in a new issue