mirror of
https://github.com/ysonc/obsidian-tasks-syncer.git
synced 2026-07-22 08:32:33 +00:00
fix: harden community plugin integration
This commit is contained in:
parent
29f3359ba5
commit
393ee7ae07
59 changed files with 7125 additions and 1982 deletions
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
|
|
@ -27,6 +27,9 @@ jobs:
|
|||
- name: Lint, test, build, and validate release
|
||||
run: npm run check
|
||||
|
||||
- name: Audit all dependencies
|
||||
run: npm audit
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: npm audit --omit=dev
|
||||
|
||||
|
|
|
|||
141
.github/workflows/release.yaml
vendored
141
.github/workflows/release.yaml
vendored
|
|
@ -48,6 +48,9 @@ jobs:
|
|||
- name: Run full checks and production build
|
||||
run: npm run check
|
||||
|
||||
- name: Audit all dependencies
|
||||
run: npm audit
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: npm audit --omit=dev
|
||||
|
||||
|
|
@ -130,8 +133,6 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
required_assets=(main.js manifest.json styles.css)
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/$VERSION")
|
||||
if [[ -n "$REMOTE_TAG" ]]; then
|
||||
git fetch --force origin "refs/tags/$VERSION:refs/tags/$VERSION"
|
||||
|
|
@ -153,51 +154,52 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE_EXISTS" == true && "$TAG_EXISTS" != true ]]; then
|
||||
echo "Release $VERSION exists without the expected exact remote tag."
|
||||
exit 1
|
||||
rm -rf existing-release
|
||||
mkdir existing-release
|
||||
if [[ "$RELEASE_EXISTS" == true ]] && jq -e '.assets | length > 0' release.json >/dev/null; then
|
||||
gh release download "$VERSION" --dir existing-release
|
||||
fi
|
||||
|
||||
RELEASE_COMPLETE=false
|
||||
if [[ "$RELEASE_EXISTS" == true ]]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION" \
|
||||
--jq '.assets[] | [.name, .size] | @tsv' > asset-rows.txt
|
||||
mapfile -t ASSET_ROWS < asset-rows.txt
|
||||
if [[ ${#ASSET_ROWS[@]} -eq ${#required_assets[@]} ]]; then
|
||||
RELEASE_COMPLETE=true
|
||||
for required in "${required_assets[@]}"; do
|
||||
MATCHED=false
|
||||
for row in "${ASSET_ROWS[@]}"; do
|
||||
IFS=$'\t' read -r name size <<< "$row"
|
||||
if [[ "$name" == "$required" && "$size" =~ ^[1-9][0-9]*$ ]]; then
|
||||
MATCHED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$MATCHED" != true ]]; then
|
||||
RELEASE_COMPLETE=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
export TAG_EXISTS TAG_COMMIT RELEASE_EXISTS
|
||||
node scripts/evaluate-release-state.mjs \
|
||||
release.json release-assets existing-release > release-state.txt
|
||||
cat release-state.txt >> "$GITHUB_OUTPUT"
|
||||
ACTION=$(grep '^action=' release-state.txt | cut -d= -f2)
|
||||
REASON=$(grep '^reason=' release-state.txt | cut -d= -f2- || true)
|
||||
|
||||
if [[ "$TAG_EXISTS" == true && "$RELEASE_COMPLETE" == true ]]; then
|
||||
echo "Tag and complete GitHub release $VERSION already exist; skipping safely."
|
||||
SKIP_RELEASE=true
|
||||
elif [[ "$TAG_EXISTS" == true && "$TAG_COMMIT" != "$GITHUB_SHA" ]]; then
|
||||
echo "Incomplete release recovery refused: $VERSION points to $TAG_COMMIT, not $GITHUB_SHA."
|
||||
exit 1
|
||||
else
|
||||
SKIP_RELEASE=false
|
||||
fi
|
||||
case "$ACTION" in
|
||||
skip)
|
||||
echo "Tag and byte-identical GitHub release $VERSION already exist; skipping safely."
|
||||
;;
|
||||
fail)
|
||||
echo "Release refused ($REASON): tag/release state is inconsistent with current source $GITHUB_SHA."
|
||||
exit 1
|
||||
;;
|
||||
create|complete|repair)
|
||||
;;
|
||||
*)
|
||||
echo "Release-state helper returned unknown action: $ACTION"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'tag_exists=%s\n' "$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
printf 'release_exists=%s\n' "$RELEASE_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
printf 'skip=%s\n' "$SKIP_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reconfirm current main before release mutation
|
||||
if: steps.existing_release.outputs.action != 'skip'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
MAIN_COMMIT=$(git rev-parse FETCH_HEAD)
|
||||
if [[ "$MAIN_COMMIT" != "$GITHUB_SHA" ]]; then
|
||||
echo "Refusing release mutation for $GITHUB_SHA because origin/main advanced to $MAIN_COMMIT after inspection."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create exact version tag
|
||||
if: steps.existing_release.outputs.tag_exists == 'false'
|
||||
if: steps.existing_release.outputs.action == 'create'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
|
|
@ -209,7 +211,7 @@ jobs:
|
|||
-f sha="$GITHUB_SHA" >/dev/null
|
||||
|
||||
- name: Create release with verified assets
|
||||
if: steps.existing_release.outputs.skip == 'false' && steps.existing_release.outputs.release_exists == 'false'
|
||||
if: steps.existing_release.outputs.action == 'create' || steps.existing_release.outputs.action == 'complete'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
|
|
@ -224,28 +226,52 @@ jobs:
|
|||
--title "$VERSION" \
|
||||
--generate-notes
|
||||
|
||||
- name: Replace assets on incomplete release
|
||||
if: steps.existing_release.outputs.skip == 'false' && steps.existing_release.outputs.release_exists == 'true'
|
||||
- name: Repair incomplete release without replacing existing required assets
|
||||
if: steps.existing_release.outputs.action == 'repair'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
MISSING_ASSETS: ${{ steps.existing_release.outputs.missing_assets }}
|
||||
UNEXPECTED_ASSET_IDS: ${{ steps.existing_release.outputs.unexpected_asset_ids }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION" \
|
||||
--jq '.assets[].id' > asset-ids.txt
|
||||
mapfile -t ASSET_IDS < asset-ids.txt
|
||||
for asset_id in "${ASSET_IDS[@]}"; do
|
||||
gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"
|
||||
if [[ -n "$MISSING_ASSETS" ]]; then
|
||||
IFS=',' read -ra missing_assets <<< "$MISSING_ASSETS"
|
||||
for asset in "${missing_assets[@]}"; do
|
||||
case "$asset" in
|
||||
main.js|manifest.json|styles.css)
|
||||
gh release upload "$VERSION" "release-assets/$asset"
|
||||
;;
|
||||
*)
|
||||
echo "Release-state helper returned an unknown missing asset: $asset"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
# Verify every required asset byte-for-byte before removing any extras.
|
||||
rm -rf repaired-release
|
||||
mkdir repaired-release
|
||||
for asset in main.js manifest.json styles.css; do
|
||||
gh release download "$VERSION" --pattern "$asset" --dir repaired-release
|
||||
if ! cmp -s "release-assets/$asset" "repaired-release/$asset"; then
|
||||
echo "Required release asset $asset does not match the verified build; preserving all existing assets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
gh release upload "$VERSION" \
|
||||
release-assets/main.js \
|
||||
release-assets/manifest.json \
|
||||
release-assets/styles.css \
|
||||
--clobber
|
||||
|
||||
if [[ -n "$UNEXPECTED_ASSET_IDS" ]]; then
|
||||
IFS=',' read -ra unexpected_asset_ids <<< "$UNEXPECTED_ASSET_IDS"
|
||||
for asset_id in "${unexpected_asset_ids[@]}"; do
|
||||
[[ "$asset_id" =~ ^[0-9]+$ ]] || { echo "Invalid unexpected asset ID: $asset_id"; exit 1; }
|
||||
gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Verify final release asset set
|
||||
if: steps.existing_release.outputs.skip == 'false'
|
||||
if: steps.existing_release.outputs.action != 'skip'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
|
|
@ -275,3 +301,12 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
done
|
||||
rm -rf final-release
|
||||
mkdir final-release
|
||||
gh release download "$VERSION" --dir final-release
|
||||
for required in "${required_assets[@]}"; do
|
||||
if ! cmp -s "release-assets/$required" "final-release/$required"; then
|
||||
echo "Published release asset $required does not match the verified build."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
# npm
|
||||
node_modules
|
||||
coverage/
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
|
|
|
|||
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -2,6 +2,32 @@
|
|||
|
||||
All notable user-facing changes to Task Syncer are documented here.
|
||||
|
||||
## [2.1.2] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented stale refreshes from replacing tasks after switching providers or lists.
|
||||
- Added complete Microsoft Graph pagination with same-origin next-link validation.
|
||||
- Refused ambiguous title-based mutations when duplicate remote task titles exist.
|
||||
- Preserved timed due dates during title-only edits and rejected blank task titles.
|
||||
- Updated only a managed section in `Tasks List.md`, preserving user-authored content.
|
||||
- Made calendar-day labels respect the configured time zone.
|
||||
- Validated persisted settings and rejected invalid automatic-refresh intervals.
|
||||
|
||||
### Changed
|
||||
|
||||
- Added Obsidian's official plugin lint rules, comprehensive coverage checks, and a private npm package declaration.
|
||||
- Modernized the test toolchain and removed known dependency advisories.
|
||||
- Improved sidebar accessibility, theme compatibility, and settings UI wording.
|
||||
- Clarified required provider accounts and SecretStorage operating-system limitations.
|
||||
|
||||
### Security
|
||||
|
||||
- Cancelled OAuth sessions on plugin unload, denied popup windows, isolated browser sessions, and enforced exact callback/query matching at the provider boundary.
|
||||
- Quarantined conflicting legacy plaintext credentials in verified SecretStorage entries before removing plaintext sources.
|
||||
- Prevented delayed or overlapping operations from mutating tasks after provider or list changes.
|
||||
- Made release recovery fail closed on tag, source-SHA, or asset-digest mismatches and non-destructive when repairing missing assets.
|
||||
|
||||
## [2.1.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
|
@ -34,3 +60,4 @@ All notable user-facing changes to Task Syncer are documented here.
|
|||
|
||||
[2.1.0]: https://github.com/ysonC/obsidian-tasks-syncer/releases/tag/2.1.0
|
||||
[2.1.1]: https://github.com/ysonC/obsidian-tasks-syncer/releases/tag/2.1.1
|
||||
[2.1.2]: https://github.com/ysonC/obsidian-tasks-syncer/releases/tag/2.1.2
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Task Syncer connects an Obsidian **desktop** vault to either Microsoft To Do or TickTick. Commands and the sidebar operate on one selected provider and remote list at a time.
|
||||
|
||||
Task Syncer requires either a Microsoft account or a TickTick account, plus a user-created OAuth application for the selected provider.
|
||||
|
||||
## Installation
|
||||
|
||||
Task Syncer requires Obsidian 1.11.4 or newer and is desktop-only.
|
||||
|
|
@ -67,7 +69,7 @@ Task Syncer is not a bidirectional file synchronizer:
|
|||
- Refresh and automatic refresh fetch the selected remote list into an in-memory, ID-based cache and sidebar. They do not edit Markdown.
|
||||
- **Push all tasks from note** reads top-level Markdown checkbox lines (`- [ ]` and `- [x]`) from the active note and creates or completes matching remote tasks. For this operation only, trimmed, whitespace-collapsed, case-insensitive titles prevent duplicate pushes. Remote tasks with duplicate titles are otherwise preserved by ID.
|
||||
- **Create and push task** creates one task unless a normalized title already exists.
|
||||
- **Organize tasks from all notes** scans local Markdown files and creates or replaces local `Tasks List.md`; it does not contact a provider.
|
||||
- **Organize tasks from all notes** scans local Markdown files and creates or updates a Task Syncer-managed section in local `Tasks List.md`; user-authored content outside the managed markers is preserved, and the command does not contact a provider.
|
||||
- Changes made in the sidebar are sent to the selected remote provider but are not written back to source notes.
|
||||
|
||||
**Delete completed tasks** first fetches completed tasks and shows the provider, selected list, and count in a destructive confirmation dialog. Closing or cancelling the dialog performs no deletion. Confirming permanently deletes those completed remote tasks one at a time.
|
||||
|
|
@ -79,15 +81,16 @@ Task Syncer makes network requests only for the selected provider:
|
|||
- **Microsoft login:** `login.microsoftonline.com` is opened for OAuth authorization. The client ID, redirect URL, requested scopes, random state, authorization code, and client secret/token-exchange data are used to authenticate the configured app.
|
||||
- **Microsoft Graph:** `graph.microsoft.com/v1.0` receives the access token and list/task identifiers plus task titles, completion status, and due-date fields as needed to list, create, update, complete/reopen, or delete Microsoft To Do data.
|
||||
- **TickTick OAuth:** `ticktick.com/oauth/authorize` and `ticktick.com/oauth/token` receive the client ID, redirect URL, scopes, random state, authorization code, client credentials, and token-exchange data needed to authenticate.
|
||||
- **TickTick Open API:** `api.ticktick.com/open/v1` receives the access token and project/task identifiers plus titles, completion state, due dates, and configured time zone as needed to list, create, update, complete, rename lists, or delete TickTick data.
|
||||
- **TickTick Open API:** `api.ticktick.com/open/v1` receives the access token and project/task identifiers plus titles, completion state, due dates, and configured time zone as needed to list, create, update, complete, or delete TickTick data.
|
||||
|
||||
OAuth authorization runs in an isolated Electron window and validates state and the configured redirect. Task Syncer has **no telemetry, analytics, advertising, or self-updater**. Updates are delivered through Obsidian Community Plugins or manually installed releases.
|
||||
|
||||
## Local data and secrets
|
||||
|
||||
- OAuth client secrets and provider token caches are stored through Obsidian SecretStorage.
|
||||
- SecretStorage protection depends on an available and unlocked operating-system secret store. Obsidian displays a warning when secure storage is unavailable, which can occur on Linux systems without a configured keyring.
|
||||
- Normal configuration—including client IDs, SecretStorage reference IDs, redirect URLs, selected provider/list, display choices, refresh choices, and time zone—is stored in the plugin's `data.json`.
|
||||
- During upgrade, legacy plaintext credentials/token files are copied to SecretStorage and deleted only after exact read-back verification. If a different token already exists in SecretStorage, the legacy file is left in place rather than deleting unverified data.
|
||||
- During upgrade, legacy plaintext credentials/token files are copied to SecretStorage and deleted only after exact read-back verification. When a current SecretStorage value differs, Task Syncer keeps it unchanged, copies the legacy value to a deterministic `-legacy-conflict` SecretStorage entry, verifies both values, and then removes the plaintext source.
|
||||
- The plugin reads Markdown only for explicit note-push/organize commands and writes only `Tasks List.md` for the organize command.
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -97,19 +100,19 @@ OAuth authorization runs in an isolated Electron window and validates state and
|
|||
- **Session expired / 401:** disconnect if possible, then connect again. TickTick requires reconnection when its token expires.
|
||||
- **403:** verify Microsoft `Tasks.ReadWrite` consent or TickTick `tasks:read tasks:write` scopes.
|
||||
- **No lists or tasks:** connect, load lists, select a list, then refresh. Automatic refresh is skipped until a list is selected.
|
||||
- **Legacy token file remains after upgrade:** Task Syncer preserves it when an existing SecretStorage token cannot be proven identical. Confirm the account works before manually removing the old file from the plugin directory.
|
||||
- **Legacy migration conflict:** the current credential/token remains active. The differing legacy value is retained only in a SecretStorage entry whose ID ends in `-legacy-conflict` (older generic Microsoft caches use `-legacy-conflict-generic`); no secret value is included in the ID or logs.
|
||||
- **Mobile:** this plugin cannot run on mobile because OAuth uses Electron desktop APIs.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js and npm.
|
||||
Requirements: Node.js 22 and npm.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
npm run check # lint + tests + production build
|
||||
npm run check # lint + tests + coverage + production build + release validation
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "module";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
|
|
@ -30,8 +30,8 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
...builtins.map(module => `node:${module}`),
|
||||
...builtinModules,
|
||||
...builtinModules.map(module => `node:${module}`),
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
|
|
|
|||
|
|
@ -1,34 +1,62 @@
|
|||
import js from "@eslint/js";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
export default defineConfig([
|
||||
{ ignores: ["node_modules/**", "main.js", "coverage/**"] },
|
||||
js.configs.recommended,
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["**/*.js", "**/*.mjs"],
|
||||
languageOptions: {
|
||||
sourceType: "module",
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: { sourceType: "module" },
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
parserOptions: { project: "./tsconfig.json", sourceType: "module" },
|
||||
},
|
||||
plugins: { "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"obsidianmd/ui/sentence-case": ["warn", {
|
||||
brands: ["Task Syncer", "Microsoft To Do", "TickTick", "SecretStorage", "OAuth", "Obsidian", "Markdown", "America/Toronto"],
|
||||
acronyms: ["API", "URL", "ID", "HTTP", "IANA"],
|
||||
}],
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
files: ["src/setting.ts", "src/delete-confirmation-modal.ts"],
|
||||
rules: {
|
||||
// Keep the APIs available at the declared Obsidian 1.11.4 compatibility floor.
|
||||
"@typescript-eslint/no-deprecated": "off",
|
||||
"obsidianmd/settings-tab/prefer-setting-definitions": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.ts"],
|
||||
languageOptions: { parser: tsParser, parserOptions: { project: "./tsconfig.json", sourceType: "module" } },
|
||||
rules: {
|
||||
// Test doubles intentionally cross untyped framework boundaries. Keep
|
||||
// syntax and Obsidian linting without production's type-aware unsafe rules.
|
||||
"@typescript-eslint/await-thenable": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"obsidianmd/hardcoded-config-path": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/main-lifecycle.test.ts"],
|
||||
rules: {
|
||||
// TaskService methods are Vitest function properties in this integration mock.
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["*.config.ts"],
|
||||
languageOptions: { parser: tsParser, parserOptions: { project: "./tsconfig.json", sourceType: "module" } },
|
||||
},
|
||||
{
|
||||
files: ["scripts/validate-release.mjs"],
|
||||
rules: { "obsidianmd/rule-custom-message": "off" },
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "task-syncer",
|
||||
"name": "Task Syncer",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.2",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "Sync tasks with Microsoft To Do and TickTick.",
|
||||
"author": "Wyson Cheng",
|
||||
|
|
|
|||
6336
package-lock.json
generated
6336
package-lock.json
generated
File diff suppressed because it is too large
Load diff
24
package.json
24
package.json
|
|
@ -1,16 +1,19 @@
|
|||
{
|
||||
"name": "task-syncer-plugin",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"version": "2.1.2",
|
||||
"description": "Sync tasks with Microsoft To Do and TickTick.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run && node --test tests/validate-release.test.mjs",
|
||||
"test": "node --test tests/*.test.mjs && vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"validate:release": "node scripts/validate-release.mjs",
|
||||
"check": "npm run lint && npm test && npm run build && npm run validate:release",
|
||||
"check": "npm run lint && npm test && npm run coverage && npm run build && npm run validate:release && git diff --check",
|
||||
"prepublishOnly": "node -e \"console.error('Task Syncer is released through GitHub, not npm.'); process.exit(1)\"",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
@ -28,18 +31,21 @@
|
|||
"@electron/remote": "^2.1.3",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||
"@typescript-eslint/parser": "^8.29.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"@types/node": "22.18.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"electron": "^39.8.10",
|
||||
"esbuild": "^0.25.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-obsidianmd": "0.4.1",
|
||||
"flatted": "3.4.2",
|
||||
"globals": "^16.5.0",
|
||||
"obsidian": "1.13.1",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.4",
|
||||
"vitest": "^0.34.6"
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.1",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.4.1",
|
||||
|
|
|
|||
63
scripts/evaluate-release-state.mjs
Normal file
63
scripts/evaluate-release-state.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { decideReleaseState, REQUIRED_RELEASE_ASSETS } from "./release-state.mjs";
|
||||
|
||||
async function describeFile(directory, name) {
|
||||
const path = join(directory, name);
|
||||
const [contents, metadata] = await Promise.all([readFile(path), stat(path)]);
|
||||
return {
|
||||
name,
|
||||
size: metadata.size,
|
||||
digest: `sha256:${createHash("sha256").update(contents).digest("hex")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const [releaseJsonPath, verifiedDirectory, downloadedDirectory] = process.argv.slice(2);
|
||||
if (!releaseJsonPath || !verifiedDirectory || !downloadedDirectory) {
|
||||
throw new Error("Usage: node scripts/evaluate-release-state.mjs <release.json> <verified-dir> <downloaded-dir>");
|
||||
}
|
||||
|
||||
const tagExists = process.env.TAG_EXISTS === "true";
|
||||
const releaseExists = process.env.RELEASE_EXISTS === "true";
|
||||
const expectedAssets = await Promise.all(REQUIRED_RELEASE_ASSETS.map((name) => describeFile(verifiedDirectory, name)));
|
||||
let release = null;
|
||||
|
||||
if (releaseExists) {
|
||||
const releaseDocument = JSON.parse(await readFile(releaseJsonPath, "utf8"));
|
||||
if (!releaseDocument || typeof releaseDocument !== "object" || Array.isArray(releaseDocument)) {
|
||||
throw new Error("release.json must contain a JSON object");
|
||||
}
|
||||
if (!Array.isArray(releaseDocument.assets)) throw new Error("release.json assets must be an array");
|
||||
|
||||
const assets = await Promise.all(
|
||||
releaseDocument.assets.map(async ({ id, name, size }) => {
|
||||
if (typeof name !== "string" || !REQUIRED_RELEASE_ASSETS.includes(name)) {
|
||||
return { id, name, size, digest: null };
|
||||
}
|
||||
try {
|
||||
const file = await describeFile(downloadedDirectory, name);
|
||||
return { id, name, size, digest: file.digest };
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return { id, name, size, digest: null };
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
release = { assets };
|
||||
}
|
||||
|
||||
const decision = decideReleaseState({
|
||||
currentSha: process.env.GITHUB_SHA,
|
||||
tag: tagExists ? { sha: process.env.TAG_COMMIT } : null,
|
||||
release,
|
||||
expectedAssets,
|
||||
});
|
||||
|
||||
process.stdout.write(`action=${decision.action}\n`);
|
||||
if (decision.reason) process.stdout.write(`reason=${decision.reason}\n`);
|
||||
if (decision.action === "repair") {
|
||||
process.stdout.write(`missing_assets=${decision.missingAssets.join(",")}\n`);
|
||||
process.stdout.write(`unexpected_asset_ids=${decision.unexpectedAssetIds.join(",")}\n`);
|
||||
}
|
||||
44
scripts/release-state.mjs
Normal file
44
scripts/release-state.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export const REQUIRED_RELEASE_ASSETS = Object.freeze(["main.js", "manifest.json", "styles.css"]);
|
||||
|
||||
function sameAsset(expected, actual) {
|
||||
return Number.isInteger(actual.size)
|
||||
&& actual.size > 0
|
||||
&& actual.size === expected.size
|
||||
&& typeof actual.digest === "string"
|
||||
&& actual.digest === expected.digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the only safe mutation for an observed release state.
|
||||
* This function is deliberately pure so CI state transitions are unit-testable.
|
||||
*/
|
||||
export function decideReleaseState({ currentSha, tag, release, expectedAssets }) {
|
||||
if (release && !tag) return { action: "fail", reason: "release-without-tag" };
|
||||
if (tag && tag.sha !== currentSha) return { action: "fail", reason: "tag-sha-mismatch" };
|
||||
if (!tag) return { action: "create" };
|
||||
if (!release) return { action: "complete" };
|
||||
if (!Array.isArray(expectedAssets) || !Array.isArray(release.assets)) return { action: "fail", reason: "invalid-asset-state" };
|
||||
|
||||
const expectedByName = new Map(expectedAssets.map((asset) => [asset.name, asset]));
|
||||
const actualByName = new Map();
|
||||
const unexpectedAssetIds = [];
|
||||
for (const asset of release.assets) {
|
||||
if (!asset || typeof asset.name !== "string") return { action: "fail", reason: "invalid-asset-state" };
|
||||
if (!expectedByName.has(asset.name)) {
|
||||
if (typeof asset.id !== "number" && typeof asset.id !== "string") return { action: "fail", reason: "unexpected-asset-missing-id" };
|
||||
unexpectedAssetIds.push(asset.id);
|
||||
continue;
|
||||
}
|
||||
if (actualByName.has(asset.name)) return { action: "fail", reason: "duplicate-required-asset" };
|
||||
actualByName.set(asset.name, asset);
|
||||
}
|
||||
|
||||
const missingAssets = [];
|
||||
for (const expected of expectedAssets) {
|
||||
const actual = actualByName.get(expected.name);
|
||||
if (!actual) missingAssets.push(expected.name);
|
||||
else if (!sameAsset(expected, actual)) return { action: "fail", reason: "required-asset-digest-mismatch" };
|
||||
}
|
||||
if (missingAssets.length === 0 && unexpectedAssetIds.length === 0) return { action: "skip" };
|
||||
return { action: "repair", missingAssets, unexpectedAssetIds };
|
||||
}
|
||||
|
|
@ -8,14 +8,28 @@ const SCOPES = ["Tasks.ReadWrite", "offline_access"];
|
|||
|
||||
interface MicrosoftAuthDependencies {
|
||||
client?: ConfidentialClientApplication;
|
||||
authorize?: (authUrl: string, redirectUrl: string) => Promise<string>;
|
||||
authorize?: OAuthAuthorize;
|
||||
createState?: () => string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type OAuthAuthorize = (authUrl: string, redirectUrl: string, signal?: AbortSignal) => Promise<string>;
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("OAuth authorization was aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw abortError();
|
||||
}
|
||||
|
||||
export class MicrosoftAuthProvider implements AuthProvider {
|
||||
private client: ConfidentialClientApplication;
|
||||
private authorize: (authUrl: string, redirectUrl: string) => Promise<string>;
|
||||
private authorize: OAuthAuthorize;
|
||||
private createState: () => string;
|
||||
private signal?: AbortSignal;
|
||||
constructor(
|
||||
private config: { clientId: string; clientSecret: string; redirectUrl: string },
|
||||
private store: TokenStore,
|
||||
|
|
@ -26,19 +40,27 @@ export class MicrosoftAuthProvider implements AuthProvider {
|
|||
this.client = dependencies.client || new ConfidentialClientApplication(msal);
|
||||
this.authorize = dependencies.authorize || openOAuthWindow;
|
||||
this.createState = dependencies.createState || (() => randomBytes(32).toString("hex"));
|
||||
this.signal = dependencies.signal;
|
||||
}
|
||||
private async loadCache() { const data = await this.store.read(); if (data) this.client.getTokenCache().deserialize(data); }
|
||||
private async saveCache() { await this.store.write(this.client.getTokenCache().serialize()); }
|
||||
async login(): Promise<string> {
|
||||
throwIfAborted(this.signal);
|
||||
const state = this.createState();
|
||||
const authUrl = await this.client.getAuthCodeUrl({ scopes: SCOPES, redirectUri: this.config.redirectUrl, prompt: "consent", state });
|
||||
const callback = await this.authorize(authUrl, this.config.redirectUrl);
|
||||
throwIfAborted(this.signal);
|
||||
const callback = await this.authorize(authUrl, this.config.redirectUrl, this.signal);
|
||||
throwIfAborted(this.signal);
|
||||
if (!isExactRedirect(callback, this.config.redirectUrl)) throw new Error("Microsoft OAuth redirect did not exactly match the configured redirect URL.");
|
||||
const callbackUrl = new URL(callback);
|
||||
if (callbackUrl.searchParams.get("state") !== state) throw new Error("Microsoft OAuth state validation failed.");
|
||||
const code = callbackUrl.searchParams.get("code"); if (!code) throw new Error("Microsoft login did not return an authorization code.");
|
||||
const result = await this.client.acquireTokenByCode({ code, scopes: SCOPES, redirectUri: this.config.redirectUrl });
|
||||
if (!result?.accessToken) throw new Error("Microsoft login returned no access token.");
|
||||
await this.saveCache(); return result.accessToken;
|
||||
throwIfAborted(this.signal);
|
||||
await this.saveCache();
|
||||
if (this.signal?.aborted) { await this.store.remove(); throw abortError(); }
|
||||
return result.accessToken;
|
||||
}
|
||||
async getAccessToken(): Promise<string> {
|
||||
await this.loadCache(); const accounts = await this.client.getTokenCache().getAllAccounts();
|
||||
|
|
@ -50,7 +72,8 @@ export class MicrosoftAuthProvider implements AuthProvider {
|
|||
async isAuthenticated() { await this.loadCache(); return (await this.client.getTokenCache().getAllAccounts()).length > 0; }
|
||||
}
|
||||
|
||||
export function openOAuthWindow(authUrl: string, redirectUrl: string): Promise<string> {
|
||||
export function openOAuthWindow(authUrl: string, redirectUrl: string, signal?: AbortSignal): Promise<string> {
|
||||
if (signal?.aborted) return Promise.reject(abortError());
|
||||
let configuredRedirect: URL;
|
||||
try {
|
||||
configuredRedirect = new URL(redirectUrl);
|
||||
|
|
@ -58,10 +81,22 @@ export function openOAuthWindow(authUrl: string, redirectUrl: string): Promise<s
|
|||
return Promise.reject(new Error(`Invalid Microsoft OAuth redirect URL: ${redirectUrl}`));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const win = new BrowserWindow({ width: 600, height: 700, show: true, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, partition: `task-syncer-oauth-${Date.now()}` } });
|
||||
const win = new BrowserWindow({ width: 600, height: 700, show: true, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, partition: `task-syncer-oauth-${randomBytes(16).toString("hex")}` } });
|
||||
let settled = false;
|
||||
const finish = (error?: Error, url?: string) => { if (settled) return; settled = true; if (!win.isDestroyed()) win.close(); if (error) reject(error); else resolve(url!); };
|
||||
const inspect = (event: any, url: string) => {
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
win.webContents.removeListener("will-redirect", inspect);
|
||||
win.webContents.removeListener("will-navigate", inspect);
|
||||
win.removeListener("closed", closed);
|
||||
};
|
||||
const finish = (error?: Error, url?: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (!win.isDestroyed()) win.close();
|
||||
if (error) reject(error); else resolve(url!);
|
||||
};
|
||||
const inspect = (event: { preventDefault(): void }, url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!isSameRedirect(parsed, configuredRedirect)) return;
|
||||
|
|
@ -72,8 +107,28 @@ export function openOAuthWindow(authUrl: string, redirectUrl: string): Promise<s
|
|||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
win.webContents.on("will-redirect", inspect); win.webContents.on("will-navigate", inspect); win.on("closed", () => finish(new Error("OAuth login window was closed."))); win.loadURL(authUrl).catch((e: Error) => finish(e));
|
||||
const abort = () => finish(abortError());
|
||||
const closed = () => finish(new Error("OAuth login window was closed."));
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
win.webContents.on("will-redirect", inspect);
|
||||
win.webContents.on("will-navigate", inspect);
|
||||
win.on("closed", closed);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) { abort(); return; }
|
||||
void win.loadURL(authUrl).catch((error: Error) => finish(error));
|
||||
});
|
||||
}
|
||||
function isSameRedirect(callback: URL, configured: URL) { return callback.protocol === configured.protocol && callback.host === configured.host && callback.pathname === configured.pathname; }
|
||||
function isSameRedirect(callback: URL, configured: URL) {
|
||||
if (callback.protocol !== configured.protocol || callback.host !== configured.host || callback.pathname !== configured.pathname) return false;
|
||||
const oauthResponseParameters = new Set(["code", "state", "error", "error_description", "error_uri"]);
|
||||
const fixedCallback = Array.from(callback.searchParams)
|
||||
.filter(([key]) => !oauthResponseParameters.has(key))
|
||||
.map(([key, value]) => `${key}\u0000${value}`)
|
||||
.sort();
|
||||
const fixedConfigured = Array.from(configured.searchParams)
|
||||
.map(([key, value]) => `${key}\u0000${value}`)
|
||||
.sort();
|
||||
return fixedCallback.length === fixedConfigured.length
|
||||
&& fixedCallback.every((entry, index) => entry === fixedConfigured[index]);
|
||||
}
|
||||
export function isExactRedirect(callback: string, configured: string) { return isSameRedirect(new URL(callback), new URL(configured)); }
|
||||
|
|
|
|||
|
|
@ -1,31 +1,45 @@
|
|||
import { randomBytes } from "crypto";
|
||||
import { AuthProvider, TokenStore, requireCredentials } from "./index";
|
||||
import { isExactRedirect, type OAuthAuthorize } from "./microsoft";
|
||||
import { HttpClient } from "../http";
|
||||
|
||||
const AUTHORIZE_URL = "https://ticktick.com/oauth/authorize";
|
||||
const TOKEN_URL = "https://ticktick.com/oauth/token";
|
||||
const SCOPES = "tasks:read tasks:write";
|
||||
interface TokenRecord { accessToken: string; expiresAt?: number; }
|
||||
interface TickTickTokenResponse { access_token?: string; expires_in?: number; }
|
||||
|
||||
export function buildTickTickAuthorizationUrl(clientId: string, redirectUrl: string, state: string): string {
|
||||
const url = new URL(AUTHORIZE_URL); url.searchParams.set("client_id", clientId); url.searchParams.set("scope", SCOPES); url.searchParams.set("state", state); url.searchParams.set("redirect_uri", redirectUrl); url.searchParams.set("response_type", "code"); return url.toString();
|
||||
}
|
||||
export class TickTickAuthProvider implements AuthProvider {
|
||||
constructor(private config: { clientId: string; clientSecret: string; redirectUrl: string }, private store: TokenStore, private http: HttpClient, private authorize: (url: string, redirect: string) => Promise<string>, private createState: () => string = () => randomBytes(32).toString("hex")) { requireCredentials(config.clientId, config.clientSecret, config.redirectUrl); }
|
||||
constructor(private config: { clientId: string; clientSecret: string; redirectUrl: string }, private store: TokenStore, private http: HttpClient, private authorize: OAuthAuthorize, private createState: () => string = () => randomBytes(32).toString("hex"), private signal?: AbortSignal) { requireCredentials(config.clientId, config.clientSecret, config.redirectUrl); }
|
||||
async login(): Promise<string> {
|
||||
const state = this.createState(); const callback = await this.authorize(buildTickTickAuthorizationUrl(this.config.clientId, this.config.redirectUrl, state), this.config.redirectUrl);
|
||||
if (this.signal?.aborted) throw abortError();
|
||||
const state = this.createState(); const callback = await this.authorize(buildTickTickAuthorizationUrl(this.config.clientId, this.config.redirectUrl, state), this.config.redirectUrl, this.signal);
|
||||
if (this.signal?.aborted) throw abortError();
|
||||
const callbackUrl = new URL(callback), configured = new URL(this.config.redirectUrl);
|
||||
if (callbackUrl.protocol !== configured.protocol || callbackUrl.host !== configured.host || callbackUrl.pathname !== configured.pathname) throw new Error("TickTick OAuth redirect did not exactly match the configured redirect URL.");
|
||||
if (!isExactRedirect(callbackUrl.toString(), configured.toString())) throw new Error("TickTick OAuth redirect did not exactly match the configured redirect URL.");
|
||||
if (callbackUrl.searchParams.get("state") !== state) throw new Error("TickTick OAuth state validation failed.");
|
||||
const oauthError = callbackUrl.searchParams.get("error"); if (oauthError) throw new Error(`TickTick authorization failed: ${oauthError}`);
|
||||
const code = callbackUrl.searchParams.get("code"); if (!code) throw new Error("TickTick authorization returned no code.");
|
||||
const body = new URLSearchParams({ code, grant_type: "authorization_code", scope: SCOPES, redirect_uri: this.config.redirectUrl }).toString();
|
||||
const res = await this.http<any>({ url: TOKEN_URL, method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded" }, body });
|
||||
const authorization = "Basic " + Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64");
|
||||
const res = await this.http<TickTickTokenResponse>({ url: TOKEN_URL, method: "POST", headers: { Authorization: authorization, "Content-Type": "application/x-www-form-urlencoded" }, body });
|
||||
if (res.status < 200 || res.status >= 300 || !res.json?.access_token) throw new Error("TickTick token exchange failed. Verify the client credentials and redirect URL.");
|
||||
const record: TokenRecord = { accessToken: res.json.access_token }; if (typeof res.json.expires_in === "number") record.expiresAt = Date.now() + res.json.expires_in * 1000;
|
||||
await this.store.write(JSON.stringify(record)); return record.accessToken;
|
||||
if (this.signal?.aborted) throw abortError();
|
||||
await this.store.write(JSON.stringify(record));
|
||||
if (this.signal?.aborted) { await this.store.remove(); throw abortError(); }
|
||||
return record.accessToken;
|
||||
}
|
||||
async getAccessToken(): Promise<string> { const raw = await this.store.read(); if (!raw) throw new Error("Connect TickTick before syncing."); try { const record = JSON.parse(raw) as TokenRecord; if (!record.accessToken || (record.expiresAt && Date.now() >= record.expiresAt)) { await this.store.remove(); throw new Error("TickTick session expired. Connect TickTick again."); } return record.accessToken; } catch (e) { if (e instanceof Error && e.message.includes("TickTick session")) throw e; await this.store.remove(); throw new Error("TickTick token cache is invalid. Connect TickTick again."); } }
|
||||
async logout() { await this.store.remove(); }
|
||||
async isAuthenticated() { try { await this.getAccessToken(); return true; } catch { return false; } }
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("OAuth authorization was aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ export interface AutoSyncTimers {
|
|||
clearInterval(id: number): void;
|
||||
}
|
||||
|
||||
const ALLOWED_INTERVALS = new Set([0, 1, 5, 10, 15, 30, 60]);
|
||||
|
||||
const browserTimers: AutoSyncTimers = {
|
||||
setInterval: (callback, milliseconds) => window.setInterval(callback, milliseconds),
|
||||
clearInterval: id => window.clearInterval(id),
|
||||
|
|
@ -26,7 +28,7 @@ export class AutoSyncController {
|
|||
|
||||
configure(intervalMinutes: number): void {
|
||||
this.stop();
|
||||
if (intervalMinutes <= 0) return;
|
||||
if (!Number.isFinite(intervalMinutes) || !ALLOWED_INTERVALS.has(intervalMinutes) || intervalMinutes === 0) return;
|
||||
this.intervalId = this.timers.setInterval(
|
||||
() => { void this.run(); },
|
||||
minutesToMilliseconds(intervalMinutes),
|
||||
|
|
|
|||
|
|
@ -11,3 +11,23 @@ export const COMMAND_IDS = {
|
|||
organizeTasks: "organize-tasks",
|
||||
deleteCompletedTasks: "delete-completed-tasks",
|
||||
} as const;
|
||||
|
||||
export type CommandId = typeof COMMAND_IDS[keyof typeof COMMAND_IDS];
|
||||
|
||||
export interface CommandPrerequisites {
|
||||
hasCredentials: boolean;
|
||||
hasSelectedList: boolean;
|
||||
hasTaskLists: boolean;
|
||||
hasActiveFile: boolean;
|
||||
}
|
||||
|
||||
/** Synchronous availability checks used by Obsidian command callbacks. */
|
||||
export function canRunCommand(id: CommandId, state: CommandPrerequisites): boolean {
|
||||
if (id === COMMAND_IDS.openSidebar || id === COMMAND_IDS.organizeTasks) return true;
|
||||
if (id === COMMAND_IDS.connectProvider || id === COMMAND_IDS.disconnectProvider || id === COMMAND_IDS.loadTaskLists) {
|
||||
return state.hasCredentials;
|
||||
}
|
||||
if (id === COMMAND_IDS.selectTaskList) return state.hasCredentials && state.hasTaskLists;
|
||||
if (id === COMMAND_IDS.pushAllTasks) return state.hasCredentials && state.hasSelectedList && state.hasActiveFile;
|
||||
return state.hasCredentials && state.hasSelectedList;
|
||||
}
|
||||
|
|
|
|||
23
src/date-only.ts
Normal file
23
src/date-only.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TaskStatus } from "./types";
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function calendarDateInTimeZone(now: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(now);
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value;
|
||||
return `${value("year")}-${value("month")}-${value("day")}`;
|
||||
}
|
||||
|
||||
export function addCalendarDays(value: string, days: number): string {
|
||||
if (!DATE_ONLY.test(value)) throw new Error("Invalid calendar date.");
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day + days));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function dueDateLabel(date: string, status: TaskStatus, today: string): string {
|
||||
if (status === "completed" || !date) return date;
|
||||
if (date === today) return "Today";
|
||||
if (date === addCalendarDays(today, 1)) return "Tomorrow";
|
||||
return date < today ? "Past due" : date;
|
||||
}
|
||||
|
|
@ -34,23 +34,28 @@ export async function deleteCompletedTasksWithConfirmation(
|
|||
listId: string,
|
||||
listTitle: string,
|
||||
confirm: ConfirmDeletion,
|
||||
assertCurrent: () => void = () => undefined,
|
||||
): Promise<number> {
|
||||
const completed = (await service.fetchTasks(listId, true)).filter(task => task.status === "completed");
|
||||
assertCurrent();
|
||||
if (!completed.length) return 0;
|
||||
const accepted = await confirm({
|
||||
provider: provider === "microsoft" ? "Microsoft To Do" : "TickTick",
|
||||
list: listTitle || listId,
|
||||
count: completed.length,
|
||||
});
|
||||
assertCurrent();
|
||||
if (!accepted) return 0;
|
||||
let deleted = 0;
|
||||
for (const task of completed) {
|
||||
assertCurrent();
|
||||
try {
|
||||
await service.deleteTask(listId, task.id);
|
||||
deleted++;
|
||||
} catch (error) {
|
||||
throw new DeleteCompletedTasksError(deleted, completed.length, error);
|
||||
}
|
||||
assertCurrent();
|
||||
}
|
||||
return completed.length;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export interface HttpRequest { url: string; method: string; headers?: Record<string, string>; body?: string; }
|
||||
export interface HttpResponse<T = any> { status: number; json: T; text: string; }
|
||||
export type HttpClient = <T = any>(request: HttpRequest) => Promise<HttpResponse<T>>;
|
||||
export interface HttpResponse<T = unknown> { status: number; json: T; text: string; }
|
||||
export type HttpClient = <T = unknown>(request: HttpRequest) => Promise<HttpResponse<T>>;
|
||||
|
|
|
|||
334
src/main.ts
334
src/main.ts
|
|
@ -1,5 +1,4 @@
|
|||
import { Plugin, TFile } from "obsidian";
|
||||
import * as path from "path";
|
||||
import { Plugin, TFile, normalizePath } from "obsidian";
|
||||
import { TaskSyncerSettingTab } from "./setting";
|
||||
import { VIEW_TYPE_TODO_SIDEBAR, TaskSidebarView } from "./right-sidebar-view";
|
||||
import { TaskTitleModal } from "./task-title-modal";
|
||||
|
|
@ -7,135 +6,288 @@ import { GenericSelectModal } from "./select-modal";
|
|||
import { notify } from "./utils";
|
||||
import { migrateSettings, TaskSyncerSettings, tokenCacheSecretId } from "./settings-model";
|
||||
import { createProviderRuntime, ProviderRuntime } from "./provider";
|
||||
import { ProviderId, TaskCache, TaskInputResult, TaskItem, TaskList, TaskService } from "./types";
|
||||
|
||||
import { COMMAND_IDS } from "./commands";
|
||||
import { ProviderId, TaskCache, TaskItem, TaskList, TaskService } from "./types";
|
||||
import { canRunCommand, CommandId, COMMAND_IDS } from "./commands";
|
||||
import { changeProviderCredential, changeTimeZone, SettingsEffects } from "./settings-actions";
|
||||
import { resolvePluginDirectory } from "./plugin-path";
|
||||
import { AutoSyncController } from "./auto-sync";
|
||||
import { migrateLegacyClientSecrets, migrateLegacyTokenFile, ObsidianSecretStorageApi, ObsidianSecretStore, SecretStore, SecretTokenStore } from "./secret-store";
|
||||
import { legacyConflictSecretId, migrateLegacyClientSecrets, migrateLegacyTokenFile, ObsidianSecretStorageApi, ObsidianSecretStore, SecretStore, SecretTokenStore } from "./secret-store";
|
||||
import { deleteCompletedTasksAndRefresh, deleteCompletedTasksWithConfirmation } from "./delete-completed";
|
||||
import { confirmCompletedTaskDeletion } from "./delete-confirmation-modal";
|
||||
import { RefreshCoordinator } from "./refresh-controller";
|
||||
import { matchRemoteTask } from "./task-matching";
|
||||
import { GENERATED_END, GENERATED_START, updateManagedTaskSection } from "./task-organizer";
|
||||
|
||||
export interface MutationContext {
|
||||
provider: ProviderId;
|
||||
listId: string;
|
||||
generation: number;
|
||||
service: TaskService;
|
||||
}
|
||||
|
||||
export default class TaskSyncerPlugin extends Plugin {
|
||||
settings: TaskSyncerSettings;
|
||||
sidebarView: TaskSidebarView | null = null;
|
||||
taskCache: TaskCache | null = null;
|
||||
private runtime?: ProviderRuntime;
|
||||
private pluginDirectory: string;
|
||||
private autoSync: AutoSyncController;
|
||||
private secretStore: SecretStore;
|
||||
private refreshCoordinator: RefreshCoordinator<TaskItem[]>;
|
||||
private generation = 0;
|
||||
private unloaded = false;
|
||||
private oauthAbortController = new AbortController();
|
||||
get api(): TaskService { return this.ensureRuntime().tasks; }
|
||||
get providerSettings() { return this.settings.providers[this.settings.provider]; }
|
||||
|
||||
async onload(): Promise<void> {
|
||||
const basePath = (this.app.vault.adapter as any).basePath;
|
||||
this.pluginDirectory = resolvePluginDirectory(basePath, this.manifest.dir, this.manifest.id);
|
||||
this.pluginDirectory = resolvePluginDirectory(this.manifest.dir, this.manifest.id, this.app.vault.configDir);
|
||||
this.secretStore = new ObsidianSecretStore(this.app.secretStorage as unknown as ObsidianSecretStorageApi);
|
||||
await this.loadSettings();
|
||||
this.refreshCoordinator = new RefreshCoordinator(
|
||||
() => ({ provider: this.settings.provider, listId: this.providerSettings.selectedListId, showCompleted: this.settings.showCompleted, generation: this.generation }),
|
||||
async identity => this.ensureRuntime().tasks.fetchTasks(identity.listId, identity.showCompleted),
|
||||
(tasks, identity) => { this.taskCache = { provider: identity.provider as ProviderId, listId: identity.listId, tasks }; },
|
||||
);
|
||||
this.autoSync = new AutoSyncController(
|
||||
() => this.refreshViewAndCache(),
|
||||
() => Boolean(this.providerSettings.selectedListId),
|
||||
() => undefined,
|
||||
{
|
||||
setInterval: (callback, milliseconds) => this.registerInterval(window.setInterval(callback, milliseconds)),
|
||||
clearInterval: id => window.clearInterval(id),
|
||||
},
|
||||
() => !this.unloaded && Boolean(this.providerSettings.selectedListId),
|
||||
error => this.reportDiagnostic("Automatic task refresh failed", error),
|
||||
{ setInterval: (callback, milliseconds) => this.registerInterval(window.setInterval(callback, milliseconds)), clearInterval: id => window.clearInterval(id) },
|
||||
);
|
||||
this.addSettingTab(new TaskSyncerSettingTab(this.app, this));
|
||||
this.registerView(VIEW_TYPE_TODO_SIDEBAR, leaf => { const view = new TaskSidebarView(leaf, this); this.sidebarView = view; return view; });
|
||||
this.registerView(VIEW_TYPE_TODO_SIDEBAR, leaf => new TaskSidebarView(leaf, this));
|
||||
this.initializeCommands();
|
||||
this.configureAutoSync();
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
if (this.settings.autoSyncOnStartup) void this.autoSync.run();
|
||||
});
|
||||
this.app.workspace.onLayoutReady(() => { if (!this.unloaded && this.settings.autoSyncOnStartup) void this.autoSync.run(); });
|
||||
}
|
||||
onunload(): void { this.autoSync?.stop(); }
|
||||
ensureRuntime(): ProviderRuntime { if (!this.runtime || this.runtime.id !== this.settings.provider) this.runtime = createProviderRuntime(this.settings.provider, this.settings, this.secretStore); return this.runtime; }
|
||||
async rebuildRuntime() { this.runtime = undefined; this.taskCache = null; }
|
||||
async switchProvider(provider: ProviderId) { if (provider === this.settings.provider) return; this.settings.provider = provider; await this.rebuildRuntime(); await this.saveSettings(); if (this.sidebarView) await this.sidebarView.render(); }
|
||||
async loadSettings() {
|
||||
const raw = await this.loadData();
|
||||
|
||||
onunload(): void { this.unloaded = true; this.oauthAbortController.abort(); this.autoSync?.stop(); this.refreshCoordinator?.dispose(); }
|
||||
ensureRuntime(): ProviderRuntime { if (!this.runtime || this.runtime.id !== this.settings.provider) this.runtime = createProviderRuntime(this.settings.provider, this.settings, this.secretStore, undefined, this.oauthAbortController.signal); return this.runtime; }
|
||||
private invalidateRuntime(): void {
|
||||
this.oauthAbortController.abort();
|
||||
if (!this.unloaded) this.oauthAbortController = new AbortController();
|
||||
this.generation++;
|
||||
this.runtime = undefined;
|
||||
this.taskCache = null;
|
||||
}
|
||||
captureMutationContext(): MutationContext {
|
||||
return { provider: this.settings.provider, listId: this.providerSettings.selectedListId, generation: this.generation, service: this.api };
|
||||
}
|
||||
assertMutationContextCurrent(context: MutationContext): void {
|
||||
if (this.settings.provider !== context.provider || this.providerSettings.selectedListId !== context.listId || this.generation !== context.generation || this.runtime?.tasks !== context.service) {
|
||||
throw new Error("Task context changed while the dialog was open. Reopen it and try again.");
|
||||
}
|
||||
}
|
||||
async runMutationInContext<T>(context: MutationContext, mutation: (service: TaskService) => T | Promise<T>): Promise<T> {
|
||||
this.assertMutationContextCurrent(context);
|
||||
const result = await mutation(context.service);
|
||||
this.assertMutationContextCurrent(context);
|
||||
return result;
|
||||
}
|
||||
async rebuildRuntime(): Promise<void> { this.invalidateRuntime(); }
|
||||
async switchProvider(provider: ProviderId): Promise<void> { if (provider === this.settings.provider) return; this.settings.provider = provider; this.invalidateRuntime(); await this.saveSettings(); await this.refreshSidebar(); }
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const raw: unknown = await this.loadData();
|
||||
this.settings = migrateSettings(raw);
|
||||
await migrateLegacyClientSecrets(raw, this.settings, this.secretStore, () => this.saveData(this.settings));
|
||||
await migrateLegacyClientSecrets(raw, this.settings, this.secretStore, () => this.saveData(this.settings), {
|
||||
microsoft: new SecretTokenStore(this.secretStore, legacyConflictSecretId(this.settings.providers.microsoft.clientSecretId)),
|
||||
ticktick: new SecretTokenStore(this.secretStore, legacyConflictSecretId(this.settings.providers.ticktick.clientSecretId)),
|
||||
});
|
||||
for (const provider of ["microsoft", "ticktick"] as const) {
|
||||
const tokens = new SecretTokenStore(this.secretStore, tokenCacheSecretId(provider));
|
||||
await migrateLegacyTokenFile(path.join(this.pluginDirectory, `${provider}-token-cache.json`), tokens);
|
||||
if (provider === "microsoft") await migrateLegacyTokenFile(path.join(this.pluginDirectory, "token_cache.json"), tokens);
|
||||
const tokenId = tokenCacheSecretId(provider);
|
||||
const tokens = new SecretTokenStore(this.secretStore, tokenId);
|
||||
await migrateLegacyTokenFile(this.app.vault.adapter, normalizePath(`${this.pluginDirectory}/${provider}-token-cache.json`), tokens, new SecretTokenStore(this.secretStore, legacyConflictSecretId(tokenId)));
|
||||
if (provider === "microsoft") await migrateLegacyTokenFile(this.app.vault.adapter, normalizePath(`${this.pluginDirectory}/token_cache.json`), tokens, new SecretTokenStore(this.secretStore, legacyConflictSecretId(tokenId, "generic")));
|
||||
}
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
async saveSettings() { await this.saveData(this.settings); }
|
||||
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
|
||||
|
||||
reportError(action: string, error: unknown) { const message = error instanceof Error ? error.message : String(error); console.error(`${action}:`, message); notify(message, "error"); }
|
||||
private async refreshSidebar() { if (this.sidebarView) await this.sidebarView.render(); }
|
||||
private settingsEffects(): SettingsEffects {
|
||||
return {
|
||||
logout: () => this.invalidateCurrentProviderAuth(),
|
||||
|
||||
rebuild: () => this.rebuildRuntime(),
|
||||
save: () => this.saveSettings(),
|
||||
refresh: () => this.refreshSidebar(),
|
||||
};
|
||||
reportError(action: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error); console.error(`${action}:`, message); notify(message, "error"); }
|
||||
private reportDiagnostic(action: string, error: unknown): void { console.error(action, error instanceof Error ? error.message : "Unknown error"); }
|
||||
private sidebarViews(): TaskSidebarView[] { return this.app.workspace.getLeavesOfType(VIEW_TYPE_TODO_SIDEBAR).map(leaf => leaf.view).filter((view): view is TaskSidebarView => view instanceof TaskSidebarView); }
|
||||
private async refreshSidebar(): Promise<void> { await Promise.all(this.sidebarViews().map(view => view.render())); }
|
||||
private settingsEffects(): SettingsEffects { return { logout: () => this.invalidateCurrentProviderAuth(), rebuild: () => this.rebuildRuntime(), save: () => this.saveSettings(), refresh: () => this.refreshSidebar() }; }
|
||||
private async invalidateCurrentProviderAuth(): Promise<void> {
|
||||
try { if (this.runtime?.id === this.settings.provider) await this.runtime.auth.logout(); }
|
||||
finally { await new SecretTokenStore(this.secretStore, tokenCacheSecretId(this.settings.provider)).remove(); this.invalidateRuntime(); }
|
||||
}
|
||||
private async invalidateCurrentProviderAuth() {
|
||||
try {
|
||||
if (this.runtime?.id === this.settings.provider) await this.runtime.auth.logout();
|
||||
} finally {
|
||||
await new SecretTokenStore(this.secretStore, tokenCacheSecretId(this.settings.provider)).remove();
|
||||
async updateProviderCredential(key: "clientId" | "clientSecretId" | "redirectUrl", value: string): Promise<void> { await changeProviderCredential(this.settings, key, value, this.settingsEffects()); }
|
||||
async updateTimeZone(value: string): Promise<void> { await changeTimeZone(this.settings, value, this.settingsEffects()); }
|
||||
async updateAutoSyncInterval(minutes: number): Promise<void> { this.settings.autoSyncIntervalMinutes = minutes; this.generation++; await this.saveSettings(); this.configureAutoSync(); }
|
||||
async updateAutoSyncOnStartup(enabled: boolean): Promise<void> { this.settings.autoSyncOnStartup = enabled; this.generation++; await this.saveSettings(); }
|
||||
async updateShowCompleted(enabled: boolean): Promise<void> { this.settings.showCompleted = enabled; this.generation++; this.taskCache = null; await this.saveSettings(); }
|
||||
async selectTaskList(id: string, title: string): Promise<void> { this.providerSettings.selectedListId = id; this.providerSettings.selectedListTitle = title; this.generation++; this.taskCache = null; await this.saveSettings(); }
|
||||
private async selectTaskListInContext(context: MutationContext, id: string, title: string): Promise<void> {
|
||||
this.assertMutationContextCurrent(context);
|
||||
const providerSettings = this.settings.providers[context.provider];
|
||||
providerSettings.selectedListId = id;
|
||||
providerSettings.selectedListTitle = title;
|
||||
this.generation++;
|
||||
this.taskCache = null;
|
||||
const updatedContext = this.captureMutationContext();
|
||||
await this.saveSettings();
|
||||
this.assertMutationContextCurrent(updatedContext);
|
||||
await this.refreshViewAndCache();
|
||||
}
|
||||
private configureAutoSync(): void { this.autoSync.configure(this.settings.autoSyncIntervalMinutes); }
|
||||
async connectCurrentProvider(): Promise<void> {
|
||||
const context = this.captureMutationContext();
|
||||
const runtime = this.runtime;
|
||||
if (!runtime || runtime.tasks !== context.service) throw new Error("Task provider context changed.");
|
||||
await runtime.auth.login();
|
||||
this.assertMutationContextCurrent(context);
|
||||
this.generation++;
|
||||
notify(`${context.provider} connected.`, "success");
|
||||
await this.refreshSidebar();
|
||||
}
|
||||
async disconnectCurrentProvider(): Promise<void> {
|
||||
const context = this.captureMutationContext();
|
||||
const runtime = this.runtime;
|
||||
if (!runtime || runtime.tasks !== context.service) throw new Error("Task provider context changed.");
|
||||
await runtime.auth.logout();
|
||||
this.assertMutationContextCurrent(context);
|
||||
this.invalidateRuntime();
|
||||
notify(`${context.provider} disconnected.`, "success");
|
||||
await this.refreshSidebar();
|
||||
}
|
||||
private async runCommand(action: string, work: () => void | Promise<void>): Promise<void> { try { await work(); } catch (error) { this.reportError(action, error); } }
|
||||
private commandAvailable(id: CommandId): boolean {
|
||||
const config = this.providerSettings;
|
||||
return canRunCommand(id, {
|
||||
hasCredentials: Boolean(config.clientId.trim() && config.redirectUrl.trim() && this.secretStore.read(config.clientSecretId)),
|
||||
hasSelectedList: Boolean(config.selectedListId),
|
||||
hasTaskLists: config.taskLists.length > 0,
|
||||
hasActiveFile: this.app.workspace.getActiveFile() !== null,
|
||||
});
|
||||
}
|
||||
private checkCommand(id: CommandId, checking: boolean, action: string, work: () => void | Promise<void>): boolean {
|
||||
if (!this.commandAvailable(id)) return false;
|
||||
if (!checking) void this.runCommand(action, work);
|
||||
return true;
|
||||
}
|
||||
|
||||
private initializeCommands(): void {
|
||||
this.addCommand({ id: COMMAND_IDS.openSidebar, name: "Open task sidebar", checkCallback: checking => this.checkCommand(COMMAND_IDS.openSidebar, checking, "Open sidebar failed", () => this.activateSidebar()) });
|
||||
this.addCommand({ id: COMMAND_IDS.connectProvider, name: "Connect current task provider", checkCallback: checking => this.checkCommand(COMMAND_IDS.connectProvider, checking, "Connect failed", () => this.connectCurrentProvider()) });
|
||||
this.addCommand({ id: COMMAND_IDS.disconnectProvider, name: "Disconnect current task provider", checkCallback: checking => this.checkCommand(COMMAND_IDS.disconnectProvider, checking, "Disconnect failed", () => this.disconnectCurrentProvider()) });
|
||||
this.addCommand({ id: COMMAND_IDS.loadTaskLists, name: "Load task lists", checkCallback: checking => this.checkCommand(COMMAND_IDS.loadTaskLists, checking, "Load lists failed", () => this.loadAvailableTaskLists()) });
|
||||
this.addCommand({ id: COMMAND_IDS.selectTaskList, name: "Select task list", checkCallback: checking => this.checkCommand(COMMAND_IDS.selectTaskList, checking, "Select list failed", () => this.openTaskListsModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.refreshTasks, name: "Refresh tasks", checkCallback: checking => this.checkCommand(COMMAND_IDS.refreshTasks, checking, "Refresh failed", () => this.refreshViewAndCache()) });
|
||||
this.addCommand({ id: COMMAND_IDS.pushAllTasks, name: "Push all tasks from note", editorCheckCallback: checking => this.checkCommand(COMMAND_IDS.pushAllTasks, checking, "Push failed", async () => { const count = await this.pushTasksFromNote(); notify(`${count} new tasks added.`, "success"); await this.refreshViewAndCache(); }) });
|
||||
this.addCommand({ id: COMMAND_IDS.pushOneTask, name: "Create and push task", checkCallback: checking => this.checkCommand(COMMAND_IDS.pushOneTask, checking, "Create task failed", () => this.openPushTaskModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.showOpenTasks, name: "Show open tasks list", checkCallback: checking => this.checkCommand(COMMAND_IDS.showOpenTasks, checking, "Show tasks failed", () => this.openTaskCompleteModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.organizeTasks, name: "Organize tasks from all notes", checkCallback: checking => this.checkCommand(COMMAND_IDS.organizeTasks, checking, "Organize failed", async () => { await this.gatherTasks(); notify("Tasks organized.", "success"); }) });
|
||||
this.addCommand({ id: COMMAND_IDS.deleteCompletedTasks, name: "Delete completed tasks", checkCallback: checking => this.checkCommand(COMMAND_IDS.deleteCompletedTasks, checking, "Delete failed", async () => { const count = await this.deleteAllCompletedTasks(); if (count) notify(`${count} completed tasks deleted.`, "success"); }) });
|
||||
}
|
||||
|
||||
async activateSidebar(): Promise<void> { const leaf = this.app.workspace.getRightLeaf(false); if (!leaf) return; await leaf.setViewState({ type: VIEW_TYPE_TODO_SIDEBAR, active: true }); await this.app.workspace.revealLeaf(leaf); }
|
||||
private requireListId(): string { const id = this.providerSettings.selectedListId; if (!id) throw new Error("Select a task list before syncing."); return id; }
|
||||
async loadAvailableTaskLists(): Promise<void> {
|
||||
const context = this.captureMutationContext();
|
||||
const lists = await context.service.fetchTaskLists();
|
||||
this.assertMutationContextCurrent(context);
|
||||
const providerSettings = this.settings.providers[context.provider];
|
||||
providerSettings.taskLists = lists;
|
||||
if (!lists.some(list => list.id === providerSettings.selectedListId)) {
|
||||
providerSettings.selectedListId = "";
|
||||
providerSettings.selectedListTitle = "";
|
||||
this.generation++;
|
||||
this.taskCache = null;
|
||||
}
|
||||
}
|
||||
async updateProviderCredential(key: "clientId" | "clientSecretId" | "redirectUrl", value: string) {
|
||||
await changeProviderCredential(this.settings, key, value, this.settingsEffects());
|
||||
}
|
||||
async updateTimeZone(value: string) { await changeTimeZone(this.settings, value, this.settingsEffects()); }
|
||||
async updateAutoSyncInterval(minutes: number) {
|
||||
this.settings.autoSyncIntervalMinutes = minutes;
|
||||
await this.saveSettings();
|
||||
this.configureAutoSync();
|
||||
notify("Task lists loaded.", "success");
|
||||
}
|
||||
async updateAutoSyncOnStartup(enabled: boolean) {
|
||||
this.settings.autoSyncOnStartup = enabled;
|
||||
await this.saveSettings();
|
||||
}
|
||||
private configureAutoSync() { this.autoSync.configure(this.settings.autoSyncIntervalMinutes); }
|
||||
async connectCurrentProvider() { await this.ensureRuntime().auth.login(); notify(`${this.settings.provider} connected.`, "success"); await this.refreshSidebar(); }
|
||||
async disconnectCurrentProvider() { await this.ensureRuntime().auth.logout(); this.taskCache = null; notify(`${this.settings.provider} disconnected.`, "success"); await this.refreshSidebar(); }
|
||||
private async runCommand(action: string, work: () => void | Promise<void>) { try { await work(); } catch (error) { this.reportError(action, error); } }
|
||||
private initializeCommands() {
|
||||
this.addCommand({ id: COMMAND_IDS.openSidebar, name: "Open task sidebar", callback: () => this.runCommand("Open sidebar failed", () => this.activateSidebar()) });
|
||||
this.addCommand({ id: COMMAND_IDS.connectProvider, name: "Connect current task provider", callback: () => this.runCommand("Connect failed", () => this.connectCurrentProvider()) });
|
||||
this.addCommand({ id: COMMAND_IDS.disconnectProvider, name: "Disconnect current task provider", callback: () => this.runCommand("Disconnect failed", () => this.disconnectCurrentProvider()) });
|
||||
this.addCommand({ id: COMMAND_IDS.loadTaskLists, name: "Load task lists", callback: () => this.loadAvailableTaskLists() });
|
||||
this.addCommand({ id: COMMAND_IDS.selectTaskList, name: "Select task list", callback: () => this.runCommand("Select list failed", () => this.openTaskListsModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.refreshTasks, name: "Refresh tasks", callback: () => this.runCommand("Refresh failed", () => this.refreshViewAndCache()) });
|
||||
this.addCommand({ id: COMMAND_IDS.pushAllTasks, name: "Push all tasks from note", callback: async () => { try { const count = await this.pushTasksFromNote(); notify(`${count} new tasks added.`, "success"); await this.refreshViewAndCache(); } catch (e) { this.reportError("Push failed", e); } } });
|
||||
this.addCommand({ id: COMMAND_IDS.pushOneTask, name: "Create and push task", callback: () => this.runCommand("Create task failed", () => this.openPushTaskModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.showOpenTasks, name: "Show open tasks list", callback: () => this.runCommand("Show tasks failed", () => this.openTaskCompleteModal()) });
|
||||
this.addCommand({ id: COMMAND_IDS.organizeTasks, name: "Organize tasks from all notes", callback: async () => { try { await this.gatherTasks(); notify("Tasks organized.", "success"); } catch (e) { this.reportError("Organize failed", e); } } });
|
||||
this.addCommand({ id: COMMAND_IDS.deleteCompletedTasks, name: "Delete completed tasks", callback: async () => { try { const count = await this.deleteAllCompletedTasks(); if (count) notify(`${count} completed tasks deleted.`, "success"); } catch (e) { this.reportError("Delete failed", e); } } });
|
||||
}
|
||||
async activateSidebar() { const leaf = this.app.workspace.getRightLeaf(false); if (!leaf) return; await leaf.setViewState({ type: VIEW_TYPE_TODO_SIDEBAR, active: true }); this.app.workspace.revealLeaf(leaf); }
|
||||
private requireListId() { const id = this.providerSettings.selectedListId; if (!id) throw new Error("Select a task list before syncing."); return id; }
|
||||
async loadAvailableTaskLists() { try { const lists = await this.api.fetchTaskLists(); this.providerSettings.taskLists = lists; if (!lists.some(l => l.id === this.providerSettings.selectedListId)) { this.providerSettings.selectedListId = ""; this.providerSettings.selectedListTitle = ""; this.taskCache = null; } await this.saveSettings(); notify("Task lists loaded.", "success"); } catch (e) { this.reportError("Load lists failed", e); } }
|
||||
async getTaskLists(): Promise<TaskList[]> { return this.api.fetchTaskLists(); }
|
||||
async getTasksFromSelectedList(): Promise<TaskItem[]> { const listId = this.requireListId(); if (this.taskCache?.provider === this.settings.provider && this.taskCache.listId === listId) return this.taskCache.tasks; return this.refreshTaskCache(); }
|
||||
private titleKey(title: string) { return title.trim().replace(/\s+/g, " ").toLocaleLowerCase(); }
|
||||
|
||||
async pushTasksFromNote(): Promise<number> {
|
||||
const listId = this.requireListId(); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) throw new Error("No active file found.");
|
||||
const context = this.captureMutationContext();
|
||||
if (!context.listId) throw new Error("Select a task list before syncing.");
|
||||
const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) throw new Error("No active file found.");
|
||||
const content = await this.app.vault.read(activeFile); const regex = /^-\s*\[( |x|X)\]\s+(.+)$/gm; const noteTasks: Array<{ title: string; completed: boolean }> = []; let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content))) noteTasks.push({ completed: match[1].toLowerCase() === "x", title: match[2].trim() }); if (!noteTasks.length) throw new Error("No tasks found in the active note.");
|
||||
const existing = await this.api.fetchTasks(listId, true); const byTitle = new Map(existing.map(task => [this.titleKey(task.title), task])); let created = 0;
|
||||
for (const task of noteTasks) { const found = byTitle.get(this.titleKey(task.title)); if (found) { if (task.completed && found.status === "open") await this.api.completeTask(listId, found.id); continue; } const made = await this.api.createTask(listId, { title: task.title }); byTitle.set(this.titleKey(task.title), made); if (task.completed) await this.api.completeTask(listId, made.id); created++; }
|
||||
while ((match = regex.exec(content))) noteTasks.push({ completed: match[1].toLowerCase() === "x", title: match[2].trim() });
|
||||
if (!noteTasks.length) throw new Error("No tasks found in the active note.");
|
||||
this.assertMutationContextCurrent(context);
|
||||
const existing = await context.service.fetchTasks(context.listId, true); let created = 0;
|
||||
this.assertMutationContextCurrent(context);
|
||||
for (const task of noteTasks) {
|
||||
const found = matchRemoteTask(existing, task.title);
|
||||
if (found.status === "ambiguous") throw new Error(`Multiple remote tasks match “${task.title}”; no ambiguous task was changed.`);
|
||||
if (found.status === "matched") {
|
||||
if (task.completed && found.task.status === "open") {
|
||||
this.assertMutationContextCurrent(context);
|
||||
await context.service.completeTask(context.listId, found.task.id);
|
||||
this.assertMutationContextCurrent(context);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
this.assertMutationContextCurrent(context);
|
||||
const made = await context.service.createTask(context.listId, { title: task.title });
|
||||
this.assertMutationContextCurrent(context);
|
||||
existing.push(made);
|
||||
if (task.completed) {
|
||||
await context.service.completeTask(context.listId, made.id);
|
||||
this.assertMutationContextCurrent(context);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
async pushOneTask(title: string, dueDate?: string): Promise<boolean> { const listId = this.requireListId(); const existing = await this.api.fetchTasks(listId, true); if (existing.some(t => this.titleKey(t.title) === this.titleKey(title))) return false; await this.api.createTask(listId, { title, dueDate }); await this.refreshViewAndCache(); return true; }
|
||||
async gatherTasks(): Promise<Map<string, string>> { const output = new Map<string, string>(); const regex = /^\s*-\s*\[( |x|X)\]\s+(.*)$/gm; for (const file of this.app.vault.getMarkdownFiles()) { const content = await this.app.vault.read(file); let match: RegExpExecArray | null; while ((match = regex.exec(content))) { const title = match[2].trim(), state = match[1].toLowerCase() === "x" ? "[x]" : "[ ]"; if (!output.has(title) || state === "[x]") output.set(title, state); } } const body = Array.from(output, ([title, state]) => `- ${state} ${title}`).join("\n"); const target = this.app.vault.getAbstractFileByPath("Tasks List.md"); if (!target) await this.app.vault.create("Tasks List.md", body); else if (target instanceof TFile) await this.app.vault.modify(target, body); else throw new Error("Unexpected file type for Tasks List.md"); return output; }
|
||||
async openPushTaskModal() { new TaskTitleModal(this.app, async (result: TaskInputResult) => { try { const made = await this.pushOneTask(result.title, result.dueDate); notify(made ? "Task created." : "A task with that title already exists.", made ? "success" : "info"); } catch (e) { this.reportError("Create task failed", e); } }).open(); }
|
||||
async openTaskListsModal() { new GenericSelectModal<TaskList>(this.app, this.providerSettings.taskLists, item => item.title, async item => { this.providerSettings.selectedListId = item.id; this.providerSettings.selectedListTitle = item.title; this.taskCache = null; await this.saveSettings(); await this.refreshViewAndCache(); }).open(); }
|
||||
async openTaskCompleteModal() { const listId = this.requireListId(); const open = (await this.getTasksFromSelectedList()).filter(t => t.status === "open"); new GenericSelectModal<TaskItem>(this.app, open, item => item.title, async item => { await this.api.completeTask(listId, item.id); await this.refreshViewAndCache(); }).open(); }
|
||||
async refreshTaskCache(): Promise<TaskItem[]> { const listId = this.requireListId(); const tasks = await this.api.fetchTasks(listId, this.settings.showCompleted); this.taskCache = { provider: this.settings.provider, listId, tasks }; return tasks; }
|
||||
async deleteAllCompletedTasks(): Promise<number> { const listId = this.requireListId(); return deleteCompletedTasksAndRefresh(() => deleteCompletedTasksWithConfirmation(this.api, this.settings.provider, listId, this.providerSettings.selectedListTitle, details => confirmCompletedTaskDeletion(this.app, details)), () => this.refreshViewAndCache()); }
|
||||
async refreshViewAndCache() { await this.refreshTaskCache(); if (this.sidebarView) await this.sidebarView.render(); }
|
||||
async pushOneTask(title: string, dueDate?: string): Promise<boolean> { return this.pushOneTaskInContext(this.captureMutationContext(), title, dueDate); }
|
||||
private async pushOneTaskInContext(context: MutationContext, title: string, dueDate?: string): Promise<boolean> {
|
||||
this.assertMutationContextCurrent(context);
|
||||
if (!context.listId) throw new Error("Select a task list before syncing.");
|
||||
const existing = await context.service.fetchTasks(context.listId, true);
|
||||
this.assertMutationContextCurrent(context);
|
||||
if (matchRemoteTask(existing, title).status !== "none") return false;
|
||||
this.assertMutationContextCurrent(context);
|
||||
await context.service.createTask(context.listId, { title, dueDate });
|
||||
this.assertMutationContextCurrent(context);
|
||||
await this.refreshViewAndCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async gatherTasks(): Promise<Map<string, string>> {
|
||||
const output = new Map<string, string>(); const regex = /^\s*-\s*\[( |x|X)\]\s+(.*)$/gm;
|
||||
for (const file of this.app.vault.getMarkdownFiles()) { if (file.path === "Tasks List.md") continue; const content = await this.app.vault.read(file); let match: RegExpExecArray | null; while ((match = regex.exec(content))) { const title = match[2].trim(), state = match[1].toLowerCase() === "x" ? "[x]" : "[ ]"; if (!output.has(title) || state === "[x]") output.set(title, state); } }
|
||||
const body = Array.from(output, ([title, state]) => `- ${state} ${title}`).join("\n"); const target = this.app.vault.getAbstractFileByPath("Tasks List.md");
|
||||
if (!target) await this.app.vault.create("Tasks List.md", `${GENERATED_START}\n${body}\n${GENERATED_END}\n`);
|
||||
else if (target instanceof TFile) await this.app.vault.process(target, content => updateManagedTaskSection(content, body));
|
||||
else throw new Error("Unexpected file type for Tasks List.md");
|
||||
return output;
|
||||
}
|
||||
|
||||
async openPushTaskModal(): Promise<void> { const context = this.captureMutationContext(); new TaskTitleModal(this.app, result => this.runCommand("Create task failed", async () => { const made = await this.pushOneTaskInContext(context, result.title, result.dueDate); notify(made ? "Task created." : "A task with that title already exists.", made ? "success" : "info"); })).open(); }
|
||||
async openTaskListsModal(): Promise<void> { const context = this.captureMutationContext(); new GenericSelectModal<TaskList>(this.app, this.providerSettings.taskLists, item => item.title, item => this.selectTaskListInContext(context, item.id, item.title)).open(); }
|
||||
async openTaskCompleteModal(): Promise<void> {
|
||||
const context = this.captureMutationContext();
|
||||
if (!context.listId) throw new Error("Select a task list before syncing.");
|
||||
const cached = this.taskCache?.provider === context.provider && this.taskCache.listId === context.listId ? this.taskCache.tasks : undefined;
|
||||
const tasks = cached ?? await context.service.fetchTasks(context.listId, this.settings.showCompleted);
|
||||
this.assertMutationContextCurrent(context);
|
||||
const open = tasks.filter(task => task.status === "open");
|
||||
new GenericSelectModal<TaskItem>(this.app, open, item => item.title, async item => {
|
||||
await this.runMutationInContext(context, service => service.completeTask(context.listId, item.id));
|
||||
await this.refreshViewAndCache();
|
||||
}).open();
|
||||
}
|
||||
async refreshTaskCache(): Promise<TaskItem[]> { this.requireListId(); const result = await this.refreshCoordinator.refresh(); return result.status === "committed" ? result.value : this.taskCache?.tasks ?? []; }
|
||||
async deleteAllCompletedTasks(): Promise<number> {
|
||||
const context = this.captureMutationContext();
|
||||
if (!context.listId) throw new Error("Select a task list before syncing.");
|
||||
const listTitle = this.settings.providers[context.provider].selectedListTitle;
|
||||
return deleteCompletedTasksAndRefresh(
|
||||
() => deleteCompletedTasksWithConfirmation(
|
||||
context.service,
|
||||
context.provider,
|
||||
context.listId,
|
||||
listTitle,
|
||||
details => confirmCompletedTaskDeletion(this.app, details),
|
||||
() => this.assertMutationContextCurrent(context),
|
||||
),
|
||||
() => this.runMutationInContext(context, () => this.refreshViewAndCache()),
|
||||
);
|
||||
}
|
||||
async refreshViewAndCache(): Promise<void> { const result = await this.refreshCoordinator.refresh(); if (result.status === "committed") await this.refreshSidebar(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Resolve the real plugin installation directory reported by Obsidian.
|
||||
* `manifest.dir` is a vault-relative path and may differ from the plugin ID
|
||||
* when the plugin is installed manually from a repository checkout.
|
||||
*/
|
||||
export function resolvePluginDirectory(
|
||||
vaultBasePath: string,
|
||||
manifestDirectory: string | undefined,
|
||||
pluginId: string,
|
||||
configDirectory: string,
|
||||
): string {
|
||||
return manifestDirectory
|
||||
? path.join(vaultBasePath, manifestDirectory)
|
||||
: path.join(vaultBasePath, ".obsidian", "plugins", pluginId);
|
||||
const normalize = (value: string) => value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
return manifestDirectory ? normalize(manifestDirectory) : `${normalize(configDirectory)}/plugins/${pluginId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import { TickTickTaskService } from "./services/ticktick";
|
|||
import { ProviderId, TaskService } from "./types";
|
||||
|
||||
export interface ProviderRuntime { id: ProviderId; auth: AuthProvider; tasks: TaskService; }
|
||||
export const obsidianHttpClient: HttpClient = async (request) => requestUrl(request as any) as any;
|
||||
export const obsidianHttpClient: HttpClient = async <T>(request: Parameters<HttpClient>[0]) => {
|
||||
const response = await requestUrl(request);
|
||||
return { status: response.status, json: response.json as T, text: response.text };
|
||||
};
|
||||
|
||||
export function createProviderRuntime(id: ProviderId, settings: TaskSyncerSettings, secrets: SecretStore, http: HttpClient = obsidianHttpClient): ProviderRuntime {
|
||||
export function createProviderRuntime(id: ProviderId, settings: TaskSyncerSettings, secrets: SecretStore, http: HttpClient = obsidianHttpClient, signal?: AbortSignal): ProviderRuntime {
|
||||
const persisted = settings.providers[id];
|
||||
const config = {
|
||||
clientId: persisted.clientId,
|
||||
|
|
@ -21,9 +24,9 @@ export function createProviderRuntime(id: ProviderId, settings: TaskSyncerSettin
|
|||
};
|
||||
const store = new SecretTokenStore(secrets, tokenCacheSecretId(id));
|
||||
if (id === "ticktick") {
|
||||
const auth = new TickTickAuthProvider(config, store, http, openOAuthWindow);
|
||||
const auth = new TickTickAuthProvider(config, store, http, openOAuthWindow, undefined, signal);
|
||||
return { id, auth, tasks: new TickTickTaskService(() => auth.getAccessToken(), http, settings.timeZone, () => auth.logout()) };
|
||||
}
|
||||
const auth = new MicrosoftAuthProvider(config, store);
|
||||
const auth = new MicrosoftAuthProvider(config, store, { signal });
|
||||
return { id, auth, tasks: new MicrosoftTaskService(() => auth.getAccessToken(), http) };
|
||||
}
|
||||
|
|
|
|||
88
src/refresh-controller.ts
Normal file
88
src/refresh-controller.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
export interface RefreshIdentity {
|
||||
provider: string;
|
||||
listId: string;
|
||||
showCompleted: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export type RefreshResult<T> = { status: "committed"; value: T } | { status: "discarded" };
|
||||
|
||||
function sameIdentity(left: RefreshIdentity, right: RefreshIdentity): boolean {
|
||||
return left.provider === right.provider
|
||||
&& left.listId === right.listId
|
||||
&& left.showCompleted === right.showCompleted
|
||||
&& left.generation === right.generation;
|
||||
}
|
||||
|
||||
/** Serializes refreshes and commits only responses for the current runtime identity. */
|
||||
export class RefreshCoordinator<T> {
|
||||
private active?: { identity: RefreshIdentity; promise: Promise<RefreshResult<T>> };
|
||||
private queued?: {
|
||||
identity: RefreshIdentity;
|
||||
promise: Promise<RefreshResult<T>>;
|
||||
resolve: (result: RefreshResult<T>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly identity: () => RefreshIdentity,
|
||||
private readonly fetch: (identity: RefreshIdentity) => Promise<T>,
|
||||
private readonly commit: (value: T, identity: RefreshIdentity) => void,
|
||||
) {}
|
||||
|
||||
refresh(): Promise<RefreshResult<T>> {
|
||||
if (this.disposed) return Promise.resolve({ status: "discarded" });
|
||||
const snapshot = { ...this.identity() };
|
||||
if (!this.active) return this.start(snapshot);
|
||||
if (sameIdentity(this.active.identity, snapshot)) {
|
||||
this.discardQueued();
|
||||
return this.active.promise;
|
||||
}
|
||||
if (this.queued && sameIdentity(this.queued.identity, snapshot)) return this.queued.promise;
|
||||
this.discardQueued();
|
||||
let resolve!: (result: RefreshResult<T>) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<RefreshResult<T>>((done, fail) => { resolve = done; reject = fail; });
|
||||
this.queued = { identity: snapshot, promise, resolve, reject };
|
||||
return promise;
|
||||
}
|
||||
|
||||
private start(snapshot: RefreshIdentity): Promise<RefreshResult<T>> {
|
||||
const work = Promise.resolve().then(() => this.fetch(snapshot)).then(value => {
|
||||
if (this.disposed || !sameIdentity(snapshot, this.identity())) return { status: "discarded" } as const;
|
||||
this.commit(value, snapshot);
|
||||
return { status: "committed", value } as const;
|
||||
});
|
||||
let promise!: Promise<RefreshResult<T>>;
|
||||
promise = work.then(
|
||||
result => { this.finish(promise); return result; },
|
||||
error => { this.finish(promise); throw error; },
|
||||
);
|
||||
this.active = { identity: snapshot, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
private finish(promise: Promise<RefreshResult<T>>): void {
|
||||
if (this.active?.promise !== promise) return;
|
||||
this.active = undefined;
|
||||
const queued = this.queued;
|
||||
this.queued = undefined;
|
||||
if (!queued) return;
|
||||
if (this.disposed || !sameIdentity(queued.identity, this.identity())) {
|
||||
queued.resolve({ status: "discarded" });
|
||||
return;
|
||||
}
|
||||
this.start(queued.identity).then(queued.resolve, queued.reject);
|
||||
}
|
||||
|
||||
private discardQueued(): void {
|
||||
this.queued?.resolve({ status: "discarded" });
|
||||
this.queued = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.discardQueued();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +1,108 @@
|
|||
import { ItemView, setIcon, WorkspaceLeaf } from "obsidian";
|
||||
import type TaskSyncerPlugin from "./main";
|
||||
import { playConfetti } from "./utils";
|
||||
import { TaskItem, TaskInputResult } from "./types";
|
||||
import { TaskItem, TaskUpdate } from "./types";
|
||||
import { TaskTitleModal } from "./task-title-modal";
|
||||
import { sortTasksForSidebar } from "./task-sort";
|
||||
import { calendarDateInTimeZone, dueDateLabel } from "./date-only";
|
||||
|
||||
export const VIEW_TYPE_TODO_SIDEBAR = "tasks-syncer-sidebar";
|
||||
|
||||
export class TaskSidebarView extends ItemView {
|
||||
plugin: TaskSyncerPlugin;
|
||||
contentContainer: HTMLElement;
|
||||
taskContainer: HTMLElement;
|
||||
private contentContainer: HTMLElement;
|
||||
private taskContainer: HTMLElement;
|
||||
constructor(leaf: WorkspaceLeaf, private readonly plugin: TaskSyncerPlugin) { super(leaf); }
|
||||
getViewType(): string { return VIEW_TYPE_TODO_SIDEBAR; }
|
||||
getDisplayText(): string { return "Task Syncer"; }
|
||||
getIcon(): string { return "list-todo"; }
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: TaskSyncerPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
getViewType() { return VIEW_TYPE_TODO_SIDEBAR; }
|
||||
getDisplayText() { return "Task Syncer"; }
|
||||
getIcon() { return "list-todo"; }
|
||||
|
||||
async onOpen() {
|
||||
const view = this.containerEl.querySelector(".view-content") as HTMLElement | null;
|
||||
this.contentContainer = (view || this.containerEl).createDiv("tasks-syncer-content");
|
||||
async onOpen(): Promise<void> {
|
||||
this.contentContainer = this.containerEl.createDiv("task-syncer-content");
|
||||
this.setupNavHeader();
|
||||
this.taskContainer = this.contentContainer.createDiv("tasks-group");
|
||||
this.taskContainer = this.contentContainer.createDiv("task-syncer-tasks");
|
||||
await this.refresh(true);
|
||||
}
|
||||
|
||||
private setupNavHeader() {
|
||||
const buttons = this.contentContainer.createDiv("nav-header").createDiv({ cls: "nav-buttons" });
|
||||
const refresh = buttons.createEl("a", { cls: "nav-action-button" });
|
||||
setIcon(refresh, "refresh-cw");
|
||||
refresh.title = "Refresh tasks";
|
||||
refresh.onclick = () => this.refresh(true);
|
||||
private setupNavHeader(): void {
|
||||
const buttons = this.contentContainer.createDiv("task-syncer-nav");
|
||||
const refresh = buttons.createEl("button", { cls: "task-syncer-nav-button", type: "button" });
|
||||
setIcon(refresh, "refresh-cw"); refresh.setAttr("aria-label", "Refresh tasks"); refresh.title = "Refresh tasks";
|
||||
refresh.addEventListener("click", () => { void this.refresh(true); });
|
||||
this.toggle(buttons, "showCompleted", { on: "eye-off", off: "eye" }, "Toggle completed tasks");
|
||||
this.toggle(buttons, "showDueDate", { on: "calendar", off: "calendar-arrow-up" }, "Toggle due dates");
|
||||
}
|
||||
|
||||
private toggle(
|
||||
parent: HTMLElement,
|
||||
key: "showCompleted" | "showDueDate",
|
||||
icons: { on: string; off: string },
|
||||
title: string,
|
||||
) {
|
||||
const button = parent.createEl("a", { cls: "nav-action-button" });
|
||||
button.title = title;
|
||||
const updateIcon = () => setIcon(button, this.plugin.settings[key] ? icons.off : icons.on);
|
||||
private toggle(parent: HTMLElement, key: "showCompleted" | "showDueDate", icons: { on: string; off: string }, title: string): void {
|
||||
const button = parent.createEl("button", { cls: "task-syncer-nav-button", type: "button" });
|
||||
button.title = title; button.setAttr("aria-label", title);
|
||||
const updateIcon = (): void => setIcon(button, this.plugin.settings[key] ? icons.off : icons.on);
|
||||
updateIcon();
|
||||
button.onclick = async () => {
|
||||
button.addEventListener("click", () => { void (async () => {
|
||||
try {
|
||||
this.plugin.settings[key] = !this.plugin.settings[key];
|
||||
if (key === "showCompleted") this.plugin.taskCache = null;
|
||||
await this.plugin.saveSettings();
|
||||
updateIcon();
|
||||
await this.refresh(key === "showCompleted");
|
||||
} catch (error) {
|
||||
this.plugin.reportError("Sidebar setting update failed", error);
|
||||
}
|
||||
};
|
||||
const enabled = !this.plugin.settings[key];
|
||||
if (key === "showCompleted") await this.plugin.updateShowCompleted(enabled);
|
||||
else { this.plugin.settings.showDueDate = enabled; await this.plugin.saveSettings(); }
|
||||
updateIcon(); await this.refresh(key === "showCompleted");
|
||||
} catch (error) { this.plugin.reportError("Sidebar setting update failed", error); }
|
||||
})(); });
|
||||
}
|
||||
|
||||
async render() {
|
||||
async render(): Promise<void> {
|
||||
if (!this.taskContainer) return;
|
||||
this.taskContainer.empty();
|
||||
this.taskContainer.createEl("h4", { text: this.plugin.providerSettings.selectedListTitle || "No task list selected" });
|
||||
const tasks = this.plugin.taskCache?.tasks || [];
|
||||
const visible = sortTasksForSidebar(
|
||||
tasks.filter(task => this.plugin.settings.showCompleted || task.status === "open"),
|
||||
this.plugin.settings.showDueDate,
|
||||
);
|
||||
if (!visible.length) {
|
||||
this.taskContainer.createEl("p", { text: "No tasks found" });
|
||||
return;
|
||||
}
|
||||
visible.forEach(task => this.renderTask(task));
|
||||
const today = calendarDateInTimeZone(new Date(), this.plugin.settings.timeZone);
|
||||
const visible = sortTasksForSidebar((this.plugin.taskCache?.tasks ?? []).filter(task => this.plugin.settings.showCompleted || task.status === "open"), this.plugin.settings.showDueDate, today);
|
||||
if (!visible.length) { this.taskContainer.createEl("p", { text: "No tasks found" }); return; }
|
||||
visible.forEach(task => this.renderTask(task, today));
|
||||
}
|
||||
|
||||
private renderTask(task: TaskItem) {
|
||||
const line = this.taskContainer.createEl("div", { cls: "task-line" });
|
||||
const checkbox = line.createEl("input", { type: "checkbox" }) as HTMLInputElement;
|
||||
checkbox.checked = task.status === "completed";
|
||||
const canReopen = this.plugin.api.capabilities.reopenTask && !!this.plugin.api.reopenTask;
|
||||
if (checkbox.checked && !canReopen) {
|
||||
checkbox.disabled = true;
|
||||
checkbox.title = "TickTick's Open API does not support reopening completed tasks.";
|
||||
}
|
||||
const details = line.createDiv("task-details");
|
||||
details.createDiv({ cls: "task-title", text: task.title });
|
||||
details.createDiv(this.formatDueDate(task.dueDate?.slice(0, 10) || "", task.status));
|
||||
private renderTask(task: TaskItem, today: string): void {
|
||||
const context = this.plugin.captureMutationContext();
|
||||
const line = this.taskContainer.createDiv({ cls: "task-syncer-task-line" });
|
||||
const checkbox = line.createEl("input", { type: "checkbox" }); checkbox.checked = task.status === "completed"; checkbox.setAttr("aria-label", `Mark ${task.title} ${checkbox.checked ? "open" : "completed"}`);
|
||||
const canReopen = this.plugin.api.capabilities.reopenTask && this.plugin.api.reopenTask !== undefined;
|
||||
if (checkbox.checked && !canReopen) { checkbox.disabled = true; checkbox.title = "TickTick's open API does not support reopening completed tasks."; }
|
||||
const details = line.createDiv("task-syncer-task-details");
|
||||
details.createDiv({ cls: "task-syncer-task-title", text: task.title });
|
||||
const date = task.dueDate?.slice(0, 10) ?? "";
|
||||
const label = this.plugin.settings.showDueDate ? dueDateLabel(date, task.status, today) : "";
|
||||
details.createDiv({ cls: this.dueDateClass(label), text: label });
|
||||
details.addEventListener("dblclick", () => this.editTask(task));
|
||||
checkbox.addEventListener("change", async () => {
|
||||
checkbox.addEventListener("change", () => { void (async () => {
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
if (checkbox.checked) {
|
||||
await this.plugin.api.completeTask(task.listId, task.id);
|
||||
if (this.plugin.settings.enableConfetti) playConfetti(this.plugin.settings.confettiType);
|
||||
} else if (this.plugin.api.reopenTask) {
|
||||
await this.plugin.api.reopenTask(task.listId, task.id);
|
||||
} else {
|
||||
throw new Error("This provider cannot reopen completed tasks.");
|
||||
}
|
||||
await this.plugin.runMutationInContext(context, async service => {
|
||||
if (checkbox.checked) { await service.completeTask(task.listId, task.id); if (this.plugin.settings.enableConfetti) playConfetti(this.plugin.settings.confettiType); }
|
||||
else if (service.reopenTask) await service.reopenTask(task.listId, task.id);
|
||||
else throw new Error("This provider cannot reopen completed tasks.");
|
||||
});
|
||||
await this.refresh(false);
|
||||
} catch (error) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
this.plugin.reportError("Task status update failed", error);
|
||||
} finally {
|
||||
if (!checkbox.checked || canReopen) checkbox.disabled = false;
|
||||
}
|
||||
});
|
||||
} catch (error) { checkbox.checked = !checkbox.checked; this.plugin.reportError("Task status update failed", error); }
|
||||
finally { if (!checkbox.checked || canReopen) checkbox.disabled = false; }
|
||||
})(); });
|
||||
}
|
||||
|
||||
private editTask(task: TaskItem) {
|
||||
new TaskTitleModal(this.app, async (result: TaskInputResult) => {
|
||||
try {
|
||||
await this.plugin.api.updateTask(task.listId, task.id, { title: result.title, dueDate: result.dueDate });
|
||||
await this.refresh(false);
|
||||
} catch (error) {
|
||||
this.plugin.reportError("Task edit failed", error);
|
||||
}
|
||||
private dueDateClass(label: string): string {
|
||||
if (label === "Today") return "task-syncer-due-date task-syncer-due-date-now";
|
||||
if (label === "Tomorrow") return "task-syncer-due-date task-syncer-due-date-tomorrow";
|
||||
if (label === "Past due") return "task-syncer-due-date task-syncer-due-date-past";
|
||||
return "task-syncer-due-date";
|
||||
}
|
||||
|
||||
private editTask(task: TaskItem): void {
|
||||
const context = this.plugin.captureMutationContext();
|
||||
new TaskTitleModal(this.app, async result => {
|
||||
const update: TaskUpdate = { title: result.title }; if (result.dueDate !== undefined) update.dueDate = result.dueDate;
|
||||
await this.plugin.runMutationInContext(context, service => service.updateTask(task.listId, task.id, update)); await this.refresh(false);
|
||||
}, { title: task.title, dueDate: task.dueDate }).open();
|
||||
}
|
||||
|
||||
private async refresh(spin: boolean) {
|
||||
private async refresh(spin: boolean): Promise<void> {
|
||||
let wrapper: HTMLElement | undefined;
|
||||
if (spin && this.taskContainer) {
|
||||
this.taskContainer.empty();
|
||||
wrapper = this.taskContainer.createDiv("spinner-wrapper");
|
||||
wrapper.createDiv("loading-spinner");
|
||||
}
|
||||
try { await this.plugin.refreshTaskCache(); }
|
||||
if (spin && this.taskContainer) { this.taskContainer.empty(); wrapper = this.taskContainer.createDiv("task-syncer-spinner-wrapper"); wrapper.createDiv("task-syncer-loading-spinner"); }
|
||||
try { await this.plugin.refreshViewAndCache(); }
|
||||
catch (error) { this.plugin.reportError("Task refresh failed", error); }
|
||||
finally { wrapper?.remove(); await this.render(); }
|
||||
}
|
||||
|
||||
private formatDueDate(date: string, status: string) {
|
||||
if (!this.plugin.settings.showDueDate) return { text: "", cls: "task-due-date" };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + 1);
|
||||
const tomorrow = next.toISOString().slice(0, 10);
|
||||
if (status === "completed") return { text: date, cls: "task-due-date" };
|
||||
if (date === today) return { text: "Today", cls: "task-due-date-now" };
|
||||
if (date === tomorrow) return { text: "Tomorrow", cls: "task-due-date-tomorrow" };
|
||||
if (date && date < today) return { text: "Past Due", cls: "task-due-date-past" };
|
||||
return { text: date, cls: "task-due-date" };
|
||||
}
|
||||
|
||||
async onClose() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import * as fs from "fs";
|
||||
|
||||
import type { TokenStore } from "./auth";
|
||||
import type { TaskSyncerSettings } from "./settings-model";
|
||||
|
|
@ -36,45 +35,74 @@ export class SecretTokenStore implements TokenStore {
|
|||
async remove(): Promise<void> { this.secrets.remove(this.id); }
|
||||
}
|
||||
|
||||
function legacyClientSecret(raw: any, provider: ProviderId): string {
|
||||
if (provider === "microsoft" && typeof raw?.clientSecret === "string") return raw.clientSecret;
|
||||
const value = raw?.providers?.[provider]?.clientSecret;
|
||||
export const legacyConflictSecretId = (id: string, source = ""): string =>
|
||||
`${id}-legacy-conflict${source ? `-${source}` : ""}`;
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is UnknownRecord => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
function legacyClientSecret(raw: unknown, provider: ProviderId): string {
|
||||
if (!isRecord(raw)) return "";
|
||||
if (provider === "microsoft" && typeof raw.clientSecret === "string") return raw.clientSecret;
|
||||
const providers = isRecord(raw.providers) ? raw.providers : undefined;
|
||||
const providerSettings = providers && isRecord(providers[provider]) ? providers[provider] : undefined;
|
||||
const value = providerSettings?.clientSecret;
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** Transactionally moves legacy data.json client secrets into Obsidian SecretStorage. */
|
||||
export async function migrateLegacyClientSecrets(
|
||||
raw: any,
|
||||
raw: unknown,
|
||||
settings: TaskSyncerSettings,
|
||||
secrets: SecretStore,
|
||||
clearPlaintext: () => Promise<void>,
|
||||
conflictStores: Partial<Record<ProviderId, TokenStore>> = {},
|
||||
): Promise<void> {
|
||||
const legacy: Array<{ provider: ProviderId; id: string; value: string }> = [];
|
||||
const legacy: Array<{ provider: ProviderId; id: string; value: string; destination?: TokenStore }> = [];
|
||||
for (const provider of ["microsoft", "ticktick"] as const) {
|
||||
const value = legacyClientSecret(raw, provider);
|
||||
if (!value) continue;
|
||||
const id = settings.providers[provider].clientSecretId;
|
||||
const existing = secrets.read(id);
|
||||
if (existing && existing !== value) {
|
||||
throw new Error(`SecretStorage conflict for ${provider}: the stored client secret differs from the legacy plaintext. Resolve the conflict before retrying migration.`);
|
||||
const destination = conflictStores[provider];
|
||||
if (!destination) throw new Error(`No secure conflict store was provided for the legacy ${provider} client secret.`);
|
||||
legacy.push({ provider, id, value, destination });
|
||||
continue;
|
||||
}
|
||||
legacy.push({ provider, id, value });
|
||||
}
|
||||
for (const entry of legacy) {
|
||||
if (!secrets.read(entry.id)) secrets.write(entry.id, entry.value);
|
||||
if (entry.destination) {
|
||||
const existing = await entry.destination.read();
|
||||
if (existing && existing !== entry.value) throw new Error(`SecretStorage legacy conflict slot is already occupied for ${entry.provider}.`);
|
||||
if (!existing) await entry.destination.write(entry.value);
|
||||
} else if (!secrets.read(entry.id)) secrets.write(entry.id, entry.value);
|
||||
}
|
||||
for (const entry of legacy) {
|
||||
if (secrets.read(entry.id) !== entry.value) throw new Error(`Could not verify migrated ${entry.provider} client secret.`);
|
||||
const migrated = entry.destination ? await entry.destination.read() : secrets.read(entry.id);
|
||||
if (migrated !== entry.value) throw new Error(`Could not verify migrated ${entry.provider} client secret.`);
|
||||
}
|
||||
if (legacy.length) await clearPlaintext();
|
||||
}
|
||||
|
||||
/** Moves a plaintext token cache only after SecretStorage returns the exact value. */
|
||||
export async function migrateLegacyTokenFile(filePath: string, store: TokenStore): Promise<void> {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
if (await store.read()) return;
|
||||
const value = fs.readFileSync(filePath, "utf8");
|
||||
await store.write(value);
|
||||
if (await store.read() !== value) throw new Error(`Could not verify migrated token cache: ${filePath}`);
|
||||
fs.unlinkSync(filePath);
|
||||
export interface LegacyFileAdapter {
|
||||
exists(path: string): Promise<boolean>;
|
||||
read(path: string): Promise<string>;
|
||||
remove(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Migrates a vault-relative legacy token file through Obsidian's adapter. */
|
||||
export async function migrateLegacyTokenFile(adapter: LegacyFileAdapter, filePath: string, store: TokenStore, conflictStore?: TokenStore): Promise<void> {
|
||||
if (!await adapter.exists(filePath)) return;
|
||||
const value = await adapter.read(filePath);
|
||||
const existing = await store.read();
|
||||
const destination = existing && existing !== value ? conflictStore : store;
|
||||
if (!destination) throw new Error(`No secure conflict store was provided for legacy token cache: ${filePath}`);
|
||||
const destinationValue = await destination.read();
|
||||
if (destinationValue && destinationValue !== value) throw new Error(`SecretStorage legacy conflict slot is already occupied for token cache: ${filePath}`);
|
||||
if (!destinationValue) await destination.write(value);
|
||||
if (await destination.read() !== value) throw new Error(`Could not verify migrated token cache: ${filePath}`);
|
||||
await adapter.remove(filePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, FuzzySuggestModal } from "obsidian";
|
||||
import { notify } from "./utils";
|
||||
|
||||
export class GenericSelectModal<T> extends FuzzySuggestModal<T> {
|
||||
items: T[];
|
||||
|
|
@ -25,7 +26,9 @@ export class GenericSelectModal<T> extends FuzzySuggestModal<T> {
|
|||
return this.getText(item);
|
||||
}
|
||||
|
||||
async onChooseItem(item: T): Promise<void> {
|
||||
await this.onSelect(item);
|
||||
onChooseItem(item: T): void {
|
||||
void this.onSelect(item).catch(error => {
|
||||
notify(error instanceof Error ? error.message : "The selected task action failed.", "error");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,113 @@ import { HttpClient, HttpRequest, HttpResponse } from "../http";
|
|||
import { TaskItem, TaskList, TaskService, TaskUpdate } from "../types";
|
||||
|
||||
const BASE = "https://graph.microsoft.com/v1.0";
|
||||
export class MicrosoftTaskService implements TaskService {
|
||||
readonly capabilities = { reopenTask: true, renameTaskList: true };
|
||||
constructor(private token: () => Promise<string>, private http: HttpClient) {}
|
||||
private async request<T>(path: string, method = "GET", body?: any): Promise<HttpResponse<T>> {
|
||||
const accessToken = await this.token(); const request: HttpRequest = { url: `${BASE}${path}`, method, headers: { Authorization: `Bearer ${accessToken}`, Prefer: `outlook.timezone="UTC"` } };
|
||||
if (body !== undefined) { request.headers!["Content-Type"] = "application/json"; request.body = JSON.stringify(body); }
|
||||
const res = await this.http<T>(request); if (res.status < 200 || res.status >= 300) throw new Error(this.errorFor(res.status)); return res;
|
||||
}
|
||||
private errorFor(status: number) { if (status === 401) return "Microsoft session expired. Connect Microsoft To Do again."; if (status === 403) return "Microsoft denied task permission."; if (status === 404) return "Microsoft task or list was not found."; if (status === 429) return "Microsoft rate limit reached. Try again later."; return `Microsoft request failed (${status}).`; }
|
||||
async fetchTaskLists(): Promise<TaskList[]> { const res = await this.request<{ value: any[] }>("/me/todo/lists"); return (res.json.value || []).map(l => ({ id: l.id, title: String(l.displayName || "").trim() })); }
|
||||
async fetchTasks(listId: string, includeCompleted = true): Promise<TaskItem[]> { const filter = includeCompleted ? "" : "?$filter=status ne 'completed'"; const res = await this.request<{ value: any[] }>(`/me/todo/lists/${encodeURIComponent(listId)}/tasks${filter}`); return (res.json.value || []).map(t => ({ id: t.id, listId, title: String(t.title || "").trim(), status: t.status === "completed" ? "completed" : "open", ...(t.dueDateTime?.dateTime ? { dueDate: t.dueDateTime.dateTime } : {}) })); }
|
||||
async createTask(listId: string, task: TaskUpdate & { title: string }): Promise<TaskItem> { const body: any = { title: task.title }; if (task.dueDate) body.dueDateTime = { dateTime: task.dueDate, timeZone: "UTC" }; const res = await this.request<any>(`/me/todo/lists/${encodeURIComponent(listId)}/tasks`, "POST", body); return { id: res.json.id, listId, title: res.json.title || task.title, status: res.json.status === "completed" ? "completed" : "open", ...(res.json.dueDateTime?.dateTime ? { dueDate: res.json.dueDateTime.dateTime } : task.dueDate ? { dueDate: task.dueDate } : {}) }; }
|
||||
async updateTask(listId: string, taskId: string, update: TaskUpdate): Promise<TaskItem> { const body: any = { ...update }; if (update.dueDate !== undefined) { delete body.dueDate; body.dueDateTime = update.dueDate ? { dateTime: update.dueDate, timeZone: "UTC" } : null; } const res = await this.request<any>(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", body); return { id: taskId, listId, title: res.json.title || update.title || "", status: res.json.status === "completed" ? "completed" : "open", ...(res.json.dueDateTime?.dateTime ? { dueDate: res.json.dueDateTime.dateTime } : {}) }; }
|
||||
async completeTask(listId: string, taskId: string) { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", { status: "completed" }); }
|
||||
async reopenTask(listId: string, taskId: string) { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", { status: "notStarted" }); }
|
||||
async deleteTask(listId: string, taskId: string) { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "DELETE"); }
|
||||
async renameTaskList(listId: string, title: string) { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}`, "PATCH", { displayName: title }); }
|
||||
const MAX_PAGES = 100;
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
interface GraphCollection { value: unknown[]; "@odata.nextLink"?: string; }
|
||||
|
||||
const isRecord = (value: unknown): value is UnknownRecord => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
function requiredString(value: unknown, context: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`Microsoft ${context} response is missing a valid string.`);
|
||||
return value;
|
||||
}
|
||||
function collection(value: unknown, context: string): GraphCollection {
|
||||
if (!isRecord(value) || !Array.isArray(value.value)) throw new Error(`Microsoft ${context} response must contain an array.`);
|
||||
const next = value["@odata.nextLink"];
|
||||
if (next !== undefined && typeof next !== "string") throw new Error(`Microsoft ${context} pagination next link is malformed.`);
|
||||
return { value: value.value, ...(typeof next === "string" ? { "@odata.nextLink": next } : {}) };
|
||||
}
|
||||
function safeNextLink(value: string): string {
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { throw new Error("Microsoft pagination next link is malformed."); }
|
||||
if (url.origin !== new URL(BASE).origin || url.username || url.password) {
|
||||
throw new Error("Microsoft pagination next link must use the exact HTTPS graph.microsoft.com origin without credentials.");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export class MicrosoftTaskService implements TaskService {
|
||||
readonly capabilities = { reopenTask: true };
|
||||
constructor(private readonly token: () => Promise<string>, private readonly http: HttpClient) {}
|
||||
|
||||
private async request(pathOrUrl: string, method = "GET", body?: unknown): Promise<HttpResponse<unknown>> {
|
||||
const accessToken = await this.token();
|
||||
const request: HttpRequest = {
|
||||
url: pathOrUrl.startsWith("https://") ? pathOrUrl : `${BASE}${pathOrUrl}`,
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Prefer: `outlook.timezone="UTC"` },
|
||||
};
|
||||
if (body !== undefined) { request.headers!["Content-Type"] = "application/json"; request.body = JSON.stringify(body); }
|
||||
const response = await this.http(request);
|
||||
if (response.status < 200 || response.status >= 300) throw new Error(this.errorFor(response.status));
|
||||
return response;
|
||||
}
|
||||
|
||||
private errorFor(status: number): string {
|
||||
if (status === 401) return "Microsoft session expired. Connect Microsoft To Do again.";
|
||||
if (status === 403) return "Microsoft denied task permission.";
|
||||
if (status === 404) return "Microsoft task or list was not found.";
|
||||
if (status === 429) return "Microsoft rate limit reached. Try again later.";
|
||||
return `Microsoft request failed (${status}).`;
|
||||
}
|
||||
|
||||
private async pages(path: string, context: string): Promise<unknown[]> {
|
||||
const output: unknown[] = [];
|
||||
const seen = new Set<string>();
|
||||
let next: string | undefined = path;
|
||||
for (let page = 0; next !== undefined; page++) {
|
||||
if (page >= MAX_PAGES) throw new Error(`Microsoft ${context} pagination exceeded ${MAX_PAGES} pages.`);
|
||||
const key = next.startsWith("https://") ? new URL(next).toString() : `${BASE}${next}`;
|
||||
if (seen.has(key)) throw new Error(`Microsoft ${context} pagination cycle detected.`);
|
||||
seen.add(key);
|
||||
const response = await this.request(next);
|
||||
const parsed = collection(response.json, context);
|
||||
output.push(...parsed.value);
|
||||
next = parsed["@odata.nextLink"] === undefined ? undefined : safeNextLink(parsed["@odata.nextLink"]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async fetchTaskLists(): Promise<TaskList[]> {
|
||||
return (await this.pages("/me/todo/lists", "task lists")).map(raw => {
|
||||
if (!isRecord(raw)) throw new Error("Microsoft task lists response contains an invalid item.");
|
||||
return { id: requiredString(raw.id, "task list ID"), title: requiredString(raw.displayName, "task list title").trim() };
|
||||
});
|
||||
}
|
||||
|
||||
async fetchTasks(listId: string, includeCompleted = true): Promise<TaskItem[]> {
|
||||
const filter = includeCompleted ? "" : "?$filter=status ne 'completed'";
|
||||
return (await this.pages(`/me/todo/lists/${encodeURIComponent(listId)}/tasks${filter}`, "tasks")).map(raw => this.normalizeTask(raw, listId));
|
||||
}
|
||||
|
||||
private normalizeTask(value: unknown, listId: string, fallbackTitle?: string): TaskItem {
|
||||
if (!isRecord(value)) throw new Error("Microsoft tasks response contains an invalid item.");
|
||||
const due = isRecord(value.dueDateTime) && typeof value.dueDateTime.dateTime === "string" ? value.dueDateTime.dateTime : undefined;
|
||||
const title = typeof value.title === "string" && value.title.trim() ? value.title.trim() : fallbackTitle;
|
||||
if (!title) throw new Error("Microsoft task response is missing a valid title.");
|
||||
return {
|
||||
id: requiredString(value.id, "task ID"), listId, title,
|
||||
status: value.status === "completed" ? "completed" : "open",
|
||||
...(due ? { dueDate: due } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async createTask(listId: string, task: TaskUpdate & { title: string }): Promise<TaskItem> {
|
||||
const body: UnknownRecord = { title: task.title };
|
||||
if (task.dueDate) body.dueDateTime = { dateTime: task.dueDate, timeZone: "UTC" };
|
||||
const response = await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks`, "POST", body);
|
||||
return this.normalizeTask(response.json, listId, task.title);
|
||||
}
|
||||
|
||||
async updateTask(listId: string, taskId: string, update: TaskUpdate): Promise<TaskItem> {
|
||||
const body: UnknownRecord = {};
|
||||
if (update.title !== undefined) body.title = update.title;
|
||||
if (update.dueDate !== undefined) body.dueDateTime = update.dueDate ? { dateTime: update.dueDate, timeZone: "UTC" } : null;
|
||||
const response = await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", body);
|
||||
if (!isRecord(response.json)) throw new Error("Microsoft task update response is malformed.");
|
||||
return this.normalizeTask({ id: taskId, ...response.json }, listId, update.title);
|
||||
}
|
||||
|
||||
async completeTask(listId: string, taskId: string): Promise<void> { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", { status: "completed" }); }
|
||||
async reopenTask(listId: string, taskId: string): Promise<void> { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "PATCH", { status: "notStarted" }); }
|
||||
async deleteTask(listId: string, taskId: string): Promise<void> { await this.request(`/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, "DELETE"); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,109 @@
|
|||
import { HttpClient, HttpRequest, HttpResponse } from "../http";
|
||||
import { TaskItem, TaskList, TaskService, TaskUpdate } from "../types";
|
||||
|
||||
const BASE = "https://api.ticktick.com/open/v1";
|
||||
export function formatTickTickDate(value: string): string { const date = value.trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return `${date}T00:00:00+0000`; const noZone = date.replace(/Z$/, "+0000").replace(/([+-]\d{2}):(\d{2})$/, "$1$2"); return /[+-]\d{4}$/.test(noZone) ? noZone : `${noZone}+0000`; }
|
||||
export class TickTickTaskService implements TaskService {
|
||||
readonly capabilities = { reopenTask: false, renameTaskList: true };
|
||||
constructor(private token: () => Promise<string>, private http: HttpClient, private timeZone: string, private clearToken?: () => void | Promise<void>) {}
|
||||
private async request<T>(path: string, method = "GET", body?: any): Promise<HttpResponse<T>> { const accessToken = await this.token(); const req: HttpRequest = { url: `${BASE}${path}`, method, headers: { Authorization: `Bearer ${accessToken}` } }; if (body !== undefined) { req.headers!["Content-Type"] = "application/json"; req.body = JSON.stringify(body); } const res = await this.http<T>(req); if (res.status < 200 || res.status >= 300) { if (res.status === 401 && this.clearToken) await this.clearToken(); throw new Error(this.errorFor(res.status)); } return res; }
|
||||
private errorFor(status: number) { if (status === 401) return "TickTick session expired. Connect TickTick again."; if (status === 403) return "TickTick denied task permission. Verify tasks:read and tasks:write scopes."; if (status === 404) return "TickTick task or list was not found."; if (status === 429) return "TickTick rate limit reached. Try again later."; return `TickTick request failed (${status}).`; }
|
||||
private normalize(raw: any, fallbackListId: string): TaskItem { return { id: String(raw.id), listId: String(raw.projectId || fallbackListId), title: String(raw.title || "").trim(), status: Number(raw.status) === 2 ? "completed" : "open", ...(raw.dueDate ? { dueDate: String(raw.dueDate) } : {}) }; }
|
||||
async fetchTaskLists(): Promise<TaskList[]> { const res = await this.request<any[]>("/project"); return (res.json || []).map(p => ({ id: String(p.id), title: String(p.name || "").trim() })); }
|
||||
async fetchTasks(listId: string, includeCompleted = false): Promise<TaskItem[]> { const active = await this.request<any>(`/project/${encodeURIComponent(listId)}/data`); const byId = new Map<string, TaskItem>(); for (const raw of active.json?.tasks || []) { const task = this.normalize(raw, listId); byId.set(task.id, task); } if (includeCompleted) { const completed = await this.request<any[]>("/task/completed", "POST", { projectIds: [listId] }); for (const raw of completed.json || []) { const task = this.normalize(raw, listId); const activeTask = byId.get(task.id); byId.set(task.id, activeTask ? { ...activeTask, status: "completed" } : task); } } return Array.from(byId.values()); }
|
||||
private payload(listId: string, task: TaskUpdate & { title?: string }) { const body: any = { projectId: listId }; if (task.title !== undefined) body.title = task.title; if (task.dueDate !== undefined) { body.dueDate = task.dueDate ? formatTickTickDate(task.dueDate) : null; if (task.dueDate) { body.timeZone = this.timeZone || "UTC"; body.isAllDay = true; } } return body; }
|
||||
async createTask(listId: string, task: TaskUpdate & { title: string }): Promise<TaskItem> { const res = await this.request<any>("/task", "POST", this.payload(listId, task)); return this.normalize(res.json || { ...task, projectId: listId }, listId); }
|
||||
async updateTask(listId: string, taskId: string, update: TaskUpdate): Promise<TaskItem> { const res = await this.request<any>(`/task/${encodeURIComponent(taskId)}`, "POST", { id: taskId, ...this.payload(listId, update) }); return this.normalize(res.json || { id: taskId, ...update, projectId: listId }, listId); }
|
||||
async completeTask(listId: string, taskId: string) { await this.request(`/project/${encodeURIComponent(listId)}/task/${encodeURIComponent(taskId)}/complete`, "POST"); }
|
||||
async deleteTask(listId: string, taskId: string) { await this.request(`/project/${encodeURIComponent(listId)}/task/${encodeURIComponent(taskId)}`, "DELETE"); }
|
||||
async renameTaskList(listId: string, title: string) { await this.request(`/project/${encodeURIComponent(listId)}`, "POST", { id: listId, name: title }); }
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is UnknownRecord => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export function formatTickTickDate(value: string): string {
|
||||
const date = value.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return `${date}T00:00:00+0000`;
|
||||
const noZone = date.replace(/Z$/, "+0000").replace(/([+-]\d{2}):(\d{2})$/, "$1$2");
|
||||
return /[+-]\d{4}$/.test(noZone) ? noZone : `${noZone}+0000`;
|
||||
}
|
||||
|
||||
export class TickTickTaskService implements TaskService {
|
||||
readonly capabilities = { reopenTask: false };
|
||||
constructor(
|
||||
private readonly token: () => Promise<string>,
|
||||
private readonly http: HttpClient,
|
||||
private readonly timeZone: string,
|
||||
private readonly clearToken?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private async request<T = unknown>(path: string, method = "GET", body?: unknown): Promise<HttpResponse<T>> {
|
||||
const accessToken = await this.token();
|
||||
const request: HttpRequest = { url: `${BASE}${path}`, method, headers: { Authorization: "Bearer " + accessToken } };
|
||||
if (body !== undefined) {
|
||||
request.headers = { ...request.headers, "Content-Type": "application/json" };
|
||||
request.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await this.http<T>(request);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401 && this.clearToken) await this.clearToken();
|
||||
throw new Error(this.errorFor(response.status));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private errorFor(status: number): string {
|
||||
if (status === 401) return "TickTick session expired. Connect TickTick again.";
|
||||
if (status === 403) return "TickTick denied task permission. Verify tasks:read and tasks:write scopes.";
|
||||
if (status === 404) return "TickTick task or list was not found.";
|
||||
if (status === 429) return "TickTick rate limit reached. Try again later.";
|
||||
return `TickTick request failed (${status}).`;
|
||||
}
|
||||
|
||||
private normalize(value: unknown, fallbackListId: string): TaskItem {
|
||||
if (!isRecord(value)) throw new Error("TickTick task response contains an invalid item.");
|
||||
const id = typeof value.id === "string" || typeof value.id === "number" ? String(value.id) : "";
|
||||
const title = typeof value.title === "string" ? value.title.trim() : "";
|
||||
if (!id || !title) throw new Error("TickTick task response is missing an ID or title.");
|
||||
return {
|
||||
id,
|
||||
listId: typeof value.projectId === "string" ? value.projectId : fallbackListId,
|
||||
title,
|
||||
status: Number(value.status) === 2 ? "completed" : "open",
|
||||
...(typeof value.dueDate === "string" ? { dueDate: value.dueDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async fetchTaskLists(): Promise<TaskList[]> {
|
||||
const response = await this.request<unknown[]>("/project");
|
||||
if (!Array.isArray(response.json)) throw new Error("TickTick project response must be an array.");
|
||||
return response.json.map(value => {
|
||||
if (!isRecord(value) || (typeof value.id !== "string" && typeof value.id !== "number") || typeof value.name !== "string") {
|
||||
throw new Error("TickTick project response contains an invalid item.");
|
||||
}
|
||||
return { id: String(value.id), title: value.name.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
async fetchTasks(listId: string, includeCompleted = false): Promise<TaskItem[]> {
|
||||
const active = await this.request<unknown>(`/project/${encodeURIComponent(listId)}/data`);
|
||||
if (!isRecord(active.json) || !Array.isArray(active.json.tasks)) throw new Error("TickTick project data response is malformed.");
|
||||
const byId = new Map<string, TaskItem>();
|
||||
for (const value of active.json.tasks) { const task = this.normalize(value, listId); byId.set(task.id, task); }
|
||||
if (includeCompleted) {
|
||||
const completed = await this.request<unknown[]>("/task/completed", "POST", { projectIds: [listId] });
|
||||
if (!Array.isArray(completed.json)) throw new Error("TickTick completed-task response must be an array.");
|
||||
for (const value of completed.json) {
|
||||
const task = this.normalize(value, listId);
|
||||
const activeTask = byId.get(task.id);
|
||||
byId.set(task.id, activeTask ? { ...activeTask, status: "completed" } : task);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
private payload(listId: string, task: TaskUpdate): UnknownRecord {
|
||||
const body: UnknownRecord = { projectId: listId };
|
||||
if (task.title !== undefined) body.title = task.title;
|
||||
if (task.dueDate !== undefined) {
|
||||
body.dueDate = task.dueDate ? formatTickTickDate(task.dueDate) : null;
|
||||
if (task.dueDate) { body.timeZone = this.timeZone || "UTC"; body.isAllDay = true; }
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async createTask(listId: string, task: TaskUpdate & { title: string }): Promise<TaskItem> {
|
||||
const response = await this.request<unknown>("/task", "POST", this.payload(listId, task));
|
||||
return this.normalize(response.json, listId);
|
||||
}
|
||||
async updateTask(listId: string, taskId: string, update: TaskUpdate): Promise<TaskItem> {
|
||||
const response = await this.request<unknown>(`/task/${encodeURIComponent(taskId)}`, "POST", { id: taskId, ...this.payload(listId, update) });
|
||||
return this.normalize(response.json, listId);
|
||||
}
|
||||
async completeTask(listId: string, taskId: string): Promise<void> { await this.request(`/project/${encodeURIComponent(listId)}/task/${encodeURIComponent(taskId)}/complete`, "POST"); }
|
||||
async deleteTask(listId: string, taskId: string): Promise<void> { await this.request(`/project/${encodeURIComponent(listId)}/task/${encodeURIComponent(taskId)}`, "DELETE"); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export class TaskSyncerSettingTab extends PluginSettingTab {
|
|||
containerEl.empty();
|
||||
const provider = this.plugin.settings.provider;
|
||||
const config = this.plugin.providerSettings;
|
||||
new Setting(containerEl).setName("Task Syncer").setHeading();
|
||||
new Setting(containerEl).setName("Provider and account").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -40,7 +39,7 @@ export class TaskSyncerSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("Redirect URL")
|
||||
.setDesc("Must exactly match the URL registered with the provider.")
|
||||
.addText(text => text.setPlaceholder("http://localhost:5000").setValue(config.redirectUrl).onChange(value =>
|
||||
.addText(text => text.setPlaceholder("Redirect URL").setValue(config.redirectUrl).onChange(value =>
|
||||
this.run("Redirect URL update failed", () => this.plugin.updateProviderCredential("redirectUrl", value.trim()))));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -54,21 +53,17 @@ export class TaskSyncerSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("Task lists")
|
||||
.setDesc("Load lists after connecting, then select the list used by commands.")
|
||||
.addButton(button => button.setButtonText("Load lists").onClick(async () => {
|
||||
await this.plugin.loadAvailableTaskLists();
|
||||
this.display();
|
||||
.addButton(button => button.setButtonText("Load lists").onClick(() => {
|
||||
void this.run("Load lists failed", async () => { await this.plugin.loadAvailableTaskLists(); this.display(); });
|
||||
}));
|
||||
|
||||
new Setting(containerEl).setName("Selected task list").addDropdown(drop => {
|
||||
drop.addOption("", "Select a task list");
|
||||
config.taskLists.forEach(list => drop.addOption(list.id, list.title));
|
||||
config.taskLists.forEach(list => { drop.addOption(list.id, list.title); });
|
||||
drop.setValue(config.selectedListId);
|
||||
drop.onChange(value => this.run("Task list update failed", async () => {
|
||||
const list = config.taskLists.find(item => item.id === value);
|
||||
config.selectedListId = value;
|
||||
config.selectedListTitle = list?.title || "";
|
||||
this.plugin.taskCache = null;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.selectTaskList(value, list?.title || "");
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -102,9 +97,7 @@ export class TaskSyncerSettingTab extends PluginSettingTab {
|
|||
.setDesc(provider === "ticktick" ? "Loads TickTick's documented completed-task endpoint. Completed TickTick tasks cannot be reopened." : "Include completed tasks in the sidebar.")
|
||||
.addToggle(toggle => toggle.setValue(this.plugin.settings.showCompleted).onChange(value =>
|
||||
this.run("Setting update failed", async () => {
|
||||
this.plugin.settings.showCompleted = value;
|
||||
this.plugin.taskCache = null;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateShowCompleted(value);
|
||||
})));
|
||||
|
||||
new Setting(containerEl).setName("Show due dates").addToggle(toggle =>
|
||||
|
|
@ -122,7 +115,7 @@ export class TaskSyncerSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("Confetti")
|
||||
.addDropdown(drop => drop
|
||||
.addOption("regular", "Regular").addOption("big", "Big").addOption("superbig", "Super BIG")
|
||||
.addOption("regular", "Regular").addOption("big", "Big").addOption("superbig", "Super big")
|
||||
.setValue(this.plugin.settings.confettiType)
|
||||
.onChange(value => this.run("Confetti setting failed", async () => {
|
||||
this.plugin.settings.confettiType = value as "regular" | "big" | "superbig";
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export async function changeTimeZone(
|
|||
value: string,
|
||||
effects: SettingsEffects,
|
||||
): Promise<void> {
|
||||
try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); }
|
||||
catch { throw new Error(`Invalid IANA time zone: ${value}`); }
|
||||
settings.timeZone = value;
|
||||
await effects.rebuild();
|
||||
await effects.save();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ProviderId, TaskList } from "./types";
|
|||
|
||||
export const clientSecretId = (provider: ProviderId): string => `task-syncer-plugin-${provider}-client-secret`;
|
||||
export const tokenCacheSecretId = (provider: ProviderId): string => `task-syncer-plugin-${provider}-token-cache`;
|
||||
export const AUTO_SYNC_INTERVALS = [0, 1, 5, 10, 15, 30, 60] as const;
|
||||
|
||||
export interface ProviderSettings {
|
||||
clientId: string; clientSecretId: string; redirectUrl: string;
|
||||
|
|
@ -13,45 +14,75 @@ export interface TaskSyncerSettings {
|
|||
confettiType: "regular" | "big" | "superbig"; timeZone: string;
|
||||
autoSyncIntervalMinutes: number; autoSyncOnStartup: boolean;
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is UnknownRecord => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const stringValue = (value: unknown, fallback: string): string => typeof value === "string" ? value : fallback;
|
||||
const booleanValue = (value: unknown, fallback: boolean): boolean => typeof value === "boolean" ? value : fallback;
|
||||
const providerDefaults = (provider: ProviderId, redirectUrl: string): ProviderSettings => ({ clientId: "", clientSecretId: clientSecretId(provider), redirectUrl, selectedListId: "", selectedListTitle: "", taskLists: [] });
|
||||
|
||||
function systemTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; }
|
||||
function validTimeZone(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
try { new Intl.DateTimeFormat("en", { timeZone: value }).format(); return value; } catch { return fallback; }
|
||||
}
|
||||
function validLists(value: unknown): TaskList[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap(item => isRecord(item) && typeof item.id === "string" && typeof item.title === "string"
|
||||
? [{ id: item.id, title: item.title }]
|
||||
: []);
|
||||
}
|
||||
function interval(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && (AUTO_SYNC_INTERVALS as readonly number[]).includes(value) ? value : fallback;
|
||||
}
|
||||
function confettiType(value: unknown, fallback: TaskSyncerSettings["confettiType"]): TaskSyncerSettings["confettiType"] {
|
||||
return value === "regular" || value === "big" || value === "superbig" ? value : fallback;
|
||||
}
|
||||
|
||||
export const createDefaultSettings = (): TaskSyncerSettings => ({
|
||||
version: 3, provider: "microsoft",
|
||||
providers: { microsoft: providerDefaults("microsoft", "http://localhost:5000"), ticktick: providerDefaults("ticktick", "http://localhost:5000") },
|
||||
showCompleted: true, showDueDate: false, enableConfetti: true, confettiType: "regular",
|
||||
autoSyncIntervalMinutes: 10, autoSyncOnStartup: false,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
autoSyncIntervalMinutes: 10, autoSyncOnStartup: false, timeZone: systemTimeZone(),
|
||||
});
|
||||
export const DEFAULT_SETTINGS = createDefaultSettings();
|
||||
|
||||
function cleanProvider(raw: any, defaults: ProviderSettings): ProviderSettings {
|
||||
function cleanProvider(value: unknown, defaults: ProviderSettings): ProviderSettings {
|
||||
const raw = isRecord(value) ? value : {};
|
||||
return {
|
||||
clientId: typeof raw?.clientId === "string" ? raw.clientId : defaults.clientId,
|
||||
clientSecretId: typeof raw?.clientSecretId === "string" ? raw.clientSecretId : defaults.clientSecretId,
|
||||
redirectUrl: typeof raw?.redirectUrl === "string" ? raw.redirectUrl : defaults.redirectUrl,
|
||||
selectedListId: typeof raw?.selectedListId === "string" ? raw.selectedListId : defaults.selectedListId,
|
||||
selectedListTitle: typeof raw?.selectedListTitle === "string" ? raw.selectedListTitle : defaults.selectedListTitle,
|
||||
taskLists: [...(raw?.taskLists || [])],
|
||||
clientId: stringValue(raw.clientId, defaults.clientId),
|
||||
// Secret references remain stable; arbitrary persisted IDs are accepted only when syntactically valid.
|
||||
clientSecretId: typeof raw.clientSecretId === "string" && /^[a-z0-9-]+$/.test(raw.clientSecretId) ? raw.clientSecretId : defaults.clientSecretId,
|
||||
redirectUrl: stringValue(raw.redirectUrl, defaults.redirectUrl),
|
||||
selectedListId: stringValue(raw.selectedListId, defaults.selectedListId),
|
||||
selectedListTitle: stringValue(raw.selectedListTitle, defaults.selectedListTitle),
|
||||
taskLists: validLists(raw.taskLists),
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateSettings(raw: any): TaskSyncerSettings {
|
||||
export function migrateSettings(value: unknown): TaskSyncerSettings {
|
||||
const defaults = createDefaultSettings();
|
||||
if (!raw) return defaults;
|
||||
if ((raw.version === 2 || raw.version === 3) && raw.providers) {
|
||||
return {
|
||||
...defaults, ...raw,
|
||||
version: 3,
|
||||
provider: raw.provider === "ticktick" ? "ticktick" : "microsoft",
|
||||
providers: {
|
||||
microsoft: cleanProvider(raw.providers.microsoft, defaults.providers.microsoft),
|
||||
ticktick: cleanProvider(raw.providers.ticktick, defaults.providers.ticktick),
|
||||
},
|
||||
};
|
||||
}
|
||||
const microsoft: ProviderSettings = {
|
||||
...defaults.providers.microsoft,
|
||||
clientId: raw.clientId || "", redirectUrl: raw.redirectUrl || defaults.providers.microsoft.redirectUrl,
|
||||
selectedListId: raw.selectedTaskListId || "", selectedListTitle: raw.selectedTaskListTitle || "", taskLists: [...(raw.taskLists || [])],
|
||||
if (!isRecord(value)) return defaults;
|
||||
const providers = isRecord(value.providers) ? value.providers : {};
|
||||
const modern = value.version === 2 || value.version === 3 || isRecord(value.providers);
|
||||
const microsoftValue: unknown = modern ? providers.microsoft : {
|
||||
clientId: value.clientId, redirectUrl: value.redirectUrl,
|
||||
selectedListId: value.selectedTaskListId, selectedListTitle: value.selectedTaskListTitle,
|
||||
taskLists: value.taskLists,
|
||||
};
|
||||
return {
|
||||
version: 3,
|
||||
provider: (modern ? value.provider : value.selectedService) === "ticktick" ? "ticktick" : "microsoft",
|
||||
providers: {
|
||||
microsoft: cleanProvider(microsoftValue, defaults.providers.microsoft),
|
||||
ticktick: cleanProvider(providers.ticktick, defaults.providers.ticktick),
|
||||
},
|
||||
showCompleted: booleanValue(modern ? value.showCompleted : value.showComplete, defaults.showCompleted),
|
||||
showDueDate: booleanValue(value.showDueDate, defaults.showDueDate),
|
||||
enableConfetti: booleanValue(value.enableConfetti, defaults.enableConfetti),
|
||||
confettiType: confettiType(value.confettiType, defaults.confettiType),
|
||||
timeZone: validTimeZone(value.timeZone, defaults.timeZone),
|
||||
autoSyncIntervalMinutes: interval(value.autoSyncIntervalMinutes, defaults.autoSyncIntervalMinutes),
|
||||
autoSyncOnStartup: booleanValue(value.autoSyncOnStartup, defaults.autoSyncOnStartup),
|
||||
};
|
||||
return { ...defaults, provider: raw.selectedService === "ticktick" ? "ticktick" : "microsoft", providers: { ...defaults.providers, microsoft }, showCompleted: raw.showComplete ?? defaults.showCompleted, showDueDate: raw.showDueDate ?? defaults.showDueDate, enableConfetti: raw.enableConfetti ?? defaults.enableConfetti, confettiType: raw.confettiType || defaults.confettiType };
|
||||
}
|
||||
|
|
|
|||
19
src/task-matching.ts
Normal file
19
src/task-matching.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { TaskItem } from "./types";
|
||||
|
||||
export function normalizedTitle(title: string): string {
|
||||
return title.trim().replace(/\s+/g, " ").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export type TaskMatch =
|
||||
| { status: "none" }
|
||||
| { status: "matched"; task: TaskItem }
|
||||
| { status: "ambiguous"; matches: number };
|
||||
|
||||
/** Title matching is only a duplicate guard; ambiguous remote identity fails closed. */
|
||||
export function matchRemoteTask(tasks: readonly TaskItem[], title: string): TaskMatch {
|
||||
const key = normalizedTitle(title);
|
||||
const matches = tasks.filter(task => normalizedTitle(task.title) === key);
|
||||
if (matches.length === 0) return { status: "none" };
|
||||
if (matches.length > 1) return { status: "ambiguous", matches: matches.length };
|
||||
return { status: "matched", task: matches[0] };
|
||||
}
|
||||
19
src/task-organizer.ts
Normal file
19
src/task-organizer.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const GENERATED_START = "<!-- task-syncer:generated:start -->";
|
||||
export const GENERATED_END = "<!-- task-syncer:generated:end -->";
|
||||
|
||||
/** Replaces only the section owned by Task Syncer and preserves all user-authored content. */
|
||||
export function updateManagedTaskSection(content: string, generated: string): string {
|
||||
const start = content.indexOf(GENERATED_START);
|
||||
const end = content.indexOf(GENERATED_END);
|
||||
const duplicateStart = start >= 0 && content.indexOf(GENERATED_START, start + GENERATED_START.length) >= 0;
|
||||
const duplicateEnd = end >= 0 && content.indexOf(GENERATED_END, end + GENERATED_END.length) >= 0;
|
||||
if ((start < 0) !== (end < 0) || (start >= 0 && end < start) || duplicateStart || duplicateEnd) {
|
||||
throw new Error("Task Syncer managed-section markers are missing, duplicated, or corrupt; no changes were written.");
|
||||
}
|
||||
const section = `${GENERATED_START}\n${generated}\n${GENERATED_END}`;
|
||||
if (start < 0) {
|
||||
const separator = content.endsWith("\n\n") ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
||||
return `${content}${separator}${section}\n`;
|
||||
}
|
||||
return `${content.slice(0, start)}${section}${content.slice(end + GENERATED_END.length)}`;
|
||||
}
|
||||
|
|
@ -1,81 +1,51 @@
|
|||
import { Modal, App } from "obsidian";
|
||||
import { TaskInputResult } from "./types";
|
||||
|
||||
export function buildTaskInputResult(title: string, date: string, editing: boolean): TaskInputResult {
|
||||
const result: TaskInputResult = { title: title.trim() };
|
||||
if (date) result.dueDate = `${date}T00:00:00`;
|
||||
else if (editing) result.dueDate = "";
|
||||
export function buildTaskInputResult(title: string, date: string, editing: boolean, dateChanged = editing): TaskInputResult {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) throw new Error("Task title cannot be blank.");
|
||||
const result: TaskInputResult = { title: trimmed };
|
||||
if (dateChanged && date) result.dueDate = `${date}T00:00:00`;
|
||||
else if (dateChanged && editing) result.dueDate = "";
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple modal that prompts the user to enter a task title.
|
||||
* The modal is centered, and the task is submitted when the user presses Enter.
|
||||
*/
|
||||
export class TaskTitleModal extends Modal {
|
||||
result: string;
|
||||
onSubmit: (result: TaskInputResult) => void;
|
||||
private initial?: TaskInputResult;
|
||||
|
||||
/**
|
||||
* Constructs the modal.
|
||||
* @param app - The Obsidian app instance.
|
||||
* @param onSubmit - A callback that receives the entered task title.
|
||||
* @param initial - Optional initial values to pre-populate the inputs.
|
||||
*/
|
||||
private readonly initial?: TaskInputResult;
|
||||
constructor(
|
||||
app: App,
|
||||
onSubmit: (result: TaskInputResult) => void,
|
||||
private readonly onSubmit: (result: TaskInputResult) => void | Promise<void>,
|
||||
initial?: TaskInputResult,
|
||||
) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.initial = initial;
|
||||
}
|
||||
) { super(app); this.initial = initial; }
|
||||
|
||||
/**
|
||||
* Called when the modal is opened. Renders the input UI and centers it.
|
||||
*/
|
||||
onOpen() {
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.style.display = "flex";
|
||||
contentEl.style.flexDirection = "column";
|
||||
contentEl.style.alignItems = "center";
|
||||
contentEl.style.justifyContent = "center";
|
||||
|
||||
const inputEl = contentEl.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "Task Title",
|
||||
value: this.initial?.title ?? "",
|
||||
contentEl.addClass("task-syncer-modal");
|
||||
const form = contentEl.createEl("form", { cls: "task-syncer-task-form" });
|
||||
const inputEl = form.createEl("input", { type: "text", cls: "task-syncer-task-input", placeholder: "Task title", value: this.initial?.title ?? "" });
|
||||
const initialDate = this.initial?.dueDate?.slice(0, 10) ?? "";
|
||||
const dueInput = form.createEl("input", { type: "date", cls: "task-syncer-task-input", value: initialDate });
|
||||
dueInput.setAttr("aria-label", "Due date (optional)");
|
||||
const errorEl = form.createDiv({ cls: "task-syncer-form-error" });
|
||||
errorEl.setAttr("role", "alert");
|
||||
const actions = form.createDiv({ cls: "task-syncer-modal-actions" });
|
||||
const cancel = actions.createEl("button", { text: "Cancel", type: "button" });
|
||||
const save = actions.createEl("button", { text: "Save", type: "submit", cls: "mod-cta" });
|
||||
cancel.addEventListener("click", () => this.close());
|
||||
form.addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
errorEl.empty();
|
||||
let result: TaskInputResult;
|
||||
try { result = buildTaskInputResult(inputEl.value, dueInput.value, this.initial !== undefined, dueInput.value !== initialDate); }
|
||||
catch (error) { errorEl.setText(error instanceof Error ? error.message : "Could not save task."); inputEl.focus(); return; }
|
||||
save.disabled = true;
|
||||
void Promise.resolve(this.onSubmit(result)).then(() => this.close(), error => {
|
||||
save.disabled = false;
|
||||
errorEl.setText(error instanceof Error ? error.message : "Could not save task.");
|
||||
});
|
||||
});
|
||||
|
||||
inputEl.style.width = "100%";
|
||||
inputEl.focus();
|
||||
|
||||
const dueInput = contentEl.createEl("input", {
|
||||
type: "date",
|
||||
placeholder: "Due Date (optional)",
|
||||
value: this.initial?.dueDate
|
||||
? this.initial.dueDate.slice(0, 10)
|
||||
: "",
|
||||
});
|
||||
dueInput.style.width = "100%";
|
||||
|
||||
inputEl.onkeydown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
this.onSubmit(buildTaskInputResult(inputEl.value, dueInput.value, this.initial !== undefined));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the modal is closed. Clears the content.
|
||||
*/
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export interface TaskList { id: string; title: string; }
|
|||
export interface TaskItem { id: string; listId: string; title: string; status: TaskStatus; dueDate?: string; }
|
||||
export interface TaskUpdate { title?: string; dueDate?: string; }
|
||||
export interface TaskInputResult { title: string; dueDate?: string; }
|
||||
export interface ProviderCapabilities { reopenTask: boolean; renameTaskList: boolean; }
|
||||
export interface ProviderCapabilities { reopenTask: boolean; }
|
||||
export interface TaskCache { provider: ProviderId; listId: string; tasks: TaskItem[]; }
|
||||
|
||||
export interface TaskService {
|
||||
|
|
@ -17,5 +17,4 @@ export interface TaskService {
|
|||
completeTask(listId: string, taskId: string): Promise<void>;
|
||||
reopenTask?(listId: string, taskId: string): Promise<void>;
|
||||
deleteTask(listId: string, taskId: string): Promise<void>;
|
||||
renameTaskList?(listId: string, title: string): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
13
src/utils.ts
13
src/utils.ts
|
|
@ -1,6 +1,11 @@
|
|||
import confetti from "canvas-confetti";
|
||||
import { Notice } from "obsidian";
|
||||
|
||||
function launchConfetti(options: confetti.Options): void {
|
||||
const animation = confetti(options);
|
||||
if (animation) void animation.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a notification in Obsidian with optional type-based prefix.
|
||||
* @param message - Message to show
|
||||
|
|
@ -58,7 +63,7 @@ export function regularConfetti(
|
|||
|
||||
// helper to fire one of your bursts
|
||||
const fire = (particleRatio: number, opts: confetti.Options) => {
|
||||
confetti({
|
||||
launchConfetti({
|
||||
...defaults,
|
||||
...opts,
|
||||
particleCount: Math.floor(count * particleRatio),
|
||||
|
|
@ -87,14 +92,14 @@ export function playBIGConfetti(durationMs: number = 3_000) {
|
|||
const spread: number = 70;
|
||||
(function frame() {
|
||||
// left side burst
|
||||
confetti({
|
||||
launchConfetti({
|
||||
particleCount: countPerSide,
|
||||
angle: 60,
|
||||
spread,
|
||||
origin: { x: 0 },
|
||||
});
|
||||
// right side burst
|
||||
confetti({
|
||||
launchConfetti({
|
||||
particleCount: countPerSide,
|
||||
angle: 120,
|
||||
spread,
|
||||
|
|
@ -102,7 +107,7 @@ export function playBIGConfetti(durationMs: number = 3_000) {
|
|||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
window.requestAnimationFrame(frame);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
100
styles.css
100
styles.css
|
|
@ -1,82 +1,94 @@
|
|||
.task-line {
|
||||
.task-syncer-task-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
gap: var(--size-4-2);
|
||||
padding: var(--size-2-1) 0;
|
||||
}
|
||||
|
||||
.task-line input[type="checkbox"] {
|
||||
.task-syncer-task-line input[type="checkbox"] {
|
||||
margin: 0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.task-line span {
|
||||
font-size: 14px;
|
||||
.task-syncer-task-title {
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.task-list-spacer {
|
||||
height: 1em;
|
||||
.task-syncer-nav {
|
||||
display: flex;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.nav-action-button {
|
||||
.task-syncer-nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
color: white;
|
||||
color: var(--text-normal);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
.nav-action-button:hover {
|
||||
background-color: var(--background-modifier-hover, #444);
|
||||
border: 0;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.task-due-date {
|
||||
font-size: 0.8em;
|
||||
color: #777;
|
||||
.task-syncer-nav-button:hover,
|
||||
.task-syncer-nav-button:focus-visible {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.task-due-date-now {
|
||||
font-size: 0.8em;
|
||||
color: #ff9900;
|
||||
.task-syncer-due-date {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-due-date-tomorrow {
|
||||
font-size: 0.8em;
|
||||
color: #999966;
|
||||
}
|
||||
.task-syncer-due-date-now { color: var(--color-orange); }
|
||||
.task-syncer-due-date-tomorrow { color: var(--color-yellow); }
|
||||
.task-syncer-due-date-past { color: var(--text-error); }
|
||||
|
||||
.task-due-date-past {
|
||||
font-size: 0.8em;
|
||||
color: #f05e48;
|
||||
}
|
||||
|
||||
.spinner-wrapper {
|
||||
.task-syncer-spinner-wrapper,
|
||||
.task-syncer-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
margin-top: 4em;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
.task-syncer-spinner-wrapper {
|
||||
padding: var(--size-4-4);
|
||||
margin-top: var(--size-4-12);
|
||||
}
|
||||
|
||||
.task-syncer-loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid var(--background-modifier-border);
|
||||
border-top: 3px solid var(--text-accent);
|
||||
border-top-color: var(--text-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 0.5em;
|
||||
animation: task-syncer-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.task-syncer-task-form,
|
||||
.task-syncer-task-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-syncer-task-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
.task-syncer-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
.task-syncer-form-error {
|
||||
min-height: var(--line-height-tight);
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
@keyframes task-syncer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ function timers() {
|
|||
}
|
||||
|
||||
describe("AutoSyncController", () => {
|
||||
it.each([Number.NaN, Infinity, -1, 2, 600])("refuses invalid interval %s", value => {
|
||||
const setInterval = vi.fn(() => 1);
|
||||
const controller = new AutoSyncController(async () => {}, () => true, () => {}, { setInterval, clearInterval: vi.fn() });
|
||||
controller.configure(value);
|
||||
expect(setInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules the configured interval and replaces an existing schedule", () => {
|
||||
const clock = timers();
|
||||
const controller = new AutoSyncController(async () => {}, () => true, () => {}, clock);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { COMMAND_IDS } from "../src/commands";
|
||||
import { canRunCommand, COMMAND_IDS } from "../src/commands";
|
||||
|
||||
describe("command compatibility", () => {
|
||||
it("preserves baseline IDs for commands that still have safe equivalents", () => {
|
||||
|
|
@ -11,3 +11,24 @@ describe("command compatibility", () => {
|
|||
expect(Object.values(COMMAND_IDS)).not.toContain("refresh-task-manager-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command prerequisites", () => {
|
||||
const ready = { hasCredentials: true, hasSelectedList: true, hasTaskLists: true, hasActiveFile: true };
|
||||
|
||||
it("requires credentials before provider and remote-list commands", () => {
|
||||
expect(canRunCommand(COMMAND_IDS.connectProvider, { ...ready, hasCredentials: false })).toBe(false);
|
||||
expect(canRunCommand(COMMAND_IDS.loadTaskLists, { ...ready, hasCredentials: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a selected list and active file where applicable", () => {
|
||||
expect(canRunCommand(COMMAND_IDS.refreshTasks, { ...ready, hasSelectedList: false })).toBe(false);
|
||||
expect(canRunCommand(COMMAND_IDS.pushAllTasks, { ...ready, hasActiveFile: false })).toBe(false);
|
||||
expect(canRunCommand(COMMAND_IDS.pushAllTasks, ready)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps local commands available without provider configuration", () => {
|
||||
const empty = { hasCredentials: false, hasSelectedList: false, hasTaskLists: false, hasActiveFile: false };
|
||||
expect(canRunCommand(COMMAND_IDS.openSidebar, empty)).toBe(true);
|
||||
expect(canRunCommand(COMMAND_IDS.organizeTasks, empty)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
15
tests/date-only.test.ts
Normal file
15
tests/date-only.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { addCalendarDays, calendarDateInTimeZone, dueDateLabel } from "../src/date-only";
|
||||
|
||||
describe("calendar date helpers", () => {
|
||||
it("uses the configured calendar zone rather than UTC", () => {
|
||||
const instant = new Date("2026-07-18T02:00:00Z");
|
||||
expect(calendarDateInTimeZone(instant, "America/Los_Angeles")).toBe("2026-07-17");
|
||||
expect(calendarDateInTimeZone(instant, "Asia/Shanghai")).toBe("2026-07-18");
|
||||
});
|
||||
it("crosses month and year boundaries without UTC label drift", () => {
|
||||
expect(addCalendarDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||
expect(dueDateLabel("2027-01-01", "open", "2026-12-31")).toBe("Tomorrow");
|
||||
expect(dueDateLabel("2026-12-30", "open", "2026-12-31")).toBe("Past due");
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,18 @@ describe("delete completed task orchestration", () => {
|
|||
expect(tasks.deleteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed if provider context changes while confirmation is open", async () => {
|
||||
const tasks = service([{ id: "1", title: "Done", status: "completed" }]);
|
||||
let current = true;
|
||||
const assertCurrent = vi.fn(() => {
|
||||
if (!current) throw new Error("Task context changed");
|
||||
});
|
||||
const confirm = vi.fn(async () => { current = false; return true; });
|
||||
await expect(deleteCompletedTasksWithConfirmation(tasks, "microsoft", "list-id", "Inbox", confirm, assertCurrent))
|
||||
.rejects.toThrow(/context changed/i);
|
||||
expect(tasks.deleteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops after a failed delete and reports the partial outcome", async () => {
|
||||
const tasks = service([
|
||||
{ id: "1", title: "First", status: "completed" },
|
||||
|
|
|
|||
226
tests/main-lifecycle.test.ts
Normal file
226
tests/main-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import TaskSyncerPlugin from "../src/main";
|
||||
import { createDefaultSettings } from "../src/settings-model";
|
||||
import type { TaskService } from "../src/types";
|
||||
import { TaskTitleModal } from "../src/task-title-modal";
|
||||
import { GenericSelectModal } from "../src/select-modal";
|
||||
import { TaskSidebarView } from "../src/right-sidebar-view";
|
||||
|
||||
function taskService(): TaskService {
|
||||
return {
|
||||
capabilities: { reopenTask: true },
|
||||
fetchTaskLists: vi.fn(async () => []),
|
||||
fetchTasks: vi.fn(async () => []),
|
||||
createTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
completeTask: vi.fn(),
|
||||
reopenTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
} as unknown as TaskService;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("plugin OAuth lifecycle", () => {
|
||||
it("owns a live OAuth cancellation signal and aborts it on unload", () => {
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
const signal = plugin.oauthAbortController.signal as AbortSignal;
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.settings.providers.microsoft.clientId = "client";
|
||||
plugin.secretStore = { read: () => "secret", write: () => undefined, remove: () => undefined };
|
||||
const runtime = plugin.ensureRuntime();
|
||||
expect(runtime.auth.signal).toBe(signal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
plugin.onunload();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels an in-flight OAuth runtime when provider context is invalidated", () => {
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.taskCache = null;
|
||||
const original = plugin.oauthAbortController.signal as AbortSignal;
|
||||
plugin.invalidateRuntime();
|
||||
expect(original.aborted).toBe(true);
|
||||
expect(plugin.oauthAbortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report a stale provider connection after switching during login", async () => {
|
||||
const login = deferred<void>();
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.settings.providers.microsoft.selectedListId = "old-list";
|
||||
plugin.runtime = { id: "microsoft", tasks: taskService(), auth: { login: vi.fn(() => login.promise) } };
|
||||
plugin.refreshSidebar = vi.fn();
|
||||
const connecting = plugin.connectCurrentProvider();
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.invalidateRuntime();
|
||||
login.resolve();
|
||||
await expect(connecting).rejects.toThrow(/context changed/i);
|
||||
expect(plugin.refreshSidebar).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal mutation identity", () => {
|
||||
function pluginWithService(service: TaskService): any {
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
plugin.app = {};
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.settings.providers.microsoft.selectedListId = "old-list";
|
||||
plugin.runtime = { id: "microsoft", tasks: service };
|
||||
plugin.reportError = vi.fn();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
it("rejects a delayed mutation after provider identity changes without touching either service", async () => {
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.settings.providers.microsoft.selectedListId = "old-list";
|
||||
const oldService = taskService();
|
||||
const newService = taskService();
|
||||
plugin.runtime = { id: "microsoft", tasks: oldService };
|
||||
const context = plugin.captureMutationContext();
|
||||
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: newService };
|
||||
|
||||
await expect(plugin.runMutationInContext(context, (service: TaskService) => service.completeTask("old-list", "task")))
|
||||
.rejects.toThrow(/context changed/i);
|
||||
expect((oldService as unknown as { completeTask: ReturnType<typeof vi.fn> }).completeTask.mock.calls).toHaveLength(0);
|
||||
expect((newService as unknown as { completeTask: ReturnType<typeof vi.fn> }).completeTask.mock.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects delayed local list selection after the list changes", async () => {
|
||||
const plugin = new TaskSyncerPlugin({} as any, {} as any) as any;
|
||||
plugin.settings = createDefaultSettings();
|
||||
plugin.settings.providers.microsoft.selectedListId = "old-list";
|
||||
plugin.runtime = { id: "microsoft", tasks: taskService() };
|
||||
const context = plugin.captureMutationContext();
|
||||
plugin.settings.providers.microsoft.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
|
||||
await expect(plugin.runMutationInContext(context, async () => plugin.selectTaskList("chosen", "Chosen")))
|
||||
.rejects.toThrow(/context changed/i);
|
||||
expect(plugin.settings.providers.microsoft.selectedListId).toBe("new-list");
|
||||
});
|
||||
|
||||
it("captures create-modal identity so switching providers before submit mutates neither service", async () => {
|
||||
const oldService = taskService();
|
||||
const newService = taskService();
|
||||
const plugin = pluginWithService(oldService);
|
||||
const captured: { modal?: TaskTitleModal } = {};
|
||||
vi.spyOn(TaskTitleModal.prototype, "open").mockImplementation(function (this: TaskTitleModal) { captured.modal = this; });
|
||||
await plugin.openPushTaskModal();
|
||||
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: newService };
|
||||
await (captured.modal as any).onSubmit({ title: "stale task" });
|
||||
|
||||
expect(oldService.fetchTasks).not.toHaveBeenCalled();
|
||||
expect(oldService.createTask).not.toHaveBeenCalled();
|
||||
expect(newService.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures list-modal identity so a stale choice cannot change the current list", async () => {
|
||||
const plugin = pluginWithService(taskService());
|
||||
plugin.providerSettings.taskLists = [{ id: "chosen", title: "Chosen" }];
|
||||
const captured: { modal?: GenericSelectModal<unknown> } = {};
|
||||
vi.spyOn(GenericSelectModal.prototype, "open").mockImplementation(function (this: GenericSelectModal<unknown>) { captured.modal = this; });
|
||||
await plugin.openTaskListsModal();
|
||||
plugin.settings.providers.microsoft.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
|
||||
await expect((captured.modal as any).onSelect({ id: "chosen", title: "Chosen" })).rejects.toThrow(/context changed/i);
|
||||
expect(plugin.providerSettings.selectedListId).toBe("new-list");
|
||||
});
|
||||
|
||||
it("captures open-task modal identity so switching providers before selection mutates neither service", async () => {
|
||||
const oldService = taskService();
|
||||
const newService = taskService();
|
||||
vi.mocked(oldService.fetchTasks).mockResolvedValue([{ id: "old-task", listId: "old-list", title: "Old", status: "open" }]);
|
||||
const plugin = pluginWithService(oldService);
|
||||
const captured: { modal?: GenericSelectModal<unknown> } = {};
|
||||
vi.spyOn(GenericSelectModal.prototype, "open").mockImplementation(function (this: GenericSelectModal<unknown>) { captured.modal = this; });
|
||||
await plugin.openTaskCompleteModal();
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: newService };
|
||||
|
||||
await expect((captured.modal as any).onSelect({ id: "old-task", listId: "old-list", title: "Old", status: "open" })).rejects.toThrow(/context changed/i);
|
||||
expect(oldService.completeTask).not.toHaveBeenCalled();
|
||||
expect(newService.completeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures sidebar edit identity so switching providers before save mutates neither service", async () => {
|
||||
const oldService = taskService();
|
||||
const newService = taskService();
|
||||
const plugin = pluginWithService(oldService);
|
||||
const view = Object.create(TaskSidebarView.prototype) as any;
|
||||
view.plugin = plugin;
|
||||
view.app = {};
|
||||
view.refresh = vi.fn();
|
||||
const captured: { modal?: TaskTitleModal } = {};
|
||||
vi.spyOn(TaskTitleModal.prototype, "open").mockImplementation(function (this: TaskTitleModal) { captured.modal = this; });
|
||||
view.editTask({ id: "old-task", listId: "old-list", title: "Old", status: "open" });
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: newService };
|
||||
|
||||
await expect((captured.modal as any).onSubmit({ title: "Changed" })).rejects.toThrow(/context changed/i);
|
||||
expect(oldService.updateTask).not.toHaveBeenCalled();
|
||||
expect(newService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not commit task lists fetched for a provider that changed while awaiting", async () => {
|
||||
const pending = deferred<Array<{ id: string; title: string }>>();
|
||||
const oldService = taskService();
|
||||
vi.mocked(oldService.fetchTaskLists).mockReturnValue(pending.promise);
|
||||
const plugin = pluginWithService(oldService);
|
||||
plugin.saveSettings = vi.fn();
|
||||
const loading = plugin.loadAvailableTaskLists();
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: taskService() };
|
||||
pending.resolve([{ id: "old-list", title: "Old list" }]);
|
||||
|
||||
await expect(loading).rejects.toThrow(/context changed/i);
|
||||
expect(plugin.settings.providers.microsoft.taskLists).toEqual([]);
|
||||
expect(plugin.settings.providers.ticktick.taskLists).toEqual([]);
|
||||
expect(plugin.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not continue a note push after provider identity changes during its fetch", async () => {
|
||||
const pending = deferred<Awaited<ReturnType<TaskService["fetchTasks"]>>>();
|
||||
const oldService = taskService();
|
||||
const newService = taskService();
|
||||
vi.mocked(oldService.fetchTasks).mockReturnValue(pending.promise);
|
||||
const plugin = pluginWithService(oldService);
|
||||
plugin.app = {
|
||||
workspace: { getActiveFile: () => ({ path: "note.md" }) },
|
||||
vault: { read: vi.fn(async () => "- [ ] New task") },
|
||||
};
|
||||
const pushing = plugin.pushTasksFromNote();
|
||||
await vi.waitFor(() => expect(oldService.fetchTasks).toHaveBeenCalledOnce());
|
||||
plugin.settings.provider = "ticktick";
|
||||
plugin.settings.providers.ticktick.selectedListId = "new-list";
|
||||
plugin.generation++;
|
||||
plugin.runtime = { id: "ticktick", tasks: newService };
|
||||
pending.resolve([]);
|
||||
|
||||
await expect(pushing).rejects.toThrow(/context changed/i);
|
||||
expect(oldService.createTask).not.toHaveBeenCalled();
|
||||
expect(newService.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrowserWindow } from "@electron/remote";
|
||||
import { MicrosoftAuthProvider, openOAuthWindow } from "../src/auth/microsoft";
|
||||
import { isExactRedirect, MicrosoftAuthProvider, openOAuthWindow } from "../src/auth/microsoft";
|
||||
|
||||
vi.mock("@electron/remote", () => ({ BrowserWindow: vi.fn() }));
|
||||
|
||||
|
|
@ -33,22 +33,123 @@ function setup(callbackState: string | undefined) {
|
|||
return { auth, client };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Microsoft OAuth state", () => {
|
||||
it("requires configured redirect query parameters to match", () => {
|
||||
expect(isExactRedirect("http://localhost:5000/callback?tenant=other&code=x", "http://localhost:5000/callback?tenant=personal")).toBe(false);
|
||||
expect(isExactRedirect("http://localhost:5000/callback?tenant=personal&code=x", "http://localhost:5000/callback?tenant=personal")).toBe(true);
|
||||
expect(isExactRedirect("http://localhost:5000/callback?tenant=personal&extra=value&code=x&state=s", "http://localhost:5000/callback?tenant=personal")).toBe(false);
|
||||
expect(isExactRedirect("http://localhost:5000/callback?tenant=personal&code=x&state=s&error_description=nope", "http://localhost:5000/callback?tenant=personal")).toBe(true);
|
||||
expect(isExactRedirect("http://localhost:5000/callback?tenant=personal&code=x", "http://localhost:5000/callback?tenant=personal&tenant=personal")).toBe(false);
|
||||
});
|
||||
it("rejects a malformed redirect before creating a browser window", async () => {
|
||||
await expect(openOAuthWindow("https://login.example/authorize", "not a URL")).rejects.toThrow(/invalid.*redirect/i);
|
||||
expect(BrowserWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an already-aborted authorization before creating a browser window", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(openOAuthWindow("https://login.example/authorize", "http://localhost:5000", controller.signal)).rejects.toThrow(/abort/i);
|
||||
expect(BrowserWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the window and removes every listener when authorization is aborted", async () => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
const removeWebListener = vi.fn();
|
||||
const removeWindowListener = vi.fn();
|
||||
const close = vi.fn();
|
||||
vi.mocked(BrowserWindow).mockImplementationOnce(function BrowserWindowMock() {
|
||||
return {
|
||||
webContents: {
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: removeWebListener,
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: removeWindowListener,
|
||||
loadURL: vi.fn(async () => {}),
|
||||
isDestroyed: () => false,
|
||||
close,
|
||||
} as any;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
|
||||
const authorization = openOAuthWindow("https://login.example/authorize", "http://localhost:5000", controller.signal);
|
||||
controller.abort();
|
||||
await expect(authorization).rejects.toThrow(/abort/i);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(removeWebListener).toHaveBeenCalledWith("will-redirect", handlers.get("will-redirect"));
|
||||
expect(removeWebListener).toHaveBeenCalledWith("will-navigate", handlers.get("will-navigate"));
|
||||
expect(removeWindowListener).toHaveBeenCalledWith("closed", handlers.get("closed"));
|
||||
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||
expect(() => handlers.get("will-navigate")?.({ preventDefault: vi.fn() }, "http://localhost:5000?code=late")).not.toThrow();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("denies popups, uses unique ephemeral partitions, and waits for fixed redirect query values", async () => {
|
||||
const windows: Array<{ handlers: Map<string, (...args: any[]) => void>; close: ReturnType<typeof vi.fn>; removeWebListener: ReturnType<typeof vi.fn>; removeWindowListener: ReturnType<typeof vi.fn> }> = [];
|
||||
vi.mocked(BrowserWindow).mockImplementation(function BrowserWindowMock() {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
const entry = { handlers, close: vi.fn(), removeWebListener: vi.fn(), removeWindowListener: vi.fn() };
|
||||
windows.push(entry);
|
||||
return {
|
||||
webContents: {
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: entry.removeWebListener,
|
||||
setWindowOpenHandler: vi.fn((handler: () => unknown) => { expect(handler()).toEqual({ action: "deny" }); }),
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: entry.removeWindowListener,
|
||||
loadURL: vi.fn(async () => {}),
|
||||
isDestroyed: () => false,
|
||||
close: entry.close,
|
||||
} as any;
|
||||
});
|
||||
const first = openOAuthWindow("https://login.example/one", "http://localhost:5000/callback?tenant=personal");
|
||||
windows[0].handlers.get("will-navigate")?.({ preventDefault: vi.fn() }, "http://localhost:5000/callback?tenant=other&code=wrong");
|
||||
expect(windows[0].close).not.toHaveBeenCalled();
|
||||
windows[0].handlers.get("will-redirect")?.({ preventDefault: vi.fn() }, "http://localhost:5000/callback?tenant=personal&code=one");
|
||||
await expect(first).resolves.toContain("code=one");
|
||||
|
||||
const second = openOAuthWindow("https://login.example/two", "http://localhost:5000/callback?tenant=personal");
|
||||
windows[1].handlers.get("will-navigate")?.({ preventDefault: vi.fn() }, "http://localhost:5000/callback?tenant=personal&code=two");
|
||||
await expect(second).resolves.toContain("code=two");
|
||||
const partitions = vi.mocked(BrowserWindow).mock.calls.map(([options]: any[]) => options.webPreferences.partition as string);
|
||||
expect(partitions).toHaveLength(2);
|
||||
expect(partitions[0]).not.toBe(partitions[1]);
|
||||
for (const partition of partitions) {
|
||||
expect(partition).toMatch(/^task-syncer-oauth-[0-9a-f]{32}$/);
|
||||
expect(partition).not.toContain("persist:");
|
||||
}
|
||||
for (const entry of windows) {
|
||||
expect(entry.removeWebListener).toHaveBeenCalledTimes(2);
|
||||
expect(entry.removeWindowListener).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes and rejects when navigation supplies a malformed URL", async () => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
const close = vi.fn();
|
||||
vi.mocked(BrowserWindow).mockImplementationOnce(() => ({
|
||||
webContents: { on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler) },
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
loadURL: vi.fn(async () => {}),
|
||||
isDestroyed: () => false,
|
||||
close,
|
||||
}) as any);
|
||||
vi.mocked(BrowserWindow).mockImplementationOnce(function BrowserWindowMock() {
|
||||
return {
|
||||
webContents: {
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => handlers.set(event, handler),
|
||||
removeListener: vi.fn(),
|
||||
loadURL: vi.fn(async () => {}),
|
||||
isDestroyed: () => false,
|
||||
close,
|
||||
} as any;
|
||||
});
|
||||
const authorization = openOAuthWindow("https://login.example/authorize", "http://localhost:5000");
|
||||
expect(() => handlers.get("will-navigate")?.({}, "not a URL")).not.toThrow();
|
||||
await expect(authorization).rejects.toThrow(/invalid url/i);
|
||||
|
|
@ -67,4 +168,43 @@ describe("Microsoft OAuth state", () => {
|
|||
await expect(auth.login()).rejects.toThrow("state");
|
||||
expect(client.acquireTokenByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an injected off-origin callback before code exchange", async () => {
|
||||
const { auth, client } = setup("expected-state");
|
||||
client.getAuthCodeUrl.mockResolvedValue("https://login.example/authorize");
|
||||
(auth as any).authorize = async () => "https://evil.example/callback?code=code&state=expected-state";
|
||||
await expect(auth.login()).rejects.toThrow(/redirect/i);
|
||||
expect(client.acquireTokenByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist a token when unloading aborts during code exchange", async () => {
|
||||
const exchange = deferred<{ accessToken: string }>();
|
||||
const store = new MemoryStore();
|
||||
const cache = {
|
||||
deserialize: vi.fn(), serialize: vi.fn(() => "cache"),
|
||||
getAllAccounts: vi.fn(async () => []), removeAccount: vi.fn(),
|
||||
};
|
||||
const client = {
|
||||
getTokenCache: () => cache,
|
||||
getAuthCodeUrl: vi.fn(async () => "https://login.example/authorize"),
|
||||
acquireTokenByCode: vi.fn(() => exchange.promise),
|
||||
acquireTokenSilent: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const authorize = vi.fn(async (_url: string, redirect: string, signal?: AbortSignal) => {
|
||||
expect(signal).toBe(controller.signal);
|
||||
return `${redirect}?code=code&state=expected-state`;
|
||||
});
|
||||
const auth = new MicrosoftAuthProvider(
|
||||
{ clientId: "id", clientSecret: "secret", redirectUrl: "http://localhost:5000" },
|
||||
store,
|
||||
{ client: client as any, authorize, createState: () => "expected-state", signal: controller.signal },
|
||||
);
|
||||
const login = auth.login();
|
||||
await vi.waitFor(() => expect(client.acquireTokenByCode).toHaveBeenCalledOnce());
|
||||
controller.abort();
|
||||
exchange.resolve({ accessToken: "access" });
|
||||
await expect(login).rejects.toThrow(/abort/i);
|
||||
expect(store.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,4 +17,35 @@ describe("Microsoft adapter", () => {
|
|||
expect(JSON.parse(request.mock.calls[0][0].body)).toEqual({ title: "Task" });
|
||||
expect(JSON.parse(request.mock.calls[1][0].body)).toEqual({ dueDateTime: null });
|
||||
});
|
||||
|
||||
it("follows safe Graph pagination for collections", async () => {
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce({ status: 200, json: { value: [{ id: "a", displayName: "A" }], "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/todo/lists?$skiptoken=x" }, text: "" })
|
||||
.mockResolvedValueOnce({ status: 200, json: { value: [{ id: "b", displayName: "B" }] }, text: "" });
|
||||
const service = new MicrosoftTaskService(async () => "token", request);
|
||||
expect(await service.fetchTaskLists()).toEqual([{ id: "a", title: "A" }, { id: "b", title: "B" }]);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://graph.microsoft.com/v1.0/next",
|
||||
"https://evil.example/steal",
|
||||
"https://graph.microsoft.com:444/v1.0/next",
|
||||
"https://attacker@graph.microsoft.com/v1.0/next",
|
||||
])("rejects unsafe Graph continuation %s", async nextLink => {
|
||||
const request = vi.fn().mockResolvedValue({ status: 200, json: { value: [], "@odata.nextLink": nextLink }, text: "" });
|
||||
await expect(new MicrosoftTaskService(async () => "token", request).fetchTaskLists()).rejects.toThrow(/next.*link|pagination/i);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects cyclic Graph pagination", async () => {
|
||||
const next = "https://graph.microsoft.com/v1.0/me/todo/lists";
|
||||
const request = vi.fn().mockResolvedValue({ status: 200, json: { value: [], "@odata.nextLink": next }, text: "" });
|
||||
await expect(new MicrosoftTaskService(async () => "token", request).fetchTaskLists()).rejects.toThrow(/cycle/i);
|
||||
});
|
||||
|
||||
it("rejects malformed list collections with provider context", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ status: 200, json: { value: {} }, text: "" });
|
||||
await expect(new MicrosoftTaskService(async () => "token", request).fetchTaskLists()).rejects.toThrow(/Microsoft.*lists/i);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1,14 @@
|
|||
export class BrowserWindow {}
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
class MockWebContents extends EventEmitter {
|
||||
setWindowOpenHandler(_handler: () => { action: "deny" }): void {}
|
||||
}
|
||||
|
||||
export class BrowserWindow extends EventEmitter {
|
||||
readonly webContents = new MockWebContents();
|
||||
private destroyed = false;
|
||||
constructor(public readonly options?: unknown) { super(); }
|
||||
async loadURL(_url: string): Promise<void> {}
|
||||
isDestroyed(): boolean { return this.destroyed; }
|
||||
close(): void { this.destroyed = true; this.emit("closed"); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export class Plugin {}
|
|||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
export class ItemView {}
|
||||
export class Modal {}
|
||||
export class FuzzySuggestModal<T> { declare readonly __itemType: T; }
|
||||
export class Modal { open(): void {} }
|
||||
export class FuzzySuggestModal<T> { declare readonly __itemType: T; open(): void {} }
|
||||
export class TFile {}
|
||||
export const setIcon = () => undefined;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { resolvePluginDirectory } from "../src/plugin-path";
|
|||
|
||||
describe("plugin directory resolution", () => {
|
||||
it("uses Obsidian's actual manifest directory instead of assuming the plugin ID", () => {
|
||||
expect(resolvePluginDirectory("/vault", ".obsidian/plugins/obsidian-tasks-syncer", "task-syncer-plugin"))
|
||||
.toBe("/vault/.obsidian/plugins/obsidian-tasks-syncer");
|
||||
expect(resolvePluginDirectory(".obsidian/plugins/obsidian-tasks-syncer", "task-syncer-plugin", ".obsidian"))
|
||||
.toBe(".obsidian/plugins/obsidian-tasks-syncer");
|
||||
});
|
||||
|
||||
it("falls back to the config directory and plugin ID when manifest.dir is unavailable", () => {
|
||||
expect(resolvePluginDirectory("/vault", undefined, "task-syncer"))
|
||||
.toBe("/vault/.obsidian/plugins/task-syncer");
|
||||
expect(resolvePluginDirectory(undefined, "task-syncer", ".config/obsidian"))
|
||||
.toBe(".config/obsidian/plugins/task-syncer");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
|||
104
tests/refresh-controller.test.ts
Normal file
104
tests/refresh-controller.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { RefreshCoordinator } from "../src/refresh-controller";
|
||||
import type { RefreshIdentity } from "../src/refresh-controller";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("RefreshCoordinator", () => {
|
||||
it("discards an older refresh after the provider or list identity changes", async () => {
|
||||
let identity: RefreshIdentity = { provider: "microsoft", listId: "a", showCompleted: true, generation: 0 };
|
||||
const old = deferred<string[]>();
|
||||
const commits: string[][] = [];
|
||||
const coordinator = new RefreshCoordinator(() => identity, async () => old.promise, tasks => { commits.push(tasks); });
|
||||
const running = coordinator.refresh();
|
||||
identity = { provider: "ticktick", listId: "b", showCompleted: true, generation: 1 };
|
||||
old.resolve(["old"]);
|
||||
expect(await running).toEqual({ status: "discarded" });
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping refreshes for the same identity", async () => {
|
||||
const identity = { provider: "microsoft", listId: "a", showCompleted: true, generation: 0 } as const;
|
||||
const result = deferred<string[]>();
|
||||
let fetches = 0;
|
||||
const coordinator = new RefreshCoordinator(() => identity, async () => { fetches++; return result.promise; }, () => {});
|
||||
const first = coordinator.refresh();
|
||||
const second = coordinator.refresh();
|
||||
result.resolve(["one"]);
|
||||
expect(await first).toEqual({ status: "committed", value: ["one"] });
|
||||
expect(await second).toEqual({ status: "committed", value: ["one"] });
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
it("serializes identity changes, supersedes queued work, and commits only the newest request", async () => {
|
||||
let identity: RefreshIdentity = { provider: "microsoft", listId: "a", showCompleted: true, generation: 0 };
|
||||
const requests = new Map<string, ReturnType<typeof deferred<string[]>>>();
|
||||
const commits: string[][] = [];
|
||||
let concurrency = 0;
|
||||
let maxConcurrency = 0;
|
||||
const coordinator = new RefreshCoordinator<string[]>(
|
||||
() => identity,
|
||||
async snapshot => {
|
||||
concurrency++;
|
||||
maxConcurrency = Math.max(maxConcurrency, concurrency);
|
||||
const request = deferred<string[]>();
|
||||
requests.set(snapshot.listId, request);
|
||||
try { return await request.promise; }
|
||||
finally { concurrency--; }
|
||||
},
|
||||
tasks => { commits.push(tasks); },
|
||||
);
|
||||
|
||||
const first = coordinator.refresh();
|
||||
identity = { ...identity, listId: "b", generation: 1 };
|
||||
const superseded = coordinator.refresh();
|
||||
identity = { ...identity, listId: "c", generation: 2 };
|
||||
const newest = coordinator.refresh();
|
||||
|
||||
expect(requests.has("b")).toBe(false);
|
||||
expect(requests.has("c")).toBe(false);
|
||||
expect(await superseded).toEqual({ status: "discarded" });
|
||||
requests.get("a")!.resolve(["old"]);
|
||||
expect(await first).toEqual({ status: "discarded" });
|
||||
await Promise.resolve();
|
||||
expect(requests.has("c")).toBe(true);
|
||||
expect(maxConcurrency).toBe(1);
|
||||
requests.get("c")!.resolve(["newest"]);
|
||||
expect(await newest).toEqual({ status: "committed", value: ["newest"] });
|
||||
expect(commits).toEqual([["newest"]]);
|
||||
});
|
||||
|
||||
it("does not commit work after unload", async () => {
|
||||
const identity = { provider: "microsoft", listId: "a", showCompleted: true, generation: 0 } as const;
|
||||
const result = deferred<string[]>();
|
||||
const commits: string[][] = [];
|
||||
const coordinator = new RefreshCoordinator(() => identity, async () => result.promise, tasks => { commits.push(tasks); });
|
||||
const running = coordinator.refresh();
|
||||
coordinator.dispose();
|
||||
result.resolve(["late"]);
|
||||
expect(await running).toEqual({ status: "discarded" });
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards queued work without starting it after unload", async () => {
|
||||
let identity: RefreshIdentity = { provider: "microsoft", listId: "a", showCompleted: true, generation: 0 };
|
||||
const active = deferred<string[]>();
|
||||
const fetched: string[] = [];
|
||||
const coordinator = new RefreshCoordinator(() => identity, async snapshot => {
|
||||
fetched.push(snapshot.listId);
|
||||
return active.promise;
|
||||
}, () => {});
|
||||
const first = coordinator.refresh();
|
||||
identity = { ...identity, listId: "b", generation: 1 };
|
||||
const queued = coordinator.refresh();
|
||||
coordinator.dispose();
|
||||
expect(await queued).toEqual({ status: "discarded" });
|
||||
active.resolve(["late"]);
|
||||
expect(await first).toEqual({ status: "discarded" });
|
||||
expect(fetched).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
94
tests/release-state.test.mjs
Normal file
94
tests/release-state.test.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { decideReleaseState } from "../scripts/release-state.mjs";
|
||||
|
||||
const currentSha = "1111111111111111111111111111111111111111";
|
||||
const oldSha = "2222222222222222222222222222222222222222";
|
||||
const expectedAssets = [
|
||||
{ name: "main.js", size: 7, digest: "sha256:main" },
|
||||
{ name: "manifest.json", size: 11, digest: "sha256:manifest" },
|
||||
{ name: "styles.css", size: 9, digest: "sha256:styles" },
|
||||
];
|
||||
|
||||
function state(overrides = {}) {
|
||||
return {
|
||||
currentSha,
|
||||
tag: { sha: currentSha },
|
||||
release: { assets: expectedAssets.map((asset) => ({ ...asset })) },
|
||||
expectedAssets,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("decideReleaseState", () => {
|
||||
it("skips a complete release at the current SHA with matching digests", () => {
|
||||
assert.deepEqual(decideReleaseState(state()), { action: "skip" });
|
||||
});
|
||||
|
||||
it("fails closed for a complete release whose tag points at an old SHA", () => {
|
||||
assert.deepEqual(decideReleaseState(state({ tag: { sha: oldSha } })), {
|
||||
action: "fail",
|
||||
reason: "tag-sha-mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs an incomplete release at the current SHA", () => {
|
||||
assert.deepEqual(
|
||||
decideReleaseState(
|
||||
state({ release: { assets: expectedAssets.filter(({ name }) => name !== "styles.css") } }),
|
||||
),
|
||||
{ action: "repair", missingAssets: ["styles.css"], unexpectedAssetIds: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for an incomplete release whose tag points at an old SHA", () => {
|
||||
assert.deepEqual(
|
||||
decideReleaseState(
|
||||
state({
|
||||
tag: { sha: oldSha },
|
||||
release: { assets: expectedAssets.filter(({ name }) => name !== "styles.css") },
|
||||
}),
|
||||
),
|
||||
{ action: "fail", reason: "tag-sha-mismatch" },
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a release exists without the exact tag", () => {
|
||||
assert.deepEqual(decideReleaseState(state({ tag: null })), {
|
||||
action: "fail",
|
||||
reason: "release-without-tag",
|
||||
});
|
||||
});
|
||||
|
||||
it("completes a tag-only release at the current SHA", () => {
|
||||
assert.deepEqual(decideReleaseState(state({ release: null })), { action: "complete" });
|
||||
});
|
||||
|
||||
it("creates the tag and release when neither exists", () => {
|
||||
assert.deepEqual(decideReleaseState(state({ tag: null, release: null })), { action: "create" });
|
||||
});
|
||||
|
||||
it("fails closed when an existing required asset differs from the verified build", () => {
|
||||
const assets = expectedAssets.map((asset) =>
|
||||
asset.name === "main.js" ? { ...asset, digest: "sha256:different" } : { ...asset },
|
||||
);
|
||||
|
||||
assert.deepEqual(decideReleaseState(state({ release: { assets } })), {
|
||||
action: "fail",
|
||||
reason: "required-asset-digest-mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs only missing required and unexpected assets at the current SHA", () => {
|
||||
const releaseAssets = [
|
||||
...expectedAssets.filter(({ name }) => name !== "styles.css"),
|
||||
{ id: 44, name: "checksums.txt", size: 5, digest: null },
|
||||
];
|
||||
assert.deepEqual(decideReleaseState(state({ release: { assets: releaseAssets } })), {
|
||||
action: "repair",
|
||||
missingAssets: ["styles.css"],
|
||||
unexpectedAssetIds: [44],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MemorySecretStore,
|
||||
ObsidianSecretStore,
|
||||
|
|
@ -11,13 +8,15 @@ import {
|
|||
} from "../src/secret-store";
|
||||
import { migrateSettings } from "../src/settings-model";
|
||||
|
||||
const directories: string[] = [];
|
||||
function tempDirectory(): string {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "task-syncer-secret-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
function memoryAdapter(entries: Record<string, string>) {
|
||||
const files = new Map(Object.entries(entries));
|
||||
return {
|
||||
files,
|
||||
exists: async (file: string) => files.has(file),
|
||||
read: async (file: string) => files.get(file) ?? "",
|
||||
remove: async (file: string) => { files.delete(file); },
|
||||
};
|
||||
}
|
||||
afterEach(() => directories.splice(0).forEach(directory => fs.rmSync(directory, { recursive: true, force: true })));
|
||||
|
||||
describe("SecretStore-backed persistence", () => {
|
||||
it("clears secrets through the Obsidian 1.13.1 API surface", () => {
|
||||
|
|
@ -67,40 +66,36 @@ describe("SecretStore-backed persistence", () => {
|
|||
expect(cleared).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a conflicting SecretStorage client secret without clearing plaintext", async () => {
|
||||
it("moves a conflicting legacy client secret to distinct SecretStorage before clearing plaintext", async () => {
|
||||
const secrets = new MemorySecretStore();
|
||||
const settings = migrateSettings({ clientSecret: "legacy" });
|
||||
const id = settings.providers.microsoft.clientSecretId;
|
||||
const conflictId = `${id}-legacy-conflict`;
|
||||
secrets.write(id, "newer-secret");
|
||||
let cleared = false;
|
||||
await expect(migrateLegacyClientSecrets(
|
||||
await migrateLegacyClientSecrets(
|
||||
{ clientSecret: "legacy" },
|
||||
settings,
|
||||
secrets,
|
||||
async () => { cleared = true; },
|
||||
)).rejects.toThrow(/conflict.*microsoft/i);
|
||||
{ microsoft: new SecretTokenStore(secrets, conflictId), ticktick: new SecretTokenStore(secrets, "ticktick-conflict") },
|
||||
);
|
||||
expect(secrets.read(id)).toBe("newer-secret");
|
||||
expect(cleared).toBe(false);
|
||||
expect(secrets.read(conflictId)).toBe("legacy");
|
||||
expect(cleared).toBe(true);
|
||||
});
|
||||
|
||||
it("preflights every provider conflict before writing any missing secret", async () => {
|
||||
it("preserves every provider value securely before clearing all plaintext", async () => {
|
||||
const settings = migrateSettings({
|
||||
providers: {
|
||||
microsoft: { clientSecret: "microsoft-legacy" },
|
||||
ticktick: { clientSecret: "ticktick-legacy" },
|
||||
},
|
||||
});
|
||||
const values = new Map<string, string>([
|
||||
[settings.providers.ticktick.clientSecretId, "ticktick-different"],
|
||||
]);
|
||||
const writes: Array<[string, string]> = [];
|
||||
const secrets = {
|
||||
read: (id: string) => values.get(id) ?? null,
|
||||
write: (id: string, value: string) => { writes.push([id, value]); values.set(id, value); },
|
||||
remove: (id: string) => { values.delete(id); },
|
||||
};
|
||||
const secrets = new MemorySecretStore();
|
||||
secrets.write(settings.providers.ticktick.clientSecretId, "ticktick-different");
|
||||
let cleared = false;
|
||||
await expect(migrateLegacyClientSecrets(
|
||||
await migrateLegacyClientSecrets(
|
||||
{
|
||||
providers: {
|
||||
microsoft: { clientSecret: "microsoft-legacy" },
|
||||
|
|
@ -110,31 +105,37 @@ describe("SecretStore-backed persistence", () => {
|
|||
settings,
|
||||
secrets,
|
||||
async () => { cleared = true; },
|
||||
)).rejects.toThrow(/conflict.*ticktick/i);
|
||||
expect(writes).toEqual([]);
|
||||
expect(cleared).toBe(false);
|
||||
{
|
||||
microsoft: new SecretTokenStore(secrets, `${settings.providers.microsoft.clientSecretId}-legacy-conflict`),
|
||||
ticktick: new SecretTokenStore(secrets, `${settings.providers.ticktick.clientSecretId}-legacy-conflict`),
|
||||
},
|
||||
);
|
||||
expect(secrets.read(settings.providers.microsoft.clientSecretId)).toBe("microsoft-legacy");
|
||||
expect(secrets.read(settings.providers.ticktick.clientSecretId)).toBe("ticktick-different");
|
||||
expect(secrets.read(`${settings.providers.ticktick.clientSecretId}-legacy-conflict`)).toBe("ticktick-legacy");
|
||||
expect(cleared).toBe(true);
|
||||
});
|
||||
|
||||
it("removes a legacy token file only after SecretStorage readback succeeds", async () => {
|
||||
const directory = tempDirectory();
|
||||
const legacyPath = path.join(directory, "token_cache.json");
|
||||
fs.writeFileSync(legacyPath, "legacy-cache");
|
||||
const legacyPath = "custom-config/plugins/task-syncer/token_cache.json";
|
||||
const adapter = memoryAdapter({ [legacyPath]: "legacy-cache" });
|
||||
const secrets = new MemorySecretStore();
|
||||
const tokens = new SecretTokenStore(secrets, "task-syncer-plugin-microsoft-token-cache");
|
||||
await migrateLegacyTokenFile(legacyPath, tokens);
|
||||
await migrateLegacyTokenFile(adapter, legacyPath, tokens);
|
||||
expect(await tokens.read()).toBe("legacy-cache");
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(adapter.files.has(legacyPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing SecretStorage token or delete an unverified legacy file", async () => {
|
||||
const directory = tempDirectory();
|
||||
const legacyPath = path.join(directory, "microsoft-token-cache.json");
|
||||
fs.writeFileSync(legacyPath, "older-file-cache");
|
||||
it("moves a conflicting legacy token to distinct SecretStorage before deleting its plaintext file", async () => {
|
||||
const legacyPath = "custom-config/plugins/task-syncer/microsoft-token-cache.json";
|
||||
const adapter = memoryAdapter({ [legacyPath]: "older-file-cache" });
|
||||
const secrets = new MemorySecretStore();
|
||||
const tokens = new SecretTokenStore(secrets, "task-syncer-plugin-microsoft-token-cache");
|
||||
const conflicts = new SecretTokenStore(secrets, "task-syncer-plugin-microsoft-token-cache-legacy-conflict");
|
||||
await tokens.write("newer-secret-cache");
|
||||
await migrateLegacyTokenFile(legacyPath, tokens);
|
||||
await migrateLegacyTokenFile(adapter, legacyPath, tokens, conflicts);
|
||||
expect(await tokens.read()).toBe("newer-secret-cache");
|
||||
expect(fs.existsSync(legacyPath)).toBe(true);
|
||||
expect(await conflicts.read()).toBe("older-file-cache");
|
||||
expect(adapter.files.has(legacyPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,4 +29,15 @@ describe("settings actions", () => {
|
|||
expect(effects.logout).not.toHaveBeenCalled();
|
||||
expect(effects.rebuild).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an invalid timezone before changing or saving settings", async () => {
|
||||
const settings = migrateSettings(undefined);
|
||||
const original = settings.timeZone;
|
||||
const effects = actions();
|
||||
await expect(changeTimeZone(settings, "Not/AZone", effects)).rejects.toThrow(/time zone/i);
|
||||
expect(settings.timeZone).toBe(original);
|
||||
expect(effects.rebuild).not.toHaveBeenCalled();
|
||||
expect(effects.save).not.toHaveBeenCalled();
|
||||
expect(effects.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,4 +38,26 @@ describe("settings migration", () => {
|
|||
expect(migrated.autoSyncIntervalMinutes).toBe(10);
|
||||
expect(migrated.autoSyncOnStartup).toBe(false);
|
||||
});
|
||||
|
||||
it("whitelists fields, validates lists, and cannot retain plaintext secrets", () => {
|
||||
const migrated = migrateSettings({
|
||||
version: 3, provider: "ticktick", unknown: "discard me", clientSecret: "top-secret",
|
||||
showCompleted: "yes", autoSyncIntervalMinutes: 0.001, timeZone: "Not/AZone",
|
||||
providers: {
|
||||
microsoft: { clientSecret: "nested-secret", taskLists: { id: "bad" } },
|
||||
ticktick: { clientId: "id", taskLists: [{ id: "ok", title: "Inbox", token: "bad" }, null, { id: 2, title: "bad" }] },
|
||||
},
|
||||
});
|
||||
expect(migrated).not.toHaveProperty("unknown");
|
||||
expect(JSON.stringify(migrated)).not.toContain("top-secret");
|
||||
expect(JSON.stringify(migrated)).not.toContain("nested-secret");
|
||||
expect(migrated.showCompleted).toBe(true);
|
||||
expect(migrated.autoSyncIntervalMinutes).toBe(10);
|
||||
expect(migrated.providers.ticktick.taskLists).toEqual([{ id: "ok", title: "Inbox" }]);
|
||||
expect(migrated.timeZone).not.toBe("Not/AZone");
|
||||
});
|
||||
|
||||
it.each(["1", Number.NaN, Infinity, -1, 2, 600])("defaults invalid refresh interval %s", value => {
|
||||
expect(migrateSettings({ version: 3, autoSyncIntervalMinutes: value, providers: {} }).autoSyncIntervalMinutes).toBe(10);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,4 +8,13 @@ describe("task input result", () => {
|
|||
it("omits the due date when creating a task without one", () => {
|
||||
expect(buildTaskInputResult(" Task ", "", false)).toEqual({ title: "Task" });
|
||||
});
|
||||
it("omits an unchanged timed due date on title-only edits", () => {
|
||||
expect(buildTaskInputResult("New title", "2026-07-18", true, false)).toEqual({ title: "New title" });
|
||||
});
|
||||
it("distinguishes an explicitly cleared date", () => {
|
||||
expect(buildTaskInputResult("Task", "", true, true)).toEqual({ title: "Task", dueDate: "" });
|
||||
});
|
||||
it("rejects a blank title", () => {
|
||||
expect(() => buildTaskInputResult(" ", "", false)).toThrow(/title/i);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
17
tests/task-matching.test.ts
Normal file
17
tests/task-matching.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { matchRemoteTask } from "../src/task-matching";
|
||||
import type { TaskItem } from "../src/types";
|
||||
|
||||
const task = (id: string, title: string, status: "open" | "completed" = "open"): TaskItem => ({ id, listId: "list", title, status });
|
||||
|
||||
describe("remote task matching", () => {
|
||||
it("refuses duplicate normalized titles instead of choosing one", () => {
|
||||
expect(matchRemoteTask([task("1", "Deploy"), task("2", " deploy ")], " DEPLOY ")).toEqual({ status: "ambiguous", matches: 2 });
|
||||
});
|
||||
it("returns the one unique normalized match", () => {
|
||||
expect(matchRemoteTask([task("1", "Deploy")], " deploy ")).toEqual({ status: "matched", task: task("1", "Deploy") });
|
||||
});
|
||||
it("reports no match", () => {
|
||||
expect(matchRemoteTask([task("1", "Deploy")], "Ship")).toEqual({ status: "none" });
|
||||
});
|
||||
});
|
||||
26
tests/task-organizer.test.ts
Normal file
26
tests/task-organizer.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { GENERATED_END, GENERATED_START, updateManagedTaskSection } from "../src/task-organizer";
|
||||
|
||||
describe("managed organized task output", () => {
|
||||
it("preserves frontmatter, headings, and prose outside its markers", () => {
|
||||
const original = `---\ntag: personal\n---\n# My tasks\nKeep this prose.\n\n${GENERATED_START}\nold\n${GENERATED_END}\n\nFooter.`;
|
||||
const updated = updateManagedTaskSection(original, "- [ ] New");
|
||||
expect(updated).toContain("---\ntag: personal\n---");
|
||||
expect(updated).toContain("Keep this prose.");
|
||||
expect(updated).toContain("Footer.");
|
||||
expect(updated).toContain(`${GENERATED_START}\n- [ ] New\n${GENERATED_END}`);
|
||||
expect(updated).not.toContain("old");
|
||||
});
|
||||
it("appends an owned section when no markers exist", () => {
|
||||
expect(updateManagedTaskSection("# Existing\n\nText", "- [x] Done")).toBe(`# Existing\n\nText\n\n${GENERATED_START}\n- [x] Done\n${GENERATED_END}\n`);
|
||||
});
|
||||
it("preserves trailing whitespace when appending the first owned section", () => {
|
||||
const original = "# Existing\n\nText \n \n";
|
||||
expect(updateManagedTaskSection(original, "- [ ] New").startsWith(original)).toBe(true);
|
||||
});
|
||||
it("fails closed when markers are corrupt or duplicated", () => {
|
||||
expect(() => updateManagedTaskSection(`${GENERATED_START}\nold`, "new")).toThrow(/marker/i);
|
||||
expect(() => updateManagedTaskSection(`${GENERATED_END}`, "new")).toThrow(/marker/i);
|
||||
expect(() => updateManagedTaskSection(`${GENERATED_START}\none\n${GENERATED_END}\n${GENERATED_START}\ntwo\n${GENERATED_END}`, "new")).toThrow(/marker/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,12 @@ class MemoryStore {
|
|||
remove = vi.fn(async () => { this.value = ""; });
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("TickTick OAuth", () => {
|
||||
it("builds official authorization URL with exact redirect, required scopes, and state", () => {
|
||||
const url = new URL(buildTickTickAuthorizationUrl("client", "obsidian://ticktick/callback", "state-123"));
|
||||
|
|
@ -23,6 +29,19 @@ describe("TickTick OAuth", () => {
|
|||
await expect(auth.login()).rejects.toThrow("state"); expect(exchange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires fixed redirect query parameters before token exchange", async () => {
|
||||
const store = new MemoryStore(); const exchange = vi.fn();
|
||||
const auth = new TickTickAuthProvider(
|
||||
{ clientId: "id", clientSecret: "secret", redirectUrl: "obsidian://ticktick/callback?tenant=personal" },
|
||||
store,
|
||||
exchange as any,
|
||||
async () => "obsidian://ticktick/callback?tenant=other&code=abc&state=expected",
|
||||
() => "expected",
|
||||
);
|
||||
await expect(auth.login()).rejects.toThrow(/redirect/i);
|
||||
expect(exchange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exchanges an authorization code using form encoding and Basic auth and caches no assumed refresh token", async () => {
|
||||
const store = new MemoryStore();
|
||||
const exchange = vi.fn(async (req: any) => ({ status: 200, json: { access_token: "access", expires_in: 3600 }, text: "" }));
|
||||
|
|
@ -36,4 +55,29 @@ describe("TickTick OAuth", () => {
|
|||
await expect(auth.isAuthenticated()).resolves.toBe(true);
|
||||
await auth.logout(); expect(store.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes cancellation to authorization and does not cache a token after unload", async () => {
|
||||
const store = new MemoryStore();
|
||||
const exchange = deferred<any>();
|
||||
const http = vi.fn(() => exchange.promise);
|
||||
const controller = new AbortController();
|
||||
const authorize = vi.fn(async (_url: string, redirect: string, signal?: AbortSignal) => {
|
||||
expect(signal).toBe(controller.signal);
|
||||
return `${redirect}?code=abc&state=expected`;
|
||||
});
|
||||
const auth = new TickTickAuthProvider(
|
||||
{ clientId: "id", clientSecret: "secret", redirectUrl: "obsidian://ticktick/callback" },
|
||||
store,
|
||||
http as any,
|
||||
authorize,
|
||||
() => "expected",
|
||||
controller.signal,
|
||||
);
|
||||
const login = auth.login();
|
||||
await vi.waitFor(() => expect(http).toHaveBeenCalledOnce());
|
||||
controller.abort();
|
||||
exchange.resolve({ status: 200, json: { access_token: "access" }, text: "" });
|
||||
await expect(login).rejects.toThrow(/abort/i);
|
||||
expect(store.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,15 +25,15 @@ describe("TickTickTaskService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("creates, updates, completes, deletes, and renames with TickTick date/timezone payloads", async () => {
|
||||
it("creates, updates, completes, and deletes with TickTick date/timezone payloads", async () => {
|
||||
const request = vi.fn().mockResolvedValue(response(200, { id: "t", projectId: "p", title: "T", status: 0 }));
|
||||
const service = new TickTickTaskService(async () => "token", request, "Europe/London");
|
||||
await service.createTask("p", { title: "T", dueDate: "2026-07-17T00:00:00" });
|
||||
await service.updateTask("p", "t", { title: "T2", dueDate: "2026-07-18T00:00:00" });
|
||||
await service.completeTask("p", "t"); await service.deleteTask("p", "t"); await service.renameTaskList!("p", "Work");
|
||||
await service.completeTask("p", "t"); await service.deleteTask("p", "t");
|
||||
expect(JSON.parse(request.mock.calls[0][0].body)).toMatchObject({ projectId: "p", title: "T", dueDate: "2026-07-17T00:00:00+0000", timeZone: "Europe/London", isAllDay: true });
|
||||
expect(request.mock.calls.slice(2).map(c => [c[0].method, c[0].url])).toEqual([
|
||||
["POST", "https://api.ticktick.com/open/v1/project/p/task/t/complete"], ["DELETE", "https://api.ticktick.com/open/v1/project/p/task/t"], ["POST", "https://api.ticktick.com/open/v1/project/p"]
|
||||
["POST", "https://api.ticktick.com/open/v1/project/p/task/t/complete"], ["DELETE", "https://api.ticktick.com/open/v1/project/p/task/t"]
|
||||
]);
|
||||
expect(formatTickTickDate("2026-07-17")).toBe("2026-07-17T00:00:00+0000");
|
||||
expect(service.capabilities.reopenTask).toBe(false);
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@
|
|||
"1.6.0": "0.15.0",
|
||||
"2.0.0": "0.15.0",
|
||||
"2.1.0": "1.11.4",
|
||||
"2.1.1": "1.11.4"
|
||||
"2.1.1": "1.11.4",
|
||||
"2.1.2": "1.11.4"
|
||||
}
|
||||
|
|
@ -6,6 +6,11 @@ export default defineConfig({
|
|||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
clearMocks: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
thresholds: { lines: 50, functions: 30, statements: 40, branches: 50 },
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue