mirror of
https://github.com/heroblackink/ultimate-todoist-sync-for-obsidian.git
synced 2026-07-22 07:40:27 +00:00
Compare commits
No commits in common. "1.0.38" and "master" have entirely different histories.
37 changed files with 12348 additions and 3708 deletions
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -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
15
.gitignore
vendored
|
|
@ -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
6
.stignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
data.json
|
||||
device-id
|
||||
.DS_Store
|
||||
/.github/
|
||||
/.git/
|
||||
/.sisyphus/
|
||||
204
CHANGELOG.md
Normal file
204
CHANGELOG.md
Normal 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
197
README.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
845
main.ts
845
main.ts
|
|
@ -1,609 +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()
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
/* In the 1.0.37 version, editor-change no longer detects new tasks.
|
||||
//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 (!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) => {
|
||||
const filepath = view.file.path
|
||||
new SetDefalutProjectInTheFilepathModal(this.app,this,this.cacheOperation,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)
|
||||
//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;
|
||||
//console.log(filesToSync)
|
||||
for (let fileKey in filesToSync) {
|
||||
//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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "ultimate-todoist-sync",
|
||||
"name": "Ultimate Todoist Sync",
|
||||
"version": "1.0.36",
|
||||
"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
573
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1213
src/api/syncApi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,460 +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}`)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//Check errors in filemata where the filepath is incorrect.
|
||||
async checkFileMetadata(){
|
||||
const metadatas = await this.getFileMetadatas()
|
||||
for (const key in metadatas) {
|
||||
const value = metadatas[key];
|
||||
if(!value.todoistTasks){
|
||||
//todo
|
||||
//delelte empty metadata
|
||||
return
|
||||
}
|
||||
let filepath = key
|
||||
//check if file exist
|
||||
let file = this.app.vault.getAbstractFileByPath(filepath)
|
||||
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
1608
src/data/cache.ts
Normal file
File diff suppressed because it is too large
Load diff
1172
src/data/databaseChecker.ts
Normal file
1172
src/data/databaseChecker.ts
Normal file
File diff suppressed because it is too large
Load diff
297
src/data/taskIssueUtils.ts
Normal file
297
src/data/taskIssueUtils.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,23 +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对象
|
||||
let localDateString = dateObj.toLocaleString(); // 将Date对象转换为本地时间字符串
|
||||
let localDateObj = new Date(localDateString);
|
||||
let year = localDateObj.getFullYear();
|
||||
let month = (localDateObj.getMonth() + 1).toString().padStart(2, '0');
|
||||
let date = localDateObj.getDate().toString().padStart(2, '0');
|
||||
let hours = localDateObj.getHours().toString().padStart(2, '0');
|
||||
let minutes = localDateObj.getMinutes().toString().padStart(2, '0');
|
||||
let result = `${year}-${month}-${date}`;
|
||||
return(result);
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error(`Error extracting date from string '${utcTimeString}': ${error}`);
|
||||
return null;
|
||||
|
|
@ -461,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){
|
||||
|
|
@ -483,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);
|
||||
|
|
@ -503,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;
|
||||
|
|
@ -535,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");
|
||||
|
|
@ -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.getLine,read 方法有延迟
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
70
src/modal.ts
70
src/modal.ts
|
|
@ -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
231
src/plugin/eventHandlers.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
383
src/settings.ts
383
src/settings.ts
|
|
@ -1,383 +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;
|
||||
}
|
||||
|
||||
|
||||
export const DEFAULT_SETTINGS: UltimateTodoistSyncSettings = {
|
||||
initialized: false,
|
||||
apiInitialized:false,
|
||||
defaultProjectName:"Inbox",
|
||||
automaticSynchronizationInterval: 300, //default aync interval 300s
|
||||
todoistTasksData:{},
|
||||
fileMetadata:{},
|
||||
enableFullVaultSync:false,
|
||||
statistics:{},
|
||||
//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
|
||||
}
|
||||
|
||||
|
||||
//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('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()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
790
src/settings/safeSettings.ts
Normal file
790
src/settings/safeSettings.ts
Normal 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
811
src/settings/settings.ts
Normal 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
193
src/storage/backup.ts
Normal 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
181
src/storage/log.ts
Normal 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
445
src/storage/pathManager.ts
Normal 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
112
src/sync/scheduler.ts
Normal 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
130
src/sync/syncLock.ts
Normal 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
246
src/sync/toObsidian.ts
Normal 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
746
src/sync/toTodoist.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,872 +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})
|
||||
|
||||
}
|
||||
|
||||
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})
|
||||
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
1419
src/ui/modals.ts
Normal file
File diff suppressed because it is too large
Load diff
82
src/utils/deviceManager.ts
Normal file
82
src/utils/deviceManager.ts
Normal 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
998
src/vault/fileOperation.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
504
styles.css
504
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue