fix: harden community plugin integration

This commit is contained in:
Bobby(Wyson's Agent) 2026-07-18 21:55:07 +08:00
parent 29f3359ba5
commit 393ee7ae07
59 changed files with 7125 additions and 1982 deletions

View file

@ -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

View file

@ -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
View file

@ -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.

View file

@ -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

View file

@ -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
```

View file

@ -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",

View file

@ -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" },
},
]);

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View 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
View 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 };
}

View file

@ -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)); }

View file

@ -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;
}

View file

@ -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),

View file

@ -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
View 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;
}

View file

@ -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;
}

View file

@ -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>>;

View file

@ -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(); }
}

View file

@ -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}`;
}

View file

@ -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
View 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();
}
}

View file

@ -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() {}
}

View file

@ -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);
}

View file

@ -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");
});
}
}

View file

@ -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"); }
}

View file

@ -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"); }
}

View file

@ -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";

View file

@ -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();

View file

@ -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
View 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
View 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)}`;
}

View file

@ -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(); }
}

View file

@ -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>;
}

View file

@ -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);
}
})();
}

View file

@ -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); }
}

View file

@ -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);

View file

@ -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
View 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");
});
});

View file

@ -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" },

View 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();
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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"); }
}

View file

@ -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;

View file

@ -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");
});
}
);

View 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"]);
});
});

View 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],
});
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View 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" });
});
});

View 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);
});
});

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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"
}

View file

@ -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: {