Compare commits

...

No commits in common. "1.0.42" and "master" have entirely different histories.

37 changed files with 12343 additions and 3750 deletions

View file

@ -6,18 +6,18 @@ on:
- "*"
env:
PLUGIN_NAME: ultimate-todoist-sync # Change this to match the id of your plugin.
PLUGIN_NAME: ultimate-todoist-sync
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20
- name: Build
id: build
@ -28,10 +28,10 @@ jobs:
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}
zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }}
ls
echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)"
echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT
- name: Create Release and Upload Assets
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
token: ${{ secrets.GH_PAT }}

15
.gitignore vendored
View file

@ -21,3 +21,18 @@ userData
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Add js build artifacts
*.js
opencode-session-history
data.json
/.sisyphus/
/tests/
AGENTS.md
device-id
/.stfolder/

6
.stignore Normal file
View file

@ -0,0 +1,6 @@
data.json
device-id
.DS_Store
/.github/
/.git/
/.sisyphus/

204
CHANGELOG.md Normal file
View file

@ -0,0 +1,204 @@
## CHANGELOG
### [2.0.3] - 2026-03-20
#### Fixed
- Add logging to 22 previously silent early returns across the sync pipeline
- Sync lock timeouts, disabled-task skips, missing sync targets, and parser failures are now logged to the operation log for troubleshooting
- New log event types: `SYNC_LOCK_TIMEOUT`, `SYNC_DISABLED_SKIP`, `SYNC_TARGET_MISSING`, `TASK_PARSE_FAILED`
---
### [2.0.2] - 2026-03-17
#### Fixed
- Full Vault Sync: new tasks are now created only when the cursor leaves the line, preventing the "typing hijack" bug where every keystroke triggered task creation
---
### [2.0.0] - 2026-03-15
#### Added
- Sync direction controls: independently enable/disable Obsidian→Todoist and Todoist→Obsidian
- Excluded folders: tree UI to select folders excluded from Full Vault Sync
- Full vault sync now scans all vault files on each sync cycle
- Automatic exclusion of template folders, hidden folders, and plugin storage
#### Changed
- Todoist API migrated from v9 to v1, library upgraded to v6.5.0
- Data architecture simplified: Todoist API as single source of truth, removed local task cache
- Backup files now use `.md.bak` extension to prevent Obsidian link pollution
- Priority and labels/tags sync is now fully bidirectional
- Database checker rewritten with three-way data consistency checks
#### Fixed
- Stability improvements for Full Vault Sync
---
### [1.0.4] - 2026-02-17
#### Added
- **Three-way sync direction controls**: Added two new settings for granular sync control
- `obsidianToTodoistEnabled` (default: true) - Enable/disable Obsidian → Todoist sync
- `todoistToObsidianEnabled` (default: false) - Enable/disable Todoist → Obsidian sync
- Both directions are independently controllable via new UI toggles in settings
- Sync status display now shows direction states
- **Full syncData persistence**: Added `syncDataCache` field to settings to store complete Todoist API response
- All API data (projects, items, sections, labels, notes, user, etc.) is now persisted
- Plugin startup loads from cache first, reducing API calls
- Data is saved to cache after full sync and incremental sync
#### Changed
- **Removed syncToken from settings**: Token now only stored in memory within syncDataCache
- Read from `this.syncData?.sync_token` instead of `settings.syncToken`
- **Improved mergeSyncData**: Now merges all API response fields dynamically instead of hardcoded fields
- **databaseChecker completely rewritten**:
- Now checks 8 combinations of 3 data sources (Vault, Todoist, taskFileMapping)
- New issue types: mapping_file_not_found, mapping_task_not_in_todoist, mapping_orphan, vault_task_no_mapping, line_number_mismatch
- Report format updated: shows data source combinations table first, then detailed issues by type
#### Fixed
- **rebuildCache refactored**: Updated to use new architecture with taskFileMapping
#### Tests
- Added comprehensive test suite: `tests/sync-api/test-todoist-sync-api-full.mjs`
---
### [1.0.3] - 2026-02-17
#### Changed
- **Major Refactoring: Simplified Data Architecture**
- Removed `todoistTasksData` from settings (was storing tasks/projects/events in persistent storage)
- Added `taskFileMapping` to settings (stores taskId → {filePath, lineNumber} mapping only)
- Now using `syncData` (in-memory from Todoist API) as single source of truth for tasks/projects
- Todoist API is now the primary data source instead of local cache
#### Refactored Files
- `src/settings.ts`: Removed `todoistTasksData` interface, added `taskFileMapping` interface
- `src/todoistSyncAPI.ts`:
- Modified `GetAllProjects()`, `GetTaskById()`, `GetActiveTasks()` to read from `syncData` with fallback to full sync
- Added `getProjectById()`, `getProjectByName()` methods for project lookups
- Added `getSyncData()` public method to access in-memory sync data
- `src/cacheOperation.ts`:
- Added 4 new taskFileMapping methods: `getTaskFileMapping()`, `setTaskFileMapping()`, `deleteTaskFileMapping()`, `getAllTaskFileMappings()`
- Deprecated all old cache methods (made them no-ops): `loadTasksFromCache`, `saveTasksToCache`, `appendTaskToCache`, `closeTaskToCacheByID`, `reopenTaskToCacheByID`, etc.
- Updated `getDefaultProjectNameForFilepath()` to use todoistSyncAPI
- `src/obsidianToTodoist.ts`: Replaced all cacheOperation calls with taskFileMapping or removed them
- `src/fileOperation.ts`: Replaced all `loadTaskFromCacheyID()` calls with `getTaskFileMapping()` + `GetTaskById()`
- `src/taskParser.ts`: Replaced cache methods with todoistSyncAPI methods
- `src/databaseChecker.ts`: Uses `syncData.items` + `taskFileMapping` instead of `todoistTasksData.tasks`
- `src/modal.ts`: Uses `todoistSyncAPI.getSyncData().projects` instead of `todoistTasksData.projects`
- `src/todoistToObsidian.ts`: Simplified to use taskFileMapping, removed event tracking calls
#### Notes
- Events (todoist → obsidian sync) are temporarily ignored to simplify one-way sync first
- This refactoring reduces data duplication and ensures Todoist is the single source of truth
---
### [1.0.2] - 2026-02-16
#### Added
- Added comprehensive operation logging system (`src/logOperation.ts`)
- Tracks 30+ log action types including task operations, file operations, cache operations, and Todoist API operations
- Persistent log storage in plugin settings
- Log viewing modal in settings
- Added bidirectional sync module split
- `src/obsidianToTodoist.ts` - Handles sync from Obsidian to Todoist
- `src/todoistToObsidian.ts` - Handles sync from Todoist to Obsidian
- `src/syncModule.ts` - Refactored to orchestrate bidirectional sync
- Added automatic file backup before modifications (`src/backupOperation.ts`)
- Backs up files to `.todoist-backups/` directory
- Configurable backup retention
- Added conflict detection and resolution for rebuild cache (`src/conflictModal.ts`)
- Interactive modal for resolving content and status conflicts
- Options: keep Obsidian, keep Todoist, or skip
- Added comprehensive `checkDatabase` function with 3-way data comparison
- **Three data sources**: Vault files, local Cache, and Todoist API
- **16 types of issue detection**:
- `task_deleted_in_todoist`: Task exists in Vault and Cache but deleted in Todoist
- `missing_in_cache`: Task exists in Vault and Todoist but not in local cache
- `new_task_not_synced`: Task in Vault not synced to Todoist and not in cache
- `file_reference_missing`: Task in Cache and Todoist but Vault file reference missing
- `orphaned_in_cache`: Task in Cache but not in Vault and deleted in Todoist
- `task_not_in_vault`: Task in Todoist but not in any Vault file
- `content_mismatch`: Content differs between Vault and Todoist
- `cache_content_outdated`: Cache content is outdated compared to Todoist
- `status_mismatch`: Completion status differs between Vault and Todoist
- `cache_status_outdated`: Cache status is outdated compared to Todoist
- `duedate_mismatch`: Due date differs between Cache and Todoist
- `duplicate_task`: Same task ID appears in multiple files
- `priority_mismatch`: Priority differs between Cache and Todoist
- `label_mismatch`: Labels differ between Vault and Todoist
- `project_mismatch`: Project differs between Cache and Todoist
- Generates detailed markdown report with task information (ID, content, file, line, due date, priority, status)
- Report saved to `.todoist-reports/` folder
#### Fixed
- Fixed checkbox regex bug (`\d+` → `\w+` to support string IDs)
- Fixed taskParser.ts regex inconsistencies
- Fixed duplicate module initialization in initializePlugin()
- Fixed AddTask date handling bug (was converting dueDatetime instead of dueDate)
- Fixed unused imports and variables in main.ts
- Improved rebuildCache with checkbox status detection and completion status comparison
- Fixed `getPluginPath()` method error - replaced `getBasePath()` with `vault.configDir` for better compatibility
- Replaced `fetch()` with Obsidian's `requestUrl()` for all Todoist API calls to fix CORS issues and ensure proper `X-Todoist-Client` header is sent
---
### [1.0.1] - 2026-02-16
#### Added
- Added tests directory with API test scripts
- Added `tests/test-todoist-api.js` - Basic API test script
- Added `tests/test-rest-api-full.mjs` - Full REST API test script (29 test cases)
- Added `tests/test-sync-api.js` - Basic Sync API test script
- Added `tests/test-sync-api-full.js` - Full Sync API test script (26 test cases)
#### Fixed
- **REST API Migration (v9 → v1)**: Updated `src/todoistRestAPI.ts` to handle new pagination format
- `GetActiveTasks()`: Now returns `result.results` instead of raw result
- `GetAllProjects()`: Now returns `result.results` instead of raw result
- **Sync API Migration (v9 → v1)**: Updated `src/todoistSyncAPI.ts` to use new API endpoints
- `getAllResources()`: Changed URL to `/api/v1/sync`
- `getUserResource()`: Changed URL to `/api/v1/sync`
- `updateUserTimezone()`: Changed URL to `/api/v1/sync`
- `getAllActivityEvents()`: Changed to GET `/api/v1/activities`, returns `{ events: [] }` format
- `getCompletedItemsActivity()`: Changed to GET `/api/v1/activities?event_type=completed`
- `getUncompletedItemsActivity()`: Changed to GET `/api/v1/activities?event_type=uncompleted`
- `getUpdatedItemsActivity()`: Changed to GET `/api/v1/activities?event_type=updated`
- `getProjectsActivity()`: Changed to GET `/api/v1/activities?object_type=project`
- Fixed null check for `extra_data.client` in `getNonObsidian*` methods
- **Build Configuration**: Updated `esbuild.config.mjs` to handle Node.js built-in modules in new `@doist/todoist-api-typescript` library (v6.5.0)
- Added `node:assert`, `node:async_hooks`, `node:buffer`, `node:console`, `node:crypto`, `node:dns`, `node:diagnostics_channel`, `node:events`, `node:fs`, `node:http`, `node:https`, `node:net`, `node:path`, `node:perf_hooks`, `node:querystring`, `node:stream`, `node:string_decoder`, `node:timers`, `node:tls`, `node:url`, `node:util`, `node:util/types`, `node:worker_threads`, `node:zlib`
#### Changed
- **Library Update**: `@doist/todoist-api-typescript` upgraded from v2.1.2 to v6.5.0
#### Known Issues
- TypeScript compilation errors due to library version incompatibility with TypeScript 4.7.4 (use `npm run build-without-tsc` for now)
---
### prelease [1.0.38] - 2023-06-09
https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases/tag/v1.0.38-beta
- New feature
- 1.0.38 beta now supports date formats for tasks.
- Todoist task link is added.
### prelease [1.0.37] - 2023-06-05
https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases/tag/v1.0.37-beta
- New feature
- Two-way automatic synchronization, no longer need to manually click the sync button.
- Full vault sync option, automatically adding `#todoist` to all tasks.
- Notes/comments one-way synchronization from Todoist to Obsidian.
- Bug fix
- Fixed the bug of time zone conversion.
- Removed the "#" from the Todoist label.
- Update the obsidian link in Todoist after moving or renaming a file.

197
README.md
View file

@ -1,33 +1,6 @@
# Ultimate Todoist Sync for Obsidian
It should be the best Obsidian plugin for synchronizing Todoist tasks so far.
## CHANGELOG
### prelease [1.0.38] - 2023-06-09
https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases/tag/v1.0.38-beta
- New feature
- 1.0.38 beta now supports date formats for tasks.
- Todoist task link is added.
## CHANGELOG
### prelease [1.0.37] - 2023-06-05
https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases/tag/v1.0.37-beta
- New feature
- Two-way automatic synchronization, no longer need to manually click the sync button.
- Full vault sync option, automatically adding `#todoist` to all tasks.
- Notes/comments one-way synchronization from Todoist to Obsidian.
- Bug fix
- Fixed the bug of time zone conversion.
- Removed the "#" from the Todoist label.
- Update the obsidian link in Todoist after moving or renaming a file.
The Ultimate Todoist Sync plugin automatically creates tasks in Todoist and synchronizes task state between Obsidian and Todoist.
## Demo
@ -39,71 +12,96 @@ https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases/tag/
<img src="/attachment/settings.png" width="500">
## Features
## Features
###
| | Sync from Obsidian to Todoist | Sync from Todoist to Obsidian | Description |
|-------------------------|-------------------------------|-------------------------------|-------------|
| Add task | ✅ | 🔜 | |
| Delete task | ✅ | 🔜 | |
| Modify task content | ✅ | ✅ | |
| Modify task due date | ✅ | ✅ | |
| Modify task description | 🔜 | 🔜 | |
| Modify task labels/tags | ✅ | 🔜 | |
| Mark task as completed | ✅ | ✅ | |
| Mark task as uncompleted| ✅ | ✅ | |
| Modify project | 🔜 | 🔜 | |
| Modify section | 🔜 | 🔜 | |
| Modify priority | ✅ | 🔜 | Currently, task priority only support one-way synchronization from Todoist to Obsidian. |
| Add reminder | 🔜 | 🔜 | |
| Move tasks between files| 🔜 | 🔜 | |
| Added-at date | 🔜 | 🔜 | |
| Completed-at date | 🔜 | 🔜 | |
| Task notes | 🔜 | ✅ | Currently, task notes/comments only support one-way synchronization from Todoist to Obsidian. |
| Feature | Obsidian → Todoist | Todoist → Obsidian |
|--------------------------|--------------------|--------------------|
| Add task | ✅ | 🔜 |
| Delete task | ✅ | 🔜 |
| Modify task content | ✅ | ✅ |
| Modify task due date | ✅ | ✅ |
| Modify task labels/tags | ✅ | ✅ |
| Mark task as completed | ✅ | ✅ |
| Mark task as uncompleted | ✅ | ✅ |
| Modify priority | ✅ | ✅ |
| Task notes/comments | 🔜 | ✅ |
| Modify task description | 🔜 | 🔜 |
| Modify project | 🔜 | 🔜 |
| Modify section | 🔜 | 🔜 |
| Add reminder | 🔜 | 🔜 |
| Move tasks between files | 🔜 | 🔜 |
## Installation
### From within Obsidian
From Obsidian v1.3.5+, you can activate this plugin within Obsidian by doing the following:
1. Open Obsidian's `Settings` window
2. Select the `Community plugins` tab on the left
3. Make sure `Restricted mode` is **off**
4. Click `Browse` next to `Community Plugins`
5. Search for and click on `Ultimate Todoist Sync`
6. Click `Install`
7. Once installed, close the `Community Plugins` window
8. Under `Installed Plugins`, activate the `Ultimate Todoist Sync` plugin
You can update the plugin following the same procedure, clicking `Update` instead of `Install`
### Manually
If you would rather install the plugin manually, you can do the following:
1. Download the latest release of the plugin from the [Releases](https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian/releases) page.
2. Extract the downloaded zip file and copy the entire folder to your Obsidian plugins directory.
3. Enable the plugin in the Obsidian settings.
## Configuration
1. In the Obsidian settings, click on the "Plugins" tab and then click the gear icon next to the "Ultimate Todoist Sync for Obsidian" plugin.
2. Enter the Todoist API..
1. Open Obsidian's `Settings` window
2. Select the `Community plugins` tab on the left
3. Under `Installed plugins`, click the gear icon next to the `Ultimate Todoist Sync` plugin
4. Enter your Todoist API token
## Settings
1. Automatic synchronization interval time
The time interval for automatic synchronization is set to 300 seconds by default, which means it runs every 5 minutes. You can modify it yourself.
2. Default project
New tasks will be added to the default project, and you can change the default project in the settings.
3. Full vault sync
By enabling this option, the plugin will automatically add `#todoist` to all tasks, which will modify all files in the vault.
1. **Automatic synchronization interval time**
The time interval for automatic synchronization is set to 300 seconds by default, which means it runs every 5 minutes. You can modify it yourself.
2. **Default project**
New tasks will be added to the default project, and you can change the default project in the settings or use a project tag to specify a particular project.
3. **Sync direction controls**
- Obsidian → Todoist (default: on)
- Todoist → Obsidian (default: off)
Each direction can be independently enabled or disabled.
4. **Full vault sync**
By enabling this option, the plugin will automatically add `#todoist` to all tasks in your vault.
5. **Excluded folders**
Select folders to exclude from Full Vault Sync. Template folders, hidden folders, and plugin storage are excluded automatically.
## Usage
### Task format
| Syntax | Description | Example |
| --- | --- | --- |
|#todoist|Tasks marked with `#todoist` will be added to Todoist, while tasks without the `#todoist` tag will not be processed.If you have enabled Full vault sync in the settings, `#todoist` will be added automatically.| `- [ ] task #todoist`|
| 📅YYYY-MM-DD | The date format is 📅YYYY-MM-DD, indicating the due date of a task. | `- [ ] task content 📅2025-02-05 #todoist` <br>Supports the following calendar emojis.📅📆🗓🗓️|
| #projectTag | New tasks will be added to the default project(For example, inbox .), and you can change the default project in the settings or use a tag with the same name to specify a particular project. | `- [ ] taskA #todoist` will be added to inbox.<br>`- [ ] taskB #tag #testProject #todoist` will be added to testProject.|
| #tag | Note that all tags without a project of the same name are treated as normal tags | `- [ ] task #tagA #tagB #tagC #todoist` |
| `!!<number>` | The priority of the task (a number between 1 and 4, 4 for very urgent and 1 for natural).<br>**Note**: Keep in mind that very urgent is the priority 1 on clients. So, the priority 1 in the client corresponds to the number 4 here (Because that's how the official API of Todoist is designed.). | `- [ ] task !!4 #todoist` |
|#todoist|Tasks marked with `#todoist` will be added to Todoist, while tasks without the `#todoist` tag will not be processed. If you have enabled Full vault sync in the settings, `#todoist` will be added automatically.| `- [ ] task #todoist`|
| 📅YYYY-MM-DD | The date format is 📅YYYY-MM-DD, indicating the due date of a task. | `- [ ] task content 📅2025-02-05 #todoist` <br>Supports the following calendar emojis: 📅📆🗓🗓️|
| #projectTag | New tasks will be added to the default project (e.g. inbox). You can change the default project in the settings or use a tag with the same name to specify a particular project. | `- [ ] taskA #todoist` will be added to inbox.<br>`- [ ] taskB #tag #testProject #todoist` will be added to testProject.|
| #tag | Note that all tags without a project of the same name are treated as normal tags. | `- [ ] task #tagA #tagB #tagC #todoist` |
| `!!<number>` | The priority of the task (a number between 1 and 4, 4 for very urgent and 1 for natural).<br>**Note**: Keep in mind that very urgent is the priority 1 on clients. So, the priority 1 in the client corresponds to the number 4 here (because that's how the official API of Todoist is designed). | `- [ ] task !!4 #todoist` |
### Set a default project for each file separately
### Set a default project for each file separately
The default project in the setting applies to all files. You can set a separate default project for each file using command.
The default project in the setting applies to all files. You can set a separate default project for each file using command.
<img src="/attachment/command-set-default-project-for-file.png" width="500">
<img src="/attachment/default-project-for-file-modal.png" width="500">
@ -112,6 +110,60 @@ You can see the current file's default project in the status bar at the bottom r
<img src="/attachment/statusBar.png" width="500">
## Development
### Prerequisites
- [Node.js](https://nodejs.org/) (v16+)
- npm
- An Obsidian vault for testing
### Quick Start
The recommended way is to clone the repo directly into your vault's plugin directory, so that builds are immediately available to Obsidian:
```bash
cd /path/to/your-vault/.obsidian/plugins/
git clone https://github.com/HeroBlackInk/ultimate-todoist-sync-for-obsidian.git
cd ultimate-todoist-sync-for-obsidian
npm install
```
### Build
```bash
# Development (watch mode, auto-rebuilds on file change)
npm run dev
# Production (type-check + bundle)
npm run build
```
After each rebuild, reload Obsidian (`Ctrl/Cmd+P` → "Reload app without saving") or disable and re-enable the plugin in settings.
### Project Structure
```
main.ts # Plugin entry point
src/
├── api/ # Todoist REST & Sync API clients
├── data/ # Cache, task parser, database checker
├── sync/ # Sync engines (toTodoist, toObsidian, scheduler)
├── vault/ # Obsidian vault file operations
├── storage/ # Persistent storage, backup, logs
├── settings/ # Settings UI and migration
├── plugin/ # Event handlers and lifecycle
└── ui/ # Modals (task manager, project picker)
```
### Manual Install
If you built the plugin elsewhere, copy these 3 files into `<vault>/.obsidian/plugins/ultimate-todoist-sync/`:
- `main.js`
- `manifest.json`
- `styles.css`
## Disclaimer
@ -121,11 +173,12 @@ The author shall not be responsible for any loss or damage, including but not li
By using this plugin, you agree to be bound by all the terms of this disclaimer. If you have any questions, please contact the author.
## Contributing
Contributions are welcome! If you'd like to contribute to the plugin, please read our [contributing guidelines](CONTRIBUTING.md) and submit a pull request.
Contributions are welcome! If you'd like to contribute to the plugin, please feel free to submit a pull request.
## License
This plugin is released under the [GNU GPLv3 License](/LICENSE.md).

View file

@ -31,6 +31,31 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
"node:assert",
"node:async_hooks",
"node:buffer",
"node:console",
"node:crypto",
"node:diagnostics_channel",
"node:dns",
"node:events",
"node:fs",
"node:http",
"node:https",
"node:net",
"node:path",
"node:perf_hooks",
"node:querystring",
"node:sqlite",
"node:stream",
"node:string_decoder",
"node:timers",
"node:tls",
"node:url",
"node:util",
"node:util/types",
"node:worker_threads",
"node:zlib",
...builtins],
format: "cjs",
target: "es2018",

860
main.ts
View file

@ -1,624 +1,338 @@
import { MarkdownView, Notice, Plugin ,Editor, WorkspaceLeaf} from 'obsidian';
import { MarkdownView, Notice, Plugin, Editor } from 'obsidian';
import { UltimateTodoistSyncSettings, DEFAULT_SETTINGS, UltimateTodoistSyncSettingTab } from './src/settings/settings';
import { TodoistRestAPI } from './src/api/restApi';
import { TodoistSyncAPI } from './src/api/syncApi';
import { TaskParser } from './src/data/taskParser';
import { CacheOperation } from './src/data/cache';
import { FileOperation } from './src/vault/fileOperation';
import { LogOperation } from './src/storage/log';
import { BackupOperation } from './src/storage/backup';
import { ObsidianToTodoistSync } from './src/sync/toTodoist';
import { TodoistToObsidianSync } from './src/sync/toObsidian';
import { DatabaseChecker } from './src/data/databaseChecker';
import { DeviceManager } from './src/utils/deviceManager';
import { StoragePathManager } from './src/storage/pathManager';
import { SafeSettings, SettingsBackup } from './src/settings/safeSettings';
//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';
//sync module
import { TodoistSync } from './src/syncModule';
//import modal
import { SetDefalutProjectInTheFilepathModal } from 'src/modal';
import { SyncLockManager } from './src/sync/syncLock';
import { SyncScheduler } from './src/sync/scheduler';
import { EventHandlers } from './src/plugin/eventHandlers';
import { SetDefalutProjectInTheFilepathModal, TaskManagerModal } from './src/ui/modals';
export default class UltimateTodoistSyncForObsidian extends Plugin {
settings: UltimateTodoistSyncSettings;
todoistRestAPI:TodoistRestAPI;
todoistSyncAPI:TodoistSyncAPI;
taskParser:TaskParser;
cacheOperation:CacheOperation;
fileOperation:FileOperation;
todoistSync:TodoistSync;
lastLines: Map<string,number>;
statusBar;
syncLock: Boolean;
todoistRestAPI: TodoistRestAPI | undefined;
todoistSyncAPI: TodoistSyncAPI | undefined;
taskParser: TaskParser | undefined;
cacheOperation: CacheOperation | undefined;
fileOperation: FileOperation | undefined;
obsidianToTodoist: ObsidianToTodoistSync | undefined;
todoistToObsidian: TodoistToObsidianSync | undefined;
logOperation: LogOperation | undefined;
backupOperation: BackupOperation | undefined;
databaseChecker: DatabaseChecker | undefined;
deviceManager: DeviceManager | undefined;
storagePathManager: StoragePathManager | undefined;
settingsBackup: SettingsBackup | undefined;
safeSettings: SafeSettings | undefined;
lastLines: Map<string, number>;
statusBar: ReturnType<Plugin['addStatusBarItem']>;
saveLock: boolean;
isProcessingModify: boolean;
isSyncingFromTodoist: boolean;
cachedDeviceId: string;
loadSucceeded: boolean;
syncLockManager: SyncLockManager;
scheduler: SyncScheduler;
private eventHandlers: EventHandlers;
private lastApiNoticeTime = 0;
private syncIntervalId: number | null = null;
debugLog(...args: unknown[]): void {
if (this.settings?.debugMode) {
console.log('[TodoistSync]', ...args);
}
}
async onload() {
this.loadSucceeded = false;
this.saveLock = false;
this.isSyncingFromTodoist = false;
this.cachedDeviceId = '';
this.syncLockManager = new SyncLockManager(this);
this.safeSettings = new SafeSettings(this);
await this.loadSettings();
// 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();
const isSettingsLoaded = await this.safeSettings.load();
this.loadSucceeded = isSettingsLoaded;
if (!isSettingsLoaded) {
new Notice('Settings failed to load. Please reload the ultimate todoist sync plugin.');
return;
}
//lastLine 对象 {path:line}保存在lastLines map中
this.statusBar = this.addStatusBarItem();
this.addSettingTab(new UltimateTodoistSyncSettingTab(this.app, this));
// No API token → not configured yet (fresh install or data.json not synced)
// Don't generate deviceId, don't initialize anything — just show settings tab
if (!this.settings.todoistAPIToken) {
new Notice('Please enter your Todoist API.');
return;
}
// API token exists → data.json is present → safe to read/generate device-id
const earlyDeviceManager = new DeviceManager(this.app, this);
this.cachedDeviceId = await earlyDeviceManager.getDeviceId();
// Non-primary device: only keep settings tab + status bar, skip everything else
if (this.settings.primaryDeviceId !== '' && this.settings.primaryDeviceId !== this.cachedDeviceId) {
this.statusBar.setText('\ud83d\udcf1 Secondary');
new Notice('Todoist Sync: Secondary device \u2014 sync disabled. Use settings to claim as primary.');
return;
}
// Primary device (or unclaimed) → full initialization
await this.initializePlugin();
this.lastLines = new Map();
this.scheduler = new SyncScheduler(this);
this.eventHandlers = new EventHandlers(this);
this.eventHandlers.register();
this.restartSyncSchedulerInterval();
//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
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
const editor = view?.app.workspace.activeEditor?.editor
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()) return;
await this.todoistSync.deletedTaskCheck();
this.syncLock = false;
this.saveSettings()
}catch(error){
console.error(`An error occurred while deleting tasks: ${error}`);
this.syncLock = false
}
}
this.app.workspace.on('active-leaf-change', () => {
this.setStatusBarText();
});
// 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');
const view = this.app.workspace.getActiveViewOfType(MarkdownView)
const editor = this.app.workspace.activeEditor?.editor
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()) 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
}
}))
/* 使obsidian
//监听删除事件当文件被删除后读取frontMatter中的tasklist,批量删除
this.registerEvent(this.app.metadataCache.on('deleted', async(file,prevCache) => {
try{
if(!this.settings.apiInitialized){
return
}
//console.log('a new file has modified')
console.log(`file deleted`)
//读取frontMatter
const frontMatter = await this.cacheOperation.getFileMetadata(file.path)
if(frontMatter === null || frontMatter.todoistTasks === undefined){
console.log('There is no task in the deleted files.')
return
}
//判断todoistTasks是否为null
console.log(frontMatter.todoistTasks)
if(!( this.checkModuleClass())){
return
}
if (!await this.checkAndHandleSyncLock()) return;
await this.todoistSync.deleteTasksByIds(frontMatter.todoistTasks)
this.syncLock = false
this.saveSettings()
}catch(error){
console.error(`An error occurred while deleting task in the file: ${error}`);
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`)
//读取frontMatter
//const frontMatter = await this.fileOperation.getFrontMatter(file)
const frontMatter = await this.cacheOperation.getFileMetadata(oldpath)
console.log(frontMatter)
if(frontMatter === null || frontMatter.todoistTasks === undefined){
//console.log('删除的文件中没有task')
return
}
if(!(this.checkModuleClass())){
return
}
await this.cacheOperation.updateRenamedFilePath(oldpath,file.path)
this.saveSettings()
//update task description
if (!await this.checkAndHandleSyncLock()) 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()) return;
await this.todoistSync.fullTextNewTaskCheck(filepath)
this.syncLock = false;
} 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)
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();
this.addCommand({
id: 'open-task-manager',
name: 'Open Task Manager',
callback: () => {
new TaskManagerModal(this.app, this).open();
}
});
}
async onunload() {
console.log(`Ultimate Todoist Sync for Obsidian id unloaded!`)
await this.saveSettings()
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
this.saveData(this.settings);
}
async modifyTodoistAPI(api:string){
await this.initializePlugin()
}
// return true of false
async initializePlugin(){
//initialize todoist restapi
this.todoistRestAPI = new TodoistRestAPI(this.app, this)
//initialize data read and write object
this.cacheOperation = new CacheOperation(this.app, this)
const ini = await this.cacheOperation.saveProjectsToCache()
if(!ini){
this.todoistRestAPI === undefined
this.todoistSyncAPI === undefined
this.taskParser === undefined
this.taskParser ===undefined
this.cacheOperation ===undefined
this.fileOperation ===undefined
this.todoistSync === undefined
new Notice(`Ultimate Todoist Sync plugin initialization failed, please check the todoist api`)
return false
this.debugLog('Ultimate Todoist Sync for Obsidian is unloaded!');
try {
await this.logOperation?.flushToFile();
} catch (error) {
console.error('An error occurred in flushToFile:', error);
}
if(!this.settings.initialized){
//创建备份文件夹备份todoist 数据
try{
//第一次启动插件备份todoist 数据
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)
//每次启动前备份所有数据
this.todoistSync.backupTodoistAllResources()
}catch(error){
console.log(`error creating user data folder: ${error}`)
new Notice(`error creating user data folder`)
return false
}
//初始化settings
this.settings.todoistTasksData.tasks = []
this.settings.todoistTasksData.events = []
this.settings.initialized = true
this.settings.automaticSynchronizationInterval = 300
this.saveSettings()
new Notice(`Ultimate Todoist Sync initialization successful. Todoist data has been backed up.`)
}
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.`)
return true
}
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)
}
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()) 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:: (\d+)\]/; // 匹配 [todoist_id:: 数字] 格式的字符串
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) {
this.todoistSync.closeTask(taskId);
} else {
this.todoistSync.repoenTask(taskId);
}
if (this.loadSucceeded) {
await this.saveSettings();
} else {
//console.log('未找到 todoist_id');
//开始全文搜索检查status更新
try{
if (!await this.checkAndHandleSyncLock()) 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;
}
console.warn('[Plugin] Skipping saveSettings on unload — load did not succeed');
}
}
//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 loadSettings(): Promise<boolean> {
return this.safeSettings!.load();
}
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 saveSettings(): Promise<boolean> {
return this.safeSettings!.save();
}
async scheduledSynchronization() {
if (!(this.checkModuleClass())) {
restartSyncSchedulerInterval(): void {
if (!this.scheduler) return;
if (this.syncIntervalId !== null) {
window.clearInterval(this.syncIntervalId);
}
const scheduler = this.scheduler;
this.syncIntervalId = window.setInterval(async () => await scheduler.run(), this.settings.automaticSynchronizationInterval * 1000);
this.registerInterval(this.syncIntervalId);
}
async modifyTodoistAPI(api: string): Promise<boolean> {
const result = await this.initializePlugin();
return result === true;
}
async initializePlugin() {
this.todoistRestAPI = new TodoistRestAPI(this.app, this);
this.logOperation = new LogOperation(this.app, this);
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.cacheOperation = undefined;
this.fileOperation = undefined;
this.obsidianToTodoist = undefined;
this.todoistToObsidian = undefined;
this.logOperation = undefined;
this.backupOperation = undefined;
await this.safeSettings?.update({ initialized: false, apiInitialized: false }, true);
new Notice(`Ultimate Todoist Sync plugin initialization failed, please check the todoist api`);
return;
}
console.log("Todoist scheduled synchronization task started at", new Date().toLocaleString());
try {
if (!await this.checkAndHandleSyncLock()) return;
if (!this.settings.initialized) {
try {
await this.todoistSync.syncTodoistToObsidian();
} catch(error) {
console.error('An error occurred in syncTodoistToObsidian:', error);
await this.initializeModuleClass();
await this.todoistToObsidian!.backupTodoistAllResources();
} catch (error) {
this.debugLog(`error creating user data folder: ${error}`);
await this.safeSettings?.update({ initialized: false, apiInitialized: false }, true);
new Notice(`error creating user data folder`);
return;
}
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));
const filesToSync = this.settings.fileMetadata;
if(this.settings.debugMode){
console.log(filesToSync)
}
for (let fileKey in filesToSync) {
if(this.settings.debugMode){
console.log(fileKey)
}
if (!await this.checkAndHandleSyncLock()) return;
try {
await this.todoistSync.fullTextNewTaskCheck(fileKey);
} catch(error) {
console.error('An error occurred in fullTextNewTaskCheck:', error);
}
this.syncLock = false;
if (!await this.checkAndHandleSyncLock()) return;
try {
await this.todoistSync.deletedTaskCheck(fileKey);
} catch(error) {
console.error('An error occurred in deletedTaskCheck:', error);
}
this.syncLock = false;
if (!await this.checkAndHandleSyncLock()) 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;
await this.safeSettings?.update({ initialized: true }, true);
new Notice(`Ultimate Todoist Sync initialization successful. Todoist data has been backed up.`);
} else {
await this.initializeModuleClass();
}
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++;
await this.safeSettings?.update({ apiInitialized: true });
this.syncLockManager.release();
// Auto-claim primary device if no primary is set yet
if (this.settings.primaryDeviceId === '' && this.cachedDeviceId) {
await this.safeSettings?.update({ primaryDeviceId: this.cachedDeviceId }, true);
this.debugLog('[Plugin] Auto-claimed as primary device: ' + this.cachedDeviceId);
}
if (this.syncLock == true) {
return false;
this.logOperation?.log('PLUGIN_INITIALIZED', 'Plugin initialized successfully');
const startupDatabaseCheckSucceeded = await this.runStartupDatabaseCheck();
if (startupDatabaseCheckSucceeded) {
new Notice(`Ultimate Todoist Sync loaded successfully.`);
} else {
new Notice(`Ultimate Todoist Sync loaded with startup sync warnings. Please check console logs.`, 8000);
}
return true;
}
async checkAndHandleSyncLock() {
if (this.syncLock) {
console.log('sync locked.');
const isSyncLockChecked = await this.checkSyncLock();
if (!isSyncLockChecked) {
async runStartupDatabaseCheck(): Promise<boolean> {
try {
if (!this.databaseChecker) {
this.debugLog('Database checker not initialized, skipping startup check');
return true;
}
this.debugLog('Running startup database check...');
const result = await this.databaseChecker.checkDatabase();
await this.safeSettings?.update({
lastDatabaseCheckTime: Date.now()
}, true);
const startupCheckFailed = result.issues.some((issue) =>
issue.type === 'issue_unclassified' && issue.details.startsWith('Database check failed:')
);
if (startupCheckFailed) {
new Notice('Startup database check failed to reach Todoist. Please verify network/API status and try sync again.', 10000);
return false;
}
console.log('sync unlocked.')
if (result.success) {
this.debugLog('Startup database check passed');
return true;
} else {
new Notice(`Found ${result.totalIssues} database issue(s). Please use "Fix Database" in settings to resolve them.`);
return true;
}
} catch (error) {
console.error('Startup database check failed:', error);
return false;
}
this.syncLock = true;
return true;
}
async initializeModuleClass() {
this.todoistRestAPI = new TodoistRestAPI(this.app, this);
this.cacheOperation = new CacheOperation(this.app, this);
this.taskParser = new TaskParser(this.app, this);
this.fileOperation = new FileOperation(this.app, this);
this.todoistSyncAPI = new TodoistSyncAPI(this.app, this);
this.obsidianToTodoist = new ObsidianToTodoistSync(this.app, this);
this.todoistToObsidian = new TodoistToObsidianSync(this.app, this);
this.backupOperation = new BackupOperation(this.app, this);
this.databaseChecker = new DatabaseChecker(this.app, this);
this.deviceManager = new DeviceManager(this.app, this);
this.cachedDeviceId = await this.deviceManager.getDeviceId();
this.storagePathManager = new StoragePathManager(this.app, this);
this.storagePathManager.ensureAllDirs().catch(error => {
console.error('[Plugin] Failed to create storage directories:', error);
});
this.logOperation?.loadFromFile().catch(error => {
console.error('[Plugin] Failed to load logs from file:', error);
});
try {
const loaded = this.todoistSyncAPI?.loadFromCache();
if (!loaded) {
await this.todoistSyncAPI?.initializeSync();
} else {
this.debugLog('[Plugin] Loaded sync cache from settings; startup database check will refresh incrementally.');
}
} catch (error) {
console.error('[Plugin] Failed to initialize sync:', error);
}
}
isPrimaryDevice(): boolean {
return this.settings.primaryDeviceId !== '' && this.settings.primaryDeviceId === this.cachedDeviceId;
}
async checkModuleClass(): Promise<boolean> {
if (this.settings.apiInitialized === true) {
if (
this.todoistRestAPI === undefined ||
this.todoistSyncAPI === undefined ||
this.cacheOperation === undefined ||
this.fileOperation === undefined ||
this.obsidianToTodoist === undefined ||
this.taskParser === undefined ||
this.deviceManager === undefined
) {
await this.initializeModuleClass();
}
return true;
} else {
const now = Date.now();
if (now - this.lastApiNoticeTime > 60_000) {
new Notice(`Please enter the correct Todoist API token`);
this.lastApiNoticeTime = now;
}
return false;
}
}
async setStatusBarText() {
if (!await this.checkModuleClass()) return;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
this.statusBar.setText('');
} else {
const filepath = view.file?.path;
if (filepath === undefined) {
this.debugLog('file path undefined');
return;
}
const defaultProjectName = await this.cacheOperation!.getDefaultProjectNameForFilepath(filepath);
if (defaultProjectName === undefined) {
this.debugLog('projectName undefined');
return;
}
this.statusBar.setText(defaultProjectName);
}
}
}

View file

@ -1,7 +1,7 @@
{
"id": "ultimate-todoist-sync",
"name": "Ultimate Todoist Sync",
"version": "1.0.42",
"version": "2.0.3",
"minAppVersion": "1.0.0",
"description": "This is the best Todoist task synchronization plugin for Obsidian so far.",
"author": "HeroBlackInk",

573
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
{
"name": "ultimate-todoist-sync",
"version": "1.0.0",
"version": "2.0.3",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build-without-tsc": "node esbuild.config.mjs production",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build-without-tsc": "node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
@ -23,6 +23,6 @@
"typescript": "4.7.4"
},
"dependencies": {
"@doist/todoist-api-typescript": "^2.1.2"
"@doist/todoist-api-typescript": "^6.5.0"
}
}

View file

@ -1,11 +1,11 @@
import { TodoistApi } from "@doist/todoist-api-typescript"
import { App} from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
import UltimateTodoistSyncForObsidian from "../../main";
//convert date from obsidian event
// 使用示例
//const str = "2023-03-27";
//const utcStr = localDateStringToUTCDatetimeString(str);
//console.log(dateStr); // 输出 2023-03-27T00:00:00.000Z
//this.plugin.debugLog(dateStr); // 输出 2023-03-27T00:00:00.000Z
function localDateStringToUTCDatetimeString(localDateString:string) {
try {
if(localDateString === null){
@ -42,8 +42,8 @@ export class TodoistRestAPI {
const api = await this.initializeAPI()
try {
if(dueDate){
dueDatetime = localDateStringToUTCDatetimeString(dueDatetime)
dueDate = null
dueDatetime = localDateStringToUTCDatetimeString(dueDate)
dueDate = undefined
}
const newTask = await api.addTask({
projectId,
@ -66,7 +66,8 @@ export class TodoistRestAPI {
const api = await this.initializeAPI()
try {
const result = await api.getTasks(options);
return result;
// API v1 返回分页格式 { results: [], nextCursor: null }
return result.results || result;
} catch (error) {
throw new Error(`Error get active tasks: ${error.message}`);
}
@ -85,10 +86,10 @@ export class TodoistRestAPI {
}
try {
if(updates.dueDate){
console.log(updates.dueDate)
this.plugin.debugLog(updates.dueDate)
updates.dueDatetime = localDateStringToUTCDatetimeString(updates.dueDate)
updates.dueDate = null
console.log(updates.dueDatetime)
this.plugin.debugLog(updates.dueDatetime)
}
const updatedTask = await api.updateTask(taskId, updates);
return updatedTask;
@ -106,7 +107,7 @@ export class TodoistRestAPI {
try {
const isSuccess = await api.reopenTask(taskId);
console.log(`Task ${taskId} is reopend`)
this.plugin.debugLog(`Task ${taskId} is reopend`)
return(isSuccess)
} catch (error) {
@ -120,7 +121,7 @@ export class TodoistRestAPI {
const api = await this.initializeAPI()
try {
const isSuccess = await api.closeTask(taskId);
console.log(`Task ${taskId} is closed`)
this.plugin.debugLog(`Task ${taskId} is closed`)
return isSuccess;
} catch (error) {
console.error('Error closing task:', error);
@ -171,7 +172,8 @@ export class TodoistRestAPI {
const api = await this.initializeAPI()
try {
const result = await api.getProjects();
return(result)
// API v1 返回分页格式 { results: [], nextCursor: null }
return result.results || result;
} catch (error) {
console.error('Error get all projects', error);

1213
src/api/syncApi.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,473 +0,0 @@
import { App} from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
interface Due {
date?: string;
[key: string]: any; // allow for additional properties
}
export class CacheOperation {
app:App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app:App, plugin: UltimateTodoistSyncForObsidian) {
//super(app,settings);
this.app = app;
this.plugin = plugin;
}
async getFileMetadata(filepath:string) {
return this.plugin.settings.fileMetadata[filepath] ?? null
}
async getFileMetadatas(){
return this.plugin.settings.fileMetadata ?? null
}
async newEmptyFileMetadata(filepath:string){
const metadatas = this.plugin.settings.fileMetadata
if(metadatas[filepath]) {
return
}
else{
metadatas[filepath] = {}
}
metadatas[filepath].todoistTasks = [];
metadatas[filepath].todoistCount = 0;
// 将更新后的metadatas对象保存回设置对象中
this.plugin.settings.fileMetadata = metadatas
}
async updateFileMetadata(filepath:string,newMetadata) {
const metadatas = this.plugin.settings.fileMetadata
// 如果元数据对象不存在则创建一个新的对象并添加到metadatas中
if (!metadatas[filepath]) {
metadatas[filepath] = {}
}
// 更新元数据对象中的属性值
metadatas[filepath].todoistTasks = newMetadata.todoistTasks;
metadatas[filepath].todoistCount = newMetadata.todoistCount;
// 将更新后的metadatas对象保存回设置对象中
this.plugin.settings.fileMetadata = metadatas
}
async deleteTaskIdFromMetadata(filepath:string,taskId:string){
console.log(filepath)
const metadata = await this.getFileMetadata(filepath)
console.log(metadata)
const newTodoistTasks = metadata.todoistTasks.filter(function(element){
return element !== taskId
})
const newTodoistCount = metadata.todoistCount - 1
let newMetadata = {}
newMetadata.todoistTasks = newTodoistTasks
newMetadata.todoistCount = newTodoistCount
console.log(`new metadata ${newMetadata}`)
}
//delete filepath from filemetadata
async deleteFilepathFromMetadata(filepath:string){
Reflect.deleteProperty(this.plugin.settings.fileMetadata, filepath);
this.plugin.saveSettings()
console.log(`${filepath} is deleted from file metadatas.`)
}
//Check errors in filemata where the filepath is incorrect.
async checkFileMetadata(){
const metadatas = await this.getFileMetadatas()
for (const key in metadatas) {
let filepath = key
const value = metadatas[key];
let file = this.app.vault.getAbstractFileByPath(key)
if(!file && (value.todoistTasks?.length === 0 || !value.todoistTasks)){
console.log(`${key} is not existed and metadata is empty.`)
await this.deleteFilepathFromMetadata(key)
continue
}
if(value.todoistTasks?.length === 0 || !value.todoistTasks){
//todo
//delelte empty metadata
continue
}
//check if file exist
if(!file){
//search new filepath
console.log(`file ${filepath} is not exist`)
const todoistId1 = value.todoistTasks[0]
console.log(todoistId1)
const searchResult = await this.plugin.fileOperation.searchFilepathsByTaskidInVault(todoistId1)
console.log(`new file path is`)
console.log(searchResult)
//update metadata
await this.updateRenamedFilePath(filepath,searchResult)
this.plugin.saveSettings()
}
//const fileContent = await this.app.vault.read(file)
//check if file include all tasks
/*
value.todoistTasks.forEach(async(taskId) => {
const taskObject = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
});
*/
}
}
getDefaultProjectNameForFilepath(filepath:string){
const metadatas = this.plugin.settings.fileMetadata
if (!metadatas[filepath] || metadatas[filepath].defaultProjectId === undefined) {
return this.plugin.settings.defaultProjectName
}
else{
const defaultProjectId = metadatas[filepath].defaultProjectId
const defaultProjectName = this.getProjectNameByIdFromCache(defaultProjectId)
return defaultProjectName
}
}
getDefaultProjectIdForFilepath(filepath:string){
const metadatas = this.plugin.settings.fileMetadata
if (!metadatas[filepath] || metadatas[filepath].defaultProjectId === undefined) {
return this.plugin.settings.defaultProjectId
}
else{
const defaultProjectId = metadatas[filepath].defaultProjectId
return defaultProjectId
}
}
setDefaultProjectIdForFilepath(filepath:string,defaultProjectId:string){
const metadatas = this.plugin.settings.fileMetadata
if (!metadatas[filepath]) {
metadatas[filepath] = {}
}
metadatas[filepath].defaultProjectId = defaultProjectId
// 将更新后的metadatas对象保存回设置对象中
this.plugin.settings.fileMetadata = metadatas
}
// 从 Cache读取所有task
loadTasksFromCache() {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
return savedTasks;
} catch (error) {
console.error(`Error loading tasks from Cache: ${error}`);
return [];
}
}
// 覆盖保存所有task到cache
saveTasksToCache(newTasks) {
try {
this.plugin.settings.todoistTasksData.tasks = newTasks
} catch (error) {
console.error(`Error saving tasks to Cache: ${error}`);
return false;
}
}
// append event 到 Cache
appendEventToCache(event:Object[]) {
try {
this.plugin.settings.todoistTasksData.events.push(event)
} catch (error) {
console.error(`Error append event to Cache: ${error}`);
}
}
// append events 到 Cache
appendEventsToCache(events:Object[]) {
try {
this.plugin.settings.todoistTasksData.events.push(...events)
} catch (error) {
console.error(`Error append events to Cache: ${error}`);
}
}
// 从 Cache 文件中读取所有events
loadEventsFromCache() {
try {
const savedEvents = this.plugin.settings.todoistTasksData.events
return savedEvents;
} catch (error) {
console.error(`Error loading events from Cache: ${error}`);
}
}
// 追加到 Cache 文件
appendTaskToCache(task) {
try {
if(task === null){
return
}
const savedTasks = this.plugin.settings.todoistTasksData.tasks
//const taskAlreadyExists = savedTasks.some((t) => t.id === task.id);
//if (!taskAlreadyExists) {
//使用push方法将字符串插入到Cache对象时它将被视为一个简单的键值对其中键是数组的数字索引而值是该字符串本身。但如果您使用push方法将另一个Cache对象或数组插入到Cache对象中则该对象将成为原始Cache对象的一个嵌套子对象。在这种情况下键是数字索引值是嵌套的Cache对象本身。
//}
this.plugin.settings.todoistTasksData.tasks.push(task);
} catch (error) {
console.error(`Error appending task to Cache: ${error}`);
}
}
//读取指定id的任务
loadTaskFromCacheyID(taskId) {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
//console.log(savedTasks)
const savedTask = savedTasks.find((t) => t.id === taskId);
//console.log(savedTask)
return(savedTask)
} catch (error) {
console.error(`Error finding task from Cache: ${error}`);
return [];
}
}
//覆盖update指定id的task
updateTaskToCacheByID(task) {
try {
//删除就的task
this.deleteTaskFromCache(task.id)
//添加新的task
this.appendTaskToCache(task)
} catch (error) {
console.error(`Error updating task to Cache: ${error}`);
return [];
}
}
//due 的结构 {date: "2025-02-25",isRecurring: false,lang: "en",string: "2025-02-25"}
modifyTaskToCacheByID(taskId: string, { content, due }: { content?: string, due?: Due }): void {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks;
const taskIndex = savedTasks.findIndex((task) => task.id === taskId);
if (taskIndex !== -1) {
const updatedTask = { ...savedTasks[taskIndex] };
if (content !== undefined) {
updatedTask.content = content;
}
if (due !== undefined) {
if (due === null) {
updatedTask.due = null;
} else {
updatedTask.due = due;
}
}
savedTasks[taskIndex] = updatedTask;
this.plugin.settings.todoistTasksData.tasks = savedTasks;
} else {
throw new Error(`Task with ID ${taskId} not found in cache.`);
}
} catch (error) {
// Handle the error appropriately, e.g. by logging it or re-throwing it.
}
}
//open a task status
reopenTaskToCacheByID(taskId:string) {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
// 遍历数组以查找具有指定 ID 的项
for (let i = 0; i < savedTasks.length; i++) {
if (savedTasks[i].id === taskId) {
// 修改对象的属性
savedTasks[i].isCompleted = false;
break; // 找到并修改了该项,跳出循环
}
}
this.plugin.settings.todoistTasksData.tasks = savedTasks
} catch (error) {
console.error(`Error open task to Cache file: ${error}`);
return [];
}
}
//close a task status
closeTaskToCacheByID(taskId:string):Promise<void> {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
// 遍历数组以查找具有指定 ID 的项
for (let i = 0; i < savedTasks.length; i++) {
if (savedTasks[i].id === taskId) {
// 修改对象的属性
savedTasks[i].isCompleted = true;
break; // 找到并修改了该项,跳出循环
}
}
this.plugin.settings.todoistTasksData.tasks = savedTasks
} catch (error) {
console.error(`Error close task to Cache file: ${error}`);
throw error; // 抛出错误使调用方能够捕获并处理它
}
}
// 通过 ID 删除任务
deleteTaskFromCache(taskId) {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
const newSavedTasks = savedTasks.filter((t) => t.id !== taskId);
this.plugin.settings.todoistTasksData.tasks = newSavedTasks
} catch (error) {
console.error(`Error deleting task from Cache file: ${error}`);
}
}
// 通过 ID 数组 删除task
deleteTaskFromCacheByIDs(deletedTaskIds) {
try {
const savedTasks = this.plugin.settings.todoistTasksData.tasks
const newSavedTasks = savedTasks.filter((t) => !deletedTaskIds.includes(t.id))
this.plugin.settings.todoistTasksData.tasks = newSavedTasks
} catch (error) {
console.error(`Error deleting task from Cache : ${error}`);
}
}
//通过 name 查找 project id
getProjectIdByNameFromCache(projectName:string) {
try {
const savedProjects = this.plugin.settings.todoistTasksData.projects
const targetProject = savedProjects.find(obj => obj.name === projectName);
const projectId = targetProject ? targetProject.id : null;
return(projectId)
} catch (error) {
console.error(`Error finding project from Cache file: ${error}`);
return(false)
}
}
getProjectNameByIdFromCache(projectId:string) {
try {
const savedProjects = this.plugin.settings.todoistTasksData.projects
const targetProject = savedProjects.find(obj => obj.id === projectId);
const projectName = targetProject ? targetProject.name : null;
return(projectName)
} catch (error) {
console.error(`Error finding project from Cache file: ${error}`);
return(false)
}
}
//save projects data to json file
async saveProjectsToCache() {
try{
//get projects
const projects = await this.plugin.todoistRestAPI.GetAllProjects()
if(!projects){
return false
}
//save to json
this.plugin.settings.todoistTasksData.projects = projects
return true
}catch(error){
return false
console.log(`error downloading projects: ${error}`)
}
}
async updateRenamedFilePath(oldpath:string,newpath:string){
try{
console.log(`oldpath is ${oldpath}`)
console.log(`newpath is ${newpath}`)
const savedTask = await this.loadTasksFromCache()
//console.log(savedTask)
const newTasks = savedTask.map(obj => {
if (obj.path === oldpath) {
return { ...obj, path: newpath };
}else {
return obj;
}
})
//console.log(newTasks)
await this.saveTasksToCache(newTasks)
//update filepath
const fileMetadatas = this.plugin.settings.fileMetadata
fileMetadatas[newpath] = fileMetadatas[oldpath]
delete fileMetadatas[oldpath]
this.plugin.settings.fileMetadata = fileMetadatas
}catch(error){
console.log(`Error updating renamed file path to cache: ${error}`)
}
}
}

1608
src/data/cache.ts Normal file

File diff suppressed because it is too large Load diff

1172
src/data/databaseChecker.ts Normal file

File diff suppressed because it is too large Load diff

297
src/data/taskIssueUtils.ts Normal file
View file

@ -0,0 +1,297 @@
export type DerivedTaskStatus = 'active' | 'nonActive' | 'conflicted' | 'issue';
export type TaskIssueState = 'open' | 'resolved' | 'ignored';
export type TaskIssueSeverity = 'low' | 'medium' | 'high';
export type TaskIssueSource = 'database_checker' | 'runtime';
export interface TaskIssueEntryLike {
state?: TaskIssueState;
severity?: TaskIssueSeverity;
source?: TaskIssueSource;
detectedAt?: number;
lastSeenAt?: number;
details?: string;
expected?: string;
actual?: string;
manualAction?: string;
}
export type TaskIssueRecordLike = Record<string, TaskIssueEntryLike>;
export interface OpenIssueEntry {
issueType: string;
details?: string;
expected?: string;
actual?: string;
manualAction?: string;
}
export type ProblemTaskDisplayType = DerivedTaskStatus | 'staleLink';
export interface DerivedTaskProblemView {
status: DerivedTaskStatus;
syncEnabled: boolean;
openIssueTypes: string[];
openIssues: OpenIssueEntry[];
hasStaleLinkIssue: boolean;
displayType: ProblemTaskDisplayType;
}
export const CONFLICT_ISSUE_TYPE_KEYS = new Set<string>([
'sync_content_mismatch',
'sync_completion_mismatch',
'sync_due_mismatch',
'sync_labels_mismatch',
'sync_priority_mismatch',
'sync_project_mismatch',
'task_duplicate_candidate',
]);
export const NON_ACTIVE_ISSUE_TYPE_KEYS = new Set<string>(['task_marked_nonactive']);
const TASK_ISSUE_TYPE_VALUES = new Set<string>([
'sync_content_mismatch',
'sync_completion_mismatch',
'sync_due_mismatch',
'sync_labels_mismatch',
'sync_priority_mismatch',
'sync_project_mismatch',
'todoist_link_stale',
'todoist_task_missing',
'vault_task_missing',
'mapping_missing_for_task',
'mapping_pointer_stale',
'mapping_file_missing',
'mapping_target_missing_in_todoist',
'mapping_orphaned',
'task_unsynced_new',
'task_requires_review',
'task_marked_nonactive',
'mapping_legacy_id',
'task_duplicate_candidate',
'issue_source_unconfirmed',
'issue_unclassified',
]);
export function normalizeTaskIssueTypeKey(issueType: string): string {
if (TASK_ISSUE_TYPE_VALUES.has(issueType)) {
return issueType;
}
return 'issue_unclassified';
}
export function normalizeDerivedTaskStatus(status: string | undefined): DerivedTaskStatus {
if (status === 'active' || status === 'nonActive' || status === 'conflicted' || status === 'issue') {
return status;
}
return 'active';
}
export function getOpenIssueEntries(issues: TaskIssueRecordLike | undefined): OpenIssueEntry[] {
if (!issues || Object.keys(issues).length === 0) return [];
const normalizedIssues = new Map<string, OpenIssueEntry>();
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());
}
export function deriveTaskStatusFromIssueEntries(
issues: TaskIssueRecordLike | undefined,
fallbackStatus: DerivedTaskStatus = 'active'
): DerivedTaskStatus {
const normalizedFallbackStatus = normalizeDerivedTaskStatus(fallbackStatus);
if (!issues || Object.keys(issues).length === 0) return normalizedFallbackStatus;
const normalizedOpenIssueTypes = getOpenIssueEntries(issues).map(issue => issue.issueType);
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';
}
export function deriveTaskProblemViewFromEntry(
entry: { status?: string; issues?: TaskIssueRecordLike } | undefined,
options?: { hasSyntheticStaleLinkIssue?: boolean; fallbackStatus?: DerivedTaskStatus }
): DerivedTaskProblemView {
const fallbackStatus = normalizeDerivedTaskStatus(options?.fallbackStatus ?? entry?.status);
const openIssues = getOpenIssueEntries(entry?.issues);
const openIssueTypes = openIssues.map(issue => issue.issueType);
const status = deriveTaskStatusFromIssueEntries(entry?.issues, fallbackStatus);
const hasStaleLinkIssue = !!options?.hasSyntheticStaleLinkIssue || openIssueTypes.includes('todoist_link_stale');
const displayType: ProblemTaskDisplayType = status === 'conflicted' || status === 'nonActive'
? status
: (hasStaleLinkIssue ? 'staleLink' : status);
return {
status,
syncEnabled: status === 'active',
openIssueTypes,
openIssues,
hasStaleLinkIssue,
displayType,
};
}
export function reconcileTaskEntryDerivedState<T extends { status?: DerivedTaskStatus; syncEnabled?: boolean; issues?: TaskIssueRecordLike }>(
entry: T,
fallbackStatus?: DerivedTaskStatus
): { entry: T; changed: boolean; status: DerivedTaskStatus; syncEnabled: boolean } {
const normalizedFallbackStatus = normalizeDerivedTaskStatus(fallbackStatus ?? entry.status);
const nextStatus = deriveTaskStatusFromIssueEntries(entry.issues, normalizedFallbackStatus);
const nextSyncEnabled = nextStatus === 'active';
let changed = false;
if (entry.status !== nextStatus) {
entry.status = nextStatus;
changed = true;
}
if (entry.syncEnabled !== nextSyncEnabled) {
entry.syncEnabled = nextSyncEnabled;
changed = true;
}
return {
entry,
changed,
status: nextStatus,
syncEnabled: nextSyncEnabled,
};
}
export function upsertTaskIssueEntry(
issues: TaskIssueRecordLike | undefined,
issueType: string,
issue: {
state?: TaskIssueState;
severity?: TaskIssueSeverity;
source?: TaskIssueSource;
details?: string;
expected?: string;
actual?: string;
manualAction?: string;
},
now = Date.now()
): { issues: TaskIssueRecordLike; normalizedIssueType: string } {
const normalizedIssues: TaskIssueRecordLike = {};
if (issues && typeof issues === 'object' && !Array.isArray(issues)) {
for (const [existingIssueType, existingIssue] of Object.entries(issues)) {
normalizedIssues[normalizeTaskIssueTypeKey(existingIssueType)] = { ...existingIssue };
}
}
const normalizedIssueType = normalizeTaskIssueTypeKey(issueType);
const previous = normalizedIssues[normalizedIssueType];
normalizedIssues[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,
};
return {
issues: normalizedIssues,
normalizedIssueType,
};
}
export function resolveTaskIssuesByType(
issues: TaskIssueRecordLike | undefined,
shouldResolve: (issueType: string) => boolean,
now = Date.now()
): { issues: TaskIssueRecordLike | undefined; changed: boolean } {
if (!issues || typeof issues !== 'object' || Array.isArray(issues)) {
return { issues: undefined, changed: false };
}
let changed = false;
const normalizedIssues: TaskIssueRecordLike = {};
for (const [rawIssueType, issueValue] of Object.entries(issues)) {
const issueType = normalizeTaskIssueTypeKey(rawIssueType);
const issueRecord: TaskIssueEntryLike = { ...issueValue };
if (issueRecord.state === 'open' && shouldResolve(issueType)) {
issueRecord.state = 'resolved';
issueRecord.lastSeenAt = 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 (Object.keys(normalizedIssues).length === 0) {
return { issues: undefined, changed: true };
}
return { issues: normalizedIssues, changed };
}
export function resolveOpenIssuesFromSourceNotSeen(
issues: TaskIssueRecordLike | undefined,
source: TaskIssueSource,
seenIssueTypes: Set<string>,
now = Date.now()
): { issues: TaskIssueRecordLike | undefined; changed: boolean } {
if (!issues || typeof issues !== 'object' || Array.isArray(issues)) {
return { issues: undefined, changed: false };
}
let changed = false;
const normalizedIssues: TaskIssueRecordLike = {};
for (const [rawIssueType, issueValue] of Object.entries(issues)) {
const issueType = normalizeTaskIssueTypeKey(rawIssueType);
const issueRecord: TaskIssueEntryLike = { ...issueValue };
if (issueRecord.source === source && issueRecord.state === 'open' && !seenIssueTypes.has(issueType)) {
issueRecord.state = 'resolved';
issueRecord.lastSeenAt = 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 (Object.keys(normalizedIssues).length === 0) {
return { issues: undefined, changed: true };
}
return { issues: normalizedIssues, changed };
}

View file

@ -1,5 +1,5 @@
import { App} from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
import UltimateTodoistSyncForObsidian from "../../main";
@ -41,6 +41,29 @@ interface todoistTaskObject {
due_lang?: string;
assignee_id?: string;
}
type ParentTaskLookup = {
project_id?: string;
};
type LineTaskComparable = {
content?: string;
labels?: string[];
isCompleted?: boolean;
dueDate?: string;
projectId?: string;
priority?: number;
};
type TodoistTaskComparable = {
content?: string;
labels?: string[];
checked?: boolean;
due?: { date?: string };
dueDate?: string;
projectId?: string;
priority?: number;
};
const keywords = {
@ -50,21 +73,28 @@ const keywords = {
const REGEX = {
TODOIST_TAG: new RegExp(`^[\\s]*[-] \\[[x ]\\] [\\s\\S]*${keywords.TODOIST_TAG}[\\s\\S]*$`, "i"),
TODOIST_ID: /\[todoist_id::\s*\d+\]/,
TODOIST_ID_NUM:/\[todoist_id::\s*(.*?)\]/,
TODOIST_LINK:/\[link\]\(.*?\)/,
TODOIST_ID: /\[todoist_id::\s*\w+\]/,
TODOIST_ID_NUM:/\[todoist_id::\s*(\S+)\]/,
TODOIST_LINK:/\[link\]\((https?:\/\/[^)]*todoist\.com[^)]*|todoist:\/\/[^)]*)\)/,
DUE_DATE_WITH_EMOJ: new RegExp(`(${keywords.DUE_DATE})\\s?\\d{4}-\\d{2}-\\d{2}`),
DUE_DATE : new RegExp(`(?:${keywords.DUE_DATE})\\s?(\\d{4}-\\d{2}-\\d{2})`),
PROJECT_NAME: /\[project::\s*(.*?)\]/,
TASK_CONTENT: {
REMOVE_PRIORITY: /\s!!([1-4])\s/,
REMOVE_TAGS: /(^|\s)(#[a-zA-Z\d\u4e00-\u9fa5-]+)/g,
// 移除所有 #标签,不要求前面有空格
REMOVE_TAGS: /#[\w\u4e00-\u9fa5-]+/g,
REMOVE_SPACE: /^\s+|\s+$/g,
REMOVE_DATE: new RegExp(`(${keywords.DUE_DATE})\\s?\\d{4}-\\d{2}-\\d{2}`),
REMOVE_INLINE_METADATA: /%%\[\w+::\s*\w+\]%%/,
REMOVE_CHECKBOX: /^(-|\*)\s+\[(x|X| )\]\s/,
REMOVE_CHECKBOX_WITH_INDENTATION: /^([ \t]*)?(-|\*)\s+\[(x|X| )\]\s/,
REMOVE_TODOIST_LINK: /\[link\]\(.*?\)/,
// 旧格式链接: [xxx](https://todoist.com/showtask?id=123) - 使用 [^\]]* 避免匹配 checkbox [ ]
REMOVE_TODOIST_LINK_OLD: /\[([^\]]*)\]\(https?:\/\/todoist\.com\/showtask\?id=\S*\)/,
// 新格式链接: [xxx](https://app.todoist.com/app/task/abc123)
REMOVE_TODOIST_LINK_NEW: /\[([^\]]*)\]\(https?:\/\/app\.todoist\.com\/app\/task\/\S*\)/,
// 混合格式: [xxx](https://todoist.com/app/task/123) - 旧域名 + 新路径
REMOVE_TODOIST_LINK_OLD_DOMAIN_NEW_PATH: /\[([^\]]*)\]\(https?:\/\/todoist\.com\/app\/task\/\S*\)/,
REMOVE_TODOIST_LINK_APP_URI: /\[([^\]]*)\]\(todoist:\/\/task\?id=\S*\)/,
},
ALL_TAGS: /#[\w\u4e00-\u9fa5-]+/g,
TASK_CHECKBOX_CHECKED: /- \[(x|X)\] /,
@ -72,7 +102,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 {
@ -90,43 +122,63 @@ export class TaskParser {
//convert line text to a task object
async convertTextToTodoistTaskObject(lineText:string,filepath:string,lineNumber?:number,fileContent?:string) {
//console.log(`linetext is:${lineText}`)
//this.plugin.debugLog(`linetext is:${lineText}`)
const todoistSyncAPI = this.plugin.todoistSyncAPI;
const cacheOperation = this.plugin.cacheOperation;
if (!todoistSyncAPI || !cacheOperation) {
const fallbackContent = this.getTaskContentFromLineText(lineText);
return {
projectId: this.plugin.settings.defaultProjectId,
content: fallbackContent || '',
parentId: null,
dueDate: this.getDueDateFromLineText(lineText) || '',
labels: this.getAllTagsFromLineText(lineText) || [],
description: '',
isCompleted: this.isTaskCheckboxChecked(lineText),
todoist_id: this.getTodoistIdFromLineText(lineText) || null,
hasParent: false,
priority: this.getTaskPriority(lineText)
};
}
let hasParent = false
let parentId = null
let parentTaskObject = null
let parentId: string | null = null
let parentTaskObject: ParentTaskLookup | null = null
// 检测 parentID
let textWithoutIndentation = lineText
if(this.getTabIndentation(lineText) > 0){
//console.log(`缩进为 ${this.getTabIndentation(lineText)}`)
const safeLineNumber = typeof lineNumber === 'number' && lineNumber > 0 ? lineNumber : 0;
if(this.getTabIndentation(lineText) > 0 && typeof fileContent === 'string' && safeLineNumber > 0){
//this.plugin.debugLog(`缩进为 ${this.getTabIndentation(lineText)}`)
textWithoutIndentation = this.removeTaskIndentation(lineText)
//console.log(textWithoutIndentation)
//console.log(`这是子任务`)
//this.plugin.debugLog(textWithoutIndentation)
//this.plugin.debugLog(`这是子任务`)
//读取filepath
//const fileContent = await this.plugin.fileOperation.readContentFromFilePath(filepath)
//遍历 line
const lines = fileContent.split('\n')
//console.log(lines)
for (let i = (lineNumber - 1 ); i >= 0; i--) {
//console.log(`正在check${i}行的缩进`)
const line = lines[i]
//console.log(line)
//this.plugin.debugLog(lines)
for (let i = (safeLineNumber - 1 ); i >= 0; i--) {
//this.plugin.debugLog(`正在check${i}行的缩进`)
const line = lines[i] ?? ''
//this.plugin.debugLog(line)
//如果是空行说明没有parent
if(this.isLineBlank(line)){
break
}
//如果tab数量大于等于当前line,跳过
if (this.getTabIndentation(line) >= this.getTabIndentation(lineText)) {
//console.log(`缩进为 ${this.getTabIndentation(line)}`)
//this.plugin.debugLog(`缩进为 ${this.getTabIndentation(line)}`)
continue
}
if((this.getTabIndentation(line) < this.getTabIndentation(lineText))){
//console.log(`缩进为 ${this.getTabIndentation(line)}`)
if((this.getTabIndentation(line) < this.getTabIndentation(lineText))){
//this.plugin.debugLog(`缩进为 ${this.getTabIndentation(line)}`)
if(this.hasTodoistId(line)){
parentId = this.getTodoistIdFromLineText(line)
hasParent = true
//console.log(`parent id is ${parentId}`)
parentTaskObject = this.plugin.cacheOperation.loadTaskFromCacheyID(parentId)
//this.plugin.debugLog(`parent id is ${parentId}`)
if (parentId) {
parentTaskObject = await todoistSyncAPI.GetTaskById(parentId)
}
hasParent = !!parentTaskObject
break
}
else{
@ -140,33 +192,35 @@ export class TaskParser {
const dueDate = this.getDueDateFromLineText(textWithoutIndentation)
const labels = this.getAllTagsFromLineText(textWithoutIndentation)
//console.log(`labels is ${labels}`)
//this.plugin.debugLog(`labels is ${labels}`)
//dataview format metadata
//const projectName = this.getProjectNameFromLineText(textWithoutIndentation) ?? this.plugin.settings.defaultProjectName
//const projectId = await this.plugin.cacheOperation.getProjectIdByNameFromCache(projectName)
//use tag as project name
let projectId = this.plugin.cacheOperation.getDefaultProjectIdForFilepath(filepath as string)
let projectName = this.plugin.cacheOperation.getProjectNameByIdFromCache(projectId)
let projectId = cacheOperation.getDefaultProjectIdForFilepath(filepath) || this.plugin.settings.defaultProjectId || ''
let projectName = projectId
? ((await todoistSyncAPI.getProjectById(projectId))?.name ?? this.plugin.settings.defaultProjectName)
: this.plugin.settings.defaultProjectName
if(hasParent){
projectId = parentTaskObject.projectId
projectName =this.plugin.cacheOperation.getProjectNameByIdFromCache(projectId)
if(hasParent && parentTaskObject && parentTaskObject.project_id){
projectId = parentTaskObject.project_id
projectName = (await todoistSyncAPI.getProjectById(projectId))?.name ?? this.plugin.settings.defaultProjectName
}
if(!hasParent){
//匹配 tag 和 peoject
for (const label of labels){
//console.log(label)
//this.plugin.debugLog(label)
let labelName = label.replace(/#/g, "");
//console.log(labelName)
let hasProjectId = this.plugin.cacheOperation.getProjectIdByNameFromCache(labelName)
//this.plugin.debugLog(labelName)
let hasProjectId = (await todoistSyncAPI.getProjectByName(labelName))?.id
if(!hasProjectId){
continue
}
projectName = labelName
//console.log(`project is ${projectName} ${label}`)
//this.plugin.debugLog(`project is ${projectName} ${label}`)
projectId = hasProjectId
break
}
@ -195,8 +249,8 @@ export class TaskParser {
hasParent:hasParent,
priority:priority
};
//console.log(`converted task `)
//console.log(todoistTask)
//this.plugin.debugLog(`converted task `)
//this.plugin.debugLog(todoistTask)
return todoistTask;
}
@ -204,8 +258,8 @@ export class TaskParser {
hasTodoistTag(text:string){
//console.log("检查是否包含 todoist tag")
//console.log(text)
//this.plugin.debugLog("检查是否包含 todoist tag")
//this.plugin.debugLog(text)
return(REGEX.TODOIST_TAG.test(text))
}
@ -213,8 +267,8 @@ export class TaskParser {
hasTodoistId(text:string){
const result = REGEX.TODOIST_ID.test(text)
//console.log("检查是否包含 todoist id")
//console.log(text)
//this.plugin.debugLog("检查是否包含 todoist id")
//this.plugin.debugLog(text)
return(result)
}
@ -238,18 +292,18 @@ export class TaskParser {
getTodoistIdFromLineText(text:string){
//console.log(text)
//this.plugin.debugLog(text)
const result = REGEX.TODOIST_ID_NUM.exec(text);
//console.log(result)
//this.plugin.debugLog(result)
return result ? result[1] : null;
}
getDueDateFromDataview(dataviewTask:object){
getDueDateFromDataview(dataviewTask:{ due?: unknown }){
if(!dataviewTask.due){
return ""
}
else{
const dataviewTaskDue = dataviewTask.due.toString().slice(0, 10)
const dataviewTaskDue = String(dataviewTask.due).slice(0, 10)
return(dataviewTaskDue)
}
@ -263,11 +317,11 @@ export class TaskParser {
//const tasks = this.app.plugins.plugins.dataview.api.pages(`"${filepath}"`).file.tasks
const tasks = await getAPI(this.app).pages(`"${filepath}"`).file.tasks
const tasksValues = tasks.values
//console.log(`dataview filepath is ${filepath}`)
//console.log(`dataview line is ${line}`)
//console.log(tasksValues)
//this.plugin.debugLog(`dataview filepath is ${filepath}`)
//this.plugin.debugLog(`dataview line is ${line}`)
//this.plugin.debugLog(tasksValues)
const currentLineTask = tasksValues.find(obj => obj.line === line )
console.log(currentLineTask)
this.plugin.debugLog(currentLineTask)
return(currentLineTask)
}
@ -277,7 +331,10 @@ export class TaskParser {
getTaskContentFromLineText(lineText:string) {
const TaskContent = lineText.replace(REGEX.TASK_CONTENT.REMOVE_INLINE_METADATA,"")
.replace(REGEX.TASK_CONTENT.REMOVE_TODOIST_LINK,"")
.replace(REGEX.TASK_CONTENT.REMOVE_TODOIST_LINK_OLD,"") // 旧格式: todoist.com/showtask?id=xxx
.replace(REGEX.TASK_CONTENT.REMOVE_TODOIST_LINK_NEW,"") // 新格式: app.todoist.com/app/task/xxx
.replace(REGEX.TASK_CONTENT.REMOVE_TODOIST_LINK_OLD_DOMAIN_NEW_PATH,"")
.replace(REGEX.TASK_CONTENT.REMOVE_TODOIST_LINK_APP_URI,"")
.replace(REGEX.TASK_CONTENT.REMOVE_PRIORITY," ") //priority 前后必须都有空格,
.replace(REGEX.TASK_CONTENT.REMOVE_TAGS,"")
.replace(REGEX.TASK_CONTENT.REMOVE_DATE,"")
@ -288,16 +345,10 @@ export class TaskParser {
}
//get all tags from task text
getAllTagsFromLineText(lineText:string){
let tags = lineText.match(REGEX.ALL_TAGS);
if (tags) {
// Remove '#' from each tag
tags = tags.map(tag => tag.replace('#', ''));
}
return tags;
getAllTagsFromLineText(lineText:string): string[] {
const tags = lineText.match(REGEX.ALL_TAGS);
if (!tags) return [];
return tags.map(tag => tag.replace('#', ''));
}
//get checkbox status
@ -307,12 +358,12 @@ export class TaskParser {
//task content compare
taskContentCompare(lineTask:Object,todoistTask:Object) {
const lineTaskContent = lineTask.content
//console.log(dataviewTaskContent)
taskContentCompare(lineTask:LineTaskComparable,todoistTask:TodoistTaskComparable) {
const lineTaskContent = (lineTask.content || '').trim();
//this.plugin.debugLog(dataviewTaskContent)
const todoistTaskContent = todoistTask.content
//console.log(todoistTask.content)
const todoistTaskContent = (todoistTask.content || '').trim();
//this.plugin.debugLog(todoistTask.content)
//content 是否修改
const contentModified = (lineTaskContent === todoistTaskContent)
@ -320,69 +371,48 @@ export class TaskParser {
}
//tag compare
taskTagCompare(lineTask:Object,todoistTask:Object) {
const lineTaskTags = lineTask.labels
//console.log(dataviewTaskTags)
const todoistTaskTags = todoistTask.labels
//console.log(todoistTaskTags)
//content 是否修改
const tagsModified = lineTaskTags.length === todoistTaskTags.length && lineTaskTags.sort().every((val, index) => val === todoistTaskTags.sort()[index]);
return(tagsModified)
normalizeLabelsForCompare(labels: string[] | undefined): string[] {
if (!labels || labels.length === 0) return [];
const normalized = labels
.map(label => (label ?? '').trim())
.filter(label => label.length > 0);
const deduped = Array.from(new Set(normalized));
return deduped.sort();
}
taskTagCompare(lineTask:LineTaskComparable,todoistTask:TodoistTaskComparable) {
const lineTaskTags = this.normalizeLabelsForCompare(lineTask.labels || []);
const todoistTaskTags = this.normalizeLabelsForCompare(todoistTask.labels || []);
return lineTaskTags.length === todoistTaskTags.length && lineTaskTags.every((val: string, index: number) => val === todoistTaskTags[index]);
}
//task status compare
taskStatusCompare(lineTask:Object,todoistTask:Object) {
//status 是否修改
const statusModified = (lineTask.isCompleted === todoistTask.isCompleted)
//console.log(lineTask)
//console.log(todoistTask)
return(statusModified)
taskStatusCompare(lineTask:LineTaskComparable,todoistTask:TodoistTaskComparable) {
return !!lineTask.isCompleted === !!todoistTask.checked;
}
//task due date compare
async compareTaskDueDate(lineTask: object, todoistTask: object): boolean {
const lineTaskDue = lineTask.dueDate
const todoistTaskDue = todoistTask.due ?? "";
//console.log(dataviewTaskDue)
//console.log(todoistTaskDue)
if (lineTaskDue === "" && todoistTaskDue === "") {
//console.log('没有due date')
return true;
}
if ((lineTaskDue || todoistTaskDue) === "") {
console.log(lineTaskDue);
console.log(todoistTaskDue)
//console.log('due date 发生了变化')
return false;
}
const oldDueDateUTCString = this.localDateStringToUTCDateString(lineTaskDue)
if (oldDueDateUTCString === todoistTaskDue.date) {
//console.log('due date 一致')
return true;
} else if (lineTaskDue.toString() === "Invalid Date" || todoistTaskDue.toString() === "Invalid Date") {
console.log('invalid date')
return false;
} else {
//console.log(lineTaskDue);
//console.log(todoistTaskDue.date)
return false;
}
compareTaskDueDate(lineTask: LineTaskComparable, todoistTask: TodoistTaskComparable): boolean {
const lineTaskDue = lineTask.dueDate || "";
const todoistDueDate = todoistTask.due?.date || todoistTask.dueDate || "";
if (lineTaskDue === "" && todoistDueDate === "") return true;
if (lineTaskDue === "" || todoistDueDate === "") return false;
return lineTaskDue === todoistDueDate;
}
taskPriorityCompare(lineTask:LineTaskComparable, todoistTask:TodoistTaskComparable): boolean {
const linePriority = lineTask.priority || 1;
const todoistPriority = todoistTask.priority || 1;
return linePriority === todoistPriority;
}
//task project id compare
async taskProjectCompare(lineTask:Object,todoistTask:Object) {
async taskProjectCompare(lineTask:LineTaskComparable,todoistTask:TodoistTaskComparable) {
//project 是否修改
//console.log(dataviewTaskProjectId)
//console.log(todoistTask.projectId)
//this.plugin.debugLog(dataviewTaskProjectId)
//this.plugin.debugLog(todoistTask.projectId)
return(lineTask.projectId === todoistTask.projectId)
}
@ -394,8 +424,8 @@ export class TaskParser {
//判断制表符的数量
//console.log(getTabIndentation("\t\t- [x] This is a task with two tabs")); // 2
//console.log(getTabIndentation(" - [x] This is a task without tabs")); // 0
//this.plugin.debugLog(getTabIndentation("\t\t- [x] This is a task with two tabs")); // 2
//this.plugin.debugLog(getTabIndentation(" - [x] This is a task without tabs")); // 0
getTabIndentation(lineText:string){
const match = REGEX.TAB_INDENTATION.exec(lineText)
return match ? match[1].length : 0;
@ -411,7 +441,7 @@ export class TaskParser {
//remove task indentation
removeTaskIndentation(text) {
removeTaskIndentation(text:string) {
const regex = /^([ \t]*)?- \[(x| )\] /;
return text.replace(regex, "- [$2] ");
}
@ -424,7 +454,7 @@ export class TaskParser {
//在linetext中插入日期
insertDueDateBeforeTodoist(text, dueDate) {
insertDueDateBeforeTodoist(text:string, dueDate:string) {
const regex = new RegExp(`(${keywords.TODOIST_TAG})`)
return text.replace(regex, `📅 ${dueDate} $1`);
}
@ -433,20 +463,22 @@ export class TaskParser {
// 使用示例
//const str = "2023-03-27T15:59:59.000000Z";
//const dateStr = ISOStringToLocalDateString(str);
//console.log(dateStr); // 输出 2023-03-27
//this.plugin.debugLog(dateStr); // 输出 2023-03-27
ISOStringToLocalDateString(utcTimeString:string) {
try {
if(utcTimeString === null){
if(!utcTimeString){
return null
}
let utcDateString = utcTimeString;
let dateObj = new Date(utcDateString); // 将UTC格式字符串转换为Date对象
if (Number.isNaN(dateObj.getTime())) {
return null
}
let year = dateObj.getFullYear();
let month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
let date = dateObj.getDate().toString().padStart(2, '0');
let localDateString = `${year}-${month}-${date}`;
return localDateString;
return(localDateString);
} catch (error) {
console.error(`Error extracting date from string '${utcTimeString}': ${error}`);
return null;
@ -458,7 +490,7 @@ export class TaskParser {
// 使用示例
//const str = "2023-03-27T15:59:59.000000Z";
//const dateStr = ISOStringToLocalDatetimeString(str);
//console.log(dateStr); // 输出 Mon Mar 27 2023 23:59:59 GMT+0800 (China Standard Time)
//this.plugin.debugLog(dateStr); // 输出 Mon Mar 27 2023 23:59:59 GMT+0800 (China Standard Time)
ISOStringToLocalDatetimeString(utcTimeString:string) {
try {
if(utcTimeString === null){
@ -480,13 +512,13 @@ export class TaskParser {
// 使用示例
//const str = "2023-03-27";
//const utcStr = localDateStringToUTCDatetimeString(str);
//console.log(dateStr); // 输出 2023-03-27T00:00:00.000Z
//this.plugin.debugLog(dateStr); // 输出 2023-03-27T00:00:00.000Z
localDateStringToUTCDatetimeString(localDateString:string) {
try {
if(localDateString === null){
return null
}
localDateString = localDateString + "T08:00";
localDateString = localDateString + "T00:00:00";
let localDateObj = new Date(localDateString);
let ISOString = localDateObj.toISOString()
return(ISOString);
@ -500,17 +532,13 @@ export class TaskParser {
// 使用示例
//const str = "2023-03-27";
//const utcStr = localDateStringToUTCDateString(str);
//console.log(dateStr); // 输出 2023-03-27
//this.plugin.debugLog(dateStr); // 输出 2023-03-27
localDateStringToUTCDateString(localDateString:string) {
try {
if(localDateString === null){
if(localDateString === null || localDateString === ""){
return null
}
localDateString = localDateString + "T08:00";
let localDateObj = new Date(localDateString);
let ISOString = localDateObj.toISOString()
let utcDateString = ISOString.slice(0,10)
return(utcDateString);
return localDateString.slice(0, 10);
} catch (error) {
console.error(`Error extracting date from string '${localDateString}': ${error}`);
return null;
@ -532,6 +560,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,462 +0,0 @@
import { App} from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
export class FileOperation {
app:App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app:App, plugin:UltimateTodoistSyncForObsidian) {
//super(app,settings);
this.app = app;
this.plugin = plugin;
}
/*
async getFrontMatter(file:TFile): Promise<FrontMatter | null> {
return new Promise((resolve) => {
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
resolve(frontMatter);
});
});
}
*/
/*
async updateFrontMatter(
file:TFile,
updater: (frontMatter: FrontMatter) => void
): Promise<void> {
//console.log(`prepare to update front matter`)
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
if (frontMatter !== null) {
const updatedFrontMatter = { ...frontMatter } as FrontMatter;
updater(updatedFrontMatter);
this.app.fileManager.processFrontMatter(file, (newFrontMatter) => {
if (newFrontMatter !== null) {
newFrontMatter.todoistTasks = updatedFrontMatter.todoistTasks;
newFrontMatter.todoistCount = updatedFrontMatter.todoistCount;
}
});
}
});
}
*/
// 完成一个任务,将其标记为已完成
async completeTaskInTheFile(taskId: string) {
// 获取任务文件路径
const currentTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
const filepath = currentTask.path
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
lines[i] = line.replace('[ ]', '[x]')
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
await this.app.vault.modify(file, newContent)
}
}
// uncheck 已完成的任务,
async uncompleteTaskInTheFile(taskId: string) {
// 获取任务文件路径
const currentTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
const filepath = currentTask.path
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
lines[i] = line.replace(/- \[(x|X)\]/g, '- [ ]');
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
await this.app.vault.modify(file, newContent)
}
}
//add #todoist at the end of task line, if full vault sync enabled
async addTodoistTagToFile(filepath: string) {
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if(!this.plugin.taskParser.isMarkdownTask(line)){
//console.log(line)
//console.log("It is not a markdown task.")
continue;
}
//if content is empty
if(this.plugin.taskParser.getTaskContentFromLineText(line) == ""){
//console.log("Line content is empty")
continue;
}
if (!this.plugin.taskParser.hasTodoistId(line) && !this.plugin.taskParser.hasTodoistTag(line)) {
//console.log(line)
//console.log('prepare to add todoist tag')
const newLine = this.plugin.taskParser.addTodoistTag(line);
//console.log(newLine)
lines[i] = newLine
modified = true
}
}
if (modified) {
console.log(`New task found in files ${filepath}`)
const newContent = lines.join('\n')
//console.log(newContent)
await this.app.vault.modify(file, newContent)
//update filemetadate
const metadata = await this.plugin.cacheOperation.getFileMetadata(filepath)
if(!metadata){
await this.plugin.cacheOperation.newEmptyFileMetadata(filepath)
}
}
}
//add todoist at the line
async addTodoistLinkToFile(filepath: string) {
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
if(this.plugin.taskParser.hasTodoistLink(line)){
return
}
console.log(line)
//console.log('prepare to add todoist link')
const taskID = this.plugin.taskParser.getTodoistIdFromLineText(line)
const taskObject = this.plugin.cacheOperation.loadTaskFromCacheyID(taskID)
const todoistLink = taskObject.url
const link = `[link](${todoistLink})`
const newLine = this.plugin.taskParser.addTodoistLink(line,link)
console.log(newLine)
lines[i] = newLine
modified = true
}else{
continue
}
}
if (modified) {
const newContent = lines.join('\n')
//console.log(newContent)
await this.app.vault.modify(file, newContent)
}
}
//add #todoist at the end of task line, if full vault sync enabled
async addTodoistTagToLine(filepath:string,lineText:string,lineNumber:number,fileContent:string) {
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = fileContent
const lines = content.split('\n')
let modified = false
const line = lineText
if(!this.plugin.taskParser.isMarkdownTask(line)){
//console.log(line)
//console.log("It is not a markdown task.")
return;
}
//if content is empty
if(this.plugin.taskParser.getTaskContentFromLineText(line) == ""){
//console.log("Line content is empty")
return;
}
if (!this.plugin.taskParser.hasTodoistId(line) && !this.plugin.taskParser.hasTodoistTag(line)) {
//console.log(line)
//console.log('prepare to add todoist tag')
const newLine = this.plugin.taskParser.addTodoistTag(line);
//console.log(newLine)
lines[lineNumber] = newLine
modified = true
}
if (modified) {
console.log(`New task found in files ${filepath}`)
const newContent = lines.join('\n')
console.log(newContent)
await this.app.vault.modify(file, newContent)
//update filemetadate
const metadata = await this.plugin.cacheOperation.getFileMetadata(filepath)
if(!metadata){
await this.plugin.cacheOperation.newEmptyFileMetadata(filepath)
}
}
}
// sync updated task content to file
async syncUpdatedTaskContentToTheFile(evt:Object) {
const taskId = evt.object_id
// 获取任务文件路径
const currentTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
const filepath = currentTask.path
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldTaskContent = this.plugin.taskParser.getTaskContentFromLineText(line)
const newTaskContent = evt.extra_data.content
lines[i] = line.replace(oldTaskContent, newTaskContent)
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
//console.log(newContent)
await this.app.vault.modify(file, newContent)
}
}
// sync updated task due date to the file
async syncUpdatedTaskDueDateToTheFile(evt:Object) {
const taskId = evt.object_id
// 获取任务文件路径
const currentTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
const filepath = currentTask.path
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldTaskDueDate = this.plugin.taskParser.getDueDateFromLineText(line) || ""
const newTaskDueDate = this.plugin.taskParser.ISOStringToLocalDateString(evt.extra_data.due_date) || ""
//console.log(`${taskId} duedate is updated`)
console.log(oldTaskDueDate)
console.log(newTaskDueDate)
if(oldTaskDueDate === ""){
//console.log(this.plugin.taskParser.insertDueDateBeforeTodoist(line,newTaskDueDate))
lines[i] = this.plugin.taskParser.insertDueDateBeforeTodoist(line,newTaskDueDate)
modified = true
}
else if(newTaskDueDate === ""){
//remove 日期from text
const regexRemoveDate = /(🗓️|📅|📆|🗓)\s?\d{4}-\d{2}-\d{2}/; //匹配日期🗓2023-03-07"
lines[i] = line.replace(regexRemoveDate,"")
modified = true
}
else{
lines[i] = line.replace(oldTaskDueDate, newTaskDueDate)
modified = true
}
break
}
}
if (modified) {
const newContent = lines.join('\n')
//console.log(newContent)
await this.app.vault.modify(file, newContent)
}
}
// sync new task note to file
async syncAddedTaskNoteToTheFile(evt:Object) {
const taskId = evt.parent_item_id
const note = evt.extra_data.content
const datetime = this.plugin.taskParser.ISOStringToLocalDatetimeString(evt.event_date)
// 获取任务文件路径
const currentTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
const filepath = currentTask.path
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const indent = '\t'.repeat(line.length - line.trimStart().length + 1);
const noteLine = `${indent}- ${datetime} ${note}`;
lines.splice(i + 1, 0, noteLine);
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
//console.log(newContent)
await this.app.vault.modify(file, newContent)
}
}
//避免使用该方式通过view可以获得实时更新的value
async readContentFromFilePath(filepath:string){
try {
const file = this.app.vault.getAbstractFileByPath(filepath);
const content = await this.app.vault.read(file);
return content
} catch (error) {
console.error(`Error loading content from ${filepath}: ${error}`);
return false;
}
}
//get line text from file path
//请使用 view.editor.getLineread 方法有延迟
async getLineTextFromFilePath(filepath:string,lineNumber:string) {
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
return(lines[lineNumber])
}
//search todoist_id by content
async searchTodoistIdFromFilePath(filepath: string, searchTerm: string): string | null {
const file = this.app.vault.getAbstractFileByPath(filepath)
const fileContent = await this.app.vault.read(file)
const fileLines = fileContent.split('\n');
let todoistId: string | null = null;
for (let i = 0; i < fileLines.length; i++) {
const line = fileLines[i];
if (line.includes(searchTerm)) {
const regexResult = /\[todoist_id::\s*(\w+)\]/.exec(line);
if (regexResult) {
todoistId = regexResult[1];
}
break;
}
}
return todoistId;
}
//get all files in the vault
async getAllFilesInTheVault(){
const files = this.app.vault.getFiles()
return(files)
}
//search filepath by taskid in vault
async searchFilepathsByTaskidInVault(taskId:string){
console.log(`preprare to search task ${taskId}`)
const files = await this.getAllFilesInTheVault()
//console.log(files)
const tasks = files.map(async (file) => {
if (!this.isMarkdownFile(file.path)) {
return;
}
const fileContent = await this.app.vault.cachedRead(file);
if (fileContent.includes(taskId)) {
return file.path;
}
});
const results = await Promise.all(tasks);
const filePaths = results.filter((filePath) => filePath !== undefined);
return filePaths[0] || null;
//return filePaths || null
}
isMarkdownFile(filename:string) {
// 获取文件名的扩展名
let extension = filename.split('.').pop();
// 将扩展名转换为小写Markdown文件的扩展名通常是.md
extension = extension.toLowerCase();
// 判断扩展名是否为.md
if (extension === 'md') {
return true;
} else {
return false;
}
}
}

View file

@ -1,70 +0,0 @@
import { App, Modal ,Setting } from "obsidian";
import UltimateTodoistSyncForObsidian from "../main"
interface MyProject {
id: string;
name: string;
}
export class SetDefalutProjectInTheFilepathModal extends Modal {
defaultProjectId: string
defaultProjectName: string
filepath:string
plugin:UltimateTodoistSyncForObsidian
constructor(app: App,plugin:UltimateTodoistSyncForObsidian, filepath:string) {
super(app);
this.filepath = filepath
this.plugin = plugin
this.open()
}
async onOpen() {
const { contentEl } = this;
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)
this.defaultProjectName = await this.plugin.cacheOperation.getProjectNameByIdFromCache(this.defaultProjectId)
console.log(this.defaultProjectId)
console.log(this.defaultProjectName)
const myProjectsOptions: MyProject | undefined = this.plugin.settings.todoistTasksData?.projects?.reduce((obj, item) => {
obj[(item.id).toString()] = item.name;
return obj;
}, {}
);
new Setting(contentEl)
.setName('Default project')
//.setDesc('Set default project for todoist tasks in the current file')
.addDropdown(component =>
component
.addOption(this.defaultProjectId,this.defaultProjectName)
.addOptions(myProjectsOptions)
.onChange((value)=>{
console.log(`project id is ${value}`)
//this.plugin.settings.defaultProjectId = this.result
//this.plugin.settings.defaultProjectName = this.plugin.cacheOperation.getProjectNameByIdFromCache(this.result)
//this.plugin.saveSettings()
this.plugin.cacheOperation.setDefaultProjectIdForFilepath(this.filepath,value)
this.plugin.setStatusBarText()
this.close();
})
)
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}

231
src/plugin/eventHandlers.ts Normal file
View file

@ -0,0 +1,231 @@
import { Editor, MarkdownView, TFile } from 'obsidian';
import UltimateTodoistSyncForObsidian from '../../main';
export class EventHandlers {
private plugin: UltimateTodoistSyncForObsidian;
constructor(plugin: UltimateTodoistSyncForObsidian) {
this.plugin = plugin;
}
register(): void {
this.plugin.registerDomEvent(document, 'keyup', (evt: KeyboardEvent) => this.onKeyUp(evt));
this.plugin.registerDomEvent(document, 'click', (evt: MouseEvent) => this.onClick(evt));
this.plugin.registerEvent(this.plugin.app.workspace.on('editor-change', (editor: Editor, view: MarkdownView) => this.onEditorChange(editor, view)));
this.plugin.registerEvent(this.plugin.app.vault.on('rename', (file, oldpath) => this.onFileRename(file as TFile, oldpath)));
this.plugin.registerEvent(this.plugin.app.vault.on('modify', (file) => this.onFileModify(file as TFile)));
}
private async onKeyUp(evt: KeyboardEvent): Promise<void> {
if (!this.plugin.settings.apiInitialized) {
this.plugin.debugLog('[onKeyUp] API not initialized, skipping');
return;
}
if (!(this.plugin.app.workspace.activeEditor?.editor?.hasFocus())) {
this.plugin.debugLog(`editor is not focused`);
return;
}
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown'];
if (arrowKeys.includes(evt.key)) {
if (!await this.plugin.checkModuleClass()) return;
this.lineNumberCheck();
}
if (evt.key === 'Delete' || evt.key === 'Backspace') {
if (!await this.plugin.checkModuleClass()) return;
const filepath = this.plugin.app.workspace.getActiveFile()?.path;
if (!filepath) return;
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
const deletedCount = await this.plugin.obsidianToTodoist!.deletedTaskCheck(filepath);
} catch (error) {
console.error(`An error occurred while deleting tasks: ${error}`);
} finally {
this.plugin.syncLockManager.release();
}
}
}
private async onClick(evt: MouseEvent): Promise<void> {
if (!this.plugin.settings.apiInitialized) {
this.plugin.debugLog('[onClick] API not initialized, skipping');
return;
}
if (this.plugin.app.workspace.activeEditor?.editor?.hasFocus()) {
this.lineNumberCheck();
}
const target = evt.target as HTMLInputElement;
if (target.type === 'checkbox') {
if (!await this.plugin.checkModuleClass()) return;
this.checkboxEventHandle(evt);
}
}
private async onEditorChange(editor: Editor, view: MarkdownView): Promise<void> {
if (!this.plugin.settings.apiInitialized) {
this.plugin.debugLog('[onEditorChange] API not initialized, skipping');
return;
}
if (this.plugin.isSyncingFromTodoist) return;
this.lineNumberCheck();
if (!await this.plugin.checkModuleClass()) return;
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
await this.plugin.obsidianToTodoist!.lineContentNewTaskCheck(editor, view);
} catch (error) {
console.error(`An error occurred while check new task in line: ${(error as Error).message}`);
} finally {
this.plugin.syncLockManager.release();
}
}
private async onFileRename(file: TFile, oldpath: string): Promise<void> {
if (!this.plugin.settings.apiInitialized) {
this.plugin.debugLog('[onFileRename] API not initialized, skipping');
return;
}
this.plugin.debugLog(`${oldpath} is renamed`);
const taskCount = this.plugin.cacheOperation!.getTaskCountInFile(oldpath);
if (taskCount === 0) {
this.plugin.debugLog('The renamed file has no tasks.');
return;
}
if (!await this.plugin.checkModuleClass()) return;
await this.plugin.cacheOperation!.updateRenamedFilePath(oldpath, file.path);
this.plugin.logOperation?.log('FILE_RENAMED', `File renamed from ${oldpath} to ${file.path}`, file.path);
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
await this.plugin.obsidianToTodoist!.updateTaskDescription(file.path);
} catch (error) {
console.error('An error occurred in updateTaskDescription:', error);
} finally {
this.plugin.syncLockManager.release();
}
}
private async onFileModify(file: TFile): Promise<void> {
if (!this.plugin.settings.apiInitialized) {
this.plugin.debugLog('[onFileModify] API not initialized, skipping');
return;
}
if (this.plugin.isSyncingFromTodoist) return;
if (this.plugin.isProcessingModify) return;
if (!file.path.endsWith('.md')) return;
if (file.path.startsWith('.obsidian/')) return;
this.plugin.isProcessingModify = true;
const filepath = file.path;
const storagePath = this.plugin.storagePathManager?.getBasePath() || 'ultimate-todoist-sync';
if (filepath.includes(storagePath)) {
this.plugin.isProcessingModify = false;
return;
}
this.plugin.debugLog(`${filepath} is modified`);
const activeFile = this.plugin.app.workspace.getActiveFile();
this.plugin.debugLog(activeFile?.path);
if (activeFile?.path === filepath) {
this.plugin.isProcessingModify = false;
return;
}
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) {
this.plugin.isProcessingModify = false;
return;
}
try {
await this.plugin.obsidianToTodoist!.fullTextNewTaskCheck(filepath);
} catch (error) {
console.error(`An error occurred while modifying the file: ${(error as Error).message}`);
} finally {
this.plugin.syncLockManager.release();
this.plugin.isProcessingModify = false;
}
}
async lineNumberCheck(): Promise<void> {
if (this.plugin.isSyncingFromTodoist) return;
const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return;
const cursor = view.editor.getCursor();
const line = cursor?.line;
const fileContent = view.data;
const fileName = view.app.workspace.activeEditor?.file?.name;
const filepath = view.app.workspace.activeEditor?.file?.path;
if (typeof this.plugin.lastLines === 'undefined' || typeof this.plugin.lastLines.get(fileName as string) === 'undefined') {
this.plugin.lastLines.set(fileName as string, line as number);
return;
}
if (this.plugin.lastLines.has(fileName as string) && line !== this.plugin.lastLines.get(fileName as string)) {
const lastLine = this.plugin.lastLines.get(fileName as string);
if (this.plugin.settings.debugMode) {
this.plugin.debugLog('Line changed!', `current line is ${line}`, `last line is ${lastLine}`);
}
const lastLineText = view.editor.getLine(lastLine as number);
if (!await this.plugin.checkModuleClass()) return;
this.plugin.lastLines.set(fileName as string, line as number);
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
await this.plugin.obsidianToTodoist!.lineModifiedTaskCheck(filepath as string, lastLineText, lastLine as number, fileContent);
await this.plugin.obsidianToTodoist!.lastLineNewTaskCheck(filepath as string, lastLineText, lastLine as number, fileContent);
} catch (error) {
console.error(`An error occurred while checking task on line leave: ${error}`);
} finally {
this.plugin.syncLockManager.release();
}
}
}
async checkboxEventHandle(evt: MouseEvent): Promise<void> {
if (!await this.plugin.checkModuleClass()) return;
const target = evt.target as HTMLInputElement;
const taskElement = target.closest('div');
if (!taskElement) return;
const regex = /\[todoist_id::\s*(\w+)\]/;
const match = taskElement.textContent?.match(regex) || false;
if (match) {
const taskId = (match as RegExpMatchArray)[1];
if (!this.plugin.cacheOperation?.isTaskSyncEnabled(taskId)) return;
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
if (target.checked) {
await this.plugin.obsidianToTodoist!.closeTask(taskId);
} else {
await this.plugin.obsidianToTodoist!.repoenTask(taskId);
}
} catch (error) {
console.error(`An error occurred while toggling task: ${error}`);
} finally {
this.plugin.syncLockManager.release();
}
} else {
const filepath = this.plugin.app.workspace.getActiveFile()?.path;
if (!filepath) return;
if (!await this.plugin.syncLockManager.acquire('obsidianToTodoist')) return;
try {
await this.plugin.obsidianToTodoist!.fullTextModifiedTaskCheck(filepath);
} catch (error) {
console.error(`An error occurred while check modified tasks in the file: ${error}`);
} finally {
this.plugin.syncLockManager.release();
}
}
}
}

View file

@ -1,403 +0,0 @@
import { App, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
interface MyProject {
id: string;
name: string;
}
export interface UltimateTodoistSyncSettings {
initialized:boolean;
//mySetting: string;
//todoistTasksFilePath: string;
todoistAPIToken: string; // replace with correct type
apiInitialized:boolean;
defaultProjectName: string;
defaultProjectId:string;
automaticSynchronizationInterval:Number;
todoistTasksData:any;
fileMetadata:any;
enableFullVaultSync: boolean;
statistics: any;
debugMode:boolean;
}
export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
initialized: false,
apiInitialized:false,
defaultProjectName:"Inbox",
automaticSynchronizationInterval: 300, //default aync interval 300s
todoistTasksData:{},
fileMetadata:{},
enableFullVaultSync:false,
statistics:{},
debugMode:false,
//mySetting: 'default',
//todoistTasksFilePath: 'todoistTasks.json'
}
export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
plugin: UltimateTodoistSyncForObsidian;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Settings for Ultimate Todoist Sync for Obsidian.' });
const myProjectsOptions: MyProject | undefined = this.plugin.settings.todoistTasksData?.projects?.reduce((obj, item) => {
obj[(item.id).toString()] = item.name;
return obj;
}, {});
new Setting(containerEl)
.setName('Todoist API')
.setDesc('Please enter todoist api token and click the paper airplane button to submit.')
.addText((text) =>
text
.setPlaceholder('Enter your API')
.setValue(this.plugin.settings.todoistAPIToken)
.onChange(async (value) => {
this.plugin.settings.todoistAPIToken = value;
this.plugin.settings.apiInitialized = false;
//
})
)
.addExtraButton((button) => {
button.setIcon('send')
.onClick(async () => {
await this.plugin.modifyTodoistAPI(this.plugin.settings.todoistAPIToken)
this.display()
})
})
new Setting(containerEl)
.setName('Automatic Sync Interval Time')
.setDesc('Please specify the desired interval time, with seconds as the default unit. The default setting is 300 seconds, which corresponds to syncing once every 5 minutes. You can customize it, but it cannot be lower than 20 seconds.')
.addText((text) =>
text
.setPlaceholder('Sync interval')
.setValue(this.plugin.settings.automaticSynchronizationInterval.toString())
.onChange(async (value) => {
const intervalNum = Number(value)
if(isNaN(intervalNum)){
new Notice(`Wrong type,please enter a number.`)
return
}
if(intervalNum < 20 ){
new Notice(`The synchronization interval time cannot be less than 20 seconds.`)
return
}
if (!Number.isInteger(intervalNum)) {
new Notice('The synchronization interval must be an integer.');
return;
}
this.plugin.settings.automaticSynchronizationInterval = intervalNum;
this.plugin.saveSettings()
new Notice('Settings have been updated.');
//
})
)
/*
new Setting(containerEl)
.setName('The default project for new tasks')
.setDesc('New tasks are automatically synced to the Inbox. You can modify the project here.')
.addText((text) =>
text
.setPlaceholder('Enter default project name:')
.setValue(this.plugin.settings.defaultProjectName)
.onChange(async (value) => {
try{
//this.plugin.cacheOperation.saveProjectsToCache()
const newProjectId = this.plugin.cacheOperation.getProjectIdByNameFromCache(value)
if(!newProjectId){
new Notice(`This project seems to not exist.`)
return
}
}catch(error){
new Notice(`Invalid project name `)
return
}
this.plugin.settings.defaultProjectName = value;
this.plugin.saveSettings()
new Notice(`The default project has been modified successfully.`)
})
);
*/
new Setting(containerEl)
.setName('Default Project')
.setDesc('New tasks are automatically synced to the default project. You can modify the project here.')
.addDropdown(component =>
component
.addOption(this.plugin.settings.defaultProjectId,this.plugin.settings.defaultProjectName)
.addOptions(myProjectsOptions)
.onChange((value)=>{
this.plugin.settings.defaultProjectId = value
this.plugin.settings.defaultProjectName = this.plugin.cacheOperation.getProjectNameByIdFromCache(value)
this.plugin.saveSettings()
})
)
new Setting(containerEl)
.setName('Full Vault Sync')
.setDesc('By default, only tasks marked with #todoist are synchronized. If this option is turned on, all tasks in the vault will be synchronized.')
.addToggle(component =>
component
.setValue(this.plugin.settings.enableFullVaultSync)
.onChange((value)=>{
this.plugin.settings.enableFullVaultSync = value
this.plugin.saveSettings()
new Notice("Full vault sync is enabled.")
})
)
new Setting(containerEl)
.setName('Manual Sync')
.setDesc('Manually perform a synchronization task.')
.addButton(button => button
.setButtonText('Sync')
.onClick(async () => {
// Add code here to handle exporting Todoist data
if(!this.plugin.settings.apiInitialized){
new Notice(`Please set the todoist api first`)
return
}
try{
await this.plugin.scheduledSynchronization()
this.plugin.syncLock = false
new Notice(`Sync completed..`)
}catch(error){
new Notice(`An error occurred while syncing.:${error}`)
this.plugin.syncLock = false
}
})
);
new Setting(containerEl)
.setName('Check Database')
.setDesc('Check for possible issues: sync error, file renaming not updated, or missed tasks not synchronized.')
.addButton(button => button
.setButtonText('Check Database')
.onClick(async () => {
// Add code here to handle exporting Todoist data
if(!this.plugin.settings.apiInitialized){
new Notice(`Please set the todoist api first`)
return
}
//reinstall plugin
if(this.plugin.settings.fileMetadata = {}){
}
//check file metadata
console.log('checking file metadata')
await this.plugin.cacheOperation.checkFileMetadata()
this.plugin.saveSettings()
const metadatas = await this.plugin.cacheOperation.getFileMetadatas()
// check default project task amounts
try{
const projectId = this.plugin.settings.defaultProjectId
let options = {}
options.projectId = projectId
const tasks = await this.plugin.todoistRestAPI.GetActiveTasks(options)
let length = tasks.length
if(length >= 300){
new Notice(`The number of tasks in the default project exceeds 300, reaching the upper limit. It is not possible to add more tasks. Please modify the default project.`)
}
//console.log(tasks)
}catch(error){
console.error(`An error occurred while get tasks from todoist: ${error.message}`);
}
if (!await this.plugin.checkAndHandleSyncLock()) return;
console.log('checking deleted tasks')
//check empty task
for (const key in metadatas) {
const value = metadatas[key];
//console.log(value)
for(const taskId of value.todoistTasks) {
//console.log(`${taskId}`)
let taskObject
try{
taskObject = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
}catch(error){
console.error(`An error occurred while loading task cache: ${error.message}`);
}
if(!taskObject){
console.log(`The task data of the ${taskId} is empty.`)
//get from todoist
try {
taskObject = await this.plugin.todoistRestAPI.getTaskById(taskId);
} catch (error) {
if (error.message.includes('404')) {
// 处理404错误
console.log(`Task ${taskId} seems to not exist.`);
await this.plugin.cacheOperation.deleteTaskIdFromMetadata(key,taskId)
continue
} else {
// 处理其他错误
console.error(error);
continue
}
}
}
};
}
this.plugin.saveSettings()
console.log('checking renamed files')
try{
//check renamed files
for (const key in metadatas) {
const value = metadatas[key];
//console.log(value)
const newDescription = this.plugin.taskParser.getObsidianUrlFromFilepath(key)
for(const taskId of value.todoistTasks) {
//console.log(`${taskId}`)
let taskObject
try{
taskObject = await this.plugin.cacheOperation.loadTaskFromCacheyID(taskId)
}catch(error){
console.error(`An error occurred while loading task ${taskId} from cache: ${error.message}`);
console.log(taskObject)
}
if(!taskObject){
console.log(`Task ${taskId} seems to not exist.`)
continue
}
if(!taskObject?.description){
console.log(`The description of the task ${taskId} is empty.`)
}
const oldDescription = taskObject?.description ?? '';
if(newDescription != oldDescription){
console.log('Preparing to update description.')
console.log(oldDescription)
console.log(newDescription)
try{
//await this.plugin.todoistSync.updateTaskDescription(key)
}catch(error){
console.error(`An error occurred while updating task discription: ${error.message}`);
}
}
};
}
//check empty file metadata
//check calendar format
//check omitted tasks
console.log('checking unsynced tasks')
const files = this.app.vault.getFiles()
files.forEach(async (v, i) => {
if(v.extension == "md"){
try{
//console.log(`Scanning file ${v.path}`)
await this.plugin.fileOperation.addTodoistLinkToFile(v.path)
if(this.plugin.settings.enableFullVaultSync){
await this.plugin.fileOperation.addTodoistTagToFile(v.path)
}
}catch(error){
console.error(`An error occurred while check new tasks in the file: ${v.path}, ${error.message}`);
}
}
});
this.plugin.syncLock = false
new Notice(`All files have been scanned.`)
}catch(error){
console.error(`An error occurred while scanning the vault.:${error}`)
this.plugin.syncLock = false
}
})
);
new Setting(containerEl)
.setName('Debug Mode')
.setDesc('After enabling this option, all log information will be output to the console, which can help check for errors.')
.addToggle(component =>
component
.setValue(this.plugin.settings.debugMode)
.onChange((value)=>{
this.plugin.settings.debugMode = value
this.plugin.saveSettings()
})
)
new Setting(containerEl)
.setName('Backup Todoist Data')
.setDesc('Click to backup Todoist data, The backed-up files will be stored in the root directory of the Obsidian vault.')
.addButton(button => button
.setButtonText('Backup')
.onClick(() => {
// Add code here to handle exporting Todoist data
if(!this.plugin.settings.apiInitialized){
new Notice(`Please set the todoist api first`)
return
}
this.plugin.todoistSync.backupTodoistAllResources()
})
);
}
}

View file

@ -0,0 +1,790 @@
import { App, Notice } from 'obsidian';
import { DEFAULT_SETTINGS, TaskIssueEntry } from './settings';
import { StoragePathManager } from '../storage/pathManager';
import UltimateTodoistSyncForObsidian from '../../main';
import { DeviceManager } from '../utils/deviceManager';
import { normalizeTaskIssueTypeKey, reconcileTaskEntryDerivedState } from '../data/taskIssueUtils';
const KNOWN_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
const REQUIRED_FIELDS = ['initialized', 'todoistAPIToken', 'taskFileMapping'];
const CURRENT_SCHEMA_VERSION = 1;
type TaskMappingEntry = {
filePath: string;
status?: 'active' | 'nonActive' | 'conflicted' | 'issue';
syncEnabled?: boolean;
updated_at?: string;
note_count?: number;
issues?: Record<string, TaskIssueEntry>;
};
// ==========================================================================================
// SafeSettings - 设置的完整生命周期管理:加载、保存、运行时变更
// ==========================================================================================
export class SafeSettings {
private plugin: UltimateTodoistSyncForObsidian;
private lastBackupTime = 0;
private static readonly BACKUP_THROTTLE_MS = 5 * 60 * 1000;
private static readonly SAVE_DEBOUNCE_MS = 1200;
private settingsIoQueue: Promise<void> = Promise.resolve();
private saveDebounceTimer: number | null = null;
private hasDirtyChanges = false;
private lastSavedSnapshot = '';
constructor(plugin: UltimateTodoistSyncForObsidian) {
this.plugin = plugin;
}
public async withSettingsIOLock<T>(_operation: string, fn: () => Promise<T>): Promise<T> {
const run = this.settingsIoQueue.then(async () => {
this.plugin.saveLock = true;
try {
return await fn();
} finally {
this.plugin.saveLock = false;
}
}, async () => {
this.plugin.saveLock = true;
try {
return await fn();
} finally {
this.plugin.saveLock = false;
}
});
this.settingsIoQueue = run.then(() => undefined, () => undefined);
return run;
}
private buildSettingsSnapshot(): string {
return JSON.stringify(this.plugin.settings, null, 2);
}
private markPersisted(snapshot: string): void {
this.hasDirtyChanges = false;
this.lastSavedSnapshot = snapshot;
}
private markDirty(): void {
this.hasDirtyChanges = true;
}
private syncPersistedStateFromMemory(isDirty: boolean): void {
try {
this.lastSavedSnapshot = this.buildSettingsSnapshot();
this.hasDirtyChanges = isDirty;
} catch (error) {
console.error('[Settings] Failed to sync persisted state snapshot:', error);
this.lastSavedSnapshot = '';
this.hasDirtyChanges = true;
}
}
private cancelDebouncedSave(): void {
if (this.saveDebounceTimer !== null) {
window.clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
}
}
private scheduleDebouncedSave(): void {
this.cancelDebouncedSave();
this.saveDebounceTimer = window.setTimeout(() => {
this.saveDebounceTimer = null;
this.save().catch(error => {
console.error('[Settings] Debounced save failed:', error);
});
}, SafeSettings.SAVE_DEBOUNCE_MS);
}
private async reloadSettingsFromDisk(): Promise<boolean> {
const rawData = await this.plugin.loadData();
if (!this.isLoadDataUsable(rawData)) return false;
const normalizedData = this.normalizeLoadedData(rawData);
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS, normalizedData);
this.stripUnknownFields();
this.sanitizeTaskFileMapping();
this.syncPersistedStateFromMemory(false);
return true;
}
async restoreFromLatestBackup(): Promise<boolean> {
if (!this.plugin.settingsBackup) return false;
const restored = await this.plugin.settingsBackup.restore();
if (!restored) return false;
return this.reloadSettingsFromDisk();
}
async load(): Promise<boolean> {
this.plugin.settingsBackup = new SettingsBackup(this.plugin.app, this.plugin);
try {
if (await this.plugin.settingsBackup.hasTempFile()) {
console.warn('[Settings] Found temp file, attempting recovery...');
new Notice('Found unsaved changes, attempting recovery...');
const recovered = await this.plugin.settingsBackup.recoverFromTempFile();
if (!recovered) {
new Notice('Failed to recover from unsaved changes, using backup');
await this.plugin.settingsBackup.restore();
} else {
new Notice('Settings recovered from unsaved changes');
}
}
const rawData = await this.plugin.loadData();
if (rawData === null || rawData === undefined) {
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
this.syncPersistedStateFromMemory(false);
return true;
}
if (!this.isLoadDataUsable(rawData)) {
console.warn('[Settings] Settings corrupted, attempting recovery...');
new Notice('Settings corrupted, attempting recovery...');
const recovered = await this.plugin.settingsBackup.restore();
if (recovered) {
new Notice('Settings recovered from backup');
const recoveredData = await this.plugin.loadData();
const normalizedRecoveredData = this.isLoadDataUsable(recoveredData)
? this.normalizeLoadedData(recoveredData)
: {};
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS, normalizedRecoveredData);
this.stripUnknownFields();
this.sanitizeTaskFileMapping();
this.syncPersistedStateFromMemory(false);
return true;
}
console.error('[Settings] Recovery failed, using default settings in memory only');
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
new Notice('Settings appear corrupted and recovery failed. Plugin stopped to avoid overwriting data.json.');
return false;
}
const data = this.normalizeLoadedData(rawData);
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS, data);
this.stripUnknownFields();
this.sanitizeTaskFileMapping();
// Run schema migrations (v0 → v1 → ... → CURRENT_SCHEMA_VERSION)
await this.runMigrations(rawData);
this.syncPersistedStateFromMemory(false);
return true;
} catch (error) {
console.error('[Settings] Failed to load data:', error);
try {
const recovered = await this.plugin.settingsBackup.restore();
if (recovered) {
const recoveredData = await this.plugin.loadData();
const normalizedRecoveredData = this.isLoadDataUsable(recoveredData)
? this.normalizeLoadedData(recoveredData)
: {};
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS, normalizedRecoveredData);
this.stripUnknownFields();
this.sanitizeTaskFileMapping();
this.syncPersistedStateFromMemory(false);
new Notice('Settings recovered from backup after load failure');
return true;
}
} catch (restoreError) {
console.error('[Settings] Backup restore after load failure also failed:', restoreError);
}
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
this.syncPersistedStateFromMemory(false);
new Notice('Settings load failed. Loaded defaults in memory only; original data was not overwritten.');
return false;
}
}
private isLoadDataUsable(data: unknown): data is Record<string, unknown> {
if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
try {
JSON.parse(JSON.stringify(data));
return true;
} catch {
return false;
}
}
private normalizeLoadedData(data: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...data };
if (typeof normalized.syncInterval === 'number' && typeof normalized.automaticSynchronizationInterval !== 'number') {
normalized.automaticSynchronizationInterval = normalized.syncInterval;
}
if (typeof normalized.todoistApiToken === 'string' && typeof normalized.todoistAPIToken !== 'string') {
normalized.todoistAPIToken = normalized.todoistApiToken;
}
if (!normalized.taskFileMapping || typeof normalized.taskFileMapping !== 'object' || Array.isArray(normalized.taskFileMapping)) {
normalized.taskFileMapping = {};
}
const taskFileMapping = normalized.taskFileMapping as Record<string, unknown>;
if (Object.keys(taskFileMapping).length === 0) {
const legacyMapping = this.buildLegacyTaskFileMapping(normalized);
if (Object.keys(legacyMapping).length > 0) {
normalized.taskFileMapping = legacyMapping;
this.plugin.debugLog(`[Settings] Migrated ${Object.keys(legacyMapping).length} mapping entries from legacy todoistTasksData`);
}
}
if (!normalized.fileMetadata || typeof normalized.fileMetadata !== 'object' || Array.isArray(normalized.fileMetadata)) {
normalized.fileMetadata = {};
}
return normalized;
}
private buildLegacyTaskFileMapping(data: Record<string, unknown>): Record<string, TaskMappingEntry> {
const legacyCache = data.todoistTasksData;
if (!legacyCache || typeof legacyCache !== 'object' || Array.isArray(legacyCache)) return {};
const tasks = (legacyCache as Record<string, unknown>).tasks;
if (!Array.isArray(tasks)) return {};
const migrated: Record<string, TaskMappingEntry> = {};
for (const task of tasks) {
if (!task || typeof task !== 'object' || Array.isArray(task)) continue;
const legacyTask = task as Record<string, unknown>;
const rawTaskId = legacyTask.id;
const rawFilePath = legacyTask.path;
if ((typeof rawTaskId !== 'string' && typeof rawTaskId !== 'number') || typeof rawFilePath !== 'string') {
continue;
}
const taskId = String(rawTaskId).trim();
const filePath = rawFilePath.trim();
if (!taskId || !filePath || migrated[taskId]) continue;
const entry: TaskMappingEntry = {
filePath,
syncEnabled: true,
status: legacyTask.isCompleted === true ? 'nonActive' : 'active',
};
if (typeof legacyTask.updated_at === 'string') {
entry.updated_at = legacyTask.updated_at;
} else if (typeof legacyTask.updatedAt === 'string') {
entry.updated_at = legacyTask.updatedAt;
}
migrated[taskId] = entry;
}
return migrated;
}
async save(): Promise<boolean> {
this.cancelDebouncedSave();
return this.withSettingsIOLock('save', async () => {
const settingsPath = StoragePathManager.settingsFilePath(this.plugin.manifest.dir);
const tempPath = StoragePathManager.settingsTempFilePath(this.plugin.manifest.dir);
try {
if (!this.plugin.settings || Object.keys(this.plugin.settings).length === 0) {
console.error('[Settings] Settings are empty or invalid, not saving to avoid data loss.');
return false;
}
if (!this.validate(this.plugin.settings)) {
console.error('[Settings] Settings validation failed');
return false;
}
const settingsJson = this.buildSettingsSnapshot();
const adapter = this.plugin.app.vault.adapter;
const settingsExists = await adapter.exists(settingsPath);
if (!this.hasDirtyChanges && settingsExists && settingsJson === this.lastSavedSnapshot) {
this.plugin.debugLog('[Settings] No changes detected, skipping save');
return true;
}
await adapter.write(tempPath, settingsJson);
if (!await adapter.exists(tempPath)) {
throw new Error('Temp file was not created');
}
await adapter.write(settingsPath, settingsJson);
try {
await adapter.remove(tempPath);
} catch (cleanupError) {
console.warn('[Settings] Failed to cleanup temp file:', cleanupError);
}
this.markPersisted(settingsJson);
this.plugin.debugLog('[Settings] Settings saved successfully');
return true;
} catch (error) {
console.error('[Settings] Error saving settings:', error);
new Notice('Settings save failed, temp file preserved for recovery');
return false;
}
});
}
validate(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const record = data as Record<string, unknown>;
for (const field of REQUIRED_FIELDS) {
if (!(field in record)) {
console.warn(`[Settings] Missing required field: ${field}`);
return false;
}
}
if (record.taskFileMapping && typeof record.taskFileMapping !== 'object') return false;
try {
JSON.parse(JSON.stringify(data));
return true;
} catch {
return false;
}
}
private stripUnknownFields(): void {
const settings = this.plugin.settings as unknown as Record<string, unknown>;
let stripped = 0;
for (const key of Object.keys(settings)) {
if (!KNOWN_SETTINGS_KEYS.has(key)) {
this.plugin.debugLog(`[Settings] Stripping unknown field: ${key}`);
delete settings[key];
stripped++;
}
}
if (stripped > 0) this.plugin.debugLog(`[Settings] Stripped ${stripped} unknown field(s) from loaded data`);
}
private sanitizeTaskFileMapping(): void {
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()) {
this.plugin.debugLog(`[Settings] Removing malformed mapping entry: ${taskId}`);
delete mapping[taskId];
fixed++;
continue;
}
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 = normalizeTaskIssueTypeKey(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++;
}
const reconciled = reconcileTaskEntryDerivedState(entry as {
status?: 'active' | 'nonActive' | 'conflicted' | 'issue';
syncEnabled?: boolean;
issues?: Record<string, TaskIssueEntry>;
});
if (reconciled.changed) {
fixed++;
}
}
if (fixed > 0) this.plugin.debugLog(`[Settings] Sanitized ${fixed} corrupted taskFileMapping entry(s)`);
}
private async runMigrations(rawData: Record<string, unknown>): Promise<void> {
const fromVersion = typeof rawData.schemaVersion === 'number' ? rawData.schemaVersion : 0;
if (fromVersion >= CURRENT_SCHEMA_VERSION) return;
console.log(`[Settings] Running migrations from schema v${fromVersion} to v${CURRENT_SCHEMA_VERSION}`);
// Force backup before first migration
try {
await this.plugin.settingsBackup?.backup();
} catch (backupError) {
console.warn('[Settings] Pre-migration backup failed:', backupError);
}
// v0 → v1: Migrate isPrimaryDevice boolean to primaryDeviceId string
if (fromVersion < 1) {
const legacyIsPrimary = rawData.isPrimaryDevice;
if (legacyIsPrimary === true && !this.plugin.settings.primaryDeviceId) {
try {
const tempDeviceManager = new DeviceManager(this.plugin.app, this.plugin);
const deviceId = await tempDeviceManager.getDeviceId();
this.plugin.settings.primaryDeviceId = deviceId;
this.plugin.debugLog('[Settings] v0→v1: Migrated isPrimaryDevice to primaryDeviceId:', deviceId);
} catch (error) {
console.error('[Settings] v0→v1: Failed to migrate isPrimaryDevice:', error);
}
}
}
// Stamp current version and persist
this.plugin.settings.schemaVersion = CURRENT_SCHEMA_VERSION;
this.markDirty();
await this.save();
console.log(`[Settings] Migration complete — now at schema v${CURRENT_SCHEMA_VERSION}`);
}
private async throttledBackup(): Promise<void> {
if (!this.plugin.settingsBackup) return;
const now = Date.now();
if (now - this.lastBackupTime < SafeSettings.BACKUP_THROTTLE_MS) return;
await this.plugin.settingsBackup.backup();
this.lastBackupTime = now;
}
async update(changes: Partial<typeof this.plugin.settings>, shouldSave = false): Promise<void> {
await this.throttledBackup();
try {
Object.assign(this.plugin.settings, changes);
this.markDirty();
if (shouldSave) {
await this.save();
} else {
this.scheduleDebouncedSave();
}
} catch (error) {
console.error('[SafeSettings] Update failed:', error);
if (this.plugin.settingsBackup) {
const restored = await this.plugin.settingsBackup.restore();
if (restored) {
await this.reloadSettingsFromDisk();
}
}
throw error;
}
}
async reset(): Promise<void> {
if (this.plugin.settingsBackup) {
await this.plugin.settingsBackup.backup();
}
try {
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
this.markDirty();
await this.save();
} catch (error) {
console.error('[SafeSettings] Reset failed:', error);
if (this.plugin.settingsBackup) {
const restored = await this.plugin.settingsBackup.restore();
if (restored) {
await this.reloadSettingsFromDisk();
}
}
throw error;
}
}
}
// ==========================================================================================
// SettingsBackup - Settings JSON 备份恢复
// ==========================================================================================
export class SettingsBackup {
private app: App;
private plugin: UltimateTodoistSyncForObsidian;
private maxBackups = 5;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
private async withSettingsIOLock<T>(operation: string, fn: () => Promise<T>): Promise<T> {
if (this.plugin.safeSettings) {
return this.plugin.safeSettings.withSettingsIOLock(`settings-backup:${operation}`, fn);
}
return fn();
}
private getBackupDirsToSearch(): string[] {
const dirs: string[] = [];
if (this.plugin.storagePathManager) {
dirs.push(this.plugin.storagePathManager.getBackupsSettingsPath());
}
const configuredDir = this.plugin.settings?.storageDirectory;
if (configuredDir) {
dirs.push(`${configuredDir}/backups/settings`);
}
const lastDir = this.plugin.settings?.lastStorageDirectory;
if (lastDir) {
dirs.push(`${lastDir}/backups/settings`);
}
dirs.push(`${StoragePathManager.DEFAULT_BASE_PATH}/backups/settings`);
dirs.push(`${StoragePathManager.LEGACY_BASE_PATH}/backups/settings`);
return Array.from(new Set(dirs));
}
private getSortedBackupFiles(): string[] {
const searchDirs = this.getBackupDirsToSearch();
const files = this.app.vault.getFiles()
.filter(file =>
searchDirs.some(dir => file.path.startsWith(dir + '/'))
&& file.name.startsWith('settings-')
&& file.name.endsWith('.json')
)
.sort((a, b) => b.stat.mtime - a.stat.mtime);
return files.map(file => file.path);
}
private getBackupDir(): string {
if (this.plugin.storagePathManager) {
return this.plugin.storagePathManager.getBackupsSettingsPath();
}
const dir = this.plugin.settings?.storageDirectory || StoragePathManager.DEFAULT_BASE_PATH;
return `${dir}/backups/settings`;
}
private async ensureBackupDir(): Promise<void> {
const dir = this.getBackupDir();
if (this.plugin.storagePathManager) {
await this.plugin.storagePathManager.ensureDir(dir);
} else {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(dir)) {
await adapter.mkdir(dir);
}
}
}
async backup(): Promise<boolean> {
return this.withSettingsIOLock('backup', async () => {
try {
await this.ensureBackupDir();
const settingsPath = StoragePathManager.settingsFilePath(this.plugin.manifest.dir);
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(settingsPath);
if (!exists) {
console.warn('[SettingsBackup] No settings data to backup');
return false;
}
const data = await adapter.read(settingsPath);
if (!data) {
console.warn('[SettingsBackup] No settings data to backup');
return false;
}
try {
JSON.parse(data);
} catch {
console.error('[SettingsBackup] Settings file contains invalid JSON, skipping backup');
return false;
}
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const backupPath = `${this.getBackupDir()}/settings-${timestamp}.json`;
await adapter.write(backupPath, data);
this.plugin.debugLog('[SettingsBackup] Backup created:', backupPath);
await this.cleanOldBackups();
return true;
} catch (error) {
console.error('[SettingsBackup] Backup failed:', error);
return false;
}
});
}
async restore(): Promise<boolean> {
return this.withSettingsIOLock('restore', async () => {
try {
const latestBackup = await this.getLatestBackup();
if (!latestBackup) {
console.warn('[SettingsBackup] No backup found to restore');
return false;
}
const adapter = this.app.vault.adapter;
const data = await adapter.read(latestBackup);
if (!data) {
console.error('[SettingsBackup] Failed to read backup file');
return false;
}
try {
JSON.parse(data);
} catch {
console.error('[SettingsBackup] Backup contains invalid JSON, cannot restore');
new Notice('Backup file is corrupted, cannot restore');
return false;
}
const settingsPath = StoragePathManager.settingsFilePath(this.plugin.manifest.dir);
const tempPath = StoragePathManager.settingsTempFilePath(this.plugin.manifest.dir);
await adapter.write(tempPath, data);
await adapter.write(settingsPath, data);
await adapter.remove(tempPath).catch(() => {});
this.plugin.debugLog('[SettingsBackup] Settings restored from:', latestBackup);
new Notice('Settings restored from backup');
return true;
} catch (error) {
console.error('[SettingsBackup] Restore failed:', error);
new Notice('Failed to restore settings from backup');
return false;
}
});
}
async getLatestBackup(): Promise<string | null> {
try {
const files = this.getSortedBackupFiles();
return files[0] ?? null;
} catch (error) {
console.error('[SettingsBackup] Failed to get latest backup:', error);
return null;
}
}
async getBackupList(): Promise<string[]> {
try {
return this.getSortedBackupFiles();
} catch (error) {
console.error('[SettingsBackup] Failed to get backup list:', error);
return [];
}
}
async restoreFromSpecific(backupPath: string): Promise<boolean> {
return this.withSettingsIOLock('restore-from-specific', async () => {
try {
const adapter = this.app.vault.adapter;
if (!await adapter.exists(backupPath)) {
console.error('[SettingsBackup] Backup file not found:', backupPath);
return false;
}
const data = await adapter.read(backupPath);
if (!data) return false;
try {
JSON.parse(data);
} catch {
new Notice('Backup file is corrupted');
return false;
}
const settingsPath = StoragePathManager.settingsFilePath(this.plugin.manifest.dir);
const tempPath = StoragePathManager.settingsTempFilePath(this.plugin.manifest.dir);
await adapter.write(tempPath, data);
await adapter.write(settingsPath, data);
await adapter.remove(tempPath).catch(() => {});
this.plugin.debugLog('[SettingsBackup] Settings restored from specific backup:', backupPath);
new Notice('Settings restored from backup');
return true;
} catch (error) {
console.error('[SettingsBackup] Restore from specific failed:', error);
new Notice('Failed to restore settings from backup');
return false;
}
});
}
async recoverFromTempFile(): Promise<boolean> {
return this.withSettingsIOLock('recover-temp', async () => {
try {
const tempPath = StoragePathManager.settingsTempFilePath(this.plugin.manifest.dir);
const settingsPath = StoragePathManager.settingsFilePath(this.plugin.manifest.dir);
const adapter = this.app.vault.adapter;
if (!await adapter.exists(tempPath)) return false;
const data = await adapter.read(tempPath);
if (!data) return false;
try {
JSON.parse(data);
} catch {
console.error('[SettingsBackup] Temp file contains invalid JSON');
await adapter.remove(tempPath).catch(() => {});
return false;
}
// Atomic: write temp copy → write actual → remove temp copy
const recoveryTempPath = settingsPath + '.recovery.tmp';
await adapter.write(recoveryTempPath, data);
await adapter.write(settingsPath, data);
await adapter.remove(recoveryTempPath).catch(() => {});
await adapter.remove(tempPath).catch(() => {});
this.plugin.debugLog('[SettingsBackup] Recovered from temp file');
new Notice('Settings recovered from temp file');
return true;
} catch (error) {
console.error('[SettingsBackup] Failed to recover from temp file:', error);
return false;
}
});
}
async hasTempFile(): Promise<boolean> {
return this.app.vault.adapter.exists(StoragePathManager.settingsTempFilePath(this.plugin.manifest.dir));
}
async clearAllBackups(): Promise<boolean> {
try {
const backupDir = this.getBackupDir();
const adapter = this.app.vault.adapter;
if (await adapter.exists(backupDir)) {
await adapter.rmdir(backupDir, true);
this.plugin.debugLog('[SettingsBackup] All backups cleared');
}
return true;
} catch (error) {
console.error('[SettingsBackup] Failed to clear backups:', error);
return false;
}
}
private async cleanOldBackups(): Promise<void> {
try {
const files = this.getSortedBackupFiles();
if (files.length > this.maxBackups) {
const adapter = this.app.vault.adapter;
for (const filePath of files.slice(this.maxBackups)) {
try {
if (await adapter.exists(filePath)) await adapter.remove(filePath);
} catch {
// concurrent cleanup, ignore
}
}
}
} catch (error) {
console.error('[SettingsBackup] Failed to clean old backups:', error);
}
}
}

811
src/settings/settings.ts Normal file
View file

@ -0,0 +1,811 @@
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import UltimateTodoistSyncForObsidian from "../../main";
import { DatabaseReportModal, ExcludedFoldersModal, LogViewerModal, TaskManagerModal } from '../ui/modals';
import type { DatabaseCheckIssue, DatabaseCheckResult } from '../data/databaseChecker';
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 interface UltimateTodoistSyncSettings {
initialized: boolean;
todoistAPIToken: string;
apiInitialized: boolean;
defaultProjectName: string;
defaultProjectId: string;
automaticSynchronizationInterval: number;
fileMetadata: Record<string, { defaultProjectId?: string }>;
taskFileMapping: {
[taskId: string]: TaskFileMappingEntry;
};
enableFullVaultSync: boolean;
debugMode: boolean;
useAppURI: boolean;
syncEnabled: boolean;
obsidianToTodoistEnabled: boolean;
todoistToObsidianEnabled: boolean;
lastDatabaseCheckTime: number | null;
syncDataCache: Record<string, any> | null;
enableLog: boolean;
logFileEnabled: boolean;
maxLogFileSize: number;
logRetentionPercent: number;
maxBackupsPerFile: number;
storageDirectory: string;
lastStorageDirectory: string | null;
conflictResolutionStrategy: 'todoist-wins' | 'obsidian-wins' | 'manual';
lastFullSyncTime: number | null;
lastDatabaseCheckAutoTime: number | null;
primaryDeviceId: string;
schemaVersion: number;
excludedFolders: string[];
}
export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
initialized: false,
apiInitialized: false,
todoistAPIToken: '',
defaultProjectName: "Inbox",
defaultProjectId: "",
automaticSynchronizationInterval: 300,
fileMetadata: {},
taskFileMapping: {},
enableFullVaultSync: false,
debugMode: false,
useAppURI: true,
syncEnabled: true,
obsidianToTodoistEnabled: true,
todoistToObsidianEnabled: false,
lastDatabaseCheckTime: null,
syncDataCache: null,
enableLog: true,
logFileEnabled: true,
maxLogFileSize: 1024 * 1024,
logRetentionPercent: 80,
maxBackupsPerFile: 100,
storageDirectory: 'ultimate-todoist-sync',
lastStorageDirectory: null,
conflictResolutionStrategy: 'manual',
lastFullSyncTime: null,
lastDatabaseCheckAutoTime: null,
primaryDeviceId: '',
schemaVersion: 1,
excludedFolders: [],
}
export class UltimateTodoistSyncSettingTab extends PluginSettingTab {
plugin: UltimateTodoistSyncForObsidian;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
super(app, plugin);
this.plugin = plugin;
}
private async applyDatabaseIssuesToMapping(result: DatabaseCheckResult): Promise<void> {
const cacheOperation = this.plugin.cacheOperation;
if (!cacheOperation) return;
await cacheOperation.applyDatabaseCheckerIssues(result.issues as DatabaseCheckIssue[], true);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Ultimate Todoist Sync Settings', cls: 'uts-settings-title' });
// ============================================
// API Configuration Section
// ============================================
containerEl.createEl('h3', { text: 'API Configuration', cls: 'uts-section-heading' });
new Setting(containerEl)
.setName('Todoist API Token')
.setDesc('Enter your Todoist API token and click Connect.')
.addText((text) => {
text
.setPlaceholder('Enter your API token')
.setValue(this.plugin.settings.todoistAPIToken);
text.inputEl.type = 'password';
text.inputEl.addEventListener('blur', async () => {
const value = text.inputEl.value;
if (value !== this.plugin.settings.todoistAPIToken) {
await this.plugin.safeSettings?.update({ todoistAPIToken: value, apiInitialized: false });
}
});
})
.addExtraButton((button) => {
button.setIcon('eye')
.setTooltip('Toggle token visibility')
.onClick(() => {
const settingEl = button.extraSettingsEl.closest('.setting-item');
const inputEl = settingEl?.querySelector('input') as HTMLInputElement | null;
if (inputEl) {
const isHidden = inputEl.type === 'password';
inputEl.type = isHidden ? 'text' : 'password';
button.setIcon(isHidden ? 'eye-off' : 'eye');
}
});
})
.addButton((button) => {
button.setButtonText('Connect');
button.onClick(async () => {
// Save token from input before connecting (in case blur hasn't fired)
const settingEl = button.buttonEl.closest('.setting-item');
const inputEl = settingEl?.querySelector('input') as HTMLInputElement | null;
if (inputEl) {
const value = inputEl.value;
if (value !== this.plugin.settings.todoistAPIToken) {
await this.plugin.safeSettings?.update({ todoistAPIToken: value, apiInitialized: false });
}
}
button.setButtonText('Connecting...');
button.setDisabled(true);
try {
const result = await this.plugin.modifyTodoistAPI(this.plugin.settings.todoistAPIToken);
if (result) {
button.setButtonText('✓ Connected');
} else {
button.setButtonText('✗ Failed');
new Notice('Failed to connect to Todoist. Please check your API token.');
}
} catch (error) {
button.setButtonText('✗ Error');
new Notice(`Connection error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
button.setDisabled(false);
setTimeout(() => {
button.setButtonText('Connect');
}, 2000);
}
this.display();
});
});
// ============================================
// Sync Settings Section
// ============================================
containerEl.createEl('h3', { text: 'Sync Settings', cls: 'uts-section-heading' });
let intervalInputEl: HTMLInputElement | null = null;
new Setting(containerEl)
.setName('Automatic Sync Interval')
.setDesc('Set interval in seconds (minimum 20). Example: 300 = every 5 minutes. Click Apply to save.')
.addText((text) => {
text
.setPlaceholder('e.g. 300')
.setValue(this.plugin.settings.automaticSynchronizationInterval.toString());
text.inputEl.type = 'number';
text.inputEl.min = '20';
text.inputEl.step = '1';
intervalInputEl = text.inputEl;
})
.addButton((button) => {
button.setButtonText('Apply');
button.onClick(async () => {
const rawValue = intervalInputEl?.value?.trim() ?? '';
const intervalNum = Number(rawValue);
if (rawValue === '' || Number.isNaN(intervalNum)) {
new Notice('Please enter a valid number.');
return;
}
if (!Number.isInteger(intervalNum)) {
new Notice('Please enter an integer.');
return;
}
if (intervalNum < 20) {
new Notice('Minimum interval is 20 seconds.');
return;
}
if (intervalNum === this.plugin.settings.automaticSynchronizationInterval) {
new Notice('Sync interval unchanged.');
return;
}
button.setButtonText('Applying...');
button.setDisabled(true);
try {
await this.plugin.safeSettings?.update({ automaticSynchronizationInterval: intervalNum }, true);
this.plugin.restartSyncSchedulerInterval();
new Notice('Sync interval updated and applied.');
} finally {
button.setDisabled(false);
button.setButtonText('Apply');
}
});
});
const myProjectsOptions: Record<string, string> = {};
const projects = this.plugin.todoistSyncAPI?.getSyncData()?.projects || [];
for (const p of projects) {
myProjectsOptions[p.id] = p.name;
}
new Setting(containerEl)
.setName('Default Project')
.setDesc('New tasks will be created in this project.')
.addDropdown((component) => {
if (!myProjectsOptions[this.plugin.settings.defaultProjectId]) {
component.addOption(this.plugin.settings.defaultProjectId, this.plugin.settings.defaultProjectName);
}
component
.addOptions(myProjectsOptions)
.setValue(this.plugin.settings.defaultProjectId)
.onChange(async (value) => {
const project = projects.find((p: any) => p.id === value);
await this.plugin.safeSettings?.update({
defaultProjectId: value,
defaultProjectName: project?.name || value
}, true)
});
});
new Setting(containerEl)
.setName('Full Vault Sync')
.setDesc('Sync all tasks in vault, not just those with #todoist tag.')
.addToggle(component =>
component
.setValue(this.plugin.settings.enableFullVaultSync)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ enableFullVaultSync: value }, true)
new Notice(`Full vault sync ${value ? 'enabled' : 'disabled'}.`)
})
);
// Excluded Folders — summary + Configure button
this.renderExcludedFoldersSummary(containerEl);
new Setting(containerEl)
.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)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ useAppURI: value }, true)
})
);
new Setting(containerEl)
.setName('Conflict Resolution Strategy')
.setDesc('When a task is modified in both Obsidian and Todoist: todoist-wins overwrites Obsidian, obsidian-wins pushes Obsidian to Todoist, manual disables sync until resolved.')
.addDropdown(component =>
component
.addOption('manual', 'Manual (disable sync until resolved)')
.addOption('todoist-wins', 'Todoist wins (overwrite Obsidian)')
.addOption('obsidian-wins', 'Obsidian wins (overwrite Todoist)')
.setValue(this.plugin.settings.conflictResolutionStrategy)
.onChange(async (value: 'todoist-wins' | 'obsidian-wins' | 'manual') => {
await this.plugin.safeSettings?.update({ conflictResolutionStrategy: value }, true);
new Notice(`Conflict strategy set to: ${value}`);
})
);
// ============================================
// Sync Direction Control Section
// ============================================
containerEl.createEl('h3', { text: 'Sync Direction', cls: 'uts-section-heading' });
const syncStatusEl = containerEl.createEl('div', { cls: 'setting-item-description' });
const updateSyncStatus = () => {
const mainEnabled = this.plugin.settings.syncEnabled;
const o2tEnabled = this.plugin.settings.obsidianToTodoistEnabled;
const t2oEnabled = this.plugin.settings.todoistToObsidianEnabled;
let statusText = '';
if (!mainEnabled) {
statusText = '❌ Disabled';
} else {
const o2t = o2tEnabled ? '✅' : '❌';
const t2o = t2oEnabled ? '✅' : '❌';
statusText = `✅ Enabled (O→T: ${o2t}, T→O: ${t2o})`;
}
syncStatusEl.innerHTML = `<div><strong>Status:</strong> ${statusText}</div>`;
};
updateSyncStatus();
new Setting(containerEl)
.setName('Enable Sync')
.setDesc('Master switch for all synchronization.')
.addToggle(component =>
component
.setValue(this.plugin.settings.syncEnabled)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ syncEnabled: value }, true);
updateSyncStatus();
new Notice(`Sync ${value ? 'enabled' : 'disabled'}`);
})
);
new Setting(containerEl)
.setName('Obsidian → Todoist')
.setDesc('Push changes from Obsidian to Todoist.')
.addToggle(component =>
component
.setValue(this.plugin.settings.obsidianToTodoistEnabled)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ obsidianToTodoistEnabled: value }, true);
updateSyncStatus();
new Notice(`Obsidian → Todoist ${value ? 'enabled' : 'disabled'}`);
})
);
new Setting(containerEl)
.setName('Todoist → Obsidian')
.setDesc('⚠️ Reverse sync (Todoist → Obsidian) currently has known bugs and is disabled by default. Enabling is NOT recommended.')
.addToggle(component =>
component
.setValue(this.plugin.settings.todoistToObsidianEnabled)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ todoistToObsidianEnabled: value }, true);
updateSyncStatus();
new Notice(`Todoist → Obsidian ${value ? 'enabled' : 'disabled'}`);
})
);
const reverseSyncWarningEl = containerEl.createEl('div', { cls: 'setting-item-description' });
reverseSyncWarningEl.style.cssText = 'color: var(--text-error); font-weight: 600; margin: 6px 0 12px 0;';
reverseSyncWarningEl.textContent = '⚠️ Warning: Reverse sync may incorrectly overwrite vault data. Keep this switch OFF unless you are actively testing.';
// ============================================
// Device Management Section
// ============================================
containerEl.createEl('h3', { text: 'Device Management', cls: 'uts-section-heading' });
// Device status display
const deviceStatusEl = containerEl.createEl('div', { cls: 'setting-item-description' });
const updateDeviceStatus = () => {
const thisDeviceId = this.plugin.cachedDeviceId || '(loading...)';
const primaryId = this.plugin.settings.primaryDeviceId || '(none)';
const isPrimary = this.plugin.isPrimaryDevice();
const role = isPrimary ? 'Primary' : 'Secondary (read-only)';
const roleIcon = isPrimary ? '\u2705' : '\ud83d\udcf1';
deviceStatusEl.innerHTML = `<div><strong>This Device:</strong> <code>${thisDeviceId}</code></div><div><strong>Primary Device:</strong> <code>${primaryId}</code></div><div><strong>Role:</strong> ${roleIcon} ${role}</div>`;
};
updateDeviceStatus();
new Setting(containerEl)
.setName('Set as Primary Device')
.setDesc('Only the primary device pushes changes to Todoist. Secondary devices are read-only (sync disabled entirely).')
.addButton(button => button
.setButtonText(this.plugin.isPrimaryDevice() ? 'Already Primary' : 'Switch to Primary')
.setDisabled(this.plugin.isPrimaryDevice())
.onClick(async () => {
const deviceId = this.plugin.cachedDeviceId;
if (!deviceId) {
new Notice('Device ID not available yet. Please wait for plugin initialization.');
return;
}
await this.plugin.safeSettings?.update({ primaryDeviceId: deviceId }, true);
updateDeviceStatus();
this.display();
new Notice('This device is now the primary device. Please reload the plugin to activate sync.');
})
);
// ============================================
// Tools Section
// ============================================
containerEl.createEl('h3', { text: 'Tools', cls: 'uts-section-heading' });
new Setting(containerEl)
.setName('Manual Sync')
.setDesc('Manually trigger a sync now.')
.addButton(button => button
.setButtonText('Sync Now')
.onClick(async () => {
if (!this.plugin.settings.apiInitialized) {
new Notice('Please set the Todoist API first')
return
}
try {
await this.plugin.scheduler?.run()
new Notice('Sync completed.')
} catch (error) {
new Notice(`Sync error: ${error}`)
}
})
);
const checkStatusEl = containerEl.createEl('div', { cls: 'setting-item-description' });
const updateCheckStatus = () => {
const lastCheck = this.plugin.settings.lastDatabaseCheckTime
? new Date(this.plugin.settings.lastDatabaseCheckTime).toLocaleString()
: 'Never';
checkStatusEl.innerHTML = `<div><strong>Last Check:</strong> ${lastCheck}</div>`;
};
updateCheckStatus();
new Setting(containerEl)
.setName('Verify Database')
.setDesc('Run a read-only health check across Vault, Todoist, and mapping (match-first). No data will be changed.')
.addButton(button => button
.setButtonText('Run Verification')
.onClick(async () => {
if (!this.plugin.settings.apiInitialized) {
new Notice('Please set the Todoist API first');
return;
}
if (!this.plugin.databaseChecker) {
new Notice('Database checker not initialized');
return;
}
const verifyNotice = new Notice('Verifying database...', 0);
try {
const result = await this.plugin.databaseChecker.checkDatabase((msg) => {
verifyNotice.setMessage(msg);
});
await this.applyDatabaseIssuesToMapping(result);
verifyNotice.hide();
const todoistCount = this.plugin.todoistSyncAPI?.getSyncData()?.items?.length ?? 0;
const vaultCount = Object.keys(this.plugin.settings.taskFileMapping).length;
const status = result.success ? '✅ Healthy' : `⚠️ ${result.totalIssues} issues`;
new Notice(
`Verify complete — ${status}\nTodoist: ${todoistCount} tasks | Vault: ${vaultCount} mapped tasks`,
8000
);
if (result.reportPath) {
new DatabaseReportModal(this.app, this.plugin, result.reportPath).open();
}
} catch (error) {
verifyNotice.hide();
new Notice(`Verify error: ${error instanceof Error ? error.message : String(error)}`);
}
})
);
new Setting(containerEl)
.setName('Fix Database')
.setDesc('Run safe auto-repair for eligible issues only: (A) repair missing/stale mapping when Vault and Todoist already match, (B) mark completed-in-Vault and confirmed-missing-in-Todoist tasks as nonActive. Then re-check and report what still needs manual handling.')
.addButton(button => button
.setButtonText('Run Safe Repair')
.onClick(async () => {
if (!this.plugin.settings.apiInitialized) {
new Notice('Please set the Todoist API first');
return;
}
if (!this.plugin.databaseChecker) {
new Notice('Database checker not initialized');
return;
}
if (!this.plugin.cacheOperation) {
new Notice('Cache operation not initialized');
return;
}
if (!this.plugin.syncLockManager) {
new Notice('Sync lock manager not initialized');
return;
}
const databaseChecker = this.plugin.databaseChecker;
const cacheOperation = this.plugin.cacheOperation;
const syncLockManager = this.plugin.syncLockManager;
let lockAcquired = false;
const progressNotice = new Notice('Step 1/3: Checking database...', 0);
try {
lockAcquired = await syncLockManager.acquireExclusive();
if (!lockAcquired) {
throw new Error('Unable to acquire lock for safe repair. Please retry in a few seconds.');
}
// Step 1: Initial check
const before = await databaseChecker.checkDatabase((msg) => {
progressNotice.setMessage(`Step 1/3: ${msg}`);
});
if (before.success) {
progressNotice.hide();
await this.plugin.safeSettings?.update({
lastDatabaseCheckTime: Date.now()
}, true);
updateSyncStatus();
updateCheckStatus();
new Notice('✅ Database is healthy.');
return;
}
progressNotice.setMessage(`Step 2/3: Found ${before.totalIssues} issues. Applying safe auto-repair...`);
const autoRepairResult = await cacheOperation.applyMatchFirstAutoRepairs(before.issues as DatabaseCheckIssue[], true);
const autoRepairParts: string[] = [];
autoRepairParts.push(`🔧 Mapping repaired: ${autoRepairResult.mappingRepaired}`);
autoRepairParts.push(`📋 Marked nonActive: ${autoRepairResult.nonActiveMarked}`);
if (autoRepairResult.skipped > 0) {
autoRepairParts.push(`⏭️ Skipped: ${autoRepairResult.skipped}`);
}
new Notice(autoRepairParts.join(' · '), 7000);
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;
await this.plugin.safeSettings?.update({
lastDatabaseCheckTime: Date.now()
}, true);
updateSyncStatus();
updateCheckStatus();
if (after.success) {
new Notice(`✅ Fixed ${fixedCount} issues. Database is healthy.`);
} else {
new Notice(
`⚠️ Fixed ${fixedCount} issues via safe auto-repair. ${remainingCount} remain for manual handling.`,
8000
);
}
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)}`);
} finally {
if (lockAcquired) {
syncLockManager.release();
}
}
})
);
new Setting(containerEl)
.setName('Manage Problem Tasks')
.setDesc('Open the task manager to manually resolve unresolved conflicts, stale links, and issue tasks that require decisions.')
.addButton(button => button
.setButtonText('Open Task Manager')
.onClick(() => {
new TaskManagerModal(this.app, this.plugin).open();
})
);
// ============================================
// Logs & Debug Section
// ============================================
containerEl.createEl('h3', { text: 'Logs & Debug', cls: 'uts-section-heading' });
new Setting(containerEl)
.setName('Enable Logging')
.setDesc('Log file modifications and sync operations.')
.addToggle(component =>
component
.setValue(this.plugin.settings.enableLog)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ enableLog: value }, true)
})
);
new Setting(containerEl)
.setName('Debug Mode')
.setDesc('Output detailed logs to console for troubleshooting.')
.addToggle(component =>
component
.setValue(this.plugin.settings.debugMode)
.onChange(async (value) => {
await this.plugin.safeSettings?.update({ debugMode: value }, true)
})
);
new Setting(containerEl)
.setName('View Logs')
.setDesc('Browse, search and filter operation logs.')
.addButton(button => button
.setButtonText('View')
.onClick(() => {
new LogViewerModal(this.app, this.plugin).open();
})
);
// ============================================
// Backup & Recovery Section
// ============================================
containerEl.createEl('h3', { text: 'Backup & Recovery', cls: 'uts-section-heading' });
let storageDirectoryInputEl: HTMLInputElement | null = null;
new Setting(containerEl)
.setName('Storage Directory')
.setDesc('Directory for plugin data storage. Click Apply to confirm and migrate existing data.')
.addText((text) => {
text
.setPlaceholder('ultimate-todoist-sync')
.setValue(this.plugin.settings.storageDirectory);
storageDirectoryInputEl = text.inputEl;
})
.addButton((button) => {
button.setButtonText('Apply');
button.onClick(async () => {
const newDir = storageDirectoryInputEl?.value?.trim() ?? '';
const currentDir = this.plugin.settings.storageDirectory;
if (!newDir) {
new Notice('Directory name cannot be empty');
return;
}
if (newDir === currentDir) {
new Notice('Storage directory unchanged.');
return;
}
const confirmed = confirm(
`Change storage directory from "${currentDir}" to "${newDir}"?\n\n` +
'Existing data will be migrated to the new location.'
);
if (!confirmed) return;
button.setButtonText('Applying...');
button.setDisabled(true);
try {
if (this.plugin.storagePathManager) {
const success = await this.plugin.storagePathManager.migrateToNewDirectory(newDir);
if (success) {
await this.plugin.safeSettings?.update({ storageDirectory: newDir }, true);
new Notice('Storage directory changed. Please reload the plugin.');
} else {
new Notice('Migration failed. Check console for details.');
}
} else {
await this.plugin.safeSettings?.update({ storageDirectory: newDir }, true);
new Notice('Storage directory updated. Please reload the plugin.');
}
} finally {
button.setDisabled(false);
button.setButtonText('Apply');
}
});
});
new Setting(containerEl)
.setName('Backup Todoist Data')
.setDesc('Backup all Todoist data to vault.')
.addButton(button => button
.setButtonText('Backup')
.onClick(async () => {
if (!this.plugin.settings.apiInitialized) {
new Notice('Please set the Todoist API first')
return
}
if (!this.plugin.todoistToObsidian) {
new Notice('Todoist backup module not initialized');
return;
}
const todoistToObsidian = this.plugin.todoistToObsidian;
button.setButtonText('Backing up...');
button.setDisabled(true);
try {
await todoistToObsidian.backupTodoistAllResources();
new Notice('Todoist data backup completed.');
} catch (error) {
new Notice(`Backup failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
button.setButtonText('Backup');
button.setDisabled(false);
}
})
);
new Setting(containerEl)
.setName('Backup Settings')
.setDesc('Manually backup current settings.')
.addButton(button => button
.setButtonText('Backup')
.onClick(async () => {
if (!this.plugin.settingsBackup) {
new Notice('Settings backup not initialized')
return;
}
const success = await this.plugin.settingsBackup.backup();
new Notice(success ? 'Settings backed up.' : 'Backup failed.');
})
);
new Setting(containerEl)
.setName('Restore Settings')
.setDesc('Restore settings from latest backup.')
.addButton(button => button
.setButtonText('Restore')
.onClick(async () => {
if (!this.plugin.safeSettings) {
new Notice('Settings backup not initialized')
return;
}
const success = await this.plugin.safeSettings.restoreFromLatestBackup();
if (success) {
new Notice('Settings restored and reloaded.');
}
})
);
new Setting(containerEl)
.setName('View Backup History')
.setDesc('View available settings backups.')
.addButton(button => button
.setButtonText('View')
.onClick(async () => {
if (!this.plugin.settingsBackup) {
new Notice('Not initialized')
return;
}
const backups = await this.plugin.settingsBackup.getBackupList();
if (backups.length === 0) {
new Notice('No backups found');
return;
}
let msg = 'Backups:\n';
backups.slice(0, 5).forEach((b, i) => {
msg += `${i + 1}. ${b.split('/').pop()}\n`;
});
new Notice(msg, 8000);
})
);
new Setting(containerEl)
.setName('Reset Settings')
.setDesc('Reset all settings to defaults. WARNING: Will lose all task mappings!')
.addButton(button => {
button.setButtonText('Reset');
button.setWarning();
button.onClick(async () => {
const confirmed = confirm('Reset ALL settings to defaults? All task mappings will be lost!');
if (!confirmed) return;
await this.plugin.safeSettings?.reset();
new Notice('Settings reset. Please reload the plugin.');
});
});
}
private renderExcludedFoldersSummary(containerEl: HTMLElement): void {
const excluded = this.plugin.settings.excludedFolders;
const setting = new Setting(containerEl)
.setName('Excluded Folders from Sync')
.addButton(btn => btn
.setButtonText('Configure')
.onClick(() => {
new ExcludedFoldersModal(this.app, this.plugin, () => {
this.display();
}).open();
})
);
if (excluded.length === 0) {
setting.setDesc('All folders are included in sync. Click Configure to exclude specific folders.');
} else {
setting.setDesc(`${excluded.length} folder(s) excluded from Todoist sync.`);
const listEl = setting.settingEl.createDiv({ cls: 'uts-excluded-folders-summary' });
for (const folder of excluded) {
listEl.createDiv({ text: folder, cls: 'uts-excluded-folders-summary-item' });
}
}
}
}

193
src/storage/backup.ts Normal file
View file

@ -0,0 +1,193 @@
import { App } from 'obsidian';
import UltimateTodoistSyncForObsidian from "../../main";
import { StoragePathManager } from './pathManager';
export class BackupOperation {
app: App;
plugin: UltimateTodoistSyncForObsidian;
private maxBackupsPerFile = 100;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
this.maxBackupsPerFile = plugin.settings.maxBackupsPerFile || 100;
}
async ensureBackupFolderExists(): Promise<void> {
if (!this.plugin.storagePathManager) {
console.warn('[BackupOperation] StoragePathManager not initialized');
return;
}
await this.plugin.storagePathManager.ensureAllDirs();
}
getBackupFolder(): string {
return this.plugin.storagePathManager?.getBackupsFilesPath() || 'ultimate-todoist-sync/backups/files';
}
async backupFile(filePath: string): Promise<string | null> {
try {
await this.ensureBackupFolderExists();
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
this.plugin.debugLog(`File not found: ${filePath}`);
return null;
}
const content = await this.app.vault.read(file as any);
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const fileName = filePath.replace(/[/\\]/g, '_');
const tempFileName = `${fileName}-${timestamp}.tmp`;
const backupFileName = `${fileName}-${timestamp}.md.bak`;
const tempPath = `${this.getBackupFolder()}/${tempFileName}`;
const backupPath = `${this.getBackupFolder()}/${backupFileName}`;
const adapter = this.app.vault.adapter;
await adapter.write(tempPath, content);
const tempExists = await adapter.exists(tempPath);
if (!tempExists) {
throw new Error('Temp backup file was not created');
}
await adapter.write(backupPath, content);
const verifyExists = await adapter.exists(backupPath);
if (!verifyExists) {
throw new Error('Backup file verification failed');
}
try {
await adapter.remove(tempPath);
} catch (cleanupError) {
console.warn('[BackupOperation] Failed to cleanup temp file:', cleanupError);
}
await this.cleanOldBackups(filePath);
this.plugin.logOperation?.log('BACKUP_CREATED', `Backed up file before modification: ${filePath}`, filePath);
return backupPath;
} catch (error) {
console.error(`Failed to backup file ${filePath}:`, error);
return null;
}
}
private async cleanOldBackups(originalFilePath: string): Promise<void> {
try {
const fileName = originalFilePath.replace(/[/\\]/g, '_');
const prefix = `${fileName}-`;
const backupFolderPath = this.getBackupFolder();
const backupFolder = this.app.vault.getAbstractFileByPath(backupFolderPath);
if (!backupFolder) return;
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupFolderPath) && f.name.startsWith(prefix))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
if (files.length > this.maxBackupsPerFile) {
const filesToDelete = files.slice(this.maxBackupsPerFile);
for (const file of filesToDelete) {
await this.app.vault.delete(file);
}
}
} catch (error) {
console.error(`Failed to clean old backups for ${originalFilePath}:`, error);
}
}
async getBackupList(filePath?: string): Promise<string[]> {
try {
const backups: string[] = [];
const backupFolderPath = this.getBackupFolder();
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupFolderPath));
for (const file of files) {
if (filePath) {
const fileName = filePath.replace(/[/\\]/g, '_');
if (file.name.startsWith(`${fileName}-`)) {
backups.push(file.path);
}
} else {
backups.push(file.path);
}
}
return backups.sort().reverse();
} catch (error) {
console.error('Failed to get backup list:', error);
return [];
}
}
async restoreFromBackup(backupPath: string): Promise<boolean> {
try {
const backupFile = this.app.vault.getAbstractFileByPath(backupPath);
if (!backupFile) {
this.plugin.debugLog(`Backup file not found: ${backupPath}`);
return false;
}
const content = await this.app.vault.read(backupFile as any);
if (!this.isValidMarkdown(content)) {
console.error(`Backup file contains invalid content: ${backupPath}`);
return false;
}
const fileName = backupFile.name.replace(/-\d{8}-\d{6}\.md(\.bak)?$/, '').replace(/_/g, '/');
const originalPath = fileName.replace('.md', '');
const originalFile = this.app.vault.getAbstractFileByPath(originalPath);
if (!originalFile) {
this.plugin.debugLog(`Original file not found: ${originalPath}`);
return false;
}
await this.app.vault.modify(originalFile as any, content);
this.plugin.logOperation?.log('BACKUP_CREATED', `Restored file from backup: ${originalPath}`, originalPath);
return true;
} catch (error) {
console.error(`Failed to restore from backup:`, error);
return false;
}
}
private isValidMarkdown(content: string): boolean {
if (!content || typeof content !== 'string') {
return false;
}
if (content.length === 0) {
return true;
}
return true;
}
async clearAllBackups(): Promise<void> {
try {
const backupFolderPath = this.getBackupFolder();
const files = this.app.vault.getFiles()
.filter(f => f.path.startsWith(backupFolderPath));
for (const file of files) {
await this.app.vault.delete(file);
}
const backupFolder = this.app.vault.getAbstractFileByPath(backupFolderPath);
if (backupFolder) {
await this.app.vault.delete(backupFolder);
}
this.plugin.logOperation?.log('BACKUP_CREATED', 'All backups cleared');
} catch (error) {
console.error('Failed to clear backups:', error);
}
}
}

181
src/storage/log.ts Normal file
View file

@ -0,0 +1,181 @@
import { App } from 'obsidian';
import UltimateTodoistSyncForObsidian from "../../main";
export type SyncDirection = 'obsidian→todoist' | 'todoist→obsidian';
export interface LogEntry {
timestamp: number;
action: string;
details: string;
filePath?: string;
taskId?: string;
direction?: SyncDirection;
}
export class LogOperation {
private app: App;
private plugin: UltimateTodoistSyncForObsidian;
private memoryLogs: LogEntry[] = [];
private unsavedLogs: LogEntry[] = [];
private isFlushing = false;
private readonly BATCH_SIZE = 20;
private readonly MAX_MEMORY_LOGS = 500;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
async loadFromFile(): Promise<void> {
if (!this.plugin.settings.logFileEnabled || !this.plugin.storagePathManager) {
return;
}
try {
const logPath = this.plugin.storagePathManager.getLogFilePath();
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(logPath);
if (!exists) return;
const content = await adapter.read(logPath);
if (!content) return;
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
this.memoryLogs = data.slice(-this.MAX_MEMORY_LOGS);
}
} catch {
this.memoryLogs = [];
}
} catch (error) {
console.error('[LogOperation] Failed to load logs from file:', error);
}
}
log(action: string, details: string, filePath?: string, taskId?: string, direction?: SyncDirection): void {
if (!this.plugin.settings.enableLog) {
return;
}
const logEntry: LogEntry = {
timestamp: Date.now(),
action,
details,
filePath,
taskId,
direction
};
this.memoryLogs.push(logEntry);
if (this.memoryLogs.length > this.MAX_MEMORY_LOGS) {
this.memoryLogs = this.memoryLogs.slice(-this.MAX_MEMORY_LOGS);
}
if (this.plugin.settings.logFileEnabled) {
this.unsavedLogs.push(logEntry);
if (this.unsavedLogs.length >= this.BATCH_SIZE) {
this.flushToFile();
}
}
if (this.plugin.settings.debugMode) {
this.plugin.debugLog(`[Log] ${action}: ${details}`, { filePath, taskId });
}
}
public async flushToFile(): Promise<void> {
if (!this.plugin.settings.logFileEnabled || !this.plugin.storagePathManager) {
this.unsavedLogs = [];
return;
}
if (this.isFlushing) return;
this.isFlushing = true;
try {
await this.plugin.storagePathManager.ensureDir(
this.plugin.storagePathManager.getLogsBasePath()
);
const logPath = this.plugin.storagePathManager.getLogFilePath();
const adapter = this.app.vault.adapter;
const toFlush = this.unsavedLogs;
this.unsavedLogs = [];
if (toFlush.length === 0) return;
const fileLogs = await this.readLogsFromDisk(adapter, logPath);
fileLogs.push(...toFlush);
const maxSize = this.plugin.settings.maxLogFileSize || (1024 * 1024);
let output = JSON.stringify(fileLogs);
if (output.length > maxSize) {
const retentionPercent = this.plugin.settings.logRetentionPercent || 80;
const keepCount = Math.floor(fileLogs.length * retentionPercent / 100);
const trimmed = fileLogs.slice(-keepCount);
output = JSON.stringify(trimmed);
}
await adapter.write(logPath, output);
} catch (error) {
console.error('[LogOperation] Failed to flush logs to file:', error);
} finally {
this.isFlushing = false;
}
}
private async readLogsFromDisk(adapter: any, logPath: string): Promise<LogEntry[]> {
try {
const exists = await adapter.exists(logPath);
if (!exists) return [];
const content = await adapter.read(logPath);
if (!content) return [];
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async clearLogs(): Promise<void> {
this.memoryLogs = [];
this.unsavedLogs = [];
if (this.plugin.storagePathManager) {
try {
const logPath = this.plugin.storagePathManager.getLogFilePath();
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(logPath);
if (exists) {
await adapter.write(logPath, '[]');
}
} catch (error) {
console.error('[LogOperation] Failed to clear log file:', error);
}
}
}
getLogs(): LogEntry[] {
return [...this.memoryLogs];
}
getLogsAsText(): string {
if (this.memoryLogs.length === 0) {
return 'No logs available.';
}
return this.memoryLogs.map(log => {
const date = new Date(log.timestamp).toLocaleString();
const dirInfo = log.direction ? ` [${log.direction}]` : '';
const fileInfo = log.filePath ? ` [${log.filePath}]` : '';
const taskInfo = log.taskId ? ` (task: ${log.taskId})` : '';
return `[${date}]${dirInfo} ${log.action}: ${log.details}${fileInfo}${taskInfo}`;
}).join('\n');
}
}

445
src/storage/pathManager.ts Normal file
View file

@ -0,0 +1,445 @@
import { App } from 'obsidian';
import UltimateTodoistSyncForObsidian from '../../main';
export class StoragePathManager {
private app: App;
private plugin: UltimateTodoistSyncForObsidian;
private appendLock = false;
static settingsFilePath(manifestDir: string | undefined): string {
const dir = manifestDir || `.obsidian/plugins/ultimate-todoist-sync`;
return `${dir}/data.json`;
}
static settingsTempFilePath(manifestDir: string | undefined): string {
const dir = manifestDir || `.obsidian/plugins/ultimate-todoist-sync`;
return `${dir}/data.json.tmp`;
}
static readonly DEFAULT_BASE_PATH = 'ultimate-todoist-sync';
static readonly LEGACY_BASE_PATH = '.ultimate-todoist-sync';
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
getBasePath(): string {
return this.plugin.settings?.storageDirectory || StoragePathManager.DEFAULT_BASE_PATH;
}
getLogsBasePath(): string {
return `${this.getBasePath()}/logs`;
}
getLogFilePath(): string {
return `${this.getLogsBasePath()}/todoist-sync-logs.json`;
}
getBackupsBasePath(): string {
return `${this.getBasePath()}/backups`;
}
getBackupsFilesPath(): string {
return `${this.getBasePath()}/backups/files`;
}
getBackupsTodoistPath(): string {
return `${this.getBasePath()}/backups/todoist`;
}
getBackupsSettingsPath(): string {
return `${this.getBasePath()}/backups/settings`;
}
getReportsPath(): string {
return `${this.getBasePath()}/reports`;
}
async getLogsPath(): Promise<string> {
const deviceId = await this.plugin.deviceManager?.getDeviceId() || 'unknown';
return `${this.getLogsBasePath()}/${deviceId}`;
}
async getTodayLogFileName(): Promise<string> {
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const deviceId = await this.plugin.deviceManager?.getDeviceId() || 'unknown';
return `${deviceId}_${date}.json`;
}
async getTodayLogPath(): Promise<string> {
const logsPath = await this.getLogsPath();
const fileName = await this.getTodayLogFileName();
return this.joinPath(logsPath, fileName);
}
getBackupFileName(originalPath: string): string {
const timestamp = this.generateTimestamp();
const safePath = originalPath.replace(/[/\\]/g, '_');
return `${safePath}_${timestamp}.md.bak`;
}
getTodoistBackupFileName(): string {
const timestamp = this.generateTimestamp();
return `todoist-data_${timestamp}.json`;
}
getSettingsBackupFileName(): string {
const timestamp = this.generateTimestamp();
return `settings-${timestamp}.json`;
}
private generateTimestamp(): string {
const now = new Date();
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
}
private joinPath(...parts: string[]): string {
return parts.join('/');
}
async ensureDir(path: string): Promise<boolean> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(path);
if (!exists) {
await adapter.mkdir(path);
}
return true;
} catch (error) {
console.error(`[StoragePathManager] Failed to ensure directory ${path}:`, error);
return false;
}
}
async ensureAllDirs(): Promise<boolean> {
const basePath = this.getBasePath();
const dirs = [
basePath,
this.getLogsBasePath(),
`${basePath}/backups/todoist`,
`${basePath}/backups/files`,
`${basePath}/backups/settings`,
this.getReportsPath()
];
for (const dir of dirs) {
const success = await this.ensureDir(dir);
if (!success) {
console.error(`[StoragePathManager] Failed to create directory: ${dir}`);
return false;
}
}
try {
const logsPath = await this.getLogsPath();
const success = await this.ensureDir(logsPath);
if (!success) {
console.error(`[StoragePathManager] Failed to create logs directory: ${logsPath}`);
return false;
}
} catch (error) {
console.error(`[StoragePathManager] Failed to create logs directory:`, error);
return false;
}
return true;
}
async migrateToNewDirectory(newDir: string): Promise<boolean> {
const oldDir = this.plugin.settings?.lastStorageDirectory
|| StoragePathManager.LEGACY_BASE_PATH;
if (oldDir === newDir) {
this.plugin.debugLog('[StoragePathManager] Directory unchanged, no migration needed');
return true;
}
this.plugin.debugLog(`[StoragePathManager] Starting migration from "${oldDir}" to "${newDir}"`);
const adapter = this.app.vault.adapter;
try {
const oldExists = await adapter.exists(oldDir);
if (!oldExists) {
this.plugin.debugLog('[StoragePathManager] Old directory does not exist, no migration needed');
await this.plugin.safeSettings?.update({ lastStorageDirectory: newDir });
return true;
}
const newExists = await adapter.exists(newDir);
if (!newExists) {
await adapter.mkdir(newDir);
}
await this.migrateDirectoryContents(oldDir, newDir);
this.plugin.debugLog('[StoragePathManager] Migration completed successfully');
await this.plugin.safeSettings?.update({ lastStorageDirectory: newDir });
return true;
} catch (error) {
console.error('[StoragePathManager] Migration failed:', error);
return false;
}
}
private async migrateDirectoryContents(srcDir: string, destDir: string): Promise<void> {
const adapter = this.app.vault.adapter;
try {
const result = await adapter.list(srcDir);
for (const folder of result.folders) {
const newFolderPath = folder.replace(srcDir, destDir);
const folderExists = await adapter.exists(newFolderPath);
if (!folderExists) {
await adapter.mkdir(newFolderPath);
}
await this.migrateDirectoryContents(folder, newFolderPath);
}
for (const file of result.files) {
const newFilePath = file.replace(srcDir, destDir);
const content = await adapter.read(file);
const destExists = await adapter.exists(newFilePath);
if (destExists) {
const destContent = await adapter.read(newFilePath);
const srcMtime = await this.getFileMtime(file);
const destMtime = await this.getFileMtime(newFilePath);
if (srcMtime > destMtime) {
await adapter.write(newFilePath, content);
this.plugin.debugLog(`[StoragePathManager] Updated file: ${newFilePath}`);
} else {
this.plugin.debugLog(`[StoragePathManager] Kept newer file: ${newFilePath}`);
}
} else {
await adapter.write(newFilePath, content);
this.plugin.debugLog(`[StoragePathManager] Migrated file: ${newFilePath}`);
}
await adapter.remove(file);
}
const remaining = await adapter.list(srcDir);
if (remaining.folders.length === 0 && remaining.files.length === 0) {
await adapter.rmdir(srcDir, true);
this.plugin.debugLog(`[StoragePathManager] Removed empty directory: ${srcDir}`);
}
} catch (error) {
console.error('[StoragePathManager] Error during migration:', error);
throw error;
}
}
private async getFileMtime(path: string): Promise<number> {
try {
const file = this.app.vault.getAbstractFileByPath(path);
if (file && 'stat' in file) {
return file.stat.mtime;
}
return 0;
} catch {
return 0;
}
}
async readJsonFile<T>(path: string): Promise<T | null> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(path);
if (!exists) {
return null;
}
const content = await adapter.read(path);
return JSON.parse(content) as T;
} catch (error) {
console.error(`[StoragePathManager] Failed to read JSON file ${path}:`, error);
return null;
}
}
async writeJsonFile<T>(path: string, data: T): Promise<boolean> {
try {
const adapter = this.app.vault.adapter;
await adapter.write(path, JSON.stringify(data, null, 2));
return true;
} catch (error) {
console.error(`[StoragePathManager] Failed to write JSON file ${path}:`, error);
return false;
}
}
async writeJsonFileAtomic<T>(path: string, data: T): Promise<boolean> {
const tempPath = `${path}.tmp`;
const jsonContent = JSON.stringify(data, null, 2);
const adapter = this.app.vault.adapter;
try {
await adapter.write(tempPath, jsonContent);
const exists = await adapter.exists(tempPath);
if (!exists) {
throw new Error('Temp file was not created');
}
await adapter.write(path, jsonContent);
try {
await adapter.remove(tempPath);
} catch (cleanupError) {
console.warn(`[StoragePathManager] Failed to cleanup temp file:`, cleanupError);
}
return true;
} catch (error) {
console.error(`[StoragePathManager] Atomic write failed for ${path}:`, error);
return false;
}
}
async appendJsonToFile(path: string, newData: unknown): Promise<boolean> {
if (this.appendLock) {
console.warn('[StoragePathManager] Append in progress, skipping...');
return false;
}
this.appendLock = true;
try {
const adapter = this.app.vault.adapter;
let data: unknown[] = [];
const exists = await adapter.exists(path);
if (exists) {
const content = await adapter.read(path);
try {
data = JSON.parse(content);
if (!Array.isArray(data)) {
data = [];
}
} catch {
data = [];
}
}
data.push(newData);
await adapter.write(path, JSON.stringify(data, null, 2));
this.appendLock = false;
return true;
} catch (error) {
console.error(`[StoragePathManager] Failed to append to JSON file ${path}:`, error);
this.appendLock = false;
return false;
}
}
async listFiles(dirPath: string): Promise<string[]> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(dirPath);
if (!exists) {
return [];
}
const result = await adapter.list(dirPath);
return result.files;
} catch (error) {
console.error(`[StoragePathManager] Failed to list files in ${dirPath}:`, error);
return [];
}
}
async listFolders(dirPath: string): Promise<string[]> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(dirPath);
if (!exists) {
return [];
}
const result = await adapter.list(dirPath);
return result.folders;
} catch (error) {
console.error(`[StoragePathManager] Failed to list folders in ${dirPath}:`, error);
return [];
}
}
async deleteFile(path: string): Promise<boolean> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(path);
if (exists) {
await adapter.remove(path);
}
return true;
} catch (error) {
console.error(`[StoragePathManager] Failed to delete file ${path}:`, error);
return false;
}
}
async getFileSize(path: string): Promise<number> {
try {
const adapter = this.app.vault.adapter;
const exists = await adapter.exists(path);
if (!exists) {
return 0;
}
const content = await adapter.read(path);
return content.length;
} catch (error) {
console.error(`[StoragePathManager] Failed to get file size ${path}:`, error);
return 0;
}
}
async getDirSize(dirPath: string): Promise<number> {
try {
const files = await this.listFiles(dirPath);
let totalSize = 0;
for (const filePath of files) {
const size = await this.getFileSize(filePath);
totalSize += size;
}
return totalSize;
} catch (error) {
console.error(`[StoragePathManager] Failed to get directory size ${dirPath}:`, error);
return 0;
}
}
async cleanOldBackups(dirPath: string, maxCount: number, namePrefix?: string): Promise<void> {
try {
let files = await this.listFiles(dirPath);
if (namePrefix) {
files = files.filter(f => {
const fileName = f.split('/').pop() || '';
return fileName.startsWith(namePrefix);
});
}
if (files.length <= maxCount) {
return;
}
const fileObjects = await Promise.all(
files.map(async path => ({
path,
mtime: await this.getFileMtime(path)
}))
);
fileObjects.sort((a, b) => b.mtime - a.mtime);
const filesToDelete = fileObjects.slice(maxCount);
for (const file of filesToDelete) {
await this.deleteFile(file.path);
this.plugin.debugLog(`[StoragePathManager] Deleted old backup: ${file.path}`);
}
} catch (error) {
console.error(`[StoragePathManager] Failed to clean old backups in ${dirPath}:`, error);
}
}
}

112
src/sync/scheduler.ts Normal file
View file

@ -0,0 +1,112 @@
import UltimateTodoistSyncForObsidian from '../../main';
export class SyncScheduler {
private plugin: UltimateTodoistSyncForObsidian;
private inProgress = false;
constructor(plugin: UltimateTodoistSyncForObsidian) {
this.plugin = plugin;
}
async run(): Promise<void> {
if (this.inProgress) {
this.plugin.debugLog('Scheduled sync already in progress, skipping');
return;
}
if (!await this.plugin.checkModuleClass()) return;
this.inProgress = true;
this.plugin.debugLog('Todoist scheduled synchronization task started at', new Date().toLocaleString());
try {
// Periodic full sync: every 24h, reset sync_token to force full sync
const FULL_SYNC_INTERVAL = 24 * 60 * 60 * 1000;
const lastFullSync = this.plugin.settings.lastFullSyncTime || 0;
if (Date.now() - lastFullSync > FULL_SYNC_INTERVAL) {
this.plugin.debugLog('Periodic full sync triggered');
try {
await this.plugin.todoistSyncAPI.initializeSync();
await this.plugin.safeSettings?.update({ lastFullSyncTime: Date.now() }, true);
} catch (error) {
console.error('[Scheduler] Periodic full sync failed:', error);
}
}
await this.plugin.syncLockManager.run('todoistToObsidian', async () => {
await this.plugin.todoistToObsidian!.syncTodoistToObsidian();
});
await this.plugin.saveSettings();
await new Promise(resolve => setTimeout(resolve, 5000));
const filesToSync = this.getUniqueFiles();
if (this.plugin.settings.debugMode) {
this.plugin.debugLog('Files to sync:', filesToSync);
}
for (const fileKey of filesToSync) {
if (this.plugin.settings.debugMode) {
this.plugin.debugLog('Syncing file:', fileKey);
}
const lockOk = await this.plugin.syncLockManager.run('obsidianToTodoist', async () => {
await this.plugin.obsidianToTodoist!.fullTextNewTaskCheck(fileKey);
});
if (!lockOk) {
this.plugin.debugLog(`[Scheduler] Skipping file sync for ${fileKey}: lock not acquired`);
continue;
}
await this.plugin.syncLockManager.run('obsidianToTodoist', async () => {
await this.plugin.obsidianToTodoist!.deletedTaskCheck(fileKey);
});
await this.plugin.syncLockManager.run('obsidianToTodoist', async () => {
await this.plugin.obsidianToTodoist!.fullTextModifiedTaskCheck(fileKey);
});
}
// Periodic database check: every 72h, run three-way consistency check
const DB_CHECK_INTERVAL = 72 * 60 * 60 * 1000;
const lastDbCheck = this.plugin.settings.lastDatabaseCheckAutoTime || 0;
if (Date.now() - lastDbCheck > DB_CHECK_INTERVAL) {
this.plugin.debugLog('Periodic database check triggered');
try {
if (this.plugin.databaseChecker) {
await this.plugin.databaseChecker.checkDatabase();
await this.plugin.safeSettings?.update({ lastDatabaseCheckAutoTime: Date.now() }, true);
}
} catch (error) {
console.error('[Scheduler] Periodic database check failed:', error);
}
}
} catch (error) {
console.error('An error occurred during scheduled sync:', error);
} finally {
try {
await this.plugin.logOperation?.flushToFile();
} catch (error) {
console.error('An error occurred in flushToFile:', error);
}
this.inProgress = false;
this.plugin.debugLog('Todoist scheduled synchronization task completed at', new Date().toLocaleString());
}
}
private getUniqueFiles(): string[] {
const seen = new Set<string>();
for (const entry of Object.values(this.plugin.settings.taskFileMapping)) {
seen.add(entry.filePath);
}
// Full Vault Sync: include all vault .md files not yet in taskFileMapping
if (this.plugin.settings.enableFullVaultSync && this.plugin.fileOperation) {
for (const file of this.plugin.app.vault.getMarkdownFiles()) {
if (this.plugin.fileOperation.isFileExcludedFromSync(file.path)) continue;
seen.add(file.path);
}
}
return Array.from(seen);
}
}

130
src/sync/syncLock.ts Normal file
View file

@ -0,0 +1,130 @@
import UltimateTodoistSyncForObsidian from '../../main';
export type SyncDirection = 'obsidianToTodoist' | 'todoistToObsidian';
export class SyncLockManager {
private plugin: UltimateTodoistSyncForObsidian;
private locked = false;
constructor(plugin: UltimateTodoistSyncForObsidian) {
this.plugin = plugin;
}
get isLocked(): boolean {
return this.locked;
}
release(): void {
this.locked = false;
}
async waitForRelease(): Promise<boolean> {
let attempts = 0;
while (this.locked && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
const released = !this.locked;
if (!released) {
console.warn('[SyncLock] Sync lock acquire FAILED after 10s timeout');
this.plugin.debugLog('[SyncLock] Sync lock acquire FAILED after 10s timeout');
this.plugin.logOperation?.log('SYNC_LOCK_TIMEOUT', 'Sync lock acquire failed after 10s timeout');
}
return released;
}
async waitForSaveRelease(): Promise<boolean> {
let attempts = 0;
while (this.plugin.saveLock && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
const released = !this.plugin.saveLock;
if (!released) {
console.warn('[SyncLock] Save lock acquire FAILED after 5s timeout');
this.plugin.debugLog('[SyncLock] Save lock acquire FAILED after 5s timeout');
this.plugin.logOperation?.log('SYNC_LOCK_TIMEOUT', 'Save lock acquire failed after 5s timeout');
}
return released;
}
async acquireExclusive(): Promise<boolean> {
if (this.plugin.saveLock) {
this.plugin.debugLog('save locked, waiting before exclusive sync acquire.');
const saveReleased = await this.waitForSaveRelease();
if (!saveReleased) {
this.plugin.debugLog('save lock did not release in time.');
return false;
}
}
if (this.locked) {
this.plugin.debugLog('sync locked. waiting for exclusive acquire.');
const released = await this.waitForRelease();
if (!released) {
console.warn('[SyncLock] Exclusive sync lock acquire FAILED after timeout');
this.plugin.debugLog('[SyncLock] Exclusive sync lock acquire FAILED after timeout');
return false;
}
this.plugin.debugLog('sync unlocked.');
}
this.locked = true;
return true;
}
async acquire(direction?: SyncDirection): Promise<boolean> {
if (!this.plugin.settings.syncEnabled) {
this.plugin.debugLog('Sync is disabled by user (main switch off)');
return false;
}
if (direction === 'obsidianToTodoist' && !this.plugin.settings.obsidianToTodoistEnabled) {
this.plugin.debugLog('Obsidian → Todoist sync is disabled by user');
return false;
}
if (direction === 'todoistToObsidian' && !this.plugin.settings.todoistToObsidianEnabled) {
this.plugin.debugLog('Todoist → Obsidian sync is disabled by user');
return false;
}
if (this.plugin.saveLock) {
this.plugin.debugLog('save locked, waiting before sync acquire.');
const saveReleased = await this.waitForSaveRelease();
if (!saveReleased) {
this.plugin.debugLog('save lock did not release in time.');
return false;
}
}
if (this.locked) {
this.plugin.debugLog('sync locked.');
const released = await this.waitForRelease();
if (!released) {
console.warn(`[SyncLock] Sync lock acquire FAILED for direction=${direction || 'none'}`);
this.plugin.debugLog(`[SyncLock] Sync lock acquire FAILED for direction=${direction || 'none'}`);
return false;
}
this.plugin.debugLog('sync unlocked.');
}
this.locked = true;
return true;
}
async run(direction: SyncDirection, fn: () => Promise<void>): Promise<boolean> {
if (!await this.acquire(direction)) return false;
try {
await fn();
} catch (error) {
console.error(`Error during ${direction} sync:`, error);
} finally {
this.release();
}
return true;
}
}

246
src/sync/toObsidian.ts Normal file
View file

@ -0,0 +1,246 @@
import UltimateTodoistSyncForObsidian from "../../main";
import { App, Notice } from 'obsidian';
import { StoragePathManager } from '../storage/pathManager';
export class TodoistToObsidianSync {
app: App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
async syncTodoistToObsidian(): Promise<void> {
try {
this.plugin.logOperation?.log('SYNC_START', 'Starting sync from Todoist to Obsidian', undefined, undefined, 'todoist→obsidian');
await this.plugin.todoistSyncAPI.incrementalSync();
const syncData = this.plugin.todoistSyncAPI.getSyncData();
if (!syncData?.items) {
this.plugin.debugLog('[Todoist→Obsidian] No sync data available');
return;
}
const itemMap = new Map<string, any>();
for (const item of syncData.items) {
itemMap.set(item.id, item);
}
const noteMap = new Map<string, any[]>();
if (syncData.notes) {
for (const note of syncData.notes) {
const taskId = note.item_id;
if (!noteMap.has(taskId)) noteMap.set(taskId, []);
noteMap.get(taskId)!.push(note);
}
}
const taskFileMapping = this.plugin.settings.taskFileMapping || {};
let syncedCount = 0;
// Set flag: file writes below are from Todoist pull, not user edits
this.plugin.isSyncingFromTodoist = true;
try {
for (const taskId of Object.keys(taskFileMapping)) {
const mapping = taskFileMapping[taskId];
if (mapping.syncEnabled === false) continue;
const task = itemMap.get(taskId);
if (!task || task.is_deleted) {
if (this.plugin.settings.debugMode) {
this.plugin.debugLog(`[Todoist→Obsidian] Task ${taskId} deleted or not found in sync data`);
}
continue;
}
if (task.updated_at === mapping.updated_at) {
continue;
}
if (this.plugin.settings.debugMode) {
this.plugin.debugLog(`[Todoist→Obsidian] Task ${taskId} changed: ${mapping.updated_at}${task.updated_at}`);
}
try {
await this.syncSingleTaskToObsidian(taskId, task);
syncedCount++;
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, {
updated_at: task.updated_at,
note_count: task.note_count || 0
});
} catch (error) {
console.error(`[Todoist→Obsidian] Error syncing task ${taskId}:`, error);
}
}
await this.syncNotesToObsidian(taskFileMapping, noteMap);
} finally {
this.plugin.isSyncingFromTodoist = false;
}
if (syncedCount > 0) {
this.plugin.logOperation?.log('SYNC_COMPLETED', `Synced ${syncedCount} tasks from Todoist to Obsidian`);
}
} catch (err) {
console.error('An error occurred while synchronizing:', err);
this.plugin.logOperation?.log('SYNC_ERROR', `Sync failed: ${(err as Error).message}`, undefined, undefined, 'todoist→obsidian');
new Notice(`Todoist sync failed: ${(err as Error).message}`);
}
}
private async syncSingleTaskToObsidian(taskId: string, task: any): Promise<void> {
const mapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (!mapping) {
console.warn(`[syncSingleTaskToObsidian] No mapping found for task ${taskId}`);
this.plugin.debugLog(`[syncSingleTaskToObsidian] No mapping found for task ${taskId}`);
this.plugin.logOperation?.log('SYNC_TARGET_MISSING', `Todoist→Obsidian sync skipped: no mapping for task ${taskId}`, undefined, taskId);
return;
}
const file = this.app.vault.getAbstractFileByPath(mapping.filePath);
if (!file) {
console.warn(`[syncSingleTaskToObsidian] File not found: ${mapping.filePath} (task ${taskId})`);
this.plugin.debugLog(`[syncSingleTaskToObsidian] File not found: ${mapping.filePath} (task ${taskId})`);
this.plugin.logOperation?.log('SYNC_TARGET_MISSING', `Todoist→Obsidian sync skipped: file not found ${mapping.filePath}`, mapping.filePath, taskId);
return;
}
const fileContent = await this.app.vault.read(file);
const lines = fileContent.split('\n');
let taskLine = '';
for (const line of lines) {
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
taskLine = line;
break;
}
}
if (!taskLine) {
console.warn(`[syncSingleTaskToObsidian] Task line not found in file for task ${taskId}`);
this.plugin.debugLog(`[syncSingleTaskToObsidian] Task line not found in file for task ${taskId}`);
this.plugin.logOperation?.log('SYNC_TARGET_MISSING', `Todoist→Obsidian sync skipped: task line not found in file`, mapping.filePath, taskId);
return;
}
const obsidianIsChecked = /\[(x|X)\]/.test(taskLine);
const todoistIsChecked = task.checked || false;
if (todoistIsChecked && !obsidianIsChecked) {
await this.plugin.fileOperation.completeTaskInTheFile(taskId);
new Notice(`Task ${taskId} completed from Todoist`);
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Task completed in Todoist: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
} else if (!todoistIsChecked && obsidianIsChecked) {
await this.plugin.fileOperation.uncompleteTaskInTheFile(taskId);
new Notice(`Task ${taskId} reopened from Todoist`);
this.plugin.logOperation?.log('TODOIST_TASK_REOPENED', `Task reopened in Todoist: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
}
const obsidianContent = this.plugin.taskParser.getTaskContentFromLineText(taskLine);
if (obsidianContent && task.content && obsidianContent !== task.content) {
await this.plugin.fileOperation.syncTaskContentToFile(taskId, task.content);
this.plugin.logOperation?.log('FILE_TASK_CONTENT_SYNCED', `Synced content: ${taskId}`, mapping.filePath, taskId, 'todoist→obsidian');
}
const obsidianDueDate = this.plugin.taskParser.getDueDateFromLineText(taskLine) || "";
const todoistDueDate = task.due?.date ? (this.plugin.taskParser.ISOStringToLocalDateString(task.due.date) || "") : "";
if (obsidianDueDate !== todoistDueDate) {
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(
taskFileMapping: Record<string, { note_count?: number; syncEnabled?: boolean }>,
noteMap: Map<string, any[]>
): Promise<void> {
for (const [taskId, notes] of noteMap.entries()) {
const mapping = taskFileMapping[taskId];
if (!mapping) continue;
if (mapping.syncEnabled === false) continue;
const storedNoteCount = mapping.note_count || 0;
if (notes.length <= storedNoteCount) continue;
const sortedNotes = notes.sort((a: any, b: any) =>
new Date(a.posted_at || a.added_at || 0).getTime() - new Date(b.posted_at || b.added_at || 0).getTime()
);
const newNotes = sortedNotes.slice(storedNoteCount);
for (const note of newNotes) {
try {
const dateStr = this.plugin.taskParser.ISOStringToLocalDatetimeString(note.posted_at || note.added_at || '');
await this.plugin.fileOperation.syncTaskNoteToFile(taskId, note.content || '', dateStr);
new Notice(`Note synced to task ${taskId}`);
} catch (error) {
console.error(`[Todoist→Obsidian] Error syncing note for task ${taskId}:`, error);
}
}
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, {
note_count: notes.length
});
}
}
async backupTodoistAllResources(): Promise<void> {
try {
const resources = await this.plugin.todoistSyncAPI.getAllResources(true);
const now: Date = new Date();
const timeString = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const backupFolder = this.plugin.storagePathManager?.getBackupsTodoistPath() || 'ultimate-todoist-sync/backups/todoist';
const tempFileName = `todoist-data-backup-${timeString}.tmp`;
const fileName = `todoist-data-backup-${timeString}.json`;
const tempPath = `${backupFolder}/${tempFileName}`;
const fullPath = `${backupFolder}/${fileName}`;
const adapter = this.app.vault.adapter;
const folderExists = await adapter.exists(backupFolder);
if (!folderExists) {
await adapter.mkdir(backupFolder);
}
const jsonContent = JSON.stringify(resources, null, 2);
await adapter.write(tempPath, jsonContent);
const tempExists = await adapter.exists(tempPath);
if (!tempExists) {
throw new Error('Temp backup file was not created');
}
await adapter.write(fullPath, jsonContent);
const verifyExists = await adapter.exists(fullPath);
if (!verifyExists) {
throw new Error('Backup file verification failed');
}
try {
await adapter.remove(tempPath);
} catch (cleanupError) {
console.warn('[TodoistBackup] Failed to cleanup temp file:', cleanupError);
}
new Notice(`Todoist backup saved to ${fullPath}`);
this.plugin.logOperation?.log('BACKUP_CREATED', `Todoist backup created: ${fullPath}`);
} catch (error) {
console.error("An error occurred while creating Todoist backup:", error);
this.plugin.logOperation?.log('BACKUP_FAILED', `Backup failed: ${(error as Error).message}`);
new Notice('Todoist backup failed');
}
}
}

746
src/sync/toTodoist.ts Normal file
View file

@ -0,0 +1,746 @@
import UltimateTodoistSyncForObsidian from "../../main";
import { App, Editor, MarkdownView, Notice } from 'obsidian';
export class ObsidianToTodoistSync {
app: App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
async deletedTaskCheck(file_path: string): Promise<number> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return 0;
}
let file;
let currentFileValue;
let view;
let filepath;
if (file_path) {
file = this.app.vault.getAbstractFileByPath(file_path);
filepath = file_path;
currentFileValue = await this.app.vault.read(file);
} else {
view = this.app.workspace.getActiveViewOfType(MarkdownView);
file = this.app.workspace.getActiveFile();
filepath = file?.path;
currentFileValue = view?.data;
}
const taskIds = this.plugin.cacheOperation.getTasksInFile(filepath);
if (taskIds.length === 0) {
this.plugin.debugLog('No tasks in this file');
return 0;
}
const currentFileValueWithOutFrontMatter = currentFileValue.replace(/^---[\s\S]*?---\n/, '');
const tasksToDelete = taskIds.filter(
(taskId: string) =>
!currentFileValueWithOutFrontMatter.includes(taskId) &&
this.plugin.cacheOperation.isTaskSyncEnabled(taskId)
);
let deletedCount = 0;
for (const taskId of tasksToDelete) {
try {
const api = this.plugin.todoistSyncAPI.initializeAPI();
const response = await api.deleteTask(taskId);
if (response) {
new Notice(`task ${taskId} is deleted`);
this.plugin.logOperation?.log('OBSIDIAN_TASK_DELETED', `Deleted task: ${taskId}`, undefined, taskId);
await this.plugin.cacheOperation.deleteTaskFileMapping(taskId);
deletedCount++;
}
} catch (error) {
console.error(`Failed to delete task ${taskId}: ${error}`);
new Notice(`Failed to delete task ${taskId}. Check console for details.`);
}
}
if (deletedCount > 0) {
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[deletedTaskCheck] saveSettings skipped or failed');
}
try {
await this.plugin.todoistSyncAPI.incrementalSync();
} catch (syncErr) {
console.error('[deletedTaskCheck] Post-push incremental sync failed:', syncErr);
}
}
return deletedCount;
}
async lineContentNewTaskCheck(editor: Editor, view: MarkdownView): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return;
}
const filepath = view.file?.path;
const fileContent = view?.data;
const cursor = editor.getCursor();
const line = cursor.line;
const linetxt = editor.getLine(line);
const hasId = this.plugin.taskParser.hasTodoistId(linetxt);
const hasTag = this.plugin.taskParser.hasTodoistTag(linetxt);
const isNewTask = !hasId && hasTag;
if (isNewTask) {
const processedLine = hasTag ? linetxt : this.plugin.taskParser.addTodoistTag(linetxt);
this.plugin.debugLog('this is a new task');
this.plugin.debugLog(processedLine);
const currentTask = await this.plugin.taskParser.convertTextToTodoistTaskObject(processedLine, filepath, line, fileContent);
try {
const newTask = await this.plugin.todoistSyncAPI.AddTask(currentTask);
const { id: todoist_id } = newTask;
newTask.path = filepath;
new Notice(`new task ${newTask.content} id is ${newTask.id}`);
this.plugin.logOperation?.log('OBSIDIAN_TASK_CREATED', `Created task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian→todoist');
this.plugin.logOperation?.log('TODOIST_TASK_CREATED', `Created task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian→todoist');
await this.plugin.cacheOperation.setTaskFileMapping(todoist_id, filepath || '');
// Immediately sync so syncData contains the new task before any
// subsequent lineModifiedTaskCheck fires on the same line.
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const updatedTask = await this.plugin.todoistSyncAPI.GetTaskById(todoist_id);
if (updatedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(todoist_id, { updated_at: updatedTask.updated_at });
}
} catch (syncErr) {
console.error('[lineContentNewTaskCheck] Post-create incremental sync failed:', syncErr);
}
if (currentTask.isCompleted === true) {
await this.plugin.todoistSyncAPI.CloseTask(newTask.id);
// taskFileMapping already set above
this.plugin.logOperation?.log('OBSIDIAN_TASK_COMPLETED', `Completed task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian→todoist');
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Completed task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian→todoist');
}
const text_with_out_link = `${processedLine} %%[todoist_id:: ${todoist_id}]%%`;
const link = this.plugin.settings.useAppURI ? `[link](todoist://task?id=${newTask.id})` : `[link](https://app.todoist.com/app/task/${newTask.id})`;
const text = this.plugin.taskParser.addTodoistLink(text_with_out_link, link);
const from = { line: cursor.line, ch: 0 };
const to = { line: cursor.line, ch: linetxt.length };
try {
view.app.workspace.activeEditor?.editor?.replaceRange(text, from, to);
} catch (replaceError) {
// replaceRange failed — roll back Todoist task to avoid duplicate on next trigger
console.error('[lineContentNewTaskCheck] replaceRange failed, rolling back Todoist task:', replaceError);
try {
const api = this.plugin.todoistSyncAPI.initializeAPI();
await api.deleteTask(todoist_id);
} catch (deleteError) {
console.error('[lineContentNewTaskCheck] Rollback failed:', deleteError);
}
await this.plugin.cacheOperation.deleteTaskFileMapping(todoist_id);
new Notice(`Failed to write task ID to file. Todoist task rolled back. Please try again.`);
return;
}
try {
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[lineContentNewTaskCheck] saveSettings skipped or failed');
}
} catch (error) {
console.error(error);
}
} catch (error) {
console.error('Error adding task:', error);
this.plugin.debugLog(`The error occurred in the file: ${filepath}`);
new Notice(`Failed to create task. Check console for details.`);
return;
}
}
}
/**
* Detect new tasks when the user leaves a line (Full Vault Sync only).
* Unlike lineContentNewTaskCheck (which fires on every keystroke for #todoist),
* this fires once when the cursor moves away, so the user can finish typing.
*/
async lastLineNewTaskCheck(filepath: string, lineText: string, lineNumber: number, fileContent: string): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[lastLineNewTaskCheck] Push blocked: not primary device');
return;
}
if (!this.plugin.settings.enableFullVaultSync) return;
const isTask = this.plugin.taskParser.isMarkdownTask(lineText);
if (!isTask) return;
const contentNotEmpty = this.plugin.taskParser.getTaskContentFromLineText(lineText) !== '';
if (!contentNotEmpty) return;
const hasId = this.plugin.taskParser.hasTodoistId(lineText);
if (hasId) return;
const hasTag = this.plugin.taskParser.hasTodoistTag(lineText);
if (hasTag) return; // Already has #todoist — lineContentNewTaskCheck will handle it
// Add #todoist tag
const processedLine = this.plugin.taskParser.addTodoistTag(lineText);
this.plugin.debugLog('[lastLineNewTaskCheck] New task detected on line leave:', processedLine);
const currentTask = await this.plugin.taskParser.convertTextToTodoistTaskObject(processedLine, filepath, lineNumber, fileContent);
if (typeof currentTask === 'undefined') {
console.warn(`[lastLineNewTaskCheck] Task parser returned undefined for line: ${processedLine}`);
this.plugin.debugLog(`[lastLineNewTaskCheck] Task parser returned undefined for line ${lineNumber} in ${filepath}`);
this.plugin.logOperation?.log('TASK_PARSE_FAILED', `Task parser failed for line ${lineNumber}`, filepath);
return;
}
try {
const newTask = await this.plugin.todoistSyncAPI.AddTask(currentTask);
const { id: todoist_id } = newTask;
newTask.path = filepath;
new Notice(`new task ${newTask.content} id is ${newTask.id}`);
this.plugin.logOperation?.log('OBSIDIAN_TASK_CREATED', `Created task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
this.plugin.logOperation?.log('TODOIST_TASK_CREATED', `Created task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
await this.plugin.cacheOperation.setTaskFileMapping(todoist_id, filepath || '');
// Immediately sync so syncData contains the new task
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const updatedTask = await this.plugin.todoistSyncAPI.GetTaskById(todoist_id);
if (updatedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(todoist_id, { updated_at: updatedTask.updated_at });
}
} catch (syncErr) {
console.error('[lastLineNewTaskCheck] Post-create incremental sync failed:', syncErr);
}
if (currentTask.isCompleted === true) {
await this.plugin.todoistSyncAPI.CloseTask(newTask.id);
this.plugin.logOperation?.log('OBSIDIAN_TASK_COMPLETED', `Completed task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Completed task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
}
// Write tag + id + link back to the file
const text_with_out_link = `${processedLine} %%[todoist_id:: ${todoist_id}]%%`;
const link = this.plugin.settings.useAppURI ? `[link](todoist://task?id=${newTask.id})` : `[link](https://app.todoist.com/app/task/${newTask.id})`;
const text = this.plugin.taskParser.addTodoistLink(text_with_out_link, link);
// Use vault.read + vault.modify since cursor is no longer on this line
const file = this.app.vault.getAbstractFileByPath(filepath);
if (!file) {
console.error(`[lastLineNewTaskCheck] File not found: ${filepath}`);
return;
}
const currentContent = await this.app.vault.read(file);
const lines = currentContent.split('\n');
if (lineNumber < lines.length) {
lines[lineNumber] = text;
await this.app.vault.modify(file, lines.join('\n'));
}
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[lastLineNewTaskCheck] saveSettings skipped or failed');
}
} catch (error) {
console.error('[lastLineNewTaskCheck] Error adding task:', error);
this.plugin.debugLog(`The error occurred in the file: ${filepath}`);
new Notice(`Failed to create task. Check console for details.`);
}
}
async fullTextNewTaskCheck(file_path: string): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return;
}
let file;
let currentFileValue;
let view;
let filepath;
if (file_path) {
file = this.app.vault.getAbstractFileByPath(file_path);
filepath = file_path;
currentFileValue = await this.app.vault.read(file);
} else {
view = this.app.workspace.getActiveViewOfType(MarkdownView);
file = this.app.workspace.getActiveFile();
filepath = file?.path;
currentFileValue = view?.data;
}
// Prevent per-task vault.modify from triggering modify event storm
this.plugin.isProcessingModify = true;
try {
if (this.plugin.settings.enableFullVaultSync) {
await this.plugin.fileOperation.addTodoistTagToFile(filepath);
currentFileValue = await this.app.vault.read(file);
}
let lines = currentFileValue.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
this.plugin.debugLog(filepath);
const currentTask = await this.plugin.taskParser.convertTextToTodoistTaskObject(line, filepath, i, lines.join('\n'));
if (typeof currentTask === 'undefined') {
console.warn(`[fullTextNewTaskCheck] Task parser returned undefined for line ${i} in ${filepath}`);
this.plugin.debugLog(`[fullTextNewTaskCheck] Task parser returned undefined for line ${i} in ${filepath}`);
this.plugin.logOperation?.log('TASK_PARSE_FAILED', `Task parser failed for line ${i}`, filepath);
continue;
}
this.plugin.debugLog(currentTask);
let todoist_id: string | undefined;
try {
const newTask = await this.plugin.todoistSyncAPI.AddTask(currentTask);
todoist_id = newTask.id;
newTask.path = filepath;
this.plugin.debugLog(newTask);
new Notice(`new task ${newTask.content} id is ${newTask.id}`);
this.plugin.logOperation?.log('OBSIDIAN_TASK_CREATED', `Created task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
this.plugin.logOperation?.log('TODOIST_TASK_CREATED', `Created task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
await this.plugin.cacheOperation.setTaskFileMapping(todoist_id, filepath || '');
if (currentTask.isCompleted === true) {
await this.plugin.todoistSyncAPI.CloseTask(newTask.id);
this.plugin.logOperation?.log('OBSIDIAN_TASK_COMPLETED', `Completed task in Obsidian: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Completed task in Todoist: ${newTask.content}`, filepath, todoist_id, 'obsidian\u2192todoist');
}
const text_with_out_link = `${line} %%[todoist_id:: ${todoist_id}]%%`;
const link = this.plugin.settings.useAppURI ? `[link](todoist://task?id=${newTask.id})` : `[link](https://app.todoist.com/app/task/${newTask.id})`;
const text = this.plugin.taskParser.addTodoistLink(text_with_out_link, link);
lines[i] = text;
// Atomic: write file immediately after each task
const newContent = lines.join('\n');
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent);
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[fullTextNewTaskCheck] saveSettings skipped or failed');
}
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const updatedTask = await this.plugin.todoistSyncAPI.GetTaskById(todoist_id);
if (updatedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(todoist_id, { updated_at: updatedTask.updated_at });
}
} catch (syncErr) {
console.error('[fullTextNewTaskCheck] Post-push incremental sync failed:', syncErr);
}
// Re-read file so subsequent iterations use the latest content
const refreshed = await this.app.vault.read(file);
lines = refreshed.split('\n');
} catch (error) {
console.error('Error adding task:', error);
new Notice(`Failed to create task. Check console for details.`);
// Rollback: delete Todoist task + clean mapping if we got an id
if (todoist_id) {
try {
await this.plugin.todoistSyncAPI.deleteTask(todoist_id);
} catch (deleteError) {
console.error('[fullTextNewTaskCheck] Rollback deleteTask failed:', deleteError);
}
await this.plugin.cacheOperation.deleteTaskFileMapping(todoist_id);
}
continue;
}
}
}
} finally {
this.plugin.isProcessingModify = false;
}
}
async lineModifiedTaskCheck(filepath: string, lineText: string, lineNumber: number, fileContent: string): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return;
}
if (this.plugin.taskParser.hasTodoistId(lineText) && this.plugin.taskParser.hasTodoistTag(lineText)) {
const lineTask = await this.plugin.taskParser.convertTextToTodoistTaskObject(lineText, filepath, lineNumber, fileContent);
const lineTask_todoist_id = (lineTask.todoist_id).toString();
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(lineTask_todoist_id);
if (!taskMapping) {
this.plugin.debugLog(`Local cache has no task ${lineTask.todoist_id}`);
const url = this.plugin.taskParser.getObsidianUrlFromFilepath(filepath);
this.plugin.debugLog(url);
return;
}
if (!this.plugin.cacheOperation.isTaskSyncEnabled(lineTask_todoist_id)) {
this.plugin.debugLog(`[lineModifiedTaskCheck] Sync disabled for task ${lineTask_todoist_id}, skipping modification`);
this.plugin.logOperation?.log('SYNC_DISABLED_SKIP', `User edit ignored: sync disabled for task ${lineTask_todoist_id}`, filepath, lineTask_todoist_id);
return;
}
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(lineTask_todoist_id);
// Handle deleted task: task exists in cache but not in Todoist
if (!savedTask) {
// If the task was just created (mapping exists but syncData not yet updated),
// skip silently rather than marking as issue. The incremental sync running
// in the background will populate syncData shortly.
const mappingAge = taskMapping.updated_at
? Date.now() - new Date(taskMapping.updated_at).getTime()
: 0;
if (mappingAge < 30000) {
this.plugin.debugLog(`[lineModifiedTaskCheck] Task ${lineTask_todoist_id} not in syncData yet (just created), skipping`);
return;
}
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, 'todoist_task_missing', {
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;
}
// Conflict detection: if Todoist was updated since our last sync
if (taskMapping.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
console.warn(`[lineModifiedTaskCheck] Conflict on task ${lineTask_todoist_id}: strategy=${strategy}`);
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on task ${lineTask_todoist_id} (strategy: ${strategy})`, filepath, lineTask_todoist_id);
if (strategy === 'todoist-wins') {
// Let toObsidian pull overwrite Obsidian on next sync — just update cached updated_at
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(lineTask_todoist_id, { updated_at: undefined });
new Notice(`Conflict on task ${lineTask_todoist_id}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'obsidian-wins') {
// Force-update Todoist with Obsidian content — fall through to normal update logic below
new Notice(`Conflict on task ${lineTask_todoist_id}: Obsidian wins — pushing to Todoist.`);
// Reset cached updated_at so toObsidian won't overwrite back
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(lineTask_todoist_id, { updated_at: savedTask.updated_at });
// fall through
} else {
// manual: disable sync until user resolves
await this.plugin.cacheOperation.setTaskFileMapping(lineTask_todoist_id, taskMapping.filePath, 'conflicted', false);
new Notice(`Task ${lineTask_todoist_id} has a conflict: modified in both Obsidian and Todoist. Sync disabled until resolved.`);
return;
}
}
const lineTaskContent = lineTask.content;
const contentModified = !this.plugin.taskParser.taskContentCompare(lineTask, savedTask);
const tagsModified = !this.plugin.taskParser.taskTagCompare(lineTask, savedTask);
const statusModified = !this.plugin.taskParser.taskStatusCompare(lineTask, savedTask);
const dueDateModified = !this.plugin.taskParser.compareTaskDueDate(lineTask, savedTask);
const priorityModified = !this.plugin.taskParser.taskPriorityCompare(lineTask, savedTask);
try {
let contentChanged = false;
let tagsChanged = false;
let statusChanged = false;
let dueDateChanged = false;
let priorityChanged = false;
const updatedContent: Record<string, unknown> = {};
if (contentModified) {
this.plugin.debugLog(`Content modified for task ${lineTask_todoist_id}`);
updatedContent.content = lineTaskContent;
contentChanged = true;
}
if (tagsModified) {
this.plugin.debugLog(`Tags modified for task ${lineTask_todoist_id}`);
updatedContent.labels = lineTask.labels;
tagsChanged = true;
}
if (dueDateModified) {
this.plugin.debugLog(`Due date modified for task ${lineTask_todoist_id}`);
this.plugin.debugLog(lineTask.dueDate);
if (lineTask.dueDate === "") {
updatedContent.dueString = "no date";
} else {
updatedContent.dueDate = lineTask.dueDate;
}
dueDateChanged = true;
}
if (priorityModified) {
updatedContent.priority = lineTask.priority;
priorityChanged = true;
}
if (contentChanged || tagsChanged || dueDateChanged || priorityChanged) {
const updatedTask = await this.plugin.todoistSyncAPI.UpdateTask(lineTask.todoist_id.toString(), updatedContent);
// taskFileMapping already set, no need to update
this.plugin.logOperation?.log('OBSIDIAN_TASK_MODIFIED', `Updated task: ${updatedTask.content}`, filepath, lineTask_todoist_id, 'obsidian→todoist');
this.plugin.logOperation?.log('TODOIST_TASK_UPDATED', `Updated task in Todoist: ${updatedTask.content}`, filepath, lineTask_todoist_id, 'obsidian→todoist');
}
if (statusModified) {
this.plugin.debugLog(`Status modified for task ${lineTask_todoist_id}`);
if (lineTask.isCompleted === true) {
this.plugin.debugLog(`task completed`);
await this.plugin.todoistSyncAPI.CloseTask(lineTask.todoist_id.toString());
// taskFileMapping already set, no need to update
this.plugin.logOperation?.log('OBSIDIAN_TASK_COMPLETED', `Completed task: ${lineTask.content}`, filepath, lineTask_todoist_id, 'obsidian→todoist');
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Completed task in Todoist: ${lineTask.content}`, filepath, lineTask_todoist_id, 'obsidian→todoist');
} else {
this.plugin.debugLog(`task uncompleted`);
await this.plugin.todoistSyncAPI.OpenTask(lineTask.todoist_id.toString());
// taskFileMapping already set, no need to update
this.plugin.logOperation?.log('OBSIDIAN_TASK_REOPENED', `Reopened task: ${lineTask.content}`, filepath, lineTask_todoist_id);
this.plugin.logOperation?.log('TODOIST_TASK_REOPENED', `Reopened task in Todoist: ${lineTask.content}`, filepath, lineTask_todoist_id, 'obsidian→todoist');
}
statusChanged = true;
}
if (contentChanged || statusChanged || dueDateChanged || tagsChanged || priorityChanged) {
this.plugin.debugLog(lineTask);
this.plugin.debugLog(savedTask);
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[lineModifiedTaskCheck] saveSettings skipped or failed');
}
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const refreshedTask = await this.plugin.todoistSyncAPI.GetTaskById(lineTask_todoist_id);
if (refreshedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(lineTask_todoist_id, { updated_at: refreshedTask.updated_at });
}
} catch (syncErr) {
console.error('[lineModifiedTaskCheck] Post-push incremental sync failed:', syncErr);
}
let message = `Task ${lineTask_todoist_id} is updated.`;
if (contentChanged) {
message += " Content was changed.";
}
if (statusChanged) {
message += " Status was changed.";
}
if (dueDateChanged) {
message += " Due date was changed.";
}
if (tagsChanged) {
message += " Tags were changed.";
}
if (priorityChanged) {
message += " Priority was changed.";
}
new Notice(message);
}
} catch (error) {
console.error('Error updating task:', error);
new Notice(`Failed to update task ${lineTask_todoist_id}. Check console for details.`);
}
}
}
async fullTextModifiedTaskCheck(file_path: string): Promise<void> {
let file;
let currentFileValue;
let view;
let filepath;
try {
if (file_path) {
file = this.app.vault.getAbstractFileByPath(file_path);
filepath = file_path;
currentFileValue = await this.app.vault.read(file);
} else {
view = this.app.workspace.getActiveViewOfType(MarkdownView);
file = this.app.workspace.getActiveFile();
filepath = file?.path;
currentFileValue = view?.data;
}
const content = currentFileValue;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
try {
await this.lineModifiedTaskCheck(filepath, line, i, content);
} catch (error) {
console.error('Error modifying task:', error);
continue;
}
}
}
} catch (error) {
console.error('Error:', error);
}
}
async closeTask(taskId: string): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return;
}
if (!this.plugin.cacheOperation?.isTaskSyncEnabled(taskId)) {
this.plugin.debugLog(`[closeTask] Sync disabled for task ${taskId}, skipping close`);
this.plugin.logOperation?.log('SYNC_DISABLED_SKIP', `Checkbox close ignored: sync disabled for task ${taskId}`, undefined, taskId);
return;
}
try {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (!savedTask) {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', 'issue', false);
await this.plugin.cacheOperation.upsertTaskIssue(taskId, 'todoist_task_missing', {
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;
}
if (taskMapping?.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on closeTask ${taskId} (strategy: ${strategy})`, undefined, taskId);
if (strategy === 'todoist-wins') {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: undefined });
new Notice(`Conflict on task ${taskId}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'manual') {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping.filePath, 'conflicted', false);
new Notice(`Task ${taskId} has a conflict. Sync disabled until resolved.`);
return;
}
// obsidian-wins: fall through and close
}
await this.plugin.todoistSyncAPI.CloseTask(taskId);
await this.plugin.fileOperation.completeTaskInTheFile(taskId);
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[closeTask] saveSettings skipped or failed');
}
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const refreshedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (refreshedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: refreshedTask.updated_at });
}
} catch (syncErr) {
console.error('[closeTask] Post-push incremental sync failed:', syncErr);
}
new Notice(`Task ${taskId} is closed.`);
this.plugin.logOperation?.log('TODOIST_TASK_COMPLETED', `Closed task via checkbox: ${taskId}`, undefined, taskId, 'obsidian→todoist');
} catch (error) {
console.error('Error closing task:', error);
throw error;
}
}
async repoenTask(taskId: string): Promise<void> {
if (!this.plugin.isPrimaryDevice()) {
this.plugin.debugLog('[toTodoist] Push blocked: not primary device');
return;
}
if (!this.plugin.cacheOperation?.isTaskSyncEnabled(taskId)) {
this.plugin.debugLog(`[repoenTask] Sync disabled for task ${taskId}, skipping reopen`);
this.plugin.logOperation?.log('SYNC_DISABLED_SKIP', `Checkbox reopen ignored: sync disabled for task ${taskId}`, undefined, taskId);
return;
}
try {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
const savedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (!savedTask) {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping?.filePath || '', 'issue', false);
await this.plugin.cacheOperation.upsertTaskIssue(taskId, 'todoist_task_missing', {
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;
}
if (taskMapping?.updated_at && savedTask.updated_at && savedTask.updated_at !== taskMapping.updated_at) {
const strategy = this.plugin.settings.conflictResolutionStrategy;
this.plugin.logOperation?.log('CONFLICT_DETECTED', `Conflict on repoenTask ${taskId} (strategy: ${strategy})`, undefined, taskId);
if (strategy === 'todoist-wins') {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: undefined });
new Notice(`Conflict on task ${taskId}: Todoist wins — Obsidian will be updated on next sync.`);
return;
} else if (strategy === 'manual') {
await this.plugin.cacheOperation.setTaskFileMapping(taskId, taskMapping.filePath, 'conflicted', false);
new Notice(`Task ${taskId} has a conflict. Sync disabled until resolved.`);
return;
}
// obsidian-wins: fall through and reopen
}
await this.plugin.todoistSyncAPI.OpenTask(taskId);
await this.plugin.fileOperation.uncompleteTaskInTheFile(taskId);
const saved = await this.plugin.saveSettings();
if (!saved) {
console.warn('[repoenTask] saveSettings skipped or failed');
}
try {
await this.plugin.todoistSyncAPI.incrementalSync();
const refreshedTask = await this.plugin.todoistSyncAPI.GetTaskById(taskId);
if (refreshedTask?.updated_at) {
await this.plugin.cacheOperation.updateTaskMappingSyncMeta(taskId, { updated_at: refreshedTask.updated_at });
}
} catch (syncErr) {
console.error('[repoenTask] Post-push incremental sync failed:', syncErr);
}
new Notice(`Task ${taskId} is reopened.`);
this.plugin.logOperation?.log('TODOIST_TASK_REOPENED', `Reopened task via checkbox: ${taskId}`, undefined, taskId, 'obsidian→todoist');
} catch (error) {
console.error('Error opening task:', error);
throw error;
}
}
async updateTaskDescription(filepath: string): Promise<void> {
const taskIds = this.plugin.cacheOperation.getTasksInFile(filepath);
if (taskIds.length === 0) {
return;
}
for (const taskId of taskIds) {
try {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (taskMapping) {
if (!this.plugin.cacheOperation.isTaskSyncEnabled(taskId)) continue;
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');
}
} catch (error) {
console.error(`Error updating task description for ${taskId}:`, error);
}
}
}
}

View file

@ -1,874 +0,0 @@
import UltimateTodoistSyncForObsidian from "../main";
import { App, Editor, MarkdownView, Notice} from 'obsidian';
type FrontMatter = {
todoistTasks: string[];
todoistCount: number;
};
export class TodoistSync {
app:App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app:App, plugin:UltimateTodoistSyncForObsidian) {
//super(app,settings,todoistRestAPI,todoistSyncAPI,taskParser,cacheOperation);
this.app = app;
this.plugin = plugin;
}
async deletedTaskCheck(file_path:string): Promise<void> {
let file
let currentFileValue
let view
let filepath
if(file_path){
file = this.app.vault.getAbstractFileByPath(file_path)
filepath = file_path
currentFileValue = await this.app.vault.read(file)
}
else{
view = this.app.workspace.getActiveViewOfType(MarkdownView);
//const editor = this.app.workspace.activeEditor?.editor
file = this.app.workspace.getActiveFile()
filepath = file?.path
//使用view.data 代替 valut.read。vault.read有延迟
currentFileValue = view?.data
}
//console.log(filepath)
//const frontMatter = await this.plugin.fileOperation.getFrontMatter(file);
const frontMatter = await this.plugin.cacheOperation.getFileMetadata(filepath)
if (!frontMatter || !frontMatter.todoistTasks) {
console.log('frontmatter没有task')
return;
}
//console.log(currentFileValue)
const currentFileValueWithOutFrontMatter = currentFileValue.replace(/^---[\s\S]*?---\n/, '');
const frontMatter_todoistTasks = frontMatter.todoistTasks;
const frontMatter_todoistCount = frontMatter.todoistCount;
const deleteTasksPromises = frontMatter_todoistTasks
.filter((taskId) => !currentFileValueWithOutFrontMatter.includes(taskId))
.map(async (taskId) => {
try {
//console.log(`initialize todoist api`)
const api = this.plugin.todoistRestAPI.initializeAPI()
const response = await api.deleteTask(taskId);
//console.log(`response is ${response}`);
if (response) {
//console.log(`task ${taskId} 删除成功`);
new Notice(`task ${taskId} is deleted`)
return taskId; // 返回被删除的任务 ID
}
} catch (error) {
console.error(`Failed to delete task ${taskId}: ${error}`);
}
});
const deletedTaskIds = await Promise.all(deleteTasksPromises);
const deletedTaskAmount = deletedTaskIds.length
if (!deletedTaskIds.length) {
//console.log("没有删除任务");
return;
}
this.plugin.cacheOperation.deleteTaskFromCacheByIDs(deletedTaskIds)
//console.log(`删除了${deletedTaskAmount} 条 task`)
this.plugin.saveSettings()
// 更新 newFrontMatter_todoistTasks 数组
// Disable automatic merging
const newFrontMatter_todoistTasks = frontMatter_todoistTasks.filter(
(taskId) => !deletedTaskIds.includes(taskId)
);
/*
await this.plugin.fileOperation.updateFrontMatter(file, (frontMatter) => {
frontMatter.todoistTasks = newFrontMatter_todoistTasks;
frontMatter.todoistCount = frontMatter_todoistCount - deletedTaskAmount;
});
*/
const newFileMetadata = {todoistTasks:newFrontMatter_todoistTasks,todoistCount:(frontMatter_todoistCount - deletedTaskAmount)}
await this.plugin.cacheOperation.updateFileMetadata(filepath,newFileMetadata)
}
async lineContentNewTaskCheck(editor:Editor,view:MarkdownView): Promise<void>{
//const editor = this.app.workspace.activeEditor?.editor
//const view =this.app.workspace.getActiveViewOfType(MarkdownView)
const filepath = view.file?.path
const fileContent = view?.data
const cursor = editor.getCursor()
const line = cursor.line
const linetxt = editor.getLine(line)
//添加task
if ((!this.plugin.taskParser.hasTodoistId(linetxt) && this.plugin.taskParser.hasTodoistTag(linetxt))) { //是否包含#todoist
console.log('this is a new task')
console.log(linetxt)
const currentTask =await this.plugin.taskParser.convertTextToTodoistTaskObject(linetxt,filepath,line,fileContent)
//console.log(currentTask)
try {
const newTask = await this.plugin.todoistRestAPI.AddTask(currentTask)
const { id: todoist_id, projectId: todoist_projectId, url: todoist_url } = newTask;
newTask.path = filepath;
//console.log(newTask);
new Notice(`new task ${newTask.content} id is ${newTask.id}`)
//newTask写入缓存
this.plugin.cacheOperation.appendTaskToCache(newTask)
//如果任务已完成
if(currentTask.isCompleted === true){
await this.plugin.todoistRestAPI.CloseTask(newTask.id)
this.plugin.cacheOperation.closeTaskToCacheByID(todoist_id)
}
this.plugin.saveSettings()
//todoist id 保存到 任务后面
const text_with_out_link = `${linetxt} %%[todoist_id:: ${todoist_id}]%%`;
const link = `[link](${newTask.url})`
const text = this.plugin.taskParser.addTodoistLink(text_with_out_link,link)
const from = { line: cursor.line, ch: 0 };
const to = { line: cursor.line, ch: linetxt.length };
view.app.workspace.activeEditor?.editor?.replaceRange(text, from, to)
//处理frontMatter
try {
// 处理 front matter
const frontMatter = await this.plugin.cacheOperation.getFileMetadata(filepath)
//console.log(frontMatter);
if (!frontMatter) {
//console.log('frontmatter is empty');
//return;
}
// 将 todoistCount 加 1
const newFrontMatter = { ...frontMatter };
newFrontMatter.todoistCount = (newFrontMatter.todoistCount ?? 0) + 1;
// 记录 taskID
newFrontMatter.todoistTasks = [...(newFrontMatter.todoistTasks || []), todoist_id];
// 更新 front matter
/*
this.plugin.fileOperation.updateFrontMatter(view.file, (frontMatter) => {
frontMatter.todoistTasks = newFrontMatter.todoistTasks;
frontMatter.todoistCount = newFrontMatter.todoistCount;
});
*/
//console.log(newFrontMatter)
await this.plugin.cacheOperation.updateFileMetadata(filepath,newFrontMatter)
} catch (error) {
console.error(error);
}
} catch (error) {
console.error('Error adding task:', error);
console.log(`The error occurred in the file: ${filepath}`)
return
}
}
}
async fullTextNewTaskCheck(file_path:string): Promise<void>{
let file
let currentFileValue
let view
let filepath
if(file_path){
file = this.app.vault.getAbstractFileByPath(file_path)
filepath = file_path
currentFileValue = await this.app.vault.read(file)
}
else{
view = this.app.workspace.getActiveViewOfType(MarkdownView);
//const editor = this.app.workspace.activeEditor?.editor
file = this.app.workspace.getActiveFile()
filepath = file?.path
//使用view.data 代替 valut.read。vault.read有延迟
currentFileValue = view?.data
}
if(this.plugin.settings.enableFullVaultSync){
//console.log('full vault sync enabled')
//console.log(filepath)
await this.plugin.fileOperation.addTodoistTagToFile(filepath)
}
const content = currentFileValue
let newFrontMatter
//frontMatteer
const frontMatter = await this.plugin.cacheOperation.getFileMetadata(filepath)
//console.log(frontMatter);
if (!frontMatter) {
console.log('frontmatter is empty');
newFrontMatter = {};
}else{
newFrontMatter = { ...frontMatter };
}
let hasNewTask = false;
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
//console.log('this is a new task')
//console.log(`current line is ${i}`)
//console.log(`line text: ${line}`)
console.log(filepath)
const currentTask =await this.plugin.taskParser.convertTextToTodoistTaskObject(line,filepath,i,content)
if(typeof currentTask === "undefined"){
continue
}
console.log(currentTask)
try {
const newTask = await this.plugin.todoistRestAPI.AddTask(currentTask)
const { id: todoist_id, projectId: todoist_projectId, url: todoist_url } = newTask;
newTask.path = filepath;
console.log(newTask);
new Notice(`new task ${newTask.content} id is ${newTask.id}`)
//newTask写入json文件
this.plugin.cacheOperation.appendTaskToCache(newTask)
//如果任务已完成
if(currentTask.isCompleted === true){
await this.plugin.todoistRestAPI.CloseTask(newTask.id)
this.plugin.cacheOperation.closeTaskToCacheByID(todoist_id)
}
this.plugin.saveSettings()
//todoist id 保存到 任务后面
const text_with_out_link = `${line} %%[todoist_id:: ${todoist_id}]%%`;
const link = `[link](${newTask.url})`
const text = this.plugin.taskParser.addTodoistLink(text_with_out_link,link)
lines[i] = text;
newFrontMatter.todoistCount = (newFrontMatter.todoistCount ?? 0) + 1;
// 记录 taskID
newFrontMatter.todoistTasks = [...(newFrontMatter.todoistTasks || []), todoist_id];
hasNewTask = true
} catch (error) {
console.error('Error adding task:', error);
continue
}
}
}
if(hasNewTask){
//文本和 frontMatter
try {
// 保存file
const newContent = lines.join('\n')
await this.app.vault.modify(file, newContent)
// 更新 front matter
/*
this.plugin.fileOperation.updateFrontMatter(file, (frontMatter) => {
frontMatter.todoistTasks = newFrontMatter.todoistTasks;
frontMatter.todoistCount = newFrontMatter.todoistCount;
});
*/
await this.plugin.cacheOperation.updateFileMetadata(filepath,newFrontMatter)
} catch (error) {
console.error(error);
}
}
}
async lineModifiedTaskCheck(filepath:string,lineText:string,lineNumber:number,fileContent:string): Promise<void>{
//const lineText = await this.plugin.fileOperation.getLineTextFromFilePath(filepath,lineNumber)
if(this.plugin.settings.enableFullVaultSync){
//await this.plugin.fileOperation.addTodoistTagToLine(filepath,lineText,lineNumber,fileContent)
//new empty metadata
const metadata = await this.plugin.cacheOperation.getFileMetadata(filepath)
if(!metadata){
await this.plugin.cacheOperation.newEmptyFileMetadata(filepath)
}
this.plugin.saveSettings()
}
//检查task
if (this.plugin.taskParser.hasTodoistId(lineText) && this.plugin.taskParser.hasTodoistTag(lineText)) {
const lineTask = await this.plugin.taskParser.convertTextToTodoistTaskObject(lineText,filepath,lineNumber,fileContent)
//console.log(lastLineTask)
const lineTask_todoist_id = (lineTask.todoist_id).toString()
//console.log(lineTask_todoist_id )
//console.log(`lastline task id is ${lastLineTask_todoist_id}`)
const savedTask = await this.plugin.cacheOperation.loadTaskFromCacheyID(lineTask_todoist_id) //dataview中 id为数字todoist中id为字符串需要转换
if(!savedTask){
console.log(`本地缓存中没有task ${lineTask.todoist_id}`)
const url = this.plugin.taskParser.getObsidianUrlFromFilepath(filepath)
console.log(url)
return
}
//console.log(savedTask)
//检查内容是否修改
const lineTaskContent = lineTask.content;
//content 是否修改
const contentModified = !this.plugin.taskParser.taskContentCompare(lineTask,savedTask)
//tag or labels 是否修改
const tagsModified = !this.plugin.taskParser.taskTagCompare(lineTask,savedTask)
//project 是否修改
const projectModified = !(await this.plugin.taskParser.taskProjectCompare(lineTask,savedTask))
//status 是否修改
const statusModified = !this.plugin.taskParser.taskStatusCompare(lineTask,savedTask)
//due date 是否修改
const dueDateModified = !(await this.plugin.taskParser.compareTaskDueDate(lineTask,savedTask))
//parent id 是否修改
const parentIdModified = !(lineTask.parentId === savedTask.parentId)
//check priority
const priorityModified = !(lineTask.priority === savedTask.priority)
try {
let contentChanged= false;
let tagsChanged = false;
let projectChanged = false;
let statusChanged = false;
let dueDateChanged = false;
let parentIdChanged = false;
let priorityChanged = false;
let updatedContent = {}
if (contentModified) {
console.log(`Content modified for task ${lineTask_todoist_id}`)
updatedContent.content = lineTaskContent
contentChanged = true;
}
if (tagsModified) {
console.log(`Tags modified for task ${lineTask_todoist_id}`)
updatedContent.labels = lineTask.labels
tagsChanged = true;
}
if (dueDateModified) {
console.log(`Due date modified for task ${lineTask_todoist_id}`)
console.log(lineTask.dueDate)
//console.log(savedTask.due.date)
if(lineTask.dueDate === ""){
updatedContent.dueString = "no date"
}else{
updatedContent.dueDate = lineTask.dueDate
}
dueDateChanged = true;
}
//todoist Rest api 没有 move task to new project的功能
if (projectModified) {
//console.log(`Project id modified for task ${lineTask_todoist_id}`)
//updatedContent.projectId = lineTask.projectId
//projectChanged = false;
}
//todoist Rest api 没有修改 parent id 的借口
if (parentIdModified) {
//console.log(`Parnet id modified for task ${lineTask_todoist_id}`)
//updatedContent.parentId = lineTask.parentId
//parentIdChanged = false;
}
if (priorityModified) {
updatedContent.priority = lineTask.priority
priorityChanged = true;
}
if (contentChanged || tagsChanged ||dueDateChanged ||projectChanged || parentIdChanged || priorityChanged) {
//console.log("task content was modified");
//console.log(updatedContent)
const updatedTask = await this.plugin.todoistRestAPI.UpdateTask(lineTask.todoist_id.toString(),updatedContent)
updatedTask.path = filepath
this.plugin.cacheOperation.updateTaskToCacheByID(updatedTask);
}
if (statusModified) {
console.log(`Status modified for task ${lineTask_todoist_id}`)
if (lineTask.isCompleted === true) {
console.log(`task completed`)
this.plugin.todoistRestAPI.CloseTask(lineTask.todoist_id.toString());
this.plugin.cacheOperation.closeTaskToCacheByID( lineTask.todoist_id.toString());
} else {
console.log(`task umcompleted`)
this.plugin.todoistRestAPI.OpenTask(lineTask.todoist_id.toString());
this.plugin.cacheOperation.reopenTaskToCacheByID( lineTask.todoist_id.toString());
}
statusChanged = true;
}
if (contentChanged || statusChanged ||dueDateChanged ||tagsChanged || projectChanged || priorityChanged) {
console.log(lineTask)
console.log(savedTask)
//`Task ${lastLineTaskTodoistId} was modified`
this.plugin.saveSettings()
let message = `Task ${lineTask_todoist_id} is updated.`;
if (contentChanged) {
message += " Content was changed.";
}
if (statusChanged) {
message += " Status was changed.";
}
if (dueDateChanged) {
message += " Due date was changed.";
}
if (tagsChanged) {
message += " Tags were changed.";
}
if (projectChanged) {
message += " Project was changed.";
}
if (priorityChanged) {
message += " Priority was changed.";
}
new Notice(message);
} else {
//console.log(`Task ${lineTask_todoist_id} did not change`);
}
} catch (error) {
console.error('Error updating task:', error);
}
}
}
async fullTextModifiedTaskCheck(file_path: string): Promise<void> {
let file;
let currentFileValue;
let view;
let filepath;
try {
if (file_path) {
file = this.app.vault.getAbstractFileByPath(file_path);
filepath = file_path;
currentFileValue = await this.app.vault.read(file);
} else {
view = this.app.workspace.getActiveViewOfType(MarkdownView);
file = this.app.workspace.getActiveFile();
filepath = file?.path;
currentFileValue = view?.data;
}
const content = currentFileValue;
let hasModifiedTask = false;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
try {
await this.lineModifiedTaskCheck(filepath, line, i, content);
hasModifiedTask = true;
} catch (error) {
console.error('Error modifying task:', error);
continue;
}
}
}
if (hasModifiedTask) {
try {
// Perform necessary actions on the modified content and front matter
} catch (error) {
console.error('Error processing modified content:', error);
}
}
} catch (error) {
console.error('Error:', error);
}
}
// Close a task by calling API and updating JSON file
async closeTask(taskId: string): Promise<void> {
try {
await this.plugin.todoistRestAPI.CloseTask(taskId);
await this.plugin.fileOperation.completeTaskInTheFile(taskId)
await this.plugin.cacheOperation.closeTaskToCacheByID(taskId);
this.plugin.saveSettings()
new Notice(`Task ${taskId} is closed.`)
} catch (error) {
console.error('Error closing task:', error);
throw error; // 抛出错误使调用方能够捕获并处理它
}
}
//open task
async repoenTask(taskId:string) : Promise<void>{
try {
await this.plugin.todoistRestAPI.OpenTask(taskId)
await this.plugin.fileOperation.uncompleteTaskInTheFile(taskId)
await this.plugin.cacheOperation.reopenTaskToCacheByID(taskId)
this.plugin.saveSettings()
new Notice(`Task ${taskId} is reopend.`)
} catch (error) {
console.error('Error opening task:', error);
throw error; // 抛出错误使调用方能够捕获并处理它
}
}
/**
* ID JSON
* @param taskIds ID
* @returns ID
*/
async deleteTasksByIds(taskIds: string[]): Promise<string[]> {
const deletedTaskIds = [];
for (const taskId of taskIds) {
const api = await this.plugin.todoistRestAPI.initializeAPI()
try {
const response = await api.deleteTask(taskId);
console.log(`response is ${response}`);
if (response) {
//console.log(`Task ${taskId} 删除成功`);
new Notice(`Task ${taskId} is deleted.`)
deletedTaskIds.push(taskId); // 将被删除的任务 ID 加入数组
}
} catch (error) {
console.error(`Failed to delete task ${taskId}: ${error}`);
// 可以添加更好的错误处理方式,比如在这里抛出异常或者记录日志等
}
}
if (!deletedTaskIds.length) {
console.log("没有删除任务");
return [];
}
await this.plugin.cacheOperation.deleteTaskFromCacheByIDs(deletedTaskIds); // 更新 JSON 文件
this.plugin.saveSettings()
//console.log(`共删除了 ${deletedTaskIds.length} 条 task`);
return deletedTaskIds;
}
// 同步已完成的任务状态到 Obsidian file
async syncCompletedTaskStatusToObsidian(unSynchronizedEvents) {
// 获取未同步的事件
//console.log(unSynchronizedEvents)
try {
// 处理未同步的事件并等待所有处理完成
const processedEvents = []
for (const e of unSynchronizedEvents) { //如果要修改代码让completeTaskInTheFile(e.object_id)按照顺序依次执行可以将Promise.allSettled()方法改为使用for...of循环来处理未同步的事件。具体步骤如下
//console.log(`正在 complete ${e.object_id}`)
await this.plugin.fileOperation.completeTaskInTheFile(e.object_id)
await this.plugin.cacheOperation.closeTaskToCacheByID(e.object_id)
new Notice(`Task ${e.object_id} is closed.`)
processedEvents.push(e)
}
// Save events to the local database."
//const allEvents = [...savedEvents, ...unSynchronizedEvents]
await this.plugin.cacheOperation.appendEventsToCache(processedEvents)
this.plugin.saveSettings()
} catch (error) {
console.error('同步任务状态时出错:', error)
}
}
// 同步已完成的任务状态到 Obsidian file
async syncUncompletedTaskStatusToObsidian(unSynchronizedEvents) {
//console.log(unSynchronizedEvents)
try {
// 处理未同步的事件并等待所有处理完成
const processedEvents = []
for (const e of unSynchronizedEvents) { //如果要修改代码让uncompleteTaskInTheFile(e.object_id)按照顺序依次执行可以将Promise.allSettled()方法改为使用for...of循环来处理未同步的事件。具体步骤如下
//console.log(`正在 uncheck task: ${e.object_id}`)
await this.plugin.fileOperation.uncompleteTaskInTheFile(e.object_id)
await this.plugin.cacheOperation.reopenTaskToCacheByID(e.object_id)
new Notice(`Task ${e.object_id} is reopend.`)
processedEvents.push(e)
}
// 将新事件合并到现有事件中并保存到 JSON
//const allEvents = [...savedEvents, ...unSynchronizedEvents]
await this.plugin.cacheOperation.appendEventsToCache(processedEvents)
this.plugin.saveSettings()
} catch (error) {
console.error('同步任务状态时出错:', error)
}
}
// 同步updated item状态到 Obsidian 中
async syncUpdatedTaskToObsidian(unSynchronizedEvents) {
//console.log(unSynchronizedEvents)
try {
// 处理未同步的事件并等待所有处理完成
const processedEvents = []
for (const e of unSynchronizedEvents) { //如果要修改代码让completeTaskInTheFile(e.object_id)按照顺序依次执行可以将Promise.allSettled()方法改为使用for...of循环来处理未同步的事件。具体步骤如下
//console.log(`正在 sync ${e.object_id} 的变化到本地`)
console.log(e)
console.log(typeof e.extra_data.last_due_date === 'undefined')
if(!(typeof e.extra_data.last_due_date === 'undefined')){
//console.log(`prepare update dueDate`)
await this.syncUpdatedTaskDueDateToObsidian(e)
}
if(!(typeof e.extra_data.last_content === 'undefined')){
//console.log(`prepare update content`)
await this.syncUpdatedTaskContentToObsidian(e)
}
//await this.plugin.fileOperation.syncUpdatedTaskToTheFile(e)
//还要修改cache中的数据
//new Notice(`Task ${e.object_id} is updated.`)
processedEvents.push(e)
}
// 将新事件合并到现有事件中并保存到 JSON
//const allEvents = [...savedEvents, ...unSynchronizedEvents]
await this.plugin.cacheOperation.appendEventsToCache(processedEvents)
this.plugin.saveSettings()
} catch (error) {
console.error('Error syncing updated item', error)
}
}
async syncUpdatedTaskContentToObsidian(e){
this.plugin.fileOperation.syncUpdatedTaskContentToTheFile(e)
const content = e.extra_data.content
this.plugin.cacheOperation.modifyTaskToCacheByID(e.object_id,{content})
new Notice(`The content of Task ${e.parent_item_id} has been modified.`)
}
async syncUpdatedTaskDueDateToObsidian(e){
this.plugin.fileOperation.syncUpdatedTaskDueDateToTheFile(e)
//修改cache的日期要使用todoist的格式
const due = await this.plugin.todoistRestAPI.getTaskDueById(e.object_id)
this.plugin.cacheOperation.modifyTaskToCacheByID(e.object_id,{due})
new Notice(`The due date of Task ${e.parent_item_id} has been modified.`)
}
// sync added task note to obsidian
async syncAddedTaskNoteToObsidian(unSynchronizedEvents) {
// 获取未同步的事件
//console.log(unSynchronizedEvents)
try {
// 处理未同步的事件并等待所有处理完成
const processedEvents = []
for (const e of unSynchronizedEvents) { //如果要修改代码让completeTaskInTheFile(e.object_id)按照顺序依次执行可以将Promise.allSettled()方法改为使用for...of循环来处理未同步的事件。具体步骤如下
console.log(e)
//const taskid = e.parent_item_id
//const note = e.extra_data.content
await this.plugin.fileOperation.syncAddedTaskNoteToTheFile(e)
//await this.plugin.cacheOperation.closeTaskToCacheByID(e.object_id)
new Notice(`Task ${e.parent_item_id} note is added.`)
processedEvents.push(e)
}
// 将新事件合并到现有事件中并保存到 JSON
await this.plugin.cacheOperation.appendEventsToCache(processedEvents)
this.plugin.saveSettings()
} catch (error) {
console.error('同步任务状态时出错:', error)
}
}
async syncTodoistToObsidian(){
try{
const all_activity_events = await this.plugin.todoistSyncAPI.getNonObsidianAllActivityEvents()
// remove synchonized events
const savedEvents = await this.plugin.cacheOperation.loadEventsFromCache()
const result1 = all_activity_events.filter(
(objA) => !savedEvents.some((objB) => objB.id === objA.id)
)
const savedTasks = await this.plugin.cacheOperation.loadTasksFromCache()
// 找出 task id 存在于 Obsidian 中的 task activity
const result2 = result1.filter(
(objA) => savedTasks.some((objB) => objB.id === objA.object_id)
)
// 找出 task id 存在于 Obsidian 中的 note activity
const result3 = result1.filter(
(objA) => savedTasks.some((objB) => objB.id === objA.parent_item_id)
)
const unsynchronized_item_completed_events = this.plugin.todoistSyncAPI.filterActivityEvents(result2, { event_type: 'completed', object_type: 'item' })
const unsynchronized_item_uncompleted_events = this.plugin.todoistSyncAPI.filterActivityEvents(result2, { event_type: 'uncompleted', object_type: 'item' })
//Items updated (only changes to content, description, due_date and responsible_uid)
const unsynchronized_item_updated_events = this.plugin.todoistSyncAPI.filterActivityEvents(result2, { event_type: 'updated', object_type: 'item' })
const unsynchronized_notes_added_events = this.plugin.todoistSyncAPI.filterActivityEvents(result3, { event_type: 'added', object_type: 'note' })
const unsynchronized_project_events = this.plugin.todoistSyncAPI.filterActivityEvents(result1, { object_type: 'project' })
console.log(unsynchronized_item_completed_events)
console.log(unsynchronized_item_uncompleted_events)
console.log(unsynchronized_item_updated_events)
console.log(unsynchronized_project_events)
console.log(unsynchronized_notes_added_events)
await this.syncCompletedTaskStatusToObsidian(unsynchronized_item_completed_events)
await this.syncUncompletedTaskStatusToObsidian(unsynchronized_item_uncompleted_events)
await this.syncUpdatedTaskToObsidian(unsynchronized_item_updated_events)
await this.syncAddedTaskNoteToObsidian(unsynchronized_notes_added_events)
if(unsynchronized_project_events.length){
console.log('New project event')
await this.plugin.cacheOperation.saveProjectsToCache()
await this.plugin.cacheOperation.appendEventsToCache(unsynchronized_project_events)
}
}catch (err){
console.error('An error occurred while synchronizing:', err);
}
}
async backupTodoistAllResources() {
try {
const resources = await this.plugin.todoistSyncAPI.getAllResources()
const now: Date = new Date();
const timeString: string = `${now.getFullYear()}${now.getMonth()+1}${now.getDate()}${now.getHours()}${now.getMinutes()}${now.getSeconds()}`;
const name = "todoist-backup-"+timeString+".json"
this.app.vault.create(name,JSON.stringify(resources))
//console.log(`todoist 备份成功`)
new Notice(`Todoist backup data is saved in the path ${name}`)
} catch (error) {
console.error("An error occurred while creating Todoist backup:", error);
}
}
//After renaming the file, check all tasks in the file and update all links.
async updateTaskDescription(filepath:string){
const metadata = await this.plugin.cacheOperation.getFileMetadata(filepath)
if(!metadata || !metadata.todoistTasks){
return
}
const description = this.plugin.taskParser.getObsidianUrlFromFilepath(filepath)
let updatedContent = {}
updatedContent.description = description
try {
metadata.todoistTasks.forEach(async(taskId) => {
const updatedTask = await this.plugin.todoistRestAPI.UpdateTask(taskId,updatedContent)
updatedTask.path = filepath
this.plugin.cacheOperation.updateTaskToCacheByID(updatedTask);
});
} catch(error) {
console.error('An error occurred in updateTaskDescription:', error);
}
}
}

View file

@ -1,380 +0,0 @@
import { App} from 'obsidian';
import UltimateTodoistSyncForObsidian from "../main";
type Event = {
id: string;
object_type: string;
object_id: string;
event_type: string;
event_date: string;
parent_project_id: string;
parent_item_id: string | null;
initiator_id: string | null;
extra_data: Record<string, any>;
};
type FilterOptions = {
event_type?: string;
object_type?: string;
};
export class TodoistSyncAPI {
app:App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app:App, plugin:UltimateTodoistSyncForObsidian) {
//super(app,settings);
this.app = app;
this.plugin = plugin;
}
//backup todoist
async getAllResources() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/sync';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
sync_token: "*",
resource_types: '["all"]'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch all resources: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch all resources due to network error');
}
}
//backup todoist
async getUserResource() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/sync';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
sync_token: "*",
resource_types: '["user_plan_limits"]'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch all resources: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log(data)
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch user resources due to network error');
}
}
//update user timezone
async updateUserTimezone() {
const unixTimestampString: string = Math.floor(Date.now() / 1000).toString();
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/sync';
const commands = [
{
'type': "user_update",
'uuid': unixTimestampString,
'args': { 'timezone': 'Asia/Shanghai' },
},
];
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ commands: JSON.stringify(commands) })
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch all resources: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log(data)
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch user resources due to network error');
}
}
//get activity logs
//result {count:number,events:[]}
async getAllActivityEvents() {
const accessToken = this.plugin.settings.todoistAPIToken
const headers = new Headers({
Authorization: `Bearer ${accessToken}`
});
try {
const response = await fetch('https://api.todoist.com/sync/v9/activity/get', {
method: 'POST',
headers,
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error(`API returned error status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
throw error;
}
}
async getNonObsidianAllActivityEvents() {
try{
const allActivity = await this.getAllActivityEvents()
//console.log(allActivity)
const allActivityEvents = allActivity.events
//client中不包含obsidian 的activity
const filteredArray = allActivityEvents.filter(obj => !obj.extra_data.client?.includes("obsidian"));
//console.log(filteredArray)
return(filteredArray)
}catch(err){
console.error('An error occurred:', err);
}
}
filterActivityEvents(events: Event[], options: FilterOptions): Event[] {
return events.filter(event =>
(options.event_type ? event.event_type === options.event_type : true) &&
(options.object_type ? event.object_type === options.object_type : true)
);
};
//get completed items activity
//result {count:number,events:[]}
async getCompletedItemsActivity() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/activity/get';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'object_type': 'item',
'event_type': 'completed'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch completed items: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch completed items due to network error');
}
}
//get uncompleted items activity
//result {count:number,events:[]}
async getUncompletedItemsActivity() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/activity/get';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'object_type': 'item',
'event_type': 'uncompleted'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch uncompleted items: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch uncompleted items due to network error');
}
}
//get non-obsidian completed event
async getNonObsidianCompletedItemsActivity() {
const accessToken = this.plugin.settings.todoistAPIToken
const completedItemsActivity = await this.getCompletedItemsActivity()
const completedItemsActivityEvents = completedItemsActivity.events
//client中不包含obsidian 的activity
const filteredArray = completedItemsActivityEvents.filter(obj => !obj.extra_data.client.includes("obsidian"));
return(filteredArray)
}
//get non-obsidian uncompleted event
async getNonObsidianUncompletedItemsActivity() {
const uncompletedItemsActivity = await this.getUncompletedItemsActivity()
const uncompletedItemsActivityEvents = uncompletedItemsActivity.events
//client中不包含obsidian 的activity
const filteredArray = uncompletedItemsActivityEvents.filter(obj => !obj.extra_data.client.includes("obsidian"));
return(filteredArray)
}
//get updated items activity
//result {count:number,events:[]}
async getUpdatedItemsActivity() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/activity/get';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'object_type': 'item',
'event_type': 'updated'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch updated items: ${response.status} ${response.statusText}`);
}
const data = await response.json();
//console.log(data)
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch updated items due to network error');
}
}
//get non-obsidian updated event
async getNonObsidianUpdatedItemsActivity() {
const updatedItemsActivity = await this.getUpdatedItemsActivity()
const updatedItemsActivityEvents = updatedItemsActivity.events
//client中不包含obsidian 的activity
const filteredArray = updatedItemsActivityEvents.filter(obj => {
const client = obj.extra_data && obj.extra_data.client;
return !client || !client.includes("obsidian");
});
return(filteredArray)
}
//get completed items activity
//result {count:number,events:[]}
async getProjectsActivity() {
const accessToken = this.plugin.settings.todoistAPIToken
const url = 'https://api.todoist.com/sync/v9/activity/get';
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'object_type': 'project'
})
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Failed to fetch projects activities: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch projects activities due to network error');
}
}
}

1419
src/ui/modals.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
import { App } from 'obsidian';
import UltimateTodoistSyncForObsidian from '../../main';
export class DeviceManager {
private app: App;
private plugin: UltimateTodoistSyncForObsidian;
private deviceId = '';
constructor(app: App, plugin: UltimateTodoistSyncForObsidian) {
this.app = app;
this.plugin = plugin;
}
private getPluginPath(): string {
return this.plugin.manifest.dir || `${this.app.vault.configDir}/plugins/ultimate-todoist-sync`;
}
private getDeviceIdPath(): string {
return `${this.getPluginPath()}/device-id`;
}
private getDeviceName(): string {
try {
const os = require('os');
return os.hostname() || 'unknown';
} catch {
return 'unknown';
}
}
private generateRandomString(length: number): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
async getDeviceId(): Promise<string> {
if (this.deviceId) {
return this.deviceId;
}
const deviceIdPath = this.getDeviceIdPath();
try {
const adapter = this.app.vault.adapter;
if (await adapter.exists(deviceIdPath)) {
const content = await adapter.read(deviceIdPath);
this.deviceId = content.trim();
return this.deviceId;
}
const deviceName = this.getDeviceName();
const randomStr = this.generateRandomString(16);
this.deviceId = `obsidian_${deviceName}_${randomStr}`;
await adapter.write(deviceIdPath, this.deviceId);
this.plugin.debugLog(`[DeviceManager] Created new device ID: ${this.deviceId}`);
return this.deviceId;
} catch (error) {
console.error('[DeviceManager] Error getting/creating device ID:', error);
const fallbackId = `obsidian_unknown_${this.generateRandomString(16)}`;
console.warn(`[DeviceManager] Using fallback device ID: ${fallbackId}`);
return fallbackId;
}
}
async getClientHeader(): Promise<string> {
return await this.getDeviceId();
}
async getDeviceIdInfo(): Promise<{ deviceId: string; path: string }> {
const deviceId = await this.getDeviceId();
const path = this.getDeviceIdPath();
return { deviceId, path };
}
}

998
src/vault/fileOperation.ts Normal file
View file

@ -0,0 +1,998 @@
import { App, TFile } from 'obsidian';
import UltimateTodoistSyncForObsidian from "../../main";
export interface VaultTask {
taskId: string;
content: string;
isCompleted: boolean;
filePath: string;
lineNumber: number;
labels: string[];
dueDate?: string;
priority?: number;
}
export interface VaultTaskWithoutId {
content: string;
isCompleted: boolean;
filePath: string;
lineNumber: number;
labels: string[];
}
export interface TodoistTask {
taskId: string;
content: string;
description?: string;
checked: boolean;
dueDate?: string;
priority: number;
projectId: string;
labels: string[];
}
export class FileOperation {
app:App;
plugin: UltimateTodoistSyncForObsidian;
constructor(app:App, plugin:UltimateTodoistSyncForObsidian) {
//super(app,settings);
this.app = app;
this.plugin = plugin;
}
/**
* Check if a file path should be excluded from Full Vault Sync.
* Excluded: dot-prefix dirs, plugin storage dir, Obsidian templates folder, *.excalidraw.md
*/
isFileExcludedFromSync(filepath: string): boolean {
// Dot-prefix directories (.obsidian/, .trash/, .git/, .stfolder/, etc.)
if (filepath.startsWith('.')) return true;
// Plugin storage directory
const storagePath = this.plugin.storagePathManager?.getBasePath() || 'ultimate-todoist-sync';
if (filepath.startsWith(storagePath + '/')) return true;
// Obsidian core templates folder
try {
const templatesConfig = (this.app as any).internalPlugins?.getPluginById?.('templates')?.instance?.options?.folder;
if (templatesConfig && filepath.startsWith(templatesConfig + '/')) return true;
} catch { /* ignore */ }
// Templater plugin folder
try {
const templaterFolder = (this.app as any).plugins?.getPlugin?.('templater-obsidian')?.settings?.templates_folder;
if (templaterFolder && filepath.startsWith(templaterFolder + '/')) return true;
} catch { /* ignore */ }
// Excalidraw files
if (filepath.endsWith('.excalidraw.md')) return true;
// User-configured excluded folders
for (const folder of this.plugin.settings.excludedFolders) {
if (filepath.startsWith(folder + '/') || filepath === folder) return true;
}
return false;
}
/*
async getFrontMatter(file:TFile): Promise<FrontMatter | null> {
return new Promise((resolve) => {
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
resolve(frontMatter);
});
});
}
*/
/*
async updateFrontMatter(
file:TFile,
updater: (frontMatter: FrontMatter) => void
): Promise<void> {
//this.plugin.debugLog(`prepare to update front matter`)
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
if (frontMatter !== null) {
const updatedFrontMatter = { ...frontMatter } as FrontMatter;
updater(updatedFrontMatter);
this.app.fileManager.processFrontMatter(file, (newFrontMatter) => {
if (newFrontMatter !== null) {
newFrontMatter.todoistTasks = updatedFrontMatter.todoistTasks;
newFrontMatter.todoistCount = updatedFrontMatter.todoistCount;
}
});
}
});
}
*/
// 完成一个任务,将其标记为已完成
async completeTaskInTheFile(taskId: string) {
// 获取任务文件路径
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId)
if (!taskMapping) {
console.error(`Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
lines[i] = line.replace('[ ]', '[x]')
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TASK_COMPLETED', `Completed task in file: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
}
// uncheck 已完成的任务,
async uncompleteTaskInTheFile(taskId: string) {
// 获取任务文件路径
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId)
if (!taskMapping) {
console.error(`Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
lines[i] = line.replace(/- \[(x|X)\]/g, '- [ ]');
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TASK_UNCOMPLETED', `Reopened task in file: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
}
/**
* Unbind a task from its vault line by removing todoist_id, todoist link, and #todoist tag.
* The task line remains in the file but is no longer associated with Todoist.
*/
async unbindTaskInFile(taskId: string): Promise<void> {
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId);
if (!taskMapping) {
console.error(`[FileOperation] unbindTaskInFile: Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath;
const file = this.app.vault.getAbstractFileByPath(filepath);
if (!file) {
console.error(`[FileOperation] unbindTaskInFile: File not found: ${filepath}`);
return;
}
const content = await this.app.vault.read(file);
const lines = content.split('\n');
let modified = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(taskId)) continue;
let newLine = line;
// Remove %%[todoist_id:: xxx]%%
newLine = newLine.replace(/%%\[todoist_id::\s*\S+\]%%/g, '');
// Remove todoist links: [link](https://todoist.com/...) or [link](https://app.todoist.com/...) or [link](todoist://...)
newLine = newLine.replace(/\[([^\]]*)\]\(https?:\/\/(?:app\.)?todoist\.com\/[^)]*\)/g, '');
newLine = newLine.replace(/\[([^\]]*)\]\(todoist:\/\/[^)]*\)/g, '');
// Remove #todoist tag
newLine = newLine.replace(/#todoist/g, '');
// Clean up multiple spaces left behind
newLine = newLine.replace(/ +/g, ' ');
// Trim trailing whitespace but preserve leading indentation
newLine = newLine.replace(/\s+$/, '');
if (newLine !== line) {
lines[i] = newLine;
modified = true;
}
break;
}
if (modified) {
const newContent = lines.join('\n');
await this.app.vault.modify(file, newContent);
this.plugin.logOperation?.log('FILE_TASK_UNBOUND', `Unbound task from file: ${taskId}`, filepath, taskId, 'manual');
}
}
//add #todoist at the end of task line, if full vault sync enabled
async addTodoistTagToFile(filepath: string) {
if (this.isFileExcludedFromSync(filepath)) return;
const file = this.app.vault.getAbstractFileByPath(filepath)
if (!file) {
this.plugin.debugLog(`[addTodoistTagToFile] File not found: ${filepath}`);
return;
}
const content = await this.app.vault.read(file as TFile)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if(!this.plugin.taskParser.isMarkdownTask(line)){
//this.plugin.debugLog(line)
//this.plugin.debugLog("It is not a markdown task.")
continue;
}
//if content is empty
if(this.plugin.taskParser.getTaskContentFromLineText(line) == ""){
//this.plugin.debugLog("Line content is empty")
continue;
}
if (!this.plugin.taskParser.hasTodoistId(line) && !this.plugin.taskParser.hasTodoistTag(line)) {
//this.plugin.debugLog(line)
//this.plugin.debugLog('prepare to add todoist tag')
const newLine = this.plugin.taskParser.addTodoistTag(line);
//this.plugin.debugLog(newLine)
lines[i] = newLine
modified = true
}
}
if (modified) {
this.plugin.debugLog(`New task found in files ${filepath}`)
const newContent = lines.join('\n')
//this.plugin.debugLog(newContent)
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TODOIST_TAG_ADDED', `Added todoist tag to file: ${filepath}`, filepath);
}
}
//add todoist at the line
async addTodoistLinkToFile(filepath: string) {
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (this.plugin.taskParser.hasTodoistId(line) && this.plugin.taskParser.hasTodoistTag(line)) {
if(this.plugin.taskParser.hasTodoistLink(line)){
return
}
this.plugin.debugLog(line)
//this.plugin.debugLog('prepare to add todoist link')
const taskID = this.plugin.taskParser.getTodoistIdFromLineText(line)
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskID)
if (!taskMapping) {
console.error(`Task ${taskID} not found in taskFileMapping`);
continue;
}
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)
lines[i] = newLine
modified = true
}else{
continue
}
}
if (modified) {
const newContent = lines.join('\n')
//this.plugin.debugLog(newContent)
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
}
}
// sync updated task content to file
async syncUpdatedTaskContentToTheFile(evt:Object) {
const taskId = evt.object_id
// 获取任务文件路径
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId)
if (!taskMapping) {
console.error(`Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldTaskContent = this.plugin.taskParser.getTaskContentFromLineText(line)
const newTaskContent = evt.extra_data.content
lines[i] = line.replace(oldTaskContent, newTaskContent)
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
//this.plugin.debugLog(newContent)
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TASK_CONTENT_SYNCED', `Synced task content from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
}
// sync updated task due date to the file
async syncUpdatedTaskDueDateToTheFile(evt:Object) {
const taskId = evt.object_id
// 获取任务文件路径
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId)
if (!taskMapping) {
console.error(`Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldTaskDueDate = this.plugin.taskParser.getDueDateFromLineText(line) || ""
const newTaskDueDate = this.plugin.taskParser.ISOStringToLocalDateString(evt.extra_data.due_date) || ""
//this.plugin.debugLog(`${taskId} duedate is updated`)
this.plugin.debugLog(oldTaskDueDate)
this.plugin.debugLog(newTaskDueDate)
if(oldTaskDueDate === ""){
//this.plugin.debugLog(this.plugin.taskParser.insertDueDateBeforeTodoist(line,newTaskDueDate))
lines[i] = this.plugin.taskParser.insertDueDateBeforeTodoist(line,newTaskDueDate)
modified = true
}
else if(newTaskDueDate === ""){
//remove 日期from text
const regexRemoveDate = /(🗓️|📅|📆|🗓)\s?\d{4}-\d{2}-\d{2}/; //匹配日期🗓2023-03-07"
lines[i] = line.replace(regexRemoveDate,"")
modified = true
}
else{
lines[i] = line.replace(oldTaskDueDate, newTaskDueDate)
modified = true
}
break
}
}
if (modified) {
const newContent = lines.join('\n')
//this.plugin.debugLog(newContent)
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TASK_DUEDATE_SYNCED', `Synced task due date from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
}
// sync new task note to file
async syncAddedTaskNoteToTheFile(evt:Object) {
const taskId = evt.parent_item_id
const note = evt.extra_data.content
const datetime = this.plugin.taskParser.ISOStringToLocalDatetimeString(evt.event_date)
// 获取任务文件路径
const taskMapping = this.plugin.cacheOperation.getTaskFileMapping(taskId)
if (!taskMapping) {
console.error(`Task ${taskId} not found in taskFileMapping`);
return;
}
const filepath = taskMapping.filePath
// 获取文件对象并更新内容
const file = this.app.vault.getAbstractFileByPath(filepath)
const content = await this.app.vault.read(file)
const lines = content.split('\n')
let modified = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const indent = '\t'.repeat(line.length - line.trimStart().length + 1);
const noteLine = `${indent}- ${datetime} ${note}`;
lines.splice(i + 1, 0, noteLine);
modified = true
break
}
}
if (modified) {
const newContent = lines.join('\n')
//this.plugin.debugLog(newContent)
await this.plugin.backupOperation?.backupFile(filepath);
await this.app.vault.modify(file, newContent)
this.plugin.logOperation?.log('FILE_TASK_NOTE_ADDED', `Synced task note from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
}
async syncTaskContentToFile(taskId: string, newContent: 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;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldContent = this.plugin.taskParser.getTaskContentFromLineText(line);
if (oldContent && oldContent !== newContent) {
lines[i] = line.replace(oldContent, newContent);
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_CONTENT_SYNCED', `Synced content from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
return modified;
}
async syncTaskDueDateToFile(taskId: string, newDueDate: 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;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const oldDueDate = this.plugin.taskParser.getDueDateFromLineText(line) || "";
const localDueDate = this.plugin.taskParser.ISOStringToLocalDateString(newDueDate) || "";
if (oldDueDate === localDueDate) break;
if (oldDueDate === "" && localDueDate !== "") {
lines[i] = this.plugin.taskParser.insertDueDateBeforeTodoist(line, localDueDate);
modified = true;
} else if (localDueDate === "") {
const regexRemoveDate = /(🗓️|📅|📆|🗓)\s?\d{4}-\d{2}-\d{2}/;
lines[i] = line.replace(regexRemoveDate, "");
modified = true;
} else {
lines[i] = line.replace(oldDueDate, localDueDate);
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_DUEDATE_SYNCED', `Synced due date from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
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;
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;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes(taskId) && this.plugin.taskParser.hasTodoistTag(line)) {
const indent = '\t'.repeat(line.length - line.trimStart().length + 1);
const noteLine = `${indent}- ${noteDate} ${noteContent}`;
// skip if note already exists in next lines
if (i + 1 < lines.length && lines[i + 1].includes(noteContent)) break;
lines.splice(i + 1, 0, noteLine);
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_NOTE_ADDED', `Synced note from Todoist: ${taskId}`, filepath, taskId, this.plugin.isSyncingFromTodoist ? 'todoist→obsidian' : 'obsidian→todoist');
}
return modified;
}
//避免使用该方式通过view可以获得实时更新的value
async readContentFromFilePath(filepath:string){
try {
const file = this.app.vault.getAbstractFileByPath(filepath);
const content = await this.app.vault.read(file);
return content
} catch (error) {
console.error(`Error loading content from ${filepath}: ${error}`);
return false;
}
}
//search todoist_id by content
async searchTodoistIdFromFilePath(filepath: string, searchTerm: string): Promise<string | null> {
const file = this.app.vault.getAbstractFileByPath(filepath)
const fileContent = await this.app.vault.read(file)
const fileLines = fileContent.split('\n');
let todoistId: string | null = null;
for (let i = 0; i < fileLines.length; i++) {
const line = fileLines[i];
if (line.includes(searchTerm)) {
const regexResult = /\[todoist_id::\s*(\w+)\]/.exec(line);
if (regexResult) {
todoistId = regexResult[1];
}
break;
}
}
return todoistId;
}
//get all files in the vault
async getAllFilesInTheVault(){
const files = this.app.vault.getFiles()
return(files)
}
//search filepath by taskid in vault
async searchFilepathsByTaskidInVault(taskId:string){
this.plugin.debugLog(`preprare to search task ${taskId}`)
const files = await this.getAllFilesInTheVault()
//this.plugin.debugLog(files)
const tasks = files.map(async (file) => {
if (!this.isMarkdownFile(file.path)) {
return;
}
const fileContent = await this.app.vault.cachedRead(file);
if (fileContent.includes(taskId)) {
return file.path;
}
});
const results = await Promise.all(tasks);
const filePaths = results.filter((filePath) => filePath !== undefined);
return filePaths[0] || null;
//return filePaths || null
}
isMarkdownFile(filename:string) {
// 获取文件名的扩展名
let extension = filename.split('.').pop();
// 将扩展名转换为小写Markdown文件的扩展名通常是.md
extension = extension.toLowerCase();
// 判断扩展名是否为.md
if (extension === 'md') {
return true;
} else {
return false;
}
}
/**
* Update task ID in vault file (for legacy ID conversion)
*/
async updateTaskIdInVault(
filePath: string,
oldId: string,
newId: string
): Promise<boolean> {
try {
console.log(`[updateTaskIdInVault] start file=${filePath} oldId=${oldId} newId=${newId}`);
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
console.error(`[updateTaskIdInVault] File not found: ${filePath}`);
this.plugin.debugLog(filePath)
console.log(`[updateTaskIdInVault] fail file-not-found file=${filePath}`);
return false;
}
const content = await this.app.vault.read(file);
const lines = content.split('\n');
console.log(`[updateTaskIdInVault] file-loaded lines=${lines.length} file=${filePath}`);
// Scan for the line containing the old task ID
let targetLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(`todoist_id:: ${oldId}`)) {
targetLineIndex = i;
break;
}
}
if (targetLineIndex === -1) {
console.warn(`[updateTaskIdInVault] Old ID ${oldId} not found in ${filePath}`);
console.log(`[updateTaskIdInVault] fail old-id-not-found oldId=${oldId} file=${filePath}`);
return false;
}
console.log(`[updateTaskIdInVault] target-line index=${targetLineIndex} file=${filePath}`);
let line = lines[targetLineIndex];
let hasChanges = false;
// 1. Replace todoist_id metadata: %%[todoist_id:: oldId]%% -> %%[todoist_id:: newId]%%
const oldIdPattern = new RegExp(`%%\\[todoist_id::\\s*${oldId}\\]%%`, 'g');
if (oldIdPattern.test(line)) {
line = line.replace(oldIdPattern, `%%[todoist_id:: ${newId}]%%`);
hasChanges = true;
console.log('[updateTaskIdInVault] replaced todoist_id metadata');
}
// 2. Replace App URI: todoist://task?id=oldId -> todoist://task?id=newId
const oldAppUriPattern = new RegExp(`todoist://task\\?id=${oldId}`, 'g');
if (oldAppUriPattern.test(line)) {
line = line.replace(oldAppUriPattern, `todoist://task?id=${newId}`);
hasChanges = true;
console.log('[updateTaskIdInVault] replaced app uri');
}
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;
console.log('[updateTaskIdInVault] replaced legacy web url');
}
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;
console.log('[updateTaskIdInVault] replaced new web url');
}
if (!hasChanges) {
console.warn(`[updateTaskIdInVault] No ID patterns found for ${oldId} in ${filePath}`);
console.log(`[updateTaskIdInVault] fail no-patterns-matched oldId=${oldId} file=${filePath}`);
return false;
}
lines[targetLineIndex] = line;
if (!this.plugin.backupOperation) {
console.error(`[updateTaskIdInVault] Backup module not initialized, skipping ID update ${oldId} -> ${newId}`);
console.log('[updateTaskIdInVault] fail backup-module-missing');
return false;
}
console.log(`[updateTaskIdInVault] backup-start file=${filePath}`);
const backupPath = await this.plugin.backupOperation.backupFile(filePath);
if (!backupPath) {
console.error(`[updateTaskIdInVault] Backup failed, skipping ID update ${oldId} -> ${newId}`);
console.log('[updateTaskIdInVault] fail backup-failed');
return false;
}
console.log(`[updateTaskIdInVault] backup-success path=${backupPath}`);
await this.app.vault.modify(file, lines.join('\n'));
console.log(`[updateTaskIdInVault] vault-modify-success oldId=${oldId} newId=${newId} file=${filePath}`);
this.plugin.logOperation?.log(
'FILE_TASK_ID_UPDATED',
`Updated task ID ${oldId} -> ${newId}`,
filePath,
newId
);
this.plugin.debugLog(`[updateTaskIdInVault] Updated task ID ${oldId} -> ${newId} in ${filePath}`);
console.log(`[updateTaskIdInVault] done success oldId=${oldId} newId=${newId}`);
return true;
} catch (error) {
console.error(`[updateTaskIdInVault] Failed to update task ID in vault:`, error);
console.log('[updateTaskIdInVault] fail exception thrown');
return false;
}
}
/**
* Vault Todoist
*
*
* 1. .md
* 2.
* 3. #todoist
* 4. todoist_id ID
* 5. 使 taskParser
*
* @returns {
* tasksWithId: Map<string, VaultTask>, // 有 todoist_id 的任务
* tasksWithoutId: VaultTaskWithoutId[] // 无 todoist_id 的任务(新任务未同步)
* }
*/
async scanVaultTasks(): Promise<{
tasksWithId: Map<string, VaultTask>;
tasksWithoutId: VaultTaskWithoutId[];
}> {
const tasksWithId = new Map<string, VaultTask>();
const tasksWithoutId: VaultTaskWithoutId[] = [];
const storageDir = this.plugin.settings?.storageDirectory || 'ultimate-todoist-sync';
const files = this.app.vault.getFiles().filter(f => f.extension === 'md' && !f.path.startsWith(storageDir + '/') && !f.path.startsWith('.'));
for (const file of files) {
try {
const content = await this.app.vault.read(file);
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes('#todoist')) {
continue;
}
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 dueDate = this.plugin.taskParser.getDueDateFromLineText(line) || undefined;
const priority = this.plugin.taskParser.getTaskPriority(line);
if (match && match[1]) {
const taskId = match[1];
if (tasksWithId.has(taskId)) {
console.warn(`[scanVaultTasks] Duplicate taskId ${taskId} found in ${file.path} (line ${i}), already mapped — skipping`);
continue;
}
tasksWithId.set(taskId, {
taskId,
content: taskContent,
isCompleted,
filePath: file.path,
lineNumber: i,
labels,
dueDate,
priority,
});
} else {
tasksWithoutId.push({
content: taskContent,
isCompleted,
filePath: file.path,
lineNumber: i,
labels
});
}
}
} catch (error) {
console.error(`Error reading file ${file.path}:`, error);
}
}
return { tasksWithId, tasksWithoutId };
}
/**
* syncData Todoist
*
* @param syncData - Todoist Sync API
* @returns Map<taskId, TodoistTask>
*/
getTodoistTasksFromSyncData(syncData: Record<string, any> | null): Map<string, TodoistTask> {
const todoistTasksMap = new Map<string, TodoistTask>();
if (!syncData || !syncData.items) {
return todoistTasksMap;
}
for (const task of syncData.items) {
if (!task) continue;
const taskAny = task as any;
todoistTasksMap.set(task.id, {
taskId: task.id,
content: task.content || '',
description: taskAny.description || '',
checked: !!taskAny.checked,
dueDate: task.due?.date,
priority: task.priority || 1,
projectId: taskAny.project_id || '',
labels: task.labels || []
});
}
return todoistTasksMap;
}
/**
*
*
* @param line -
* @returns #
*/
private extractLabelsFromLine(line: string): string[] {
return this.plugin.taskParser.getAllTagsFromLineText(line);
}
}

View file

@ -3,11 +3,101 @@
display: none;
}
/* ============================================
* Settings Page Title & Section Headings
* ============================================ */
.uts-settings-title {
font-size: 1.4em;
font-weight: 700;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--background-modifier-border);
}
.uts-section-heading {
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-top: 28px;
margin-bottom: 8px;
padding-top: 16px;
border-top: 1px solid var(--background-modifier-border);
}
/* ============================================
* Excluded Folders Modal
* ============================================ */
.uts-excluded-folders-modal {
width: 500px;
max-width: 90vw;
}
.uts-excluded-folders-modal h3 {
margin: 0 0 4px;
}
.uts-excluded-folders-desc {
font-size: 13px;
color: var(--text-muted);
margin: 0 0 12px;
}
.uts-excluded-folders-tree {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
padding: 4px 0;
margin-bottom: 16px;
}
.uts-excluded-folder-row {
display: flex;
align-items: center;
padding: 4px 12px;
cursor: pointer;
transition: background 0.1s ease;
}
.uts-excluded-folder-row:hover {
background: var(--background-modifier-hover);
}
.uts-excluded-folder-row input[type="checkbox"] {
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0 6px 0 0;
cursor: pointer;
accent-color: var(--interactive-accent);
}
.uts-excluded-folder-row input[type="checkbox"]:disabled {
cursor: not-allowed;
}
.uts-excluded-folder-label {
font-size: 13px;
color: var(--text-normal);
user-select: none;
cursor: pointer;
}
.uts-excluded-folders-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Settings page — summary list */
.uts-excluded-folders-summary {
width: 100%;
margin-top: 8px;
padding: 6px 10px;
border-radius: 4px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.uts-excluded-folders-summary-item {
font-size: 12px;
color: var(--text-muted);
padding: 1px 0;
font-family: var(--font-monospace);
}
/*
.cm-line span.cm-comment {
@ -20,3 +110,413 @@
}
*/
/* ============================================
Task Manager Modal
============================================ */
.task-manager-modal {
width: 860px;
max-width: 95vw;
}
.task-manager-modal .modal-content {
padding: 0;
}
/* Header */
.tm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tm-header h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.tm-summary {
display: flex;
gap: 12px;
align-items: center;
}
.tm-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.tm-badge--conflict {
background: hsla(30, 100%, 50%, 0.12);
color: hsl(30, 100%, 45%);
}
.tm-badge--issue {
background: hsla(0, 80%, 55%, 0.12);
color: hsl(0, 80%, 50%);
}
.tm-badge--nonactive {
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;
overflow-y: auto;
padding: 16px 24px 24px;
}
/* Empty state */
.tm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
gap: 8px;
}
.tm-empty-icon {
font-size: 36px;
opacity: 0.5;
}
.tm-empty-text {
font-size: 14px;
}
.tm-task-id {
font-family: var(--font-monospace);
font-size: 11px;
color: var(--text-faint);
background: var(--background-modifier-hover);
padding: 1px 6px;
border-radius: 4px;
}
.tm-file-link {
font-size: 12px;
color: var(--text-accent);
cursor: pointer;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-file-link:hover {
text-decoration: underline;
}
.tm-todoist-link {
font-size: 12px;
color: var(--text-accent);
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.tm-todoist-link:hover {
text-decoration: underline;
}
/* Diff table */
.tm-diff {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tm-diff th {
text-align: left;
padding: 6px 14px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.tm-diff td {
padding: 6px 14px;
border-bottom: 1px solid var(--background-modifier-border-hover);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-diff tr:last-child td {
border-bottom: none;
}
.tm-diff-field {
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
width: 80px;
}
.tm-diff-changed {
background: hsla(30, 100%, 50%, 0.06);
}
.tm-diff-marker {
width: 20px;
text-align: center;
padding: 6px 4px;
font-size: 11px;
}
.tm-diff-marker--changed {
color: hsl(30, 100%, 45%);
}
.tm-diff-val {
color: var(--text-normal);
}
.tm-diff-val--empty {
color: var(--text-faint);
font-style: italic;
}
.tm-btn {
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
border: none;
transition: opacity 0.15s ease;
}
.tm-btn:hover {
opacity: 0.85;
}
.tm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tm-btn--primary {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.tm-btn--secondary {
background: var(--interactive-normal);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}
.tm-btn--danger {
background: hsl(0, 70%, 55%);
color: #fff;
}
.tm-btn--danger:hover {
background: hsl(0, 70%, 48%);
}
.tm-content-preview {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-normal);
}
.tm-detail-body .tm-content-preview {
max-width: none;
white-space: normal;
word-break: break-word;
}
.tm-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
border-radius: 6px;
margin: 12px 0 0;
font-size: 13px;
background: hsla(30, 100%, 50%, 0.12);
color: hsl(30, 100%, 45%);
}
.tm-refresh-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px 8px;
border-radius: 4px;
color: var(--text-muted);
}
.tm-refresh-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.tm-bulk-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--background-secondary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
}
.tm-confirm-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.tm-confirm-box {
background: var(--background-primary);
border-radius: 8px;
padding: 20px;
max-width: 400px;
border: 1px solid var(--background-modifier-border);
}
.tm-confirm-msg {
margin-bottom: 16px;
font-size: 14px;
color: var(--text-normal);
}
.tm-confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* ============================================
List View (master-detail)
============================================ */
.tm-list-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
border-bottom: 1px solid var(--background-modifier-border-hover);
transition: background 0.1s ease;
}
.tm-list-row:last-child {
border-bottom: none;
}
.tm-list-row:hover {
background: var(--background-modifier-hover);
}
.tm-list-icon {
flex-shrink: 0;
width: 20px;
text-align: center;
font-size: 14px;
}
.tm-list-content {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--text-normal);
}
.tm-list-file {
flex-shrink: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-muted);
}
.tm-list-badge {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tm-list-badge--conflict {
background: hsla(30, 100%, 50%, 0.12);
color: hsl(30, 100%, 45%);
}
.tm-list-badge--issue {
background: hsla(0, 80%, 55%, 0.12);
color: hsl(0, 80%, 50%);
}
.tm-list-badge--nonactive {
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)
============================================ */
.tm-detail-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px 10px;
border-bottom: none;
}
.tm-detail-back {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
color: var(--text-muted);
line-height: 1;
}
.tm-detail-back:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.tm-detail-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.tm-detail-body {
padding: 0;
}
.tm-detail-info {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
padding: 12px 14px;
background: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.tm-detail-info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.tm-detail-info-label {
color: var(--text-muted);
font-weight: 500;
min-width: 60px;
}
.tm-detail-info-value {
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-detail-actions {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--background-modifier-border);
}

View file

@ -1,3 +1,7 @@
{
"1.0.0": "0.15.0"
"1.0.0": "0.15.0",
"2.0.0": "1.0.0",
"2.0.1": "1.0.0",
"2.0.2": "1.0.0",
"2.0.3": "1.0.0"
}