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:
HeroBlackInk 2026-02-25 15:18:53 +08:00
parent 9594a1cc4c
commit 37e7179213
10 changed files with 1194 additions and 160 deletions

View file

@ -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 {

View file

@ -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];

View 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';
}

View file

@ -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");

View file

@ -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++;
}
}

View file

@ -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(() => {

View file

@ -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');
}

View file

@ -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;

View file

@ -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,

View file

@ -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)
============================================ */