fix(sync): pull Todoist labels/priority to vault and align link generation

This commit is contained in:
HeroBlackInk 2026-02-26 19:10:46 +08:00
parent fa8a69b40e
commit eacd2c9e3c
4 changed files with 130 additions and 13 deletions

View file

@ -272,8 +272,8 @@ export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
);
new Setting(containerEl)
.setName('Use Desktop URIs')
.setDesc('Open Todoist tasks in desktop app (todoist://) instead of browser (https://).')
.setName('Use App URI Scheme')
.setDesc('When enabled, generated task links use todoist:// (desktop app). When disabled, links use https://app.todoist.com/app/task/... (web).')
.addToggle(component =>
component
.setValue(this.plugin.settings.useAppURI)

View file

@ -135,6 +135,16 @@ export class TodoistToObsidianSync {
await this.plugin.fileOperation.syncTaskDueDateToFile(taskId, task.due?.date || "");
this.plugin.logOperation?.log('FILE_TASK_DUEDATE_SYNCED', `Synced due date: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
}
const prioritySynced = await this.plugin.fileOperation.syncTaskPriorityToFile(taskId, task.priority || 1);
if (prioritySynced) {
this.plugin.logOperation?.log('FILE_TASK_PRIORITY_SYNCED', `Synced priority: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
}
const labelsSynced = await this.plugin.fileOperation.syncTaskLabelsToFile(taskId, task.labels || []);
if (labelsSynced) {
this.plugin.logOperation?.log('FILE_TASK_LABELS_SYNCED', `Synced labels: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
}
}
private async syncNotesToObsidian(

View file

@ -1117,14 +1117,12 @@ export class TaskManagerModal extends Modal {
// 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 || [];
const todoistLabels = taskParser.normalizeLabelsForCompare([...(task.labels || []), 'todoist']);
let currentLine = await this.getTaskLine(taskId, filePath);
if (currentLine && todoistLabels.length > 0) {
const existingTags = taskParser.getAllTagsFromLineText(currentLine);
// Remove existing tags (except #todoist and project tags)
let updatedLine = currentLine;
for (const tag of existingTags) {
if (tag === 'todoist') continue;
updatedLine = updatedLine.replace(new RegExp(`#${tag}\\b`, 'g'), '');
}
// Add Todoist labels as tags before #todoist

View file

@ -261,8 +261,10 @@ export class FileOperation {
console.error(`Task ${taskID} not found in taskFileMapping`);
continue;
}
const todoistTask = await this.plugin.todoistSyncAPI.GetTaskById(taskID)
const todoistLink = todoistTask?.url || ''
await this.plugin.todoistSyncAPI.GetTaskById(taskID)
const todoistLink = this.plugin.settings.useAppURI
? `todoist://task?id=${taskID}`
: `https://app.todoist.com/app/task/${taskID}`
const link = `[link](${todoistLink})`
const newLine = this.plugin.taskParser.addTodoistLink(line,link)
this.plugin.debugLog(newLine)
@ -501,6 +503,109 @@ export class FileOperation {
return modified;
}
private normalizeLabelsForSync(labels: string[] | undefined): string[] {
if (!labels || labels.length === 0) return [];
const normalized = labels
.map(label => (label || '').trim().replace(/^#/, ''))
.filter(label => label.length > 0);
return Array.from(new Set(normalized));
}
async syncTaskPriorityToFile(taskId: string, newPriority: number): Promise<boolean> {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (!taskMapping) return false;
const filepath = taskMapping.filePath;
const file = this.app.vault.getAbstractFileByPath(filepath);
const fileContent = await this.app.vault.read(file);
const lines = fileContent.split('\n');
let modified = false;
const numericPriority = Number(newPriority);
const targetPriority = Number.isFinite(numericPriority) && numericPriority >= 1 && numericPriority <= 4
? Math.floor(numericPriority)
: 1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(taskId) || !this.plugin.taskParser.hasTodoistTag(line)) continue;
const currentPriority = this.plugin.taskParser.getTaskPriority(line);
if (currentPriority === targetPriority) break;
const metadataIndex = line.indexOf('%%[todoist_id::');
const prefix = (metadataIndex >= 0 ? line.slice(0, metadataIndex) : line)
.replace(/\s!!([1-4])(?=\s|$)/g, '')
.replace(/ {2,}/g, ' ')
.trimEnd();
const suffix = metadataIndex >= 0 ? line.slice(metadataIndex).trimStart() : '';
const nextPrefix = targetPriority > 1 ? `${prefix} !!${targetPriority}` : prefix;
lines[i] = suffix ? `${nextPrefix} ${suffix}` : nextPrefix;
modified = true;
break;
}
if (modified) {
const newFileContent = lines.join('\n');
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newFileContent);
this.plugin.logOperation?.log('FILE_TASK_PRIORITY_SYNCED', `Synced priority from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
return modified;
}
async syncTaskLabelsToFile(taskId: string, newLabels: string[]): Promise<boolean> {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (!taskMapping) return false;
const filepath = taskMapping.filePath;
const file = this.app.vault.getAbstractFileByPath(filepath);
const fileContent = await this.app.vault.read(file);
const lines = fileContent.split('\n');
let modified = false;
const todoistLabels = this.normalizeLabelsForSync(newLabels);
const desiredLabels = this.plugin.taskParser.normalizeLabelsForCompare([...todoistLabels, 'todoist']);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(taskId) || !this.plugin.taskParser.hasTodoistTag(line)) continue;
const currentLabels = this.plugin.taskParser.normalizeLabelsForCompare(
this.plugin.taskParser.getAllTagsFromLineText(line)
);
const isSame = currentLabels.length === desiredLabels.length
&& currentLabels.every((label, idx) => label === desiredLabels[idx]);
if (isSame) break;
const metadataIndex = line.indexOf('%%[todoist_id::');
const prefix = metadataIndex >= 0 ? line.slice(0, metadataIndex) : line;
const suffix = metadataIndex >= 0 ? line.slice(metadataIndex).trimStart() : '';
const prefixWithoutTags = prefix
.replace(/#[\w\u4e00-\u9fa5-]+/g, '')
.replace(/ {2,}/g, ' ')
.trimEnd();
const tagText = desiredLabels.map(label => `#${label}`).join(' ');
const nextPrefix = tagText ? `${prefixWithoutTags} ${tagText}` : prefixWithoutTags;
lines[i] = suffix ? `${nextPrefix} ${suffix}` : nextPrefix;
modified = true;
break;
}
if (modified) {
const newFileContent = lines.join('\n');
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newFileContent);
this.plugin.logOperation?.log('FILE_TASK_LABELS_SYNCED', `Synced labels from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
return modified;
}
async syncTaskNoteToFile(taskId: string, noteContent: string, noteDate: string): Promise<boolean> {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (!taskMapping) return false;
@ -663,10 +768,15 @@ export class FileOperation {
hasChanges = true;
}
// 3. Replace Web URL: https://todoist.com/app/task/oldId -> https://todoist.com/app/task/newId
const oldWebUrlPattern = new RegExp(`https://todoist\\.com/app/task/${oldId}`, 'g');
if (oldWebUrlPattern.test(line)) {
line = line.replace(oldWebUrlPattern, `https://todoist.com/app/task/${newId}`);
const oldWebUrlPatternLegacy = new RegExp(`https://todoist\\.com/app/task/${oldId}`, 'g');
if (oldWebUrlPatternLegacy.test(line)) {
line = line.replace(oldWebUrlPatternLegacy, `https://todoist.com/app/task/${newId}`);
hasChanges = true;
}
const oldWebUrlPatternNew = new RegExp(`https://app\\.todoist\\.com/app/task/${oldId}`, 'g');
if (oldWebUrlPatternNew.test(line)) {
line = line.replace(oldWebUrlPatternNew, `https://app.todoist.com/app/task/${newId}`);
hasChanges = true;
}
@ -811,8 +921,7 @@ export class FileOperation {
* @returns #
*/
private extractLabelsFromLine(line: string): string[] {
return this.plugin.taskParser.getAllTagsFromLineText(line)
.filter(l => l !== 'todoist');
return this.plugin.taskParser.getAllTagsFromLineText(line);
}