heroblackink_ultimate-todoi.../main.ts
HeroBlackInk df2e668d70 refactor: replace fileMetadata.todoistTasks with taskFileMapping
- Remove deprecated fileMetadata.todoistTasks and todoistCount usage
- Use taskFileMapping-derived methods (getTasksInFile, getTaskCountInFile)
- Delete obsolete frontMatter update logic in obsidianToTodoist.ts
- Remove redundant saveSettings() calls
- Simplify fileOperation.ts metadata checks
2026-02-21 19:54:56 +08:00

865 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MarkdownView, Notice, Plugin, Editor } from 'obsidian';
//settings
import { UltimateTodoistSyncSettings,DEFAULT_SETTINGS,UltimateTodoistSyncSettingTab } from './src/settings';
//todoist api
import { TodoistRestAPI } from './src/todoistRestAPI';
import { TodoistSyncAPI } from './src/todoistSyncAPI';
//task parser
import { TaskParser } from './src/taskParser';
//cache task read and write
import { CacheOperation } from './src/cacheOperation';
//file operation
import { FileOperation } from './src/fileOperation';
//log operation
import { LogOperation } from './src/logOperation';
//backup operation
import { BackupOperation } from './src/backupOperation';
//sync module
import { TodoistSync } from './src/syncModule';
//database checker
import { DatabaseChecker } from './src/databaseChecker';
//device manager
import { DeviceManager } from './src/deviceManager';
//storage path manager
import { StoragePathManager } from './src/storagePathManager';
//settings backup
import { SettingsBackup } from './src/settingsBackup';
//import modal
import { SetDefalutProjectInTheFilepathModal } from 'src/modal';
export default class UltimateTodoistSyncForObsidian extends Plugin {
settings: UltimateTodoistSyncSettings;
todoistRestAPI: TodoistRestAPI | undefined;
todoistSyncAPI: TodoistSyncAPI | undefined;
taskParser: TaskParser | undefined;
cacheOperation: CacheOperation | undefined;
fileOperation: FileOperation | undefined;
todoistSync: TodoistSync | undefined;
logOperation: LogOperation | undefined;
backupOperation: BackupOperation | undefined;
databaseChecker: DatabaseChecker | undefined;
deviceManager: DeviceManager | undefined;
storagePathManager: StoragePathManager | undefined;
settingsBackup: SettingsBackup | undefined;
lastLines: Map<string,number>;
statusBar;
syncLock: boolean;
saveLock: boolean;
async onload() {
this.saveLock = false;
const isSettingsLoaded = await this.loadSettings();
if(!isSettingsLoaded){
new Notice('Settings failed to load. Please reload the ultimate todoist sync plugin.');
return;
}
this.settingsBackup = new SettingsBackup(this.app, this);
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new UltimateTodoistSyncSettingTab(this.app, this));
if (!this.settings.todoistAPIToken) {
new Notice('Please enter your Todoist API.');
//return
}else{
await this.initializePlugin();
}
//lastLine 对象 {path:line}保存在lastLines map中
this.lastLines = new Map();
//key 事件监听,判断换行和删除
this.registerDomEvent(document, 'keyup', async (evt: KeyboardEvent) =>{
if(!this.settings.apiInitialized){
return
}
//console.log(`key pressed`)
//判断点击事件发生的区域,如果不在编辑器中return
if (!(this.app.workspace.activeEditor?.editor?.hasFocus())) {
(console.log(`editor is not focused`))
return
}
if (evt.key === 'ArrowUp' || evt.key === 'ArrowDown' || evt.key === 'ArrowLeft' || evt.key === 'ArrowRight' ||evt.key === 'PageUp' || evt.key === 'PageDown') {
//console.log(`${evt.key} arrow key is released`);
if(!( this.checkModuleClass())){
return
}
this.lineNumberCheck()
}
if(evt.key === "Delete" || evt.key === "Backspace"){
try{
//console.log(`${evt.key} key is released`);
if(!( this.checkModuleClass())){
return
}
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
await this.todoistSync.deletedTaskCheck();
this.syncLock = false;
this.saveSettings()
}catch(error){
console.error(`An error occurred while deleting tasks: ${error}`);
this.syncLock = false
}
}
});
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', async (evt: MouseEvent) => {
if(!this.settings.apiInitialized){
return
}
//console.log('click', evt);
if (this.app.workspace.activeEditor?.editor?.hasFocus()) {
//console.log('Click event: editor is focused');
this.lineNumberCheck()
}
else{
//
}
const target = evt.target as HTMLInputElement;
if (target.type === "checkbox") {
if(!(this.checkModuleClass())){
return
}
this.checkboxEventhandle(evt)
//this.todoistSync.fullTextModifiedTaskCheck()
}
});
//hook editor-change 事件如果当前line包含 #todoist,说明有new task
this.registerEvent(this.app.workspace.on('editor-change',async (editor,view:MarkdownView)=>{
try{
if(!this.settings.apiInitialized){
return
}
this.lineNumberCheck()
if(!(this.checkModuleClass())){
return
}
if(this.settings.enableFullVaultSync){
return
}
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
await this.todoistSync.lineContentNewTaskCheck(editor,view)
this.syncLock = false
this.saveSettings()
}catch(error){
console.error(`An error occurred while check new task in line: ${error.message}`);
this.syncLock = false
}
}))
//监听 rename 事件,更新 task data 中的 path
this.registerEvent(this.app.vault.on('rename', async (file,oldpath) => {
if(!this.settings.apiInitialized){
return
}
console.log(`${oldpath} is renamed`)
const taskCount = this.cacheOperation.getTaskCountInFile(oldpath)
if(taskCount === 0){
console.log('The renamed file has no tasks.')
return
}
if(!(this.checkModuleClass())){
return
}
await this.cacheOperation.updateRenamedFilePath(oldpath,file.path)
this.saveSettings()
this.logOperation?.log('FILE_RENAMED', `File renamed from ${oldpath} to ${file.path}`, file.path);
//update task description
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
try {
await this.todoistSync.updateTaskDescription(file.path)
} catch(error) {
console.error('An error occurred in updateTaskDescription:', error);
}
this.syncLock = false;
}));
//Listen for file modified events and execute fullTextNewTaskCheck
this.registerEvent(this.app.vault.on('modify', async (file) => {
try {
if(!this.settings.apiInitialized){
return
}
const filepath = file.path
console.log(`${filepath} is modified`)
//get current view
const activateFile = this.app.workspace.getActiveFile()
console.log(activateFile?.path)
//To avoid conflicts, Do not check files being edited
if(activateFile?.path == filepath){
return
}
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
await this.todoistSync.fullTextNewTaskCheck(filepath)
this.syncLock = false;
this.logOperation?.log('FILE_MODIFIED', `File modified: ${filepath}`, filepath);
} catch(error) {
console.error(`An error occurred while modifying the file: ${error.message}`);
this.syncLock = false
// You can add further error handling logic here. For example, you may want to
// revert certain operations, or alert the user about the error.
}
}));
this.registerInterval(window.setInterval(async () => await this.scheduledSynchronization(), this.settings.automaticSynchronizationInterval * 1000));
this.app.workspace.on('active-leaf-change',(leaf)=>{
this.setStatusBarText()
})
// set default project for todoist task in the current file
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'set-default-project-for-todoist-task-in-the-current-file',
name: 'Set default project for todoist task in the current file',
editorCallback: (editor: Editor, view: MarkdownView) => {
if(!view){
return
}
const filepath = view.file.path
new SetDefalutProjectInTheFilepathModal(this.app,this,filepath)
}
});
//display default project for the current file on status bar
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
this.statusBar = this.addStatusBarItem();
}
async onunload() {
console.log(`Ultimate Todoist Sync for Obsidian id unloaded!`)
await this.saveSettings()
}
async loadSettings(): Promise<boolean> {
this.settingsBackup = new SettingsBackup(this.app, this);
try {
if (await this.settingsBackup.hasTempFile()) {
console.warn('[Settings] Found temp file, attempting recovery...');
new Notice('Found unsaved changes, attempting recovery...');
const recovered = await this.settingsBackup.recoverFromTempFile();
if (recovered) {
new Notice('Settings recovered from unsaved changes');
} else {
new Notice('Failed to recover from unsaved changes, using backup');
await this.settingsBackup.restore();
}
}
const data = await this.loadData();
if (!this.validateLoadedSettings(data)) {
console.warn('[Settings] Settings corrupted, attempting recovery...');
new Notice('Settings corrupted, attempting recovery...');
const recovered = await this.settingsBackup.restore();
if (recovered) {
new Notice('Settings recovered from backup');
const recoveredData = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, recoveredData);
return true;
}
console.error('[Settings] Recovery failed, using default settings');
this.settings = Object.assign({}, DEFAULT_SETTINGS);
new Notice('Settings reset to defaults due to corruption');
await this.saveSettings();
return true;
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
return true;
} catch (error) {
console.error('[Settings] Failed to load data:', error);
this.settings = Object.assign({}, DEFAULT_SETTINGS);
return true;
}
}
private validateLoadedSettings(data: any): boolean {
if (!data || typeof data !== 'object') {
console.warn('[Settings] Settings data is not a valid object');
return false;
}
const requiredFields = ['initialized', 'todoistAPIToken', 'taskFileMapping'];
for (const field of requiredFields) {
if (!(field in data)) {
console.warn(`[Settings] Missing required field: ${field}`);
return false;
}
}
if (data.taskFileMapping && typeof data.taskFileMapping !== 'object') {
console.warn('[Settings] taskFileMapping is not a valid object');
return false;
}
try {
JSON.parse(JSON.stringify(data));
return true;
} catch {
console.warn('[Settings] Settings data cannot be stringified');
return false;
}
}
async saveSettings(): Promise<boolean> {
if (this.saveLock) {
console.log('[Settings] Save already in progress, skipping...');
return false;
}
this.saveLock = true;
const settingsPath = StoragePathManager.SETTINGS_FILE;
const tempPath = StoragePathManager.SETTINGS_TEMP_FILE;
try {
if (!this.settings || Object.keys(this.settings).length === 0) {
console.error('[Settings] Settings are empty or invalid, not saving to avoid data loss.');
this.saveLock = false;
return false;
}
if (!this.validateSettings()) {
console.error('[Settings] Settings validation failed');
this.saveLock = false;
return false;
}
if (this.settingsBackup) {
const backupSuccess = await this.settingsBackup.backup();
if (!backupSuccess) {
console.warn('[Settings] Backup failed, proceeding with save anyway');
new Notice('Warning: Settings backup failed');
}
}
const settingsJson = JSON.stringify(this.settings, null, 2);
const adapter = this.app.vault.adapter;
await adapter.write(tempPath, settingsJson);
console.log('[Settings] Written to temp file:', tempPath);
const tempExists = await adapter.exists(tempPath);
if (!tempExists) {
throw new Error('Temp file was not created');
}
await adapter.write(settingsPath, settingsJson);
console.log('[Settings] Written to actual file:', settingsPath);
try {
await adapter.remove(tempPath);
console.log('[Settings] Temp file cleaned up');
} catch (cleanupError) {
console.warn('[Settings] Failed to cleanup temp file:', cleanupError);
}
console.log('[Settings] Settings saved successfully');
this.saveLock = false;
return true;
} catch (error) {
console.error('[Settings] Error saving settings:', error);
console.error('[Settings] Temp file preserved for recovery at:', tempPath);
new Notice('Settings save failed, temp file preserved for recovery');
this.saveLock = false;
return false;
}
}
private validateSettings(): boolean {
if (!this.settings) {
return false;
}
const requiredFields = ['initialized', 'todoistAPIToken', 'taskFileMapping'];
for (const field of requiredFields) {
if (!(field in this.settings)) {
console.error(`[SettingsValidator] Missing required field: ${field}`);
return false;
}
}
if (this.settings.taskFileMapping && typeof this.settings.taskFileMapping !== 'object') {
return false;
}
try {
JSON.parse(JSON.stringify(this.settings));
return true;
} catch {
return false;
}
}
async modifyTodoistAPI(api:string){
await this.initializePlugin()
}
// return true of false
async initializePlugin(){
//initialize todoist restapi
this.todoistRestAPI = new TodoistRestAPI(this.app, this)
//initialize log operation
this.logOperation = new LogOperation(this.app, this)
//initialize data read and write object
this.cacheOperation = new CacheOperation(this.app, this)
const isProjectsSaved = await this.cacheOperation.saveProjectsToCache()
if(!isProjectsSaved){
this.todoistRestAPI = undefined
this.todoistSyncAPI = undefined
this.taskParser = undefined
this.taskParser = undefined
this.cacheOperation = undefined
this.fileOperation = undefined
this.todoistSync = undefined
this.logOperation = undefined
this.backupOperation = undefined
this.settings.initialized = false
this.settings.apiInitialized = false
await this.saveSettings()
new Notice(`Ultimate Todoist Sync plugin initialization failed, please check the todoist api`)
return;
}
if(!this.settings.initialized){
//创建备份文件夹备份todoist 数据
try{
//每次启动前备份所有数据
// Note: Initialize module class first to enable backupOperation
this.initializeModuleClass();
this.todoistSync.backupTodoistAllResources()
}catch(error){
console.log(`error creating user data folder: ${error}`)
this.settings.initialized = false
this.settings.apiInitialized = false
await this.saveSettings()
new Notice(`error creating user data folder`)
return;
}
//初始化settings
this.settings.initialized = true
this.saveSettings()
new Notice(`Ultimate Todoist Sync initialization successful. Todoist data has been backed up.`)
} else {
// Already initialized, just initialize the module class
this.initializeModuleClass();
}
//get user plan resources
//const rsp = await this.todoistSyncAPI.getUserResource()
this.settings.apiInitialized = true
this.syncLock = false
new Notice(`Ultimate Todoist Sync loaded successfully.`)
this.logOperation?.log('PLUGIN_INITIALIZED', 'Plugin initialized successfully');
// Run database check on startup
await this.runStartupDatabaseCheck();
return true
}
async runStartupDatabaseCheck(): Promise<void> {
try {
if (!this.databaseChecker) {
console.log('Database checker not initialized, skipping startup check');
return;
}
console.log('Running startup database check...');
const result = await this.databaseChecker.checkDatabase();
this.settings.lastDatabaseCheckPassed = result.success;
this.settings.lastDatabaseCheckTime = Date.now();
await this.saveSettings();
if (result.success) {
console.log('Startup database check passed');
} else {
this.settings.syncEnabled = false;
await this.saveSettings();
new Notice(`Found ${result.totalIssues} database issues. Sync has been disabled until issues are fixed.`);
}
} catch (error) {
console.error('Startup database check failed:', error);
}
}
async initializeModuleClass(){
//initialize todoist restapi
this.todoistRestAPI = new TodoistRestAPI(this.app,this)
//initialize data read and write object
this.cacheOperation = new CacheOperation(this.app,this)
this.taskParser = new TaskParser(this.app,this)
//initialize file operation
this.fileOperation = new FileOperation(this.app,this)
//initialize todoisy sync api
this.todoistSyncAPI = new TodoistSyncAPI(this.app,this)
//initialize todoist sync module
this.todoistSync = new TodoistSync(this.app,this)
//initialize backup operation
this.backupOperation = new BackupOperation(this.app,this)
//initialize database checker
this.databaseChecker = new DatabaseChecker(this.app, this)
//initialize device manager
this.deviceManager = new DeviceManager(this.app, this)
//initialize storage path manager
this.storagePathManager = new StoragePathManager(this.app, this)
//ensure all storage directories exist
this.storagePathManager.ensureAllDirs().catch(error => {
console.error('[Plugin] Failed to create storage directories:', error);
});
//initialize sync data (load from cache or full sync on startup)
try {
// Try to load from cache first
const loaded = this.todoistSyncAPI?.loadFromCache();
if (!loaded) {
// No cache, do full sync
await this.todoistSyncAPI?.initializeSync();
}
this.settings.deviceIdGenerated = true;
await this.saveSettings();
} catch (error) {
console.error('[Plugin] Failed to initialize sync:', error);
}
}
async lineNumberCheck(){
const view = this.app.workspace.getActiveViewOfType(MarkdownView)
if(view){
const cursor = view.app.workspace.getActiveViewOfType(MarkdownView)?.editor.getCursor()
const line = cursor?.line
//const lineText = view.editor.getLine(line)
const fileContent = view.data
//console.log(line)
//const fileName = view.file?.name
const fileName = view.app.workspace.getActiveViewOfType(MarkdownView)?.app.workspace.activeEditor?.file?.name
const filepath = view.app.workspace.getActiveViewOfType(MarkdownView)?.app.workspace.activeEditor?.file?.path
if (typeof this.lastLines === 'undefined' || typeof this.lastLines.get(fileName as string) === 'undefined'){
this.lastLines.set(fileName as string, line as number);
return
}
//console.log(`filename is ${fileName}`)
if(this.lastLines.has(fileName as string) && line !== this.lastLines.get(fileName as string)){
const lastLine = this.lastLines.get(fileName as string)
if(this.settings.debugMode){
console.log('Line changed!', `current line is ${line}`, `last line is ${lastLine}`);
}
// 执行你想要的操作
const lastLineText = view.editor.getLine(lastLine as number)
//console.log(lastLineText)
if(!( this.checkModuleClass())){
return
}
this.lastLines.set(fileName as string, line as number);
try{
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
await this.todoistSync.lineModifiedTaskCheck(filepath as string,lastLineText,lastLine as number,fileContent)
this.syncLock = false;
}catch(error){
console.error(`An error occurred while check modified task in line text: ${error}`);
this.syncLock = false
}
}
else {
//console.log('Line not changed');
}
}
}
async checkboxEventhandle(evt:MouseEvent){
if(!( this.checkModuleClass())){
return
}
const target = evt.target as HTMLInputElement;
const taskElement = target.closest("div"); //使用 evt.target.closest() 方法寻找特定的父元素,而不是直接访问事件路径中的特定索引
//console.log(taskElement)
if (!taskElement) return;
const regex = /\[todoist_id::\s*(\w+)\]/;
const match = taskElement.textContent?.match(regex) || false;
if (match) {
const taskId = match[1];
//console.log(taskId)
//const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (target.checked) {
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
this.todoistSync.closeTask(taskId);
} else {
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
this.todoistSync.repoenTask(taskId);
}
} else {
//console.log('未找到 todoist_id');
//开始全文搜索检查status更新
try{
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
await this.todoistSync.fullTextModifiedTaskCheck()
this.syncLock = false;
}catch(error){
console.error(`An error occurred while check modified tasks in the file: ${error}`);
this.syncLock = false;
}
}
}
//return true
checkModuleClass(){
if(this.settings.apiInitialized === true){
if(this.todoistRestAPI === undefined || this.todoistSyncAPI === undefined ||this.cacheOperation === undefined || this.fileOperation === undefined ||this.todoistSync === undefined ||this.taskParser === undefined){
this.initializeModuleClass()
}
return true
}
else{
new Notice(`Please enter the correct Todoist API token"`)
return(false)
}
}
async setStatusBarText(){
if(!( this.checkModuleClass())){
return
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView)
if(!view){
this.statusBar.setText('');
}
else{
const filepath = this.app.workspace.getActiveViewOfType(MarkdownView)?.file.path
if(filepath === undefined){
console.log(`file path undefined`)
return
}
const defaultProjectName = await this.cacheOperation.getDefaultProjectNameForFilepath(filepath as string)
if(defaultProjectName === undefined){
console.log(`projectName undefined`)
return
}
this.statusBar.setText(defaultProjectName)
}
}
async scheduledSynchronization() {
if (!(this.checkModuleClass())) {
return;
}
console.log("Todoist scheduled synchronization task started at", new Date().toLocaleString());
try {
if (!await this.checkAndHandleSyncLock('todoistToObsidian')) return;
try {
await this.todoistSync.syncTodoistToObsidian();
} catch(error) {
console.error('An error occurred in syncTodoistToObsidian:', error);
}
this.syncLock = false;
try {
await this.saveSettings();
} catch(error) {
console.error('An error occurred in saveSettings:', error);
}
// Sleep for 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000));
// 从 taskFileMapping 推导需要同步的文件列表
const filesToSyncSet = new Set<string>();
const taskFileMapping = this.settings.taskFileMapping;
for (const taskId in taskFileMapping) {
filesToSyncSet.add(taskFileMapping[taskId].filePath);
}
const filesToSync = Array.from(filesToSyncSet);
if(this.settings.debugMode){
console.log('Files to sync:', filesToSync)
}
for (const fileKey of filesToSync) {
if(this.settings.debugMode){
console.log('Syncing file:', fileKey)
}
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
try {
await this.todoistSync.fullTextNewTaskCheck(fileKey);
} catch(error) {
console.error('An error occurred in fullTextNewTaskCheck:', error);
}
this.syncLock = false;
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
try {
await this.todoistSync.deletedTaskCheck(fileKey);
} catch(error) {
console.error('An error occurred in deletedTaskCheck:', error);
}
this.syncLock = false;
if (!await this.checkAndHandleSyncLock('obsidianToTodoist')) return;
try {
await this.todoistSync.fullTextModifiedTaskCheck(fileKey);
} catch(error) {
console.error('An error occurred in fullTextModifiedTaskCheck:', error);
}
this.syncLock = false;
}
} catch (error) {
console.error('An error occurred:', error);
new Notice('An error occurred:', error);
this.syncLock = false;
}
console.log("Todoist scheduled synchronization task completed at", new Date().toLocaleString());
}
async checkSyncLock() {
let checkCount = 0;
while (this.syncLock == true && checkCount < 10) {
await new Promise(resolve => setTimeout(resolve, 1000));
checkCount++;
}
if (this.syncLock == true) {
return false;
}
return true;
}
async checkAndHandleSyncLock(direction?: 'obsidianToTodoist' | 'todoistToObsidian'): Promise<boolean> {
// Check 1: Database check status (先检查,更重要)
if (!this.settings.lastDatabaseCheckPassed) {
console.log('Sync is disabled due to database issues');
new Notice('Sync is blocked due to database issues. Please fix the issues first.');
return false;
}
// Check 2: User manual toggle (main switch)
if (!this.settings.syncEnabled) {
console.log('Sync is disabled by user (main switch off)');
return false;
}
// Check 3: Direction-specific toggle
if (direction === 'obsidianToTodoist' && !this.settings.obsidianToTodoistEnabled) {
console.log('Obsidian → Todoist sync is disabled by user');
return false;
}
if (direction === 'todoistToObsidian' && !this.settings.todoistToObsidianEnabled) {
console.log('Todoist → Obsidian sync is disabled by user');
return false;
}
if (this.syncLock) {
console.log('sync locked.');
const isSyncLockChecked = await this.checkSyncLock();
if (!isSyncLockChecked) {
return false;
}
console.log('sync unlocked.')
}
this.syncLock = true;
return true;
}
}