diff --git a/.github/workflows/plugin-tests.yml b/.github/workflows/plugin-tests.yml index 8667a8d81a..34e7e31515 100644 --- a/.github/workflows/plugin-tests.yml +++ b/.github/workflows/plugin-tests.yml @@ -34,7 +34,7 @@ jobs: run: | set -euo pipefail # Plugins that have an `npm test` script configured. - PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md) + PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md todoist-import) if [ "$EVENT_NAME" = "workflow_dispatch" ]; then CHANGED=$(printf '%s\n' "${PLUGINS[@]}") diff --git a/AGENTS.md b/AGENTS.md index 760ab568c5..475885b9c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ For local SuperSync E2E (docker-compose) and the full E2E reference, see [`e2e/C - **Translations:** UI strings go through `T` / `TranslateService`. Edit only `en.json`; never other locales. - **Privacy:** no analytics or tracking — user data stays local unless explicitly synced. +- **Dependencies:** PRs must not add new packages to the root project's `dependencies` or `devDependencies`; use platform APIs, existing packages, or a small in-repo implementation instead. Dependencies scoped to an individual plugin are allowed when they are necessary and remain isolated to that plugin. - **Electron:** check `IS_ELECTRON` before using Electron-specific APIs. - **Templates:** plain HTML, minimal CSS/classes, Angular Material sparingly. See [`docs/styling-guide.md`](docs/styling-guide.md). - **Styling review:** do not locally restyle Angular Material or shared `src/app/ui/` components for one-off context needs. This includes overriding button styles via `.mat-*`, `.mdc-*`, `button[mat-*]`, or component internals in local SCSS. Prefer existing inputs/classes/tokens; if a variant must exist, make it reusable or add it to the shared style layer. diff --git a/android/app/build.gradle b/android/app/build.gradle index e5126e2f94..086cd571b8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -20,8 +20,8 @@ android { minSdkVersion 24 targetSdkVersion 36 compileSdk 36 - versionCode 18_13_01_9000 - versionName "18.13.1" + versionCode 18_14_00_9000 + versionName "18.14.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" manifestPlaceholders = [ hostName : "app.super-productivity.com", diff --git a/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt b/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt new file mode 100644 index 0000000000..57fc2426c2 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/1814009000.txt @@ -0,0 +1,7 @@ +- New Android home-screen widget for today’s tasks +- Import Todoist data from the Import/Export launcher +- Create tasks by dropping links or EML files into the app +- Redesigned add-task bar with improved notes, toggles, and accessibility +- New completed-task sorting and file-tree nesting options +- More reliable encrypted file sync and fewer false conflict dialogs +- Improved recurring tasks, subtasks, project duplication, calendars, and mobile layouts \ No newline at end of file diff --git a/build/electronMac{.original}.js b/build/electronMac{.original}.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/build/release-notes.md b/build/release-notes.md index 84e78a3504..b84bd2cf72 100644 --- a/build/release-notes.md +++ b/build/release-notes.md @@ -1,5 +1,48 @@ For all current downloads, package links, and platform-specific notes: [check the wiki](https://github.com/super-productivity/super-productivity/wiki/2.01-Downloads-and-Install). -### Fixes +## Super Productivity 18.14.0 -- Fixed dangling tag references during sync archiving by scanning all tags (#8710). +### Highlights + +- Added a Todoist import plugin to the Import/Export launcher. +- Create tasks by dropping links or EML files onto the app, with hardened EML importing. +- Added an Android home-screen widget for today’s tasks. +- Redesigned the add-task bar with improved toggles, notes, layout, and accessibility. +- Added file-tree actions for creating subfolders and items within folders. +- Added optional sorting of completed tasks by completion date. + +### Tasks and planning + +- New everyday recurring tasks now skip overdue occurrences by default. +- Fixed selecting day-of-month recurrence. +- Shift+T now schedules overdue tasks for today more reliably. +- Improved subtask creation on touch devices and during IME composition. +- Collapsed subtask state now persists across restarts. +- Project sections are now retained when duplicating a project. +- Fixed navigation from search for tasks without a project or tag. +- Unified Due, Deadline, Planned, and Scheduled labels. + +### Sync and data safety + +- Added an opt-in split-file sync format for delta-based syncing. +- Avoids full downloads when the remote revision has not changed. +- Improved atomic file writes, backup recovery, and encrypted file-sync safety. +- Reduced false conflict dialogs and corrected displayed conflict-change counts. +- File-based providers can now offer end-to-end encryption before the first upload. +- Improved reporting when an encryption key is missing. + +### Accessibility, integrations, and mobile + +- Improved accessible names, keyboard controls, and task focus behavior. +- Agenda plugin events now appear without navigating away and back. +- Added support for Outlook and other allowed app deep links in notes. +- Fixed Android bottom-navigation insets and stale focus-timer completions. +- Improved handling of third-party keyboard heights on iOS. +- The Eisenhower “Not Completed” filter now persists across restarts. +- Fixed freezes in issue-provider calendar configuration. + +### Performance and polish + +- Reduced unnecessary task-list, planner, date-formatting, and work-context updates. +- Fixed sidebar icon movement, add-task scrollbar behavior, and small-screen reminder labels. +- The active project or tag icon is now shown in the header. diff --git a/docs/long-term-plans/multi-client-file-sync-reliability.md b/docs/long-term-plans/multi-client-file-sync-reliability.md index 3a1d0a91cc..172ab3a732 100644 --- a/docs/long-term-plans/multi-client-file-sync-reliability.md +++ b/docs/long-term-plans/multi-client-file-sync-reliability.md @@ -6,9 +6,9 @@ The single-file approach (`sync-data.json`) has these specific weaknesses when multiple clients sync simultaneously: -### 1. Single retry on upload conflict +### 1. Bounded retries on upload conflict -`_uploadWithRetry()` (`file-based-sync-adapter.service.ts:474`) retries **exactly once** on rev mismatch. With 3+ clients syncing at similar intervals, the retry can also fail — the second upload attempt has no fallback. +`_uploadWithMismatchFallback()` (`file-based-sync-adapter.service.ts`) makes up to `1 + _MAX_UPLOAD_RETRIES` conditional attempts (`_MAX_UPLOAD_RETRIES` is currently `2`, so 3 total) and never force-overwrites. On a rev mismatch it re-downloads: if the remote rev actually changed it treats that as a genuine concurrent write and throws a retryable error **immediately** (the extra attempts exist only for the transient case where the re-downloaded rev is unchanged). The next sync cycle then downloads the concurrent ops and rebuilds a consistent snapshot. This handles a concurrent write that is _visible at check time_; it does **not** close the check-then-write race described in §5. ### 2. Wide race window @@ -22,9 +22,51 @@ Every upload includes the **complete application state** (line 452: `getStateSna WebDAV uses `lastmod` (seconds resolution) as the revision. Two uploads within the same second can't be distinguished. The `syncVersion` counter inside the file compensates, but only if the file is actually re-downloaded between attempts. -### 5. No atomic CAS for LocalFile +### 5. No atomic CAS for LocalFile (accepted limitation — #8898) -For local file sync (Electron/Android), there's no server-side compare-and-swap. The rev is an MD5 hash computed client-side, but the read-modify-write is not atomic. +For local file sync there is no server-side compare-and-swap. `uploadFile()` +(`local-file-sync-base.ts`) does the rev check (`downloadFile` + hash compare) +and the `writeFile` as two separate, non-atomic steps, so a concurrent writer +that lands **between** check and write is not detected and can be overwritten (a +classic TOCTOU race). This is an **accepted limitation**, LOW severity in +practice because several layers narrow the window or soften the outcome: + +- Within one client, concurrent uploads share an upload lock (`LockService`, + `LOCK_NAMES.UPLOAD` — Web Locks cross-tab, in-process mutex fallback on + Electron/Android), so a client's own upload cycles don't race on the file. This + does **not** extend across machines. (Downloads use a separate lock; only + uploads write the file.) +- Cross-machine contention needs multiple writers on the same file — an external + folder-sync tool (Syncthing/Dropbox) or a directly shared/network-mounted sync + folder. An OS-level lock wouldn't help across machines anyway. +- A concurrent write that is _visible at check time_ is caught, not clobbered: + `_uploadWithMismatchFallback` never force-overwrites; on a rev mismatch it + re-downloads and throws retryably, and the next cycle re-applies the concurrent + ops. Only a write landing inside the check→write window escapes this. +- Backup-before-overwrite (`.bak`, #8786, best-effort): the current remote content + is copied to a `.bak` before overwrite, letting the next download recover a + **corrupt/interrupted** primary. It does **not** recover a valid concurrent + overwrite, nor a primary that went fully missing (e.g. an Android + delete-then-crash), and the `.bak` write is non-fatal if it fails. + +So the residual risk is narrow but real: a writer whose write falls inside another +client's check→write window can have its update lost — recoverable only if that +client's local op-log still holds the ops and re-uploads them on a later cycle. +Two distinct problems live here; keep them separate: + +- **Torn writes** (crash mid-write → partial/corrupt file) are already prevented + on **Electron/desktop**: `FILE_SYNC_SAVE` (`electron/local-file-sync.ts`) writes + to a temp file (`flag: 'wx'`) then `renameSync` (atomic on ext4/APFS/NTFS), with + temp cleanup on failure. **Android SAF still writes in place** + (`SafBridgePlugin.writeFile` → `openOutputStream`), so a torn write is possible + there — only partly mitigated by the best-effort `.bak` recovery above (and not + at all if the primary goes missing rather than corrupt). A native + temp-DocumentFile + rename would close it, but it's low value (mobile is + effectively single-writer). +- **The check-then-write CAS race itself** is NOT closed by atomic rename — rename + only makes the write atomic, not the read-compare-write sequence. Portably + closing it needs OS-level CAS (`O_EXCL` / advisory locks) that isn't uniformly + available across the LocalFile backends. Left as accepted. ## How Bad Is It in Practice? diff --git a/docs/plans/2026-07-09-todoist-import.md b/docs/plans/2026-07-09-todoist-import.md new file mode 100644 index 0000000000..bf0a523a40 --- /dev/null +++ b/docs/plans/2026-07-09-todoist-import.md @@ -0,0 +1,229 @@ +# Todoist → Super Productivity migration + +Status: **revised & verified against code** · 2026-07-09 + +Goal: let a Todoist user bring their **active** projects, tasks, sub-tasks, labels, +due dates and time estimates into Super Productivity in one pass, non-destructively, +without adding permanent weight to the core app. + +## Product framing + +- Migration runs **once per user** and is then dead weight. Per the manifesto (avoid + feature creep; new UI/settings are permanent costs), it lives at the edge, not in + core hot paths. +- It must be **additive**, never destructive — most people evaluating SP already have + some data. The existing `importCompleteBackup` path (wipes all state) is the wrong tool. +- It is a **one-time import**, not a live integration. The issue-provider framework + (`src/app/features/issue/`) is built for ongoing polling + remote-linked tasks and is + deliberately **not** used here. + +## Chosen approach + +**A bundled plugin does all the work; core gets only a launcher row.** + +- Front-end + parsing + mapping + preview UI = a **bundled plugin** + (`packages/plugin-dev/todoist-import/`, built into `src/assets/bundled-plugins/`). + Matches the maintainers' direction (Trello / Linear / ClickUp / Azure moved _out_ of + core into plugins); fully replaceable / community-extensible. +- Landing the data uses only existing plugin-API methods — **zero new core API**: + - `addProject` / `addTag` for containers, + - **`batchUpdateForProject`** for all task creation (see below — this was missed in + the first draft and changes the op-log story), + - `updateTask` follow-ups only for fields the batch op doesn't carry + (`dueDay`, `dueWithTime`, `tagIds`). +- The plugin ships `isSkipMenuEntry: true` — no permanent menu noise for a one-time + tool. **Discoverability** comes from a single launcher row in the Import/Export + settings screen (`src/app/imex/file-imex/`): `PluginService.activatePlugin(id, true)` + then navigate to the existing `plugins/:pluginId/index` route. Deliberately the + **in-memory** enable (plugin-management additionally persists via + `setPluginEnabled`): after a restart the importer is dormant again — zero standing + weight; relaunching from the same row re-activates it. + +### Key correction #1: use `batchUpdateForProject`, not per-task `addTask` + +Verified in `plugin-bridge.service.ts:1209` + `task-shared-meta-reducers/task-batch-update.reducer.ts`: + +- The batch API is **backed by a meta-reducer**: one dispatched chunk (≤ 50 ops, + `MAX_BATCH_OPERATIONS_SIZE`) = **one action = one op-log entry** (sync rule #3). + A 1000-task import ≈ 20 ops for structure instead of 1000+. +- It handles **parent references via temp IDs** (bridge pre-generates real IDs and + returns the mapping) and preserves creation order — root tasks land in + `project.taskIds` in array order (verified in + `validate-and-fix-data-consistency-after-batch-update.ts`). No reorder op needed for + freshly created projects. +- Per-task `addTask` would additionally **reverse ordering** (bridge hardcodes + `isAddToBottom: false`, i.e. prepend) — another reason the first draft's approach + was wrong. +- Batch create data carries `title / notes / isDone / parentId / timeEstimate` only; + `dueDay`, `dueWithTime` and `tagIds` are applied with one `updateTask` per task that + has them. Ops ≈ `ceil(tasks/50) per project + dated/labelled tasks` — a tolerable + one-time burst, and it removes the main driver for a v2 `importData` core primitive. +- **Hard constraints found in review** (the reducer enforces these silently): + - temp IDs **must** be `temp-`/`temp_`-prefixed — anything else leaves children with + dangling `parentId`s that the consistency pass **deletes** as orphans; + - a parent's create op must sit at a **lower index than its children's** — the bridge + chunks at 50 ops/action and the roots-first sort is per-chunk only; + - the plugin **chunks its own calls at ≤ 50 ops and awaits each** — every iframe call + is its own postMessage round-trip, so this keeps one dispatch per tick (sync rule + #6) even for 5k-task projects, where the bridge's internal `forEach` chunking would + dispatch 100 actions in one tick; + - **ordering alone is NOT enough across self-chunked calls** (caught by the + post-implementation multi-review): the bridge builds `createdTaskIds` **per call**, + so a child sent in a later call cannot resolve a `temp-` parent from an earlier + call — the executor must **rewrite already-created parents to their real IDs** + before sending each chunk (`resolveKnownParents` in `run-import.ts`; real IDs are + explicitly supported in `BatchTaskCreate.parentId`); + - the result is fire-and-forget (`success: true` always, `errors` never populated) — + the **post-import summary re-reads state** (`getTasks`) and compares landed vs + planned counts instead of trusting the return value. + +### Key correction #2: what the plugin API actually can't do (verified) + +1. **No `TaskRepeatCfg` creation** — `PluginTaskRepeatCfg` is read-only. v1 degrades: + keep next due date + append `Repeats: ` to notes. +2. **SP _does_ have a Section entity now** (`src/app/features/section/`) — the first + draft claimed it didn't. But there is **no plugin API / allowed action** to create + sections, so v1 still drops Todoist sections (task order within the project is + preserved; flagged as lossy). v2 candidate: expose section creation to plugins. +3. **No ProjectFolder creation API** (`Project.folderId` exists, but nothing creates + folders) — the first draft's `parent_id → folderId` mapping is unworkable. v1 + **flattens nested projects**; when two projects collide on title, the child is + disambiguated as `Parent / Child`. Flagged as lossy. +4. **Subtasks can't hold tags** (bridge forces `tagIds: []` for subtasks — SP model) + → labels on Todoist sub-tasks are dropped, flagged in the summary. + +### v2 (only if v1 validates demand) + +`addTaskRepeatCfg` and/or plugin-visible section/folder creation. Deferring is +deliberate (YAGNI): public plugin-API surface is hard to reverse. The bulk-import +primitive from the first draft is **no longer needed** — `batchUpdateForProject` +already folds structure into few ops. One more v2 candidate (from the perf review): +extend `BatchTaskUpdate` with `dueDay`/`dueWithTime`/`tagIds` — the per-task +`updateTask` follow-ups are the dominant import cost (one op each; O(k²) planner-day +scans at 5k dated tasks) and today there is no cheaper path. + +## Input source + +**API token only in v1.** The user pastes a Todoist personal token (Settings → +Integrations → Developer); the plugin makes an initial +`POST https://api.todoist.com/api/v1/sync` with `sync_token=*`, followed immediately +by an incremental request with the returned sync token. This applies changes that +arrived while Todoist prepared a potentially delayed full snapshot. Both requests use +`resource_types=["projects","items","sections","notes"]` (`notes` = task comments, +folded into SP task notes; the `labels` resource is deliberately NOT requested — +item labels arrive as names on the items themselves) via the gated `PluginAPI.request` +(`permissions:["http"]` + `allowedHosts:["api.todoist.com"]`). The old Sync **v9** +endpoint is deprecated — use unified **v1**. + +**Token privacy (hard rule):** the token lives in iframe memory for the session only — +never `persistDataSynced` (that syncs!), not even `setSecret`. Password-type input; UI +states "sent only to api.todoist.com, never stored". + +**CSV fallback: cut from v1 (YAGNI, folded review verdict).** It is a second parser + +fixture suite + multi-file UI for strictly worse fidelity (no labels, no comments, no +tz fidelity, one tedious export per project) — and its `DATE` column holds _localized +natural-language_ strings ("every day", "5 août") that cannot be parsed faithfully. +Named contingency for users who already closed their account; fast-follow, not v1. + +Completed-task history is out of scope for v1 (the sync endpoint returns only active +items by default — nothing extra to do). + +## Mapping + +| Todoist | → Super Productivity | Notes | +| --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| project | Project | hierarchy **flattened** (no folder API, see correction #2); `Parent / Child` title only on collision | +| Inbox project (`inbox_project`) | Project `Inbox (Todoist)` | never merged into SP's own Inbox — additive & reviewable | +| section | — | **v1: ignore** (no plugin API to create SP sections); flagged lossy. Sibling order key = `(section.section_order, item.child_order)` — sync items arrive unordered! | +| label | Tag | item `labels` are **names** in unified v1; match existing SP tags by title (case-insensitive), else `addTag`; only labels actually used by imported top-level tasks (SP subtasks can't hold tags — dropped + counted; the plugin must enforce this itself, the host won't) | +| item content | task `title` | | +| item description | task `notes` | markdown passes through | +| comments (sync `notes`) | appended to task `notes` | same sync call; file attachments → keep the URL line, flag files as not imported | +| priority (API `4`=p1 … `1`=p4) | — | **inverted vs UI!** Opt-in, default **off**, top-level only; **never tag API priority 1** (p4 is Todoist's default on every task). Single control with two mappings: `p1`…`p3` **Tags**, or **Eisenhower** (reuses SP's `urgent`/`important` tags: p1→both, p2→important, p3→urgent — added post-review per #8882 feedback) | +| due (all-day, `YYYY-MM-DD`) | `dueDay` | via `updateTask` after batch create | +| due (floating, `YYYY-MM-DDTHH:MM:SS`) | `dueWithTime` (unix ms) | parse as **local** time | +| due (fixed-tz, trailing `Z`) | `dueWithTime` (unix ms) | parse as **UTC instant** — parsing as local shifts every fixed-time task | +| deadline | `dueDay` if no due; else `Deadline: ` appended to notes | nothing silently dropped | +| duration `minute` | `timeEstimate` (ms) | | +| duration `day` | — | **skip + count in summary** — fabricating 8h would corrupt time-tracking stats | +| sub-task (`parent_id`) | sub-task (2 levels) | SP nests **2 levels only** — depth ≥ 2 re-parents to the depth-0 ancestor in reading (DFS) order, demotion counted | +| assignee (`responsible_uid`) | — | imported like any task; "N tasks had collaborator assignees" in summary | +| recurring (`due.is_recurring` + `due.string`) | keep next due + append verbatim `Repeats: ` to notes | verbatim preserves `every!` (recur-from-completion) semantics; real `TaskRepeatCfg` only with v2 core work. Imported timed tasks get **no reminder** (`updateTask` bypasses `scheduleTaskWithTime`) — noted, acceptable: Todoist reminders aren't imported anyway | +| completed items | — | skip v1 | + +## Architecture / file layout (v1) + +``` +packages/plugin-dev/todoist-import/ + package.json # esbuild + jest, modeled on sync-md (no framework) + scripts/build.js # bundle ui/main.ts, INLINE bundle into index.html, copy manifest/icon/i18n + src/ + manifest.json # iFrame:true, isSkipMenuEntry:true, permissions incl. "http", + # allowedHosts:["api.todoist.com"], hooks:[] + plugin.js # stub (all logic lives in the iframe UI) + ui/index.html # minimal shell; built JS inlined (iframe uses srcdoc → + # the document must be fully self-contained, verified in + # plugin-iframe.util.ts) + ui/main.ts # wizard: token → preview (per-project checkboxes) → import → summary + parse/from-api.ts # unified-v1 sync JSON → normalized model (pure) + parse/normalized-model.ts + map/plan-import.ts # normalized model → batch ops + follow-up updates (pure) + map/run-import.ts # executes the plan via PluginAPI, per-project failure boundary + *.spec.ts # jest over fixtures: due shapes, depth-3 nesting, section order, + # labels, priority inversion, duration units, deadline, comments +``` + +UI wizard specifics (trust items from review): + +- **Preview = per-project checkboxes** with task/subtask counts; projects whose title + already exists in SP are flagged "already exists — possibly from a previous import" + and default **unchecked** (re-run safety without rollback machinery). Lossy items + listed up front (sections, demotions, day-durations, subtask labels, assignees). +- **Import runs project-by-project** (batch + follow-ups per project before the next), + so an abort leaves whole projects, and the summary can say "4/6 imported, failed at + 'Errands'". +- Post-import summary counts from re-read state, names everything dropped. + +- Register in `packages/plugin-dev/scripts/build-all.js` and + `src/app/plugins/plugin.service.ts` bundled list. +- Core touch (discoverability only): one launcher button in + `src/app/imex/file-imex/` + one `en.json` key. + +## Milestones (each with its check) + +- **M0 · Spike — ✅ DONE, verdict GREEN.** Token path viable on web, Electron, mobile + via gated `PluginAPI.request` (`plugin-bridge.service.ts:508`); app CSP is + `connect-src *`; Electron injects ACAO:\*. Todoist's unified API documentation says + all endpoints except the initial OAuth authorization endpoint support CORS for any + origin; retain one live web-build sanity check during M3. +- **M1 · Parse + normalize.** Sync-v1 JSON → normalized model. → _verify:_ jest + fixtures: parent chains incl. depth 3+, the three `due.date` shapes, deadline, + recurring strings, durations (minute/day), priority inversion, section ordering, + comments incl. attachments, Inbox, assignees. +- **M2 · Plan + create.** Pure op-builder (normalized model → project/tag creates, + ≤50-op batch chunks parent-before-child with `temp-` IDs, follow-up updates) + + executor. → _verify:_ unit tests on the op-builder; manual import of a fixture into + a scratch profile: counts, nesting, order, due dates. +- **M3 · UI + preview + summary.** Token input, per-project preview, import progress, + honest summary. → _verify:_ manual run web + Electron incl. full + incremental sync. +- **M4 · Discoverability + docs.** Launcher row in Import/Export + (`activatePlugin` + route to `plugins/todoist-import/index`); "Switch from Todoist" + docs page (search is how the day-they-quit-Todoist persona finds this — no + onboarding banners, per the manifesto). → _verify:_ new user completes an import + from a cold start. + +## Risks & open decisions + +- **Partial import on failure** — additive, not transactional; per-project execution + bounds the blast radius to whole projects and the summary names what landed. + Accepted for v1 (KISS) — no rollback machinery. +- **Archived-project re-runs** — `getAllProjects()` exposes active projects only, so + an archived prior import cannot be collision-flagged. Document the restore/delete + workaround; do not widen a permanent public plugin API solely for this importer. +- **Follow-up `updateTask` volume** — one per dated/labelled task; bounded by the + batch op for structure; acceptable one-time burst. Watch 5k+ item accounts. +- **Todoist API drift** — unified v1 is current (v9 deprecated); parser is defensive + (unknown fields ignored, missing fields defaulted) and covered by fixtures. +- **Decided during review:** CSV cut from v1 · priority→tag default off (and API + priority 1 never tagged) · duration `day` skipped, not 8h · Inbox → "Inbox + (Todoist)" · collision projects default-unchecked in preview. diff --git a/docs/sync-and-op-log/README.md b/docs/sync-and-op-log/README.md index 1b552610ee..3f59504250 100644 --- a/docs/sync-and-op-log/README.md +++ b/docs/sync-and-op-log/README.md @@ -38,6 +38,7 @@ vector clocks detect concurrent edits. | [contributor-sync-model.md](./contributor-sync-model.md) | The single sync invariant for contributors (one intent = one op; replayed/remote ops must not re-trigger effects) | | [operation-rules.md](./operation-rules.md) | Design rules and guidelines for operations | | [package-boundaries.md](./package-boundaries.md) | Dependency/ownership boundaries for `@sp/sync-core`, `@sp/sync-providers`, app wiring | +| [conflict-journal-and-review.md](./conflict-journal-and-review.md) | Conflict journal (device-local record of LWW auto-resolutions), disjoint-field auto-merge, `/sync-conflicts` review UI | | [vector-clocks.md](./vector-clocks.md) | Vector clock implementation, pruning, history | | [supersync-encryption-architecture.md](./supersync-encryption-architecture.md) | End-to-end encryption (AES-256-GCM + Argon2id) | | [diagrams/](./diagrams/) | Mermaid diagrams split by topic | diff --git a/docs/sync-and-op-log/conflict-journal-and-review.md b/docs/sync-and-op-log/conflict-journal-and-review.md new file mode 100644 index 0000000000..e2a751daee --- /dev/null +++ b/docs/sync-and-op-log/conflict-journal-and-review.md @@ -0,0 +1,221 @@ +# Conflict Journal, Disjoint-Field Auto-Merge & Review UI + +How LWW conflict auto-resolutions are recorded (conflict journal), when two +concurrent edits are kept instead of one discarded (disjoint-field auto-merge), +and how the user reviews what happened (`/sync-conflicts` page, banner, badge). + +Code lives in `src/app/op-log/sync/`: + +| Concern | Files | +| ------------------------------ | ------------------------------------------------------------------- | +| Journal data model + store | `conflict-journal.model.ts`, `conflict-journal.service.ts` | +| Classification (taxonomy) | `conflict-journal-emission.util.ts` | +| Disjoint-field auto-merge | `conflict-disjoint-merge.util.ts`, `conflict-resolution.service.ts` | +| Review UI derivation + actions | `sync-conflict-review.util.ts`, `sync-conflict-ui.service.ts` | +| Banner / badge | `sync-conflict-banner.service.ts` | +| Page | `src/app/pages/sync-conflicts-page/` | + +## Conflict journal + +Every LWW conflict auto-resolution is recorded as a `ConflictJournalEntry` in a +**standalone IndexedDB database `SUP_CONFLICT_JOURNAL`** — deliberately separate +from `SUP_OPS` so journaling can never touch op-log schema/versioning or risk +its data. + +Contracts: + +- **Observe-only.** Recording an entry never influences which op LWW picked, + and every journal write swallows its own errors — a journal failure must + never throw back into conflict resolution. Corollary: the op-log write and + the journal write are **not atomic**. The op log is the source of truth; the + journal is a best-effort record, and a crash between the two can lose a + journal entry but never an operation. The never-throw contract covers reads + and status writes too (`list` → `[]`, `getEntry` → `undefined`, mark + kept/flipped swallowed): `list()` is awaited inside the post-resolution + notification step, so a journal failure degrades the badge/review surface, + never the sync. One asymmetry: a `merged` entry claims "both sides kept", so + it is journaled only AFTER the merged op is durably appended — the journal + can under-report a merge, but never report one that didn't happen. +- **Device-local, never synced.** Entries capture the discarded (losing) side + of a conflict verbatim — exactly the data the op log intentionally dropped. + Uploading them would resurrect discarded data; they are also excluded from + backups/exports (see wiki `3.06-User-Data`). +- **Cleared on full dataset replacement.** Journal entries describe conflicts + in the op history; when that history is replaced wholesale the entries are + stale (and, across user profiles, a privacy leak). + `BackupService.importCompleteBackup` — the chokepoint every replacement path + funnels through (profile switch, JSON import, local-backup restore, SuperSync + restore) — calls `ConflictJournalService.clearAll()`. +- **Retention.** Pruned on app start to whichever bound binds first: entries + older than 14 days (`JOURNAL_RETENTION_DAYS`) or beyond the newest 200 + (`JOURNAL_MAX_ENTRIES`). + +### Classification taxonomy + +`buildConflictJournalEntry` classifies each resolved conflict +(precedence order): `clock-corruption-suspected` → `delete-wins` → +`delete-lost` → `noise` → `newer`/`tie`. `noise` (status `info`) fires only +when the DISCARDED side changed nothing but NOISE_FIELDS (`modified`, +`lastModified`, `created`) — i.e. no real content was lost. Everything else is +status `unreviewed` and counts toward the badge. + +### Field diffs and per-side presence + +`fieldDiffs` is the union of both sides' changed fields, each value captured +verbatim, plus `localChanged`/`remoteChanged` flags recording whether each side +actually touched the field. The flags distinguish "this side never changed the +field" from "changed it to some value" — without them, a union diff stores the +untouched side as `undefined`, and Flip would dispatch `{ field: undefined }`, +clearing a winner-only field. Entries persisted before the flags existed lack +them; readers (`loserChangesFor`/`winnerChangesFor`) fall back to +value-presence, which is exact for that data because op payloads are pure JSON +and cannot encode a real `undefined`. + +### Non-adapter ("opaque") ops + +Not every persistent action is adapter-shaped (`{ [payloadKey]: { id, +changes } }` or a flat entity). `convertToSubTask` persists +`{ taskId, targetParentId, afterTaskId }`; scheduling/ordering/advanced-config +actions have similar domain-specific shapes. Extraction resolves each op's +delta from two sources in order: + +1. the adapter-shaped action payload (`extractUpdateChanges`); +2. the capture-time `entityChanges` computed by `OperationCaptureService` + (covers TIME_TRACKING and `syncTimeSpent`). + +An op with neither is **opaque** (`hasOpaqueChanges`). Opaque ops still +represent real state changes, so: + +- a loser side with opaque ops is **never** classified `noise` — the loss + surfaces as `unreviewed`; +- the raw action payload is preserved in the entry as a `kind: 'action'` + field diff (field = action type), so the discarded change stays reviewable + after the op itself is gone; +- `kind: 'action'` diffs are excluded from flip/stale computations — they are + not entity fields; +- a side with opaque ops is **never disjoint-merge eligible** (see below). + +## Disjoint-field auto-merge + +When two clients concurrently edit the SAME entity but DIFFERENT (non-noise) +fields, whole-entity LWW would discard one side's real edit. Instead, both are +kept by synthesizing a single merged UPDATE op. Eligibility +(`isDisjointMergeEligible` + the archive-plan guard in +`conflict-resolution.service.ts`): + +- neither side has a DELETE op, and the plan is not an archive plan; +- neither side has opaque ops (their changes could not be carried into the + synthesized delta — merging would silently drop them and the two clients + would synthesize DIFFERENT results); +- both sides changed at least one real (non-noise) field; +- the two sides' non-noise changed-field sets are disjoint; +- the entity has only ONE conflict in this batch. `detectConflicts` emits one + conflict per remote op with no per-entity aggregation, so an entity with ≥2 + concurrent remote ops would synthesize multiple merged ops whose clocks + dominate one another — a dominated sibling can be superseded and its field + silently dropped. Such entities fall back to whole-entity LWW (honest refusal; + per-entity aggregation into one op is a possible future improvement); +- the entity type has a `RECREATE_FALLBACK` (`TASK` / `PROJECT` / `TAG` / + `SIMPLE_COUNTER`). The merged op is a partial delta, so if it wins over a + concurrent DELETE on a client that already applied that delete (a passive + observer, which does NOT pass through the full-entity reconstruction in + `_convertToLWWUpdatesIfNeeded`), `lwwUpdateMetaReducer`'s `addOne` recreate + branch must backfill it to a schema-valid entity. Types without a fallback + (`NOTE` / `METRIC` / `TASK_REPEAT_CFG` / `ISSUE_PROVIDER`) would recreate an + invalid entity, so they fall back to whole-entity LWW (whose local-win op + carries a full snapshot). Residual: fallback types can still recreate with + `DEFAULT_*` backfill diverging from holders in that rare race — the same + bounded limitation documented in `recreate-fallback.const.ts`. + +**Convergence contract:** both clients must synthesize the byte-identical +merged **changes delta** regardless of which one performs the merge. The delta +is the union of both sides' non-noise fields (disjoint, so nothing is clobbered) +plus the noise fields either side changed, resolved via a deterministic +`(timestamp, clientId)` tiebreak. Crucially the delta is derived ONLY from the +two sides' ops — **not** from either client's current entity snapshot. A +full-entity snapshot would drag along fields NEITHER side touched; if such an +untouched field momentarily differs between the two clients (an ordinary +staggered-sync race — e.g. one client already applied a third device's edit the +other has not), the two snapshots would differ, tie under LWW at the identical +`max(timestamp)`, and diverge PERMANENTLY. See `synthesizeMergedChanges`. + +**Atomicity / no-re-merge contract:** the merged resolution is exactly ONE new +UPDATE op carrying a **flat PARTIAL delta** (only the changed fields), layered +on top of both sides' history like a normal edit — there is no history rewind. +`lwwUpdateMetaReducer` applies it via `updateOne` (a shallow merge), so fields +outside the delta keep their own values on each client. Because the payload is +flat (not `{ changes }`-shaped), `extractUpdateChanges` yields `{}` for it, so +a merged op can never itself become disjoint-merge eligible: merges do not +cascade or re-merge on later syncs. Merged resolutions are journaled with +`winner: 'merged'`, status `info` (nothing was discarded), recording per-field +which side supplied each value. + +**Composition residual (pre-existing class):** the merged op is an ordinary +partial UPDATE, so it is NOT closed under later whole-op LWW composition. When +a concurrent third-device op overlapping a merged field crosses paths with the +merged op after both are synced, `_checkEntityForConflict`'s no-pending-local +fast path applies whichever op each client receives last, with no reconciling +snapshot op — clients can permanently diverge on the overlapped field. This +hole predates the merge feature: two plain concurrent user ops crossing after +both are synced hit the identical branch (present at the pre-SPAP-14 baseline); +the merged op is simply one more op subject to it, neither widening nor fixing +it. Class-level fix ideas — per-field timestamps, a reconciling op on +concurrent-apply, or carrying parent-op identity so a later conflict can +decompose a merge — belong to a follow-up at the op-log level. + +## Review UI (`/sync-conflicts`) + +Entry points: a banner after a sync that auto-resolved conflicts, an +unreviewed-count badge, and a link in Settings → Sync. Two views: unreviewed +and history (everything, newest first). + +Per-entry actions (`SyncConflictUiService`): + +- **KEEP** confirms the auto-resolution (`status: 'kept'`). Bulk keep-all + exists. +- **FLIP** re-applies the discarded side by dispatching a NORMAL entity update + action — the same action a manual edit dispatches — so the operation-capture + meta-reducer turns it into a synced op that propagates everywhere. No history + rewind; a flip is a brand-new edit on top of current state. Before applying, + a **stale guard** asks for confirmation if the entity was edited after the + conflict resolved. It checks a winner-changed field whose current value + diverged from the journaled winner value, plus — for **remote-won** entries + only — a **loser-only** field (one the flip writes but the winner never + changed, so it is invisible to the winner values) whose current value is not + already what the flip would write. The loser-only check is scoped to + `winner === 'remote'` because only then did the loser's (local, optimistically + applied) value persist in current state, giving a valid "unedited" baseline; + for a local win the loser (remote) value was never applied and no base is + journaled, so `current !== flipVal` there is the normal post-resolution state, + not an edit. The bulk flip path shows no dialog, so it **skips** stale entries + rather than overwriting them. + + KNOWN GAP: a post-resolution edit to a **loser-only field on a LOCAL-won + entry** is not yet detectable (no journaled base), so a flip there can still + overwrite it silently — a follow-up needs a per-field post-resolution baseline + in the journal. + +**Flip capability is deliberately narrow** (`canFlip`); everything else +returns `unsupported`, keeps the entry `unreviewed`, and shows an error snack — +an entry is only ever marked `flipped` when an op was actually dispatched: + +- only TASK / PROJECT / NOTE / TAG (types whose flip is expressible as a + normal `{ id, changes }` update); +- not for `delete-lost` / `delete-wins` — re-applying a delete or resurrecting + a deleted entity needs delete/restore semantics a plain update cannot + express (deferred); +- not when the loser has no re-appliable field values (empty diffs, opaque + `kind: 'action'` diffs); +- not when the loser's changes touch unsafe fields (`FLIP_UNSAFE_FIELDS`): + relationship-bearing fields (`projectId`, `parentId`, `subTaskIds`, + `tagIds`, `taskIds`, `backlogTaskIds`, `noteIds`) are kept consistent across + entities by meta-reducers, and re-applying one side of the pair via a bare + adapter update would corrupt the other entity's membership lists; + schedule/reminder fields (`dueDay`, `dueWithTime`, `deadlineDay`, + `deadlineWithTime`, `reminderId`, `remindAt`, `deadlineRemindAt`) have + invariants (mutual exclusivity, TODAY_TAG membership, reminder create/cancel) + that live in dedicated flows. + +A flipped TASK title is dispatched with `isIgnoreShortSyntax: true` — it is a +journaled literal value, not user input, so `#tag`/`+project`/`@schedule` +tokens in the discarded title must NOT re-parse into cross-entity mutations. diff --git a/docs/sync-and-op-log/operation-log-architecture.md b/docs/sync-and-op-log/operation-log-architecture.md index 0c7d7b2461..5a595f92b3 100644 --- a/docs/sync-and-op-log/operation-log-architecture.md +++ b/docs/sync-and-op-log/operation-log-architecture.md @@ -2379,7 +2379,7 @@ src/app/op-log/ │ ├── test-client.helper.ts # Test client utilities │ └── operation-factory.helper.ts # Test operation builders └── benchmarks/ - └── operation-log-stress.spec.ts # Performance stress tests + └── operation-log-stress.benchmark.ts # Manual perf harness (not auto-run in CI) src/app/features/work-context/store/ ├── work-context-meta.actions.ts # Move actions (moveTaskInTodayList, etc.) diff --git a/docs/sync-and-op-log/sqlite-migration-followup.md b/docs/sync-and-op-log/sqlite-migration-followup.md index a217613a79..31f20ef969 100644 --- a/docs/sync-and-op-log/sqlite-migration-followup.md +++ b/docs/sync-and-op-log/sqlite-migration-followup.md @@ -138,7 +138,10 @@ captured on the next tick. - ⏳ **Remains: the on-device real-engine run.** sql.js validates the engine, not the Capacitor bridge or the native plugin's specific SQLite build/flags. The `operation-log-stress.benchmark.ts` harness is the lever for the on-device - perf + behavior pass (see B1 perf note). + perf + behavior pass (see B1 perf note). Run it explicitly with + `npm run test:file src/app/op-log/testing/benchmarks/operation-log-stress.benchmark.ts`; + it is compiled by `src/tsconfig.spec.json` but excluded from the auto-run + `**/*.spec.ts` set, so it never runs in normal CI. ### B3. Flip the DI token on native — init fix ✅ landed; token flip device-gated diff --git a/docs/sync-and-op-log/supersync-encryption-architecture.md b/docs/sync-and-op-log/supersync-encryption-architecture.md index 283c7e54cd..50c9a4bb75 100644 --- a/docs/sync-and-op-log/supersync-encryption-architecture.md +++ b/docs/sync-and-op-log/supersync-encryption-architecture.md @@ -227,13 +227,56 @@ privateCfg: { ## Security Properties -| Property | Guarantee | -| ------------------- | ------------------------------------- | -| **Confidentiality** | Server cannot read operation payloads | -| **Integrity** | GCM auth tag detects tampering | -| **Key Security** | Argon2id makes brute-force expensive | -| **Forward Secrecy** | Each operation uses random IV | -| **Wrong Password** | Decryption fails, operation rejected | +| Property | Guarantee | +| ------------------- | ----------------------------------------------- | +| **Confidentiality** | Server cannot read operation payloads | +| **Integrity** | GCM auth tag detects tampering of the _payload_ | +| **Key Security** | Argon2id makes brute-force expensive | +| **Forward Secrecy** | Each operation uses random IV | +| **Wrong Password** | Decryption fails, operation rejected | + +> **Integrity scope (important).** Only `op.payload` is encrypted and covered by +> the AES-GCM authentication tag. Every other operation field — `actionType`, +> `opType`, `entityType`, `entityId`, `entityIds`, `vectorClock`, `timestamp`, +> `schemaVersion`, `syncImportReason`, **and the `isPayloadEncrypted` flag +> itself** — travels as **plaintext** and is **not** bound as Additional +> Authenticated Data (AAD), so a malicious/compromised sync server or a TLS MITM +> can tamper with it. As **defense-in-depth**, the client fails closed on two +> tamper vectors: +> +> - **Plaintext-injection downgrade:** a forged op with `isPayloadEncrypted=false` +> would skip decryption _and_ the payload check and be applied as-is — arbitrary +> op forgery on an encryption-mandatory client. `assertOpsEncryptedWhenExpected` +> rejects any inbound plaintext op (download + piggyback) when encryption is +> **enabled in config** (`isEncryptionMandatory && isEncryptionEnabled()` — +> config intent, not key presence, so it also fails closed in the +> dropped-credential state). Safe because enabling encryption deletes + +> re-uploads all data encrypted, so no legitimate plaintext op remains — this +> rests on the server contract that `deleteAllData()` removes every downloadable +> plaintext op. This is the SuperSync op-level twin of the file-based GHSA-vrc7 +> download guard and the GHSA-9544 _upload_ guard. +> - **LWW `entityId` retarget:** the client rejects an _encrypted_ LWW-update op +> whose authenticated `payload.id` does not equal `op.entityId` +> (`verify-decrypted-op-integrity.ts`). +> +> This is **not** full integrity. Still open pending the durable fix: +> +> - `opType` promotion to a full-state (`loadAllData`) op. +> - Within-LWW `entityType`/`actionType` swap (ids left equal, so it passes). +> - `vectorClock`/`timestamp` reorder/replay. +> - The restore-to-point path (`getStateAtSeq` → `importCompleteBackup`) applies +> server-reconstructed state without this guard; it is server-authored by +> nature and the server blocks it for encrypted accounts, but E2EE cannot +> authenticate it. +> +> Known limitation: a peer running an app version that predates the GHSA-9544 +> _upload_ guard can still push plaintext ops; a keyed client then fails closed +> here with the tamper message. Recovery is to update the old peer. +> +> Full protection — binding the metadata (and the encryption flag) as GCM AAD +> behind an envelope-version migration, with a monotonic "encryption floor" to +> block downgrades — is tracked in **GHSA-8pxh-mgc7-gp3g**. Do not treat +> plaintext metadata as trusted at client decision points. ## Initial Setup — Password Dialog Selection diff --git a/docs/wiki/2.00-How_To.md b/docs/wiki/2.00-How_To.md index 999a666665..2ffc0b0be3 100755 --- a/docs/wiki/2.00-How_To.md +++ b/docs/wiki/2.00-How_To.md @@ -20,3 +20,5 @@ Every How-To note in the wiki. For structure and how to write How-Tos, see [[0.0 - [[2.16-Set-Up-Development-Environment]] - [[2.17-Add-a-New-Issue-Integration]] - [[2.18-Contribute-Translations]] +- [[2.19-Sync-Proton-Drive-via-rclone]] +- [[2.20-Import-from-Todoist]] diff --git a/docs/wiki/2.03-Add-Tasks.md b/docs/wiki/2.03-Add-Tasks.md index 24c8633f1b..59fed0f00c 100755 --- a/docs/wiki/2.03-Add-Tasks.md +++ b/docs/wiki/2.03-Add-Tasks.md @@ -13,7 +13,7 @@ This how-to covers adding new tasks to your list. For subtasks, scheduling, repe 1. With the add task bar open, type the task title in the input. The placeholder shows an example: **A task title #tag @16:00 t25m** (you can use **#tag**, **@time**, **@date**, and **[t]time** in the title; see below). 2. Press **Enter** or click the **Add task** button (plus icon next to the input). The task is added to the current project or context (Today / tag). -> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject` and the email body is saved to the task's notes. +> You can also create a task by dropping an `.eml` email file onto the **add task** button (the **+** icon in the header). The task title becomes `sender: subject`. Simple, unencoded plain-text bodies are saved to the task's notes as literal text; unsupported body formats create a title-only task. The new task appears at the **top** or **bottom** of the list depending on the add bar setting (see “Add to top or bottom” below). diff --git a/docs/wiki/2.06-Manage-Repeating-Tasks.md b/docs/wiki/2.06-Manage-Repeating-Tasks.md index 8418859e6c..b361f58c82 100755 --- a/docs/wiki/2.06-Manage-Repeating-Tasks.md +++ b/docs/wiki/2.06-Manage-Repeating-Tasks.md @@ -27,6 +27,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro - Schedule type (only for intervals > 1): Fixed schedule (every X from start date) or After completion (X after I finish). - Start date — when the pattern begins (required). - For weekly cycle: check the weekdays (Monday–Sunday) on which the task should recur. + - For monthly cycle: choose a **Monthly pattern**. **Day of month** uses the day shown in **Start date**; first, second, third, fourth, and last patterns also require a **Weekday**. 5. Optionally set **Tags to add** (chip list). 6. Optionally set **Scheduled start time** (e.g. 15:00; leave blank for all-day). If you set a time, **Remind at** appears — choose when to be reminded (e.g. when it starts, 5 minutes before). 7. Optionally set **Default Estimate** (duration). **Order** (number) controls creation order when several recurring tasks are created at the same time; see the field description in the dialog. diff --git a/docs/wiki/2.16-Set-Up-Development-Environment.md b/docs/wiki/2.16-Set-Up-Development-Environment.md index 66b96f4e26..be50b5de73 100644 --- a/docs/wiki/2.16-Set-Up-Development-Environment.md +++ b/docs/wiki/2.16-Set-Up-Development-Environment.md @@ -46,3 +46,59 @@ Types and keys are derived from `.env` when you run any build or serve command. - [ENV_SETUP.md](https://github.com/super-productivity/super-productivity/blob/master/docs/ENV_SETUP.md) (full reference in the repo) - [[2.11-Run-the-Development-Server]] + +## Unit Test Browser (Chrome) + +The unit tests run in headless Chrome via Karma (`npm test`, `npm run test:file`). The `pre-push` git hook runs `npm run lint && npm run test`, so **a Chrome binary must be resolvable or `git push` will fail** with the following error: + +```text +ERROR [launcher]: No binary for Chrome browser on your platform. + Please, set "CHROME_BIN" env variable. +``` + +Karma's `karma-chrome-launcher` locates the browser via the `CHROME_BIN` environment variable, falling back to a system Chrome install. There are two paths you can take: + +### Option A — Use a system Google Chrome + +Install **Google Chrome** through your OS package manager. The karma config uses `karma-chrome-launcher`'s `Chrome` launcher (`base: 'Chrome'` in `src/karma.conf.js`), which on Linux only auto-detects real Google Chrome — it probes for `google-chrome` / `google-chrome-stable`, **not** `chromium` / `chromium-browser`. So a distro **Chromium** package is generally _not_ picked up automatically; for anything other than Google Chrome (or a Chrome in a non-standard location) set `CHROME_BIN` explicitly, as in Option B. + +#### Snap / Flatpak Caveat + +`karma-chrome-launcher` `exec`s the browser binary directly with its own flags (including a temp `--user-data-dir`), so sandboxed packages need extra care: + +**Snap** works, but isn't auto-detected (the launcher doesn't probe `/snap/bin/chromium`). Set it explicitly in your shell profile: `export CHROME_BIN=/snap/bin/chromium` + +**Flatpak** is not recommended. There's no plain executable to point `CHROME_BIN` at — you'd have to wrap `flatpak run org.chromium.Chromium "$@"` in a script — and even then the sandbox blocks Karma's temp profile directory, so it typically fails to launch. Prefer a native Google Chrome package (this option) or Chrome for Testing (Option B). + +### Option B — Install Chrome for Testing and set `CHROME_BIN` + +Install a pinned [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) build into a folder of your choice (all examples below use `~/.cache/puppeteer`), then point `CHROME_BIN` at it: + +```bash +npx @puppeteer/browsers install chrome@stable --path "$HOME/.cache/puppeteer" +``` + +The `--path` flag is recommended: the standalone `@puppeteer/browsers` CLI defaults its download location to the **current working directory** so without it you'd get an untracked `./chrome/` folder inside the repo and the `find` below would match nothing. + +Add the resolved path to your shell profile (`~/.zshrc`, `~/.bashrc`, …) so it's set for every session — including the non-interactive shell the git hook runs in: + +```bash +# Looks for the Chrome for Testing binary in ~/.cache/puppeteer/chrome, sorted by version so the latest version is picked if you happen to have multiple +export CHROME_BIN="$(find "$HOME/.cache/puppeteer/chrome" -name chrome -type f | sort -V | tail -n1)" +``` + +On macOS the binary is named `Google Chrome for Testing` inside an `.app` bundle, so the `-name chrome` filter above matches nothing. Use this instead: + +```bash +# Same idea, but matches the macOS executable inside the .app bundle +export CHROME_BIN="$(find "$HOME/.cache/puppeteer/chrome" -type f -name 'Google Chrome for Testing' -path '*Contents/MacOS*' | sort | tail -n1)" +``` + +Reload your shell (or `source` the profile) and verify: + +```bash +echo "$CHROME_BIN" # should print a path +"$CHROME_BIN" --version # should print a Chrome version +``` + +With `CHROME_BIN` set, `npm test` and `git push` (via the hook) work without further configuration. diff --git a/docs/wiki/2.20-Import-from-Todoist.md b/docs/wiki/2.20-Import-from-Todoist.md new file mode 100644 index 0000000000..6eeb1b7961 --- /dev/null +++ b/docs/wiki/2.20-Import-from-Todoist.md @@ -0,0 +1,55 @@ +# Import from Todoist + +Bring your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity in one pass. The import is **additive**: it only creates new projects, tags and tasks and never changes or removes existing Super Productivity data. + +## What you need + +- A Todoist account and its **API token**: in Todoist, open **Settings → Integrations → Developer** and copy the token. The token is sent only to `api.todoist.com` and is never stored. + +## Steps + +1. Open **Settings** (gear icon in the sidebar). +2. Open the **Sync & Backup** tab and find the **Import/Export** section. +3. Click **Import from Todoist**. +4. Paste your API token and click **Load preview**. +5. Review the preview: pick the projects to import (projects whose name already exists — for example from a previous run — are flagged and unchecked by default) and optionally choose how to carry over Todoist priorities (see [Priorities](#priorities) below). +6. Click **Import**. The summary lists what was created and everything that could not be carried over. + +## What is imported + +- Active projects (nested project hierarchy is flattened; the Todoist Inbox becomes a project named “Inbox (Todoist)”) +- Active tasks and sub-tasks (Super Productivity nests two levels; deeper sub-tasks become direct sub-tasks of their top-level task) +- Task descriptions and comments (into task notes; HTTP(S) comment file attachments keep their link) +- Labels used by imported tasks (as tags, on top-level tasks) +- Due dates, including times, and minute-based durations (as time estimates) +- Recurring tasks keep their next due date; the recurrence rule is added to the task notes (e.g. `Repeats: every 3 days`) + +## Priorities + +Todoist priorities are **off by default**. In the preview you can pick one of: + +- **p1–p3 tags** — adds a `p1`, `p2` or `p3` tag to each top-level task (Todoist's default p4 stays untagged). +- **Eisenhower matrix** — reuses Super Productivity's built-in `urgent` / `important` tags, so imported tasks appear in the Eisenhower Matrix board: p1 → urgent + important, p2 → important, p3 → urgent, p4 → neither. Because a single Todoist priority is being split across the two axes, this mapping is a sensible default rather than an exact translation. + +Priority tags are only added to top-level tasks — sub-tasks in Super Productivity can't hold tags. +The preview counts prioritized sub-tasks that will not receive the selected priority tags. + +## What is not imported + +- Completed tasks and task history +- Nested project hierarchy and sections (project and task order is preserved) +- Labels and mapped priorities on sub-tasks +- Reminders, full-day durations, collaborator assignees and attachment files +- Recurrence rules as real repeating tasks — recreate important ones via [[2.06-Manage-Repeating-Tasks]] + +Unusually long project names, task titles, labels and task notes are shortened to +safe import limits. The preview reports how many selected values are affected. + +If the import stops midway (e.g. connection loss), delete the project named in the +error because it may be incomplete. Then re-run the import and select that project +and any remaining projects. Projects already listed as imported are complete. To +undo an import, delete the created projects. + +The duplicate-title warning can check active Super Productivity projects only. If a +previously imported project was archived, restore or delete it before re-running the +import so that it can be detected. diff --git a/docs/wiki/3.03-Keyboard-Shortcuts.md b/docs/wiki/3.03-Keyboard-Shortcuts.md index d39860cb39..3d22c74d57 100755 --- a/docs/wiki/3.03-Keyboard-Shortcuts.md +++ b/docs/wiki/3.03-Keyboard-Shortcuts.md @@ -83,6 +83,7 @@ The following shortcuts apply for the currently selected task (selected via tab #### Unconfigurable - `Arrow keys`: Navigate around task list +- In an empty or whitespace-only add-subtask input, `ArrowUp` focuses the preceding subtask or parent task, and `ArrowDown` focuses the next task when available. At the end of the list, focus stays on the closest task row or control. - `ArrowRight`: Open additional info panel for currently focused task - `Ctrl`+`C` / `Cmd`+`C`: Copy the currently focused task title when focus is on the task, no text is selected, and focus is not inside an input diff --git a/docs/wiki/3.05-Web-App-vs-Desktop.md b/docs/wiki/3.05-Web-App-vs-Desktop.md index 972a3a7a6d..15eccb61b8 100644 --- a/docs/wiki/3.05-Web-App-vs-Desktop.md +++ b/docs/wiki/3.05-Web-App-vs-Desktop.md @@ -122,6 +122,12 @@ The web version does not have: Certain UI elements tied to these features are hidden in the web build via platform-specific CSS or logic. +## Platform-Specific Availability + +### Donation and Contribution Links + +The Support Us page and links that can lead to GitHub Sponsors are unavailable in native iOS and macOS desktop builds. They remain available in the web app, native Android, Windows desktop, and Linux desktop builds. + ## Documented Error Conditions (Web) - **CORS errors:** WebDAV (and similar) sync may report CORS failures with localized messages suggesting server configuration checks. diff --git a/docs/wiki/3.06-User-Data.md b/docs/wiki/3.06-User-Data.md index a51620d446..79be42cc52 100644 --- a/docs/wiki/3.06-User-Data.md +++ b/docs/wiki/3.06-User-Data.md @@ -80,6 +80,7 @@ The Windows Store build uses a different path that includes the Windows Store pa - **SUP_OPS** (current, version 8): Main database. Object stores: `ops`, `state_cache`, `import_backup`, `vector_clock`, `archive_young`, `archive_old`. Holds operations log, state snapshots, vector clocks, and archives. - **pf:** Legacy database used for migration and recovery. - **SUPPluginCache:** Plugin cache. +- **SUP_CONFLICT_JOURNAL** (version 1): Sync **conflict journal** — a device-local record of automatic sync-conflict resolutions, reviewable under `/sync-conflicts`. One object store (`conflicts`). Deliberately separate from `SUP_OPS`, **never synced or uploaded** (entries capture the discarded side of conflicts verbatim), pruned on start to 14 days / newest 200 entries, and cleared whenever the full dataset is replaced (backup import/restore, profile switch). ### localStorage Keys @@ -107,7 +108,7 @@ On mobile (Android/iOS), if the app launches with no local data but a usable on- **Manual backups:** (1) **Create manual backup** in Settings → Sync & Export creates a backup using the same mechanism as automatic backups (platform-dependent location). (2) **Safety backups** are created automatically before certain sync operations; the app keeps a limited number of slots (e.g. two most recent, one first from today, one first from day before). (3) **Export data** downloads a complete backup JSON file to a path you choose (or the browser download folder on web). -**What is included:** All application data is included in backups and exports; nothing is excluded. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate, single-slot **import backup** is stored locally in IndexedDB (`import_backup`). The app captures it before replacing local state through a file import or the sync conflict action **Use Server Data**. A successful Use Server Data replacement shows a persistent **Undo** action that restores this backup; interrupted replacements offer the same recovery action when sync resumes. The Undo remains available after an app reload while that same backup is still present. The slot is internal recovery state and is not included in exports or the user-visible automatic/manual backup lists. +**What is included:** All application data is included in backups and exports. The only exclusion is the device-local sync **conflict journal** (`SUP_CONFLICT_JOURNAL`): it is a review log of past conflict resolutions, not application data, and is neither exported nor restored. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate, single-slot **import backup** is stored locally in IndexedDB (`import_backup`). The app captures it before replacing local state through a file import or the sync conflict action **Use Server Data**. A successful Use Server Data replacement shows a persistent **Undo** action that restores this backup; interrupted replacements offer the same recovery action when sync resumes. The Undo remains available after an app reload while that same backup is still present. The slot is internal recovery state and is not included in exports or the user-visible automatic/manual backup lists. ## Import and Export diff --git a/docs/wiki/4.17-Idle-Time.md b/docs/wiki/4.17-Idle-Time.md index 8ae378729e..a673325fc8 100644 --- a/docs/wiki/4.17-Idle-Time.md +++ b/docs/wiki/4.17-Idle-Time.md @@ -42,6 +42,8 @@ In the dialog you can: - **Assign the time to a break** — Count the idle period as a break. This **resets the break-reminder timer** (see [[4.16-Break-Reminders]]) and records break time. - **Discard the time** — Don’t count it as work or break; it disappears from the log. Use this when the time was not productive and you don’t want it on any task or as a break. +Once recorded break time is greater than zero, hover over **Without break** in the work-view status bar to see the day’s recorded **Break** total. + So idle detection **corrects** the log by removing unattended time from the active task and then **lets you reallocate** it in a way that matches reality. ## Why Idle Time Is Surfaced Instead of Ignored diff --git a/docs/wiki/4.23-Managing-Your-Data.md b/docs/wiki/4.23-Managing-Your-Data.md index 523d16c225..6a4e2d2549 100644 --- a/docs/wiki/4.23-Managing-Your-Data.md +++ b/docs/wiki/4.23-Managing-Your-Data.md @@ -48,9 +48,11 @@ The app uses **local-first, operation-based sync**: your device is the primary c **Conflicts** — When the same data is changed in two places (e.g. on two devices) before they sync, the app detects a conflict. It resolves most conflicts automatically using **last-write-wins**: the change with the **newer timestamp** is kept; if timestamps tie, the **remote** (server) side wins. When your local change wins, the app creates a new operation so your version is sent to the remote. In some cases (e.g. first sync when both local and remote already have data), the app may ask you to choose **use local** or **use remote** instead of applying automatic resolution. After resolution, the app validates and can repair state so references (e.g. tasks to projects) stay consistent. -Because last-write-wins keeps the whole newer change, an older edit to a **different field** of the same task can be dropped. Routine automatic churn (rescheduling, repeat-task and archive updates) is resolved quietly, but when resolution discards a genuine **content** edit — a task's title, notes, or subtasks changed on both devices — the app shows a dismissible banner naming the affected task(s) so you can double-check that nothing important was lost. +**Disjoint-field auto-merge** — When two devices edited the SAME task (or project/tag/note) but touched DIFFERENT fields — e.g. you renamed a task on one device and edited its notes on the other — the app keeps **both** edits instead of discarding one: it merges the two changes into a single update. This only happens when it is unambiguously safe (different real fields, no deletion or archiving involved); anything else falls back to last-write-wins. -So in practice: **newer wins**; you only choose when the app prompts you (e.g. first-sync). +**Reviewing what was auto-resolved** — Every automatic conflict resolution is recorded in a device-local **conflict journal** you can inspect on the **Sync Conflicts** page (`/sync-conflicts`, also linked from Settings → Sync). When a resolution discarded a genuine edit, the app shows a dismissible banner after sync (content edits like a changed title or notes are still called out by task name, as before) and a badge with the number of entries awaiting review. For each entry you can see which side won, which fields differed and both values, and then either **Keep** (confirm the automatic resolution) or **Flip** (re-apply the discarded change as a new edit that syncs to all devices — nothing is rewound). Flip is disabled where re-applying isn't safely possible (e.g. overridden deletions or structural moves); such entries remain visible for review. The journal is informational only: it lives on the device, is never uploaded by sync, is not part of backups/exports, is cleared whenever you import a backup or switch user profiles, and old entries are pruned automatically (after 14 days or beyond the newest 200). + +So in practice: **newer wins** (both kept when fields don't overlap); you only choose when the app prompts you (e.g. first-sync), and you can always review or flip automatic resolutions afterwards on the Sync Conflicts page. ## Summary @@ -58,7 +60,7 @@ So in practice: **newer wins**; you only choose when the app prompts you (e.g. f - **Import** = replace current data with a backup file; data is validated and repaired when possible; encryption change is warned. - **Export** = snapshot at export time; relationships preserved by IDs; use for backup or transfer. - **Backups** = automatic (when enabled, platform-dependent location) and manual (manual button, safety backups, export); no data excluded. -- **Sync** = local-first, operation-based; conflicts resolved by newer-wins (or your choice when prompted). +- **Sync** = local-first, operation-based; conflicts resolved by newer-wins, non-overlapping field edits auto-merged; every auto-resolution reviewable (Keep/Flip) on the Sync Conflicts page. ## Related diff --git a/e2e/README.md b/e2e/README.md index 1a87854d0b..6d412f3957 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -83,7 +83,6 @@ e2e/ │ ├── settings.page.ts │ ├── dialog.page.ts │ ├── planner.page.ts -│ ├── schedule.page.ts │ ├── side-nav.page.ts │ ├── sync.page.ts │ ├── tag.page.ts diff --git a/e2e/constants/selectors.ts b/e2e/constants/selectors.ts index b0e961e3ec..d0206e7ce1 100644 --- a/e2e/constants/selectors.ts +++ b/e2e/constants/selectors.ts @@ -128,7 +128,7 @@ export const cssSelectors = { // TASK DETAIL PANEL SELECTORS // ============================================================================ RIGHT_PANEL: '.right-panel', - DETAIL_PANEL: 'dialog-task-detail-panel, task-detail-panel', + DETAIL_PANEL: 'task-detail-panel', DETAIL_PANEL_BTN: '.show-additional-info-btn', SCHEDULE_TASK_ITEM: 'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))', diff --git a/e2e/pages/schedule.page.ts b/e2e/pages/schedule.page.ts deleted file mode 100644 index 0ba9023b5c..0000000000 --- a/e2e/pages/schedule.page.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Schedule page removed - not used -export {}; diff --git a/e2e/pages/task.page.ts b/e2e/pages/task.page.ts index aabe7b690c..917a9a4c17 100644 --- a/e2e/pages/task.page.ts +++ b/e2e/pages/task.page.ts @@ -53,10 +53,14 @@ export class TaskPage extends BasePage { */ async markTaskAsDone(task: Locator): Promise { await task.waitFor({ state: 'visible' }); - // `data-task-id` is only bound on the host. Wrapper components such - // as (Boards) render the same done-toggle but expose no id, - // so the done-confirmation strategy is chosen based on its presence. - const taskId = await task.getAttribute('data-task-id'); + // The done-confirmation strategy differs for real rows vs. wrapper + // components ( etc.) that render the same done-toggle. Key it + // off the element actually being a , not off `data-task-id` presence: + // also carries `data-task-id` in the Planner overdue list + // (#8851), but querying `document.querySelectorAll('task')` for it finds + // nothing, so the strategy would hang for a wrapper. + const isTaskHost = (await task.evaluate((el) => el.tagName.toLowerCase())) === 'task'; + const taskId = isTaskHost ? await task.getAttribute('data-task-id') : null; await task.hover(); // Give hover effects time to settle diff --git a/e2e/utils/schedule-task-helper.ts b/e2e/utils/schedule-task-helper.ts index 3401e2a69a..0249f484c7 100644 --- a/e2e/utils/schedule-task-helper.ts +++ b/e2e/utils/schedule-task-helper.ts @@ -4,7 +4,7 @@ import { fillTimeInput } from './time-input-helper'; // Selectors for scheduling const DETAIL_PANEL_BTN = '.show-additional-info-btn'; -const DETAIL_PANEL_SELECTOR = 'dialog-task-detail-panel, task-detail-panel'; +const DETAIL_PANEL_SELECTOR = 'task-detail-panel'; const DETAIL_PANEL_SCHEDULE_ITEM = 'task-detail-item:has(mat-icon:text("alarm")), ' + 'task-detail-item:has(mat-icon:text("today")), ' + diff --git a/package-lock.json b/package-lock.json index 65a1857cb7..06b022252b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -27,7 +27,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { @@ -22711,12 +22710,6 @@ "node": ">= 0.4" } }, - "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", - "license": "MIT-0" - }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/package.json b/package.json index 62db9fe4f6..2ade6e84c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superProductivity", - "version": "18.13.1", + "version": "18.14.0", "description": "ToDo list and Time Tracking", "keywords": [ "ToDo", @@ -193,7 +193,6 @@ "hash-wasm": "^4.12.0", "https-proxy-agent": "^7.0.0", "node-fetch": "^2.7.0", - "postal-mime": "^2.7.4", "uuidv7": "^1.2.1" }, "devDependencies": { diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index 0e09f46071..08f9b73fff 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -393,6 +393,35 @@ const plugins = [ return 'Built and copied to assets'; }, }, + { + name: 'todoist-import', + path: 'todoist-import', + needsInstall: true, + copyToAssets: true, + buildCommand: async (pluginPath) => { + await execAsync(`cd ${pluginPath} && npm run build`); + const targetDir = path.join( + __dirname, + '../../../src/assets/bundled-plugins/todoist-import', + ); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + const distPath = path.join(pluginPath, 'dist'); + if (fs.existsSync(distPath)) { + const files = fs.readdirSync(distPath); + for (const file of files) { + copyRecursive(path.join(distPath, file), path.join(targetDir, file)); + } + } + assertFilesExist( + targetDir, + ['manifest.json', 'plugin.js', 'index.html', 'icon.svg', 'i18n/en.json'], + 'todoist-import', + ); + return 'Built and copied to assets'; + }, + }, ]; async function buildPlugin(plugin) { diff --git a/packages/plugin-dev/todoist-import/i18n/en.json b/packages/plugin-dev/todoist-import/i18n/en.json new file mode 100644 index 0000000000..406695ad9f --- /dev/null +++ b/packages/plugin-dev/todoist-import/i18n/en.json @@ -0,0 +1,76 @@ +{ + "TITLE": { + "IMPORT": "Import from Todoist", + "PREVIEW": "Preview", + "IMPORTING": "Importing…", + "FINISHED": "Import finished", + "INCOMPLETE": "Import incomplete" + }, + "TOKEN": { + "LABEL": "Todoist API token", + "PLACEHOLDER": "Paste your Todoist API token", + "INTRO": "Brings your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity. The import only adds data — nothing in Super Productivity is changed or removed.", + "HELP": "Find the token in Todoist under Settings → Integrations → Developer. It is sent only to api.todoist.com and never stored.", + "REQUIRED": "Please paste your Todoist API token first.", + "LOADING": "Loading your Todoist data…", + "NO_PROJECTS": "No active projects found for this Todoist account." + }, + "ERROR": { + "LOAD_FAILED": "Could not load data from Todoist: {{error}} — check the token and your connection.", + "IMPORT_FAILED": "Import failed: {{error}}", + "IMPORT_STOPPED": "Import stopped at “{{project}}”: {{error}}. That project was created only partially — delete “{{project}}” before re-running, then select it and the remaining projects again." + }, + "BUTTON": { + "LOAD_PREVIEW": "Load preview", + "IMPORT": "Import", + "BACK": "Back" + }, + "PREVIEW": { + "CHOOSE_PROJECTS": "Choose the projects to import:", + "PROJECT_COUNTS": "{{title}} — tasks: {{taskCount}}, sub-tasks: {{subTaskCount}}", + "ALREADY_EXISTS": "already exists — possibly imported before", + "PRIORITY_LEGEND": "Map Todoist priorities to:", + "PRIORITY_NONE": "Nothing", + "PRIORITY_TAGS": "p1–p3 tags (p4 stays untagged)", + "PRIORITY_EISENHOWER": "Eisenhower matrix — urgent / important tags", + "LOSS_HEADING": "What will not survive the move", + "SELECT_PROJECT": "Select at least one project to import." + }, + "LOSS": { + "NESTED_PROJECTS": "Nested projects flattened into the project list: {{count}}.", + "SECTIONS": "Sections dropped (tasks keep their order in the project): {{count}}.", + "DEMOTED_SUBTASKS": "Deeply nested sub-tasks made direct sub-tasks (2 levels max): {{count}}.", + "RECURRING": "Recurring tasks keeping only their next date (the rule is noted in task notes): {{count}}.", + "DAY_DURATIONS": "Full-day durations not imported: {{count}}.", + "SUBTASK_LABELS": "Sub-task labels not imported (sub-tasks have no tags): {{count}}.", + "SUBTASK_PRIORITIES": "Prioritized sub-tasks not receiving priority tags: {{count}}.", + "ASSIGNEES": "Task collaborator assignments dropped: {{count}}.", + "ATTACHMENTS": "Comment attachment links kept without their files: {{count}}.", + "TRUNCATED_FIELDS": "Unusually long values shortened to safe import limits: {{count}}.", + "COMPLETED_AND_REMINDERS": "Completed tasks and reminders are not imported." + }, + "IMPORT": { + "STARTING": "Starting…", + "PHASE_PROJECT": "creating project", + "PHASE_TASKS": "creating tasks", + "PHASE_DETAILS": "applying dates and tags {{current}}/{{total}}", + "PROGRESS": "Project {{current}} of {{total}}: {{title}} — {{detail}}" + }, + "SUMMARY": { + "PROJECT_RESULT": "{{title}}: {{landedTasks}} of {{plannedTasks}} tasks, {{landedSubTasks}} of {{plannedSubTasks}} sub-tasks", + "SHORTFALL": "some items did not land; please review", + "UNVERIFIED": "Could not verify the imported counts — the numbers above may show 0 even for tasks that landed.", + "CREATED_TAGS": "Created tags: {{tags}}", + "NOT_CARRIED_OVER": "Not carried over", + "SNACK_FINISHED": "Todoist import finished", + "UNDO": "The import is additive — to undo it, delete the created projects." + }, + "PARSE": { + "UNTITLED_PROJECT": "Untitled project", + "UNTITLED_TASK": "Untitled task", + "REPEATS": "Repeats: {{rule}}", + "DEADLINE": "Deadline: {{date}}", + "COMMENTS": "Comments:", + "FILE": "file" + } +} diff --git a/packages/plugin-dev/todoist-import/jest.config.cjs b/packages/plugin-dev/todoist-import/jest.config.cjs new file mode 100644 index 0000000000..98d8ac7256 --- /dev/null +++ b/packages/plugin-dev/todoist-import/jest.config.cjs @@ -0,0 +1,25 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + module: 'commonjs', + target: 'es2020', + esModuleInterop: true, + }, + }, + ], + }, + testMatch: ['**/*.spec.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + roots: ['/src'], + moduleNameMapper: { + '^@super-productivity/plugin-api$': + '/node_modules/@super-productivity/plugin-api/src/index.ts', + }, +}; diff --git a/packages/plugin-dev/todoist-import/package-lock.json b/packages/plugin-dev/todoist-import/package-lock.json new file mode 100644 index 0000000000..f8e3c25869 --- /dev/null +++ b/packages/plugin-dev/todoist-import/package-lock.json @@ -0,0 +1,5689 @@ +{ + "name": "todoist-import", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todoist-import", + "version": "1.0.0", + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^22.15.33", + "esbuild": "^0.28.1", + "jest": "^30.0.4", + "jest-environment-jsdom": "^30.0.4", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + } + }, + "../../plugin-api": { + "name": "@super-productivity/plugin-api", + "version": "1.0.1", + "license": "MIT", + "devDependencies": { + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.50", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.50.tgz", + "integrity": "sha512-ydBWw0G6WFwWHzh9RK4B5c690UkreOG0llq0r+DaI7LgKgxigf8mhHzIPI3S0850g1BPkq/zpuCfrq4QFgUlTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@super-productivity/plugin-api": { + "resolved": "../../plugin-api", + "link": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/plugin-dev/todoist-import/package.json b/packages/plugin-dev/todoist-import/package.json new file mode 100644 index 0000000000..8cf9161596 --- /dev/null +++ b/packages/plugin-dev/todoist-import/package.json @@ -0,0 +1,23 @@ +{ + "name": "todoist-import", + "version": "1.0.0", + "description": "One-time import of active Todoist projects, tasks, labels and due dates into Super Productivity", + "private": true, + "scripts": { + "build": "node scripts/build.js", + "test": "jest", + "test:watch": "jest --watch" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^22.15.33", + "esbuild": "^0.28.1", + "jest": "^30.0.4", + "jest-environment-jsdom": "^30.0.4", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + } +} diff --git a/packages/plugin-dev/todoist-import/scripts/build.js b/packages/plugin-dev/todoist-import/scripts/build.js new file mode 100644 index 0000000000..23e3688a53 --- /dev/null +++ b/packages/plugin-dev/todoist-import/scripts/build.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { build } = require('esbuild'); + +const ROOT_DIR = path.join(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const DIST_DIR = path.join(ROOT_DIR, 'dist'); + +const buildPlugin = async () => { + if (fs.existsSync(DIST_DIR)) { + fs.rmSync(DIST_DIR, { recursive: true }); + } + fs.mkdirSync(DIST_DIR); + + // Bundle the UI and inline it: the host loads index.html via iframe srcdoc, + // so the document must be fully self-contained (no relative script URLs). + const result = await build({ + entryPoints: [path.join(SRC_DIR, 'ui', 'main.ts')], + bundle: true, + write: false, + platform: 'browser', + target: 'es2020', + format: 'iife', + minify: true, + sourcemap: false, + logLevel: 'info', + }); + const bundle = result.outputFiles[0].text; + + const htmlTemplate = fs.readFileSync(path.join(SRC_DIR, 'ui', 'index.html'), 'utf8'); + const marker = ''; + if (!htmlTemplate.includes(marker)) { + throw new Error(`index.html is missing the ${marker} marker`); + } + // function replacer: a literal replacement string would corrupt the bundle + // if the minified JS ever contains `$&`/`$'`-style replacement patterns + const html = htmlTemplate.replace(marker, () => ``); + fs.writeFileSync(path.join(DIST_DIR, 'index.html'), html); + + for (const file of ['manifest.json', 'plugin.js', 'icon.svg']) { + fs.copyFileSync(path.join(SRC_DIR, file), path.join(DIST_DIR, file)); + } + fs.cpSync(path.join(ROOT_DIR, 'i18n'), path.join(DIST_DIR, 'i18n'), { + recursive: true, + }); + console.log('todoist-import build complete → dist/'); +}; + +buildPlugin().catch((err) => { + console.error('todoist-import build failed:', err); + process.exit(1); +}); diff --git a/packages/plugin-dev/todoist-import/src/icon.svg b/packages/plugin-dev/todoist-import/src/icon.svg new file mode 100644 index 0000000000..3edfaf8185 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/plugin-dev/todoist-import/src/manifest.json b/packages/plugin-dev/todoist-import/src/manifest.json new file mode 100644 index 0000000000..4cce2849ea --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/manifest.json @@ -0,0 +1,29 @@ +{ + "id": "todoist-import", + "manifestVersion": 1, + "name": "Todoist Import", + "version": "1.0.0", + "minSupVersion": "18.0.0", + "description": "One-time import of your active Todoist projects, tasks, sub-tasks, labels and due dates. Additive — your existing Super Productivity data is never touched.", + "author": "SuperProductivity", + "homepage": "https://github.com/super-productivity/super-productivity", + "hooks": [], + "permissions": [ + "http", + "getTasks", + "getAllProjects", + "addProject", + "getAllTags", + "addTag", + "batchUpdateForProject", + "updateTask", + "showSnack" + ], + "allowedHosts": ["api.todoist.com"], + "iFrame": true, + "isSkipMenuEntry": true, + "i18n": { + "languages": ["en"] + }, + "icon": "icon.svg" +} diff --git a/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts b/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts new file mode 100644 index 0000000000..cb3f0b4062 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts @@ -0,0 +1,308 @@ +import { BatchTaskCreate } from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { BATCH_CHUNK_SIZE, planImport } from './plan-import'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 't1', + projectExtId: 'p1', + parentExtId: null, + title: 'task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (overrides: Partial = {}): TodoistImportModel => ({ + projects: [ + { extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 }, + ], + sections: [], + tasks: [], + ...overrides, +}); + +describe('planImport', () => { + it('creates temp- prefixed IDs and parent references', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a' }), + task({ extId: 'b', parentExtId: 'a', title: 'sub' }), + ], + }), + { priorityMapping: 'none' }, + ); + const ops = plan.projects[0].batchChunks[0] as BatchTaskCreate[]; + expect(ops.map((o) => [o.tempId, o.data.parentId])).toEqual([ + ['temp-a', undefined], + ['temp-b', 'temp-a'], + ]); + }); + + it('chunks operations at the batch limit, keeping parents before children', () => { + const tasks: TodoistTask[] = []; + for (let i = 0; i < 60; i++) { + tasks.push(task({ extId: `root-${i}`, title: `t${i}` })); + tasks.push(task({ extId: `sub-${i}`, parentExtId: `root-${i}`, title: `s${i}` })); + } + const plan = planImport(model({ tasks }), { priorityMapping: 'none' }); + const chunks = plan.projects[0].batchChunks; + expect(chunks.length).toBe(Math.ceil(120 / BATCH_CHUNK_SIZE)); + chunks.forEach((chunk) => expect(chunk.length).toBeLessThanOrEqual(BATCH_CHUNK_SIZE)); + // a parent is always at a lower global index than its child + const flat = chunks.flat() as BatchTaskCreate[]; + const indexByTempId = new Map(flat.map((op, i) => [op.tempId, i])); + for (const op of flat) { + if (op.data.parentId) { + expect(indexByTempId.get(op.data.parentId)).toBeLessThan( + indexByTempId.get(op.tempId) as number, + ); + } + } + }); + + it('emits follow-ups only for tasks that need them, with due exclusivity', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'plain' }), + task({ extId: 'dated', dueDay: '2026-07-15' }), + task({ extId: 'timed', dueWithTime: 123456, dueDay: null }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-dated', dueDay: '2026-07-15' }, + { tempId: 'temp-timed', dueWithTime: 123456 }, + ]); + }); + + it('collects label tags for root tasks but never for sub-tasks', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a', labels: ['errand'] }), + task({ extId: 'b', parentExtId: 'a', labels: ['dropped'] }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.tagTitles).toEqual(['errand']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-a', tagTitles: ['errand'] }, + ]); + }); + + describe('priority tags (opt-in)', () => { + it('maps API 4→p1, 3→p2, 2→p3 and never tags the default priority 1', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'highest', apiPriority: 4 }), + task({ extId: 'mid', apiPriority: 3 }), + task({ extId: 'low', apiPriority: 2 }), + task({ extId: 'default', apiPriority: 1 }), + ], + }), + { priorityMapping: 'priorityTags' }, + ); + expect(plan.tagTitles.sort()).toEqual(['p1', 'p2', 'p3']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-highest', tagTitles: ['p1'] }, + { tempId: 'temp-mid', tagTitles: ['p2'] }, + { tempId: 'temp-low', tagTitles: ['p3'] }, + ]); + }); + + it('is off by default', () => { + const plan = planImport(model({ tasks: [task({ extId: 'a', apiPriority: 4 })] }), { + priorityMapping: 'none', + }); + expect(plan.tagTitles).toEqual([]); + }); + }); + + describe('eisenhower priority mapping (opt-in)', () => { + it('maps 4→urgent+important, 3→important, 2→urgent, leaving default 1 untagged', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'p1', apiPriority: 4 }), + task({ extId: 'p2', apiPriority: 3 }), + task({ extId: 'p3', apiPriority: 2 }), + task({ extId: 'p4', apiPriority: 1 }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + // reused by title → lands in SP's existing EM_URGENT / EM_IMPORTANT tags + expect(plan.tagTitles.sort()).toEqual(['important', 'urgent']); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-p1', tagTitles: ['urgent', 'important'] }, + { tempId: 'temp-p2', tagTitles: ['important'] }, + { tempId: 'temp-p3', tagTitles: ['urgent'] }, + ]); + }); + + it('merges the matrix tags after existing labels and never tags sub-tasks', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'root', apiPriority: 4, labels: ['errand'] }), + task({ extId: 'sub', parentExtId: 'root', apiPriority: 4, labels: ['x'] }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-root', tagTitles: ['errand', 'urgent', 'important'] }, + ]); + }); + + it('does not duplicate matrix tags already present as Todoist labels', () => { + const plan = planImport( + model({ + tasks: [ + task({ + extId: 'root', + apiPriority: 4, + labels: ['Urgent', 'important'], + }), + ], + }), + { priorityMapping: 'eisenhower' }, + ); + + expect(plan.projects[0].followUps).toEqual([ + { tempId: 'temp-root', tagTitles: ['Urgent', 'important'] }, + ]); + }); + }); + + describe('project selection and titles', () => { + it('only plans selected projects', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + tasks: [task({ extId: 'a', projectExtId: 'p2' })], + }), + { priorityMapping: 'none', selectedProjectExtIds: new Set(['p2']) }, + ); + expect(plan.projects.map((p) => p.extId)).toEqual(['p2']); + }); + + it('renames the inbox and disambiguates colliding nested titles', () => { + const plan = planImport( + model({ + projects: [ + { + extId: 'inbox', + title: 'Inbox', + parentExtId: null, + isInbox: true, + childOrder: 0, + }, + { + extId: 'work', + title: 'Work', + parentExtId: null, + isInbox: false, + childOrder: 1, + }, + { + extId: 'misc1', + title: 'Misc', + parentExtId: 'work', + isInbox: false, + childOrder: 2, + }, + { + extId: 'home', + title: 'Home', + parentExtId: null, + isInbox: false, + childOrder: 3, + }, + { + extId: 'misc2', + title: 'Misc', + parentExtId: 'home', + isInbox: false, + childOrder: 4, + }, + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects.map((p) => p.title)).toEqual([ + 'Inbox (Todoist)', + 'Work', + 'Work / Misc', + 'Home', + 'Home / Misc', + ]); + }); + + it('suffixes titles that still collide after prefixing', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (2)']); + }); + + it('never generates a suffix that collides with another project title', () => { + const plan = planImport( + model({ + projects: [ + { extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 }, + { + extId: 'c', + title: 'X (2)', + parentExtId: null, + isInbox: false, + childOrder: 3, + }, + ], + }), + { priorityMapping: 'none' }, + ); + + expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (3)', 'X (2)']); + }); + }); + + it('reports task and sub-task counts per project', () => { + const plan = planImport( + model({ + tasks: [ + task({ extId: 'a' }), + task({ extId: 'b', parentExtId: 'a' }), + task({ extId: 'c' }), + ], + }), + { priorityMapping: 'none' }, + ); + expect(plan.projects[0].taskCount).toBe(2); + expect(plan.projects[0].subTaskCount).toBe(1); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/map/plan-import.ts b/packages/plugin-dev/todoist-import/src/map/plan-import.ts new file mode 100644 index 0000000000..79372730ca --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/plan-import.ts @@ -0,0 +1,232 @@ +import { BatchOperation } from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; + +/** + * Batch chunk size. Must stay ≤ the host's MAX_BATCH_OPERATIONS_SIZE (50): + * the plugin chunks its own `batchUpdateForProject` calls and awaits each one, + * so every call is a single dispatched action in its own tick (sync rule #6); + * the bridge's internal chunking would fire all chunks in one tick. + */ +export const BATCH_CHUNK_SIZE = 50; + +/** Temp IDs MUST be `temp-`-prefixed — the batch reducer only resolves parent + * references with this prefix; anything else orphans (= deletes) sub-tasks. */ +const tempId = (extId: string): string => `temp-${extId}`; + +/** Todoist API priority (4 = highest) → SP tag title. API 1 = the default on + * every task and is deliberately never tagged. */ +const PRIORITY_TAG_BY_API_VALUE: Record = { + 4: 'p1', + 3: 'p2', + 2: 'p3', +}; + +/** + * Opt-in alternative to the p1–p3 tags: map Todoist's single priority axis onto + * Super Productivity's built-in Eisenhower-matrix tags. The two tags are reused + * by title (`ensureTags` matches case-insensitively), so imported tasks land in + * the existing `EM_URGENT`/`EM_IMPORTANT` quadrants instead of spawning new + * tags. Collapsing one axis onto the 2-D matrix is inherently opinionated; this + * is the conventional split and only ever applies when the user explicitly + * chooses it. API 1 (default p4) stays untagged. + */ +const EISENHOWER_TAGS_BY_API_VALUE: Record = { + 4: ['urgent', 'important'], + 3: ['important'], + 2: ['urgent'], +}; + +export interface TaskFollowUp { + tempId: string; + dueDay?: string; + dueWithTime?: number; + /** resolved to tag IDs at run time (existing tags are reused by title) */ + tagTitles?: string[]; +} + +export interface ProjectImportPlan { + extId: string; + title: string; + taskCount: number; + subTaskCount: number; + batchChunks: BatchOperation[][]; + followUps: TaskFollowUp[]; +} + +export interface ImportPlan { + projects: ProjectImportPlan[]; + /** all tag titles the import needs (used labels + opt-in priority tags) */ + tagTitles: string[]; +} + +/** + * How Todoist task priority is carried over — mutually exclusive (one UI + * control): `none` leaves priority off, `priorityTags` adds the p1–p3 tags, + * `eisenhower` adds SP's built-in urgent/important tags. + */ +export type PriorityMapping = 'none' | 'priorityTags' | 'eisenhower'; + +export interface PlanImportOptions { + priorityMapping: PriorityMapping; + /** omit to import everything */ + selectedProjectExtIds?: ReadonlySet; +} + +export const groupTasksByProject = ( + model: TodoistImportModel, +): Map => { + const byProject = new Map(); + for (const t of model.tasks) { + const list = byProject.get(t.projectExtId) || []; + list.push(t); + byProject.set(t.projectExtId, list); + } + return byProject; +}; + +const taskTagTitles = (task: TodoistTask, priorityMapping: PriorityMapping): string[] => { + // SP sub-tasks cannot hold tags (host model) — the plugin must enforce this + if (task.parentExtId) { + return []; + } + const titles = [...task.labels]; + if (priorityMapping === 'priorityTags') { + const priorityTag = PRIORITY_TAG_BY_API_VALUE[task.apiPriority]; + if (priorityTag) { + titles.push(priorityTag); + } + } else if (priorityMapping === 'eisenhower') { + const emTags = EISENHOWER_TAGS_BY_API_VALUE[task.apiPriority]; + if (emTags) { + titles.push(...emTags); + } + } + const seen = new Set(); + return titles.filter((title) => { + const key = title.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +/** + * Flattened project titles must stay unique enough to review: nested projects + * keep their plain name unless it collides, then `Parent / Child`; remaining + * duplicates get a numeric suffix. The Todoist Inbox becomes `Inbox (Todoist)` + * so it never shadows SP's own Inbox. + * + * Exported so the preview shows (and collision-checks) exactly the titles the + * import will create. + */ +export const buildProjectTitles = (model: TodoistImportModel): Map => { + const byExtId = new Map(model.projects.map((p) => [p.extId, p])); + const preferredTitles = new Map(); + const counts = new Map(); + for (const p of model.projects) { + const base = p.isInbox ? 'Inbox (Todoist)' : p.title; + const key = base.toLowerCase(); + counts.set(key, (counts.get(key) || 0) + 1); + preferredTitles.set(p.extId, base); + } + for (const p of model.projects) { + let title = preferredTitles.get(p.extId) as string; + if ((counts.get(title.toLowerCase()) || 0) > 1 && p.parentExtId) { + const parent = byExtId.get(p.parentExtId); + if (parent) { + title = `${parent.title} / ${title}`; + } + } + preferredTitles.set(p.extId, title); + } + + const reserved = new Set( + [...preferredTitles.values()].map((title) => title.toLowerCase()), + ); + const used = new Set(); + const titles = new Map(); + for (const p of model.projects) { + const base = preferredTitles.get(p.extId) as string; + let title = base; + let suffix = 2; + while (used.has(title.toLowerCase())) { + do { + title = `${base} (${suffix++})`; + } while (reserved.has(title.toLowerCase()) || used.has(title.toLowerCase())); + } + used.add(title.toLowerCase()); + titles.set(p.extId, title); + } + return titles; +}; + +/** + * Normalized model → executable plan. Pure; unit-tested. Operations are + * ordered parent-before-child (guaranteed by the model's task order), which + * keeps chunk boundaries safe. + */ +export const planImport = ( + model: TodoistImportModel, + options: PlanImportOptions, +): ImportPlan => { + const titles = buildProjectTitles(model); + const tagTitles = new Set(); + const projects: ProjectImportPlan[] = []; + const tasksByProject = groupTasksByProject(model); + + for (const project of model.projects) { + if ( + options.selectedProjectExtIds && + !options.selectedProjectExtIds.has(project.extId) + ) { + continue; + } + const tasks = tasksByProject.get(project.extId) || []; + const operations: BatchOperation[] = tasks.map((t) => ({ + type: 'create', + tempId: tempId(t.extId), + data: { + title: t.title, + notes: t.notes || undefined, + parentId: t.parentExtId ? tempId(t.parentExtId) : undefined, + timeEstimate: t.timeEstimate ?? undefined, + }, + })); + + const batchChunks: BatchOperation[][] = []; + for (let i = 0; i < operations.length; i += BATCH_CHUNK_SIZE) { + batchChunks.push(operations.slice(i, i + BATCH_CHUNK_SIZE)); + } + + const followUps: TaskFollowUp[] = []; + for (const t of tasks) { + const followUp: TaskFollowUp = { tempId: tempId(t.extId) }; + if (t.dueDay) { + followUp.dueDay = t.dueDay; + } else if (t.dueWithTime) { + followUp.dueWithTime = t.dueWithTime; + } + const titlesForTask = taskTagTitles(t, options.priorityMapping); + if (titlesForTask.length) { + followUp.tagTitles = titlesForTask; + titlesForTask.forEach((title) => tagTitles.add(title)); + } + if (followUp.dueDay || followUp.dueWithTime || followUp.tagTitles) { + followUps.push(followUp); + } + } + + projects.push({ + extId: project.extId, + title: titles.get(project.extId) as string, + taskCount: tasks.filter((t) => !t.parentExtId).length, + subTaskCount: tasks.filter((t) => !!t.parentExtId).length, + batchChunks, + followUps, + }); + } + + return { projects, tagTitles: [...tagTitles] }; +}; diff --git a/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts b/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts new file mode 100644 index 0000000000..b58ce89013 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/run-import.spec.ts @@ -0,0 +1,234 @@ +import { + BatchTaskCreate, + BatchUpdateRequest, + Tag, + Task, +} from '@super-productivity/plugin-api'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { planImport } from './plan-import'; +import { runImport } from './run-import'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 't1', + projectExtId: 'p1', + parentExtId: null, + title: 'task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (tasks: TodoistTask[]): TodoistImportModel => ({ + projects: [ + { extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 }, + ], + sections: [], + tasks, +}); + +/** In-memory fake of the PluginAPI subset the executor uses. */ +const createFakeApi = ( + opts: { + existingTags?: Partial[]; + failNthBatch?: number; + failNthTag?: number; + failGetTasks?: boolean; + } = {}, +): { + api: Parameters[0]; + sentBatches: BatchUpdateRequest[]; + updates: { taskId: string; changes: Partial }[]; + createdTags: string[]; +} => { + const sentBatches: BatchUpdateRequest[] = []; + const updates: { taskId: string; changes: Partial }[] = []; + const createdTags: string[] = []; + const createdTasks: Task[] = []; + let idCounter = 0; + let batchCounter = 0; + let tagCounter = 0; + + const api: Parameters[0] = { + getAllTags: async () => (opts.existingTags || []) as Tag[], + addTag: async (tagData) => { + tagCounter++; + if (opts.failNthTag === tagCounter) { + throw new Error('tag failed'); + } + createdTags.push(tagData.title as string); + return `tag-${createdTags.length}`; + }, + addProject: async () => `project-${++idCounter}`, + batchUpdateForProject: async (request) => { + batchCounter++; + if (opts.failNthBatch === batchCounter) { + throw new Error('batch failed'); + } + sentBatches.push(request); + const createdTaskIds: Record = {}; + for (const op of request.operations) { + if (op.type === 'create') { + const realId = `real-${op.tempId}`; + createdTaskIds[op.tempId] = realId; + const parentId = op.data.parentId || null; + createdTasks.push({ + id: realId, + title: op.data.title, + projectId: request.projectId, + // mirror the host: an unresolved temp- parent would orphan-delete + parentId: parentId && parentId.startsWith('temp-') ? undefined : parentId, + tagIds: [], + subTaskIds: [], + timeEstimate: 0, + timeSpent: 0, + isDone: false, + created: 1, + } as Task); + } + } + return { success: true, createdTaskIds }; + }, + updateTask: async (taskId, changes) => { + updates.push({ taskId, changes }); + }, + getTasks: async () => { + if (opts.failGetTasks) { + throw new Error('getTasks failed'); + } + return createdTasks; + }, + }; + return { api, sentBatches, updates, createdTags }; +}; + +describe('runImport', () => { + it('rewrites temp- parent refs to real IDs when a family straddles a chunk boundary', async () => { + // 49 filler roots + 1 root at index 49 whose 3 subtasks land in chunk 2 + const tasks: TodoistTask[] = []; + for (let i = 0; i < 49; i++) { + tasks.push(task({ extId: `filler-${i}` })); + } + tasks.push(task({ extId: 'family-root' })); + for (let i = 0; i < 3; i++) { + tasks.push(task({ extId: `family-sub-${i}`, parentExtId: 'family-root' })); + } + const plan = planImport(model(tasks), { priorityMapping: 'none' }); + expect(plan.projects[0].batchChunks.length).toBe(2); + + const { api, sentBatches } = createFakeApi(); + const result = await runImport(api, plan, () => {}); + + // the second SENT chunk must reference the parent's REAL id, not temp- + const secondChunkCreates = sentBatches[1].operations as BatchTaskCreate[]; + for (const op of secondChunkCreates) { + expect(op.data.parentId).toBe('real-temp-family-root'); + } + // and nothing was lost: 53 planned, 53 landed + expect(result.imported[0].landedTaskCount).toBe(50); + expect(result.imported[0].landedSubTaskCount).toBe(3); + expect(result.errorMessage).toBeNull(); + }); + + it('keeps temp- parent refs within the same chunk (bridge resolves those)', async () => { + const plan = planImport( + model([task({ extId: 'a' }), task({ extId: 'b', parentExtId: 'a' })]), + { priorityMapping: 'none' }, + ); + const { api, sentBatches } = createFakeApi(); + await runImport(api, plan, () => {}); + const ops = sentBatches[0].operations as BatchTaskCreate[]; + expect(ops[1].data.parentId).toBe('temp-a'); + }); + + it('never maps a label onto the virtual TODAY tag', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['Today'] })]), { + priorityMapping: 'none', + }); + const { api, updates, createdTags } = createFakeApi({ + existingTags: [{ id: 'TODAY', title: 'Today' }], + }); + await runImport(api, plan, () => {}); + expect(createdTags).toEqual(['Today']); + expect(updates[0].changes.tagIds).toEqual(['tag-1']); + }); + + it('reuses existing tags by title, case-insensitively', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['Errand'] })]), { + priorityMapping: 'none', + }); + const { api, updates, createdTags } = createFakeApi({ + existingTags: [{ id: 'tag-existing', title: 'errand' }], + }); + await runImport(api, plan, () => {}); + expect(createdTags).toEqual([]); + expect(updates[0].changes.tagIds).toEqual(['tag-existing']); + }); + + it('reports tags created before a later tag creation fails', async () => { + const plan = planImport(model([task({ extId: 'a', labels: ['first', 'second'] })]), { + priorityMapping: 'none', + }); + const { api } = createFakeApi({ failNthTag: 2 }); + + const result = await runImport(api, plan, () => {}); + + expect(result.createdTagTitles).toEqual(['first']); + expect(result.errorMessage).toBe('tag failed'); + }); + + it('records the failed project as partial and keeps earlier projects', async () => { + const m: TodoistImportModel = { + projects: [ + { extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 }, + { extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 }, + ], + sections: [], + tasks: [ + task({ extId: 'a', projectExtId: 'p1' }), + task({ extId: 'b', projectExtId: 'p2' }), + ], + }; + const plan = planImport(m, { priorityMapping: 'none' }); + const { api } = createFakeApi({ failNthBatch: 2 }); + const result = await runImport(api, plan, () => {}); + expect(result.imported.map((p) => p.title)).toEqual(['A']); + expect(result.failedProjectTitle).toBe('B'); + expect(result.errorMessage).toBe('batch failed'); + }); + + it('does not let a failed recount mask a successful import', async () => { + const plan = planImport(model([task({ extId: 'a' })]), { + priorityMapping: 'none', + }); + const { api } = createFakeApi({ failGetTasks: true }); + const result = await runImport(api, plan, () => {}); + expect(result.errorMessage).toBeNull(); + expect(result.isCountUnverified).toBe(true); + expect(result.imported.length).toBe(1); + }); + + it('reports detail progress during follow-ups', async () => { + const tasks: TodoistTask[] = []; + for (let i = 0; i < 30; i++) { + tasks.push(task({ extId: `t-${i}`, dueDay: '2026-07-15' })); + } + const plan = planImport(model(tasks), { priorityMapping: 'none' }); + const { api } = createFakeApi(); + const detailReports: (number | undefined)[] = []; + await runImport(api, plan, (p) => { + if (p.phase === 'details') { + detailReports.push(p.detailIndex); + } + }); + expect(detailReports).toEqual([0, 25]); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/map/run-import.ts b/packages/plugin-dev/todoist-import/src/map/run-import.ts new file mode 100644 index 0000000000..6e1c058cc1 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/map/run-import.ts @@ -0,0 +1,225 @@ +import { BatchOperation, PluginAPI, Task } from '@super-productivity/plugin-api'; +import { ImportPlan, ProjectImportPlan } from './plan-import'; + +/** SP's virtual Today tag — must never land in task.tagIds (sync rule #5). */ +const TODAY_TAG_ID = 'TODAY'; + +/** Report follow-up progress in steps of this many tasks. */ +const DETAIL_PROGRESS_STEP = 25; + +export interface ImportProgress { + projectTitle: string; + projectIndex: number; + totalProjects: number; + phase: 'project' | 'tasks' | 'details'; + /** follow-up progress within the 'details' phase */ + detailIndex?: number; + detailTotal?: number; +} + +export interface ImportedProjectResult { + title: string; + projectId: string; + /** planned counts, for an honest landed-vs-planned comparison */ + plannedTaskCount: number; + plannedSubTaskCount: number; + /** counted from re-read state — the batch API is fire-and-forget */ + landedTaskCount: number; + landedSubTaskCount: number; +} + +export interface ImportResult { + imported: ImportedProjectResult[]; + createdTagTitles: string[]; + /** set when the import aborted mid-way; that project exists PARTIALLY — + * the user should delete it before re-running */ + failedProjectTitle: string | null; + errorMessage: string | null; + /** the post-import recount failed; landed counts are unknown, not zero */ + isCountUnverified: boolean; +} + +type ImportApi = Pick< + PluginAPI, + | 'getAllTags' + | 'addTag' + | 'addProject' + | 'batchUpdateForProject' + | 'updateTask' + | 'getTasks' +>; + +const ensureTags = async ( + api: ImportApi, + tagTitles: string[], + createdTagTitles: string[], +): Promise> => { + const tagIdByTitle = new Map(); + const existing = await api.getAllTags(); + for (const tag of existing) { + // never map a label onto the virtual Today tag — a Todoist label named + // "Today" gets a real tag of its own instead + if (tag.id === TODAY_TAG_ID) { + continue; + } + tagIdByTitle.set(tag.title.toLowerCase(), tag.id); + } + for (const title of tagTitles) { + const key = title.toLowerCase(); + if (!tagIdByTitle.has(key)) { + tagIdByTitle.set(key, await api.addTag({ title })); + createdTagTitles.push(title); + } + } + return tagIdByTitle; +}; + +/** + * The bridge builds its temp-ID map PER CALL — a chunk sent in a later call + * cannot resolve a `temp-` parent created by an earlier call (the reducer + * would store the dangling ref and the consistency pass would orphan-DELETE + * the child). Real task IDs are supported in `parentId`, so rewrite every + * already-known temp parent to its real ID before sending. + */ +const resolveKnownParents = ( + chunk: BatchOperation[], + idByTempId: Record, +): BatchOperation[] => + chunk.map((op) => + op.type === 'create' && op.data.parentId && idByTempId[op.data.parentId] + ? { ...op, data: { ...op.data, parentId: idByTempId[op.data.parentId] } } + : op, + ); + +const importProject = async ( + api: ImportApi, + projectPlan: ProjectImportPlan, + tagIdByTitle: Map, + onProgress: ( + detail: Partial & { phase: ImportProgress['phase'] }, + ) => void, +): Promise => { + onProgress({ phase: 'project' }); + const projectId = await api.addProject({ title: projectPlan.title }); + + onProgress({ phase: 'tasks' }); + const idByTempId: Record = {}; + for (const chunk of projectPlan.batchChunks) { + // sequential awaits: one dispatched action per tick (sync rule #6) + const result = await api.batchUpdateForProject({ + projectId, + operations: resolveKnownParents(chunk, idByTempId), + }); + Object.assign(idByTempId, result.createdTaskIds); + } + + const followUps = projectPlan.followUps; + for (let i = 0; i < followUps.length; i++) { + if (i % DETAIL_PROGRESS_STEP === 0) { + onProgress({ phase: 'details', detailIndex: i, detailTotal: followUps.length }); + } + const followUp = followUps[i]; + const taskId = idByTempId[followUp.tempId]; + if (!taskId) { + continue; + } + const updates: Partial = {}; + if (followUp.dueDay) { + updates.dueDay = followUp.dueDay; + } else if (followUp.dueWithTime) { + updates.dueWithTime = followUp.dueWithTime; + } + if (followUp.tagTitles?.length) { + const tagIds = followUp.tagTitles + .map((t) => tagIdByTitle.get(t.toLowerCase())) + .filter((id): id is string => !!id); + if (tagIds.length) { + updates.tagIds = tagIds; + } + } + if (Object.keys(updates).length) { + await api.updateTask(taskId, updates); + } + } + return projectId; +}; + +const countLanded = async (api: ImportApi, result: ImportResult): Promise => { + const allTasks = await api.getTasks(); + const rootsByProject = new Map(); + const subsByProject = new Map(); + for (const task of allTasks) { + if (!task.projectId) { + continue; + } + const target = task.parentId ? subsByProject : rootsByProject; + target.set(task.projectId, (target.get(task.projectId) || 0) + 1); + } + for (const imported of result.imported) { + imported.landedTaskCount = rootsByProject.get(imported.projectId) || 0; + imported.landedSubTaskCount = subsByProject.get(imported.projectId) || 0; + } +}; + +/** + * Executes the plan project-by-project so an abort leaves at most one partial + * project (named in the result), and counts what actually landed by re-reading + * state (`batchUpdateForProject` always reports success and silently skips + * invalid operations). + */ +export const runImport = async ( + api: ImportApi, + plan: ImportPlan, + onProgress: (progress: ImportProgress) => void, +): Promise => { + const result: ImportResult = { + imported: [], + createdTagTitles: [], + failedProjectTitle: null, + errorMessage: null, + isCountUnverified: false, + }; + + try { + const tagIdByTitle = await ensureTags(api, plan.tagTitles, result.createdTagTitles); + + for (let i = 0; i < plan.projects.length; i++) { + const projectPlan = plan.projects[i]; + const report: Parameters[3] = (detail) => + onProgress({ + projectTitle: projectPlan.title, + projectIndex: i, + totalProjects: plan.projects.length, + ...detail, + }); + try { + const projectId = await importProject(api, projectPlan, tagIdByTitle, report); + result.imported.push({ + title: projectPlan.title, + projectId, + plannedTaskCount: projectPlan.taskCount, + plannedSubTaskCount: projectPlan.subTaskCount, + landedTaskCount: 0, + landedSubTaskCount: 0, + }); + } catch (e) { + result.failedProjectTitle = projectPlan.title; + result.errorMessage = e instanceof Error ? e.message : String(e); + break; + } + } + } catch (e) { + result.errorMessage = e instanceof Error ? e.message : String(e); + } + + if (result.imported.length) { + // a failed recount must not mask a successful import (or the real error) + try { + await countLanded(api, result); + } catch { + result.isCountUnverified = true; + } + } + + return result; +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts new file mode 100644 index 0000000000..1c5ea36cfb --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts @@ -0,0 +1,463 @@ +import { mergeSyncResponses, parseSyncResponse, RawSyncResponse } from './from-api'; + +const baseFixture = (): RawSyncResponse => ({ + projects: [ + { id: 'p1', name: 'Work', child_order: 1 }, + { id: 'p2', name: 'Home', child_order: 2 }, + ], + items: [], + sections: [], + notes: [], +}); + +describe('mergeSyncResponses', () => { + it('applies incremental additions, replacements, and tombstones to a full sync', () => { + const full: RawSyncResponse = { + sync_token: 'full-token', + projects: [ + { id: 'p1', name: 'Old name' }, + { id: 'p2', name: 'Deleted later' }, + { id: 'p4', name: 'Unchanged project' }, + ], + items: [{ id: 't1', project_id: 'p1', content: 'Old task title' }], + sections: [], + notes: [], + }; + const incremental: RawSyncResponse = { + sync_token: 'incremental-token', + projects: [ + { id: 'p1', name: 'New name' }, + { id: 'p2', is_deleted: true }, + { id: 'p3', name: 'New project' }, + ], + items: [{ id: 't1', project_id: 'p1', content: 'New task title' }], + }; + + const merged = mergeSyncResponses(full, incremental); + + expect(merged.sync_token).toBe('incremental-token'); + expect(merged.projects).toEqual([ + ...incremental.projects!, + { id: 'p4', name: 'Unchanged project' }, + ]); + expect(merged.items).toEqual(incremental.items); + expect(parseSyncResponse(merged).projects.map((project) => project.extId)).toEqual([ + 'p1', + 'p3', + 'p4', + ]); + expect(parseSyncResponse(merged).tasks[0].title).toBe('New task title'); + }); +}); + +describe('parseSyncResponse', () => { + it('skips archived and deleted projects and their items', () => { + const raw = baseFixture(); + raw.projects!.push( + { id: 'p3', name: 'Old', is_archived: true }, + { id: 'p4', name: 'Gone', is_deleted: 1 }, + ); + raw.items = [ + { id: 't1', project_id: 'p3', content: 'in archived' }, + { id: 't2', project_id: 'p4', content: 'in deleted' }, + { id: 't3', project_id: 'p1', content: 'kept' }, + ]; + const model = parseSyncResponse(raw); + expect(model.projects.map((p) => p.extId)).toEqual(['p1', 'p2']); + expect(model.tasks.map((t) => t.extId)).toEqual(['t3']); + }); + + it('skips completed and deleted items', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'done', checked: true }, + { id: 't2', project_id: 'p1', content: 'deleted', is_deleted: true }, + { id: 't3', project_id: 'p1', content: 'open' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual(['t3']); + }); + + it('marks the inbox project', () => { + const raw = baseFixture(); + raw.projects!.unshift({ + id: 'p0', + name: 'Inbox', + inbox_project: true, + child_order: 0, + }); + const model = parseSyncResponse(raw); + expect(model.projects[0]).toEqual( + expect.objectContaining({ extId: 'p0', isInbox: true }), + ); + }); + + describe('due dates', () => { + it('parses all-day dates as dueDay', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-07-15' } }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-07-15'); + expect(task.dueWithTime).toBeNull(); + }); + + it('parses floating datetimes as local time', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15T09:30:00' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBeNull(); + expect(task.dueWithTime).toBe(new Date(2026, 6, 15, 9, 30, 0).getTime()); + }); + + it('parses fixed-timezone datetimes (trailing Z) as UTC instants', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15T09:30:00Z', timezone: 'Europe/Berlin' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueWithTime).toBe(Date.UTC(2026, 6, 15, 9, 30, 0)); + }); + + it('uses the deadline as dueDay when there is no due date', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', deadline: { date: '2026-08-01' } }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-08-01'); + expect(task.notes).not.toContain('Deadline:'); + }); + + it('keeps the deadline as a notes line when a due date exists', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + due: { date: '2026-07-15' }, + deadline: { date: '2026-08-01' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.dueDay).toBe('2026-07-15'); + expect(task.notes).toContain('Deadline: 2026-08-01'); + }); + }); + + describe('recurring', () => { + it('flags recurring tasks and appends the verbatim rule to notes', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'water plants', + description: 'living room only', + due: { date: '2026-07-15', is_recurring: true, string: 'every! 3 days' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.isRecurring).toBe(true); + expect(task.notes).toBe('living room only\n\nRepeats: every! 3 days'); + }); + }); + + describe('durations', () => { + it('converts minute durations to a ms time estimate', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: 45, unit: 'minute' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.timeEstimate).toBe(45 * 60_000); + expect(task.isDayDurationSkipped).toBe(false); + }); + + it('skips day durations and flags them', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: 1, unit: 'day' }, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.timeEstimate).toBeNull(); + expect(task.isDayDurationSkipped).toBe(true); + }); + }); + + describe('nesting', () => { + it('keeps level-1 sub-tasks under their parent', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'root', project_id: 'p1', content: 'root', child_order: 1 }, + { id: 'sub', project_id: 'p1', content: 'sub', parent_id: 'root' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([ + ['root', null, false], + ['sub', 'root', false], + ]); + }); + + it('re-parents depth ≥ 2 to the root ancestor in DFS order and flags demotion', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'a', project_id: 'p1', content: 'a', child_order: 1 }, + { id: 'b', project_id: 'p1', content: 'b', parent_id: 'a', child_order: 1 }, + { id: 'c', project_id: 'p1', content: 'c', parent_id: 'b', child_order: 1 }, + { id: 'd', project_id: 'p1', content: 'd', parent_id: 'c', child_order: 1 }, + { id: 'b2', project_id: 'p1', content: 'b2', parent_id: 'a', child_order: 2 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([ + ['a', null, false], + ['b', 'a', false], + ['c', 'a', true], + ['d', 'a', true], + ['b2', 'a', false], + ]); + }); + + it('treats items with a missing parent as roots', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'orphan', project_id: 'p1', content: 'x', parent_id: 'completed-parent' }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks[0].parentExtId).toBeNull(); + }); + }); + + describe('ordering', () => { + it('orders roots by section order then child order; section-less first', () => { + const raw = baseFixture(); + raw.sections = [ + { id: 's2', project_id: 'p1', name: 'Later', section_order: 2 }, + { id: 's1', project_id: 'p1', name: 'Now', section_order: 1 }, + ]; + raw.items = [ + { id: 'in-s2', project_id: 'p1', section_id: 's2', content: 'x', child_order: 1 }, + { id: 'no-sec-2', project_id: 'p1', content: 'x', child_order: 2 }, + { id: 'in-s1', project_id: 'p1', section_id: 's1', content: 'x', child_order: 1 }, + { id: 'no-sec-1', project_id: 'p1', content: 'x', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual([ + 'no-sec-1', + 'no-sec-2', + 'in-s1', + 'in-s2', + ]); + }); + + it('groups tasks by project in project order', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'h1', project_id: 'p2', content: 'x', child_order: 1 }, + { id: 'w1', project_id: 'p1', content: 'x', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.tasks.map((t) => t.extId)).toEqual(['w1', 'h1']); + }); + }); + + describe('comments', () => { + it('appends comments to notes and counts attachments', () => { + const raw = baseFixture(); + raw.items = [{ id: 't1', project_id: 'p1', content: 'a', description: 'desc' }]; + raw.notes = [ + { id: 'n1', item_id: 't1', content: 'first comment' }, + { + id: 'n2', + item_id: 't1', + content: 'see file', + file_attachment: { file_name: 'report.pdf', file_url: 'https://x/report.pdf' }, + }, + { id: 'n3', item_id: 't1', content: 'deleted', is_deleted: true }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.notes).toBe( + 'desc\n\nComments:\n- first comment\n- see file report.pdf: https://x/report.pdf', + ); + expect(task.attachmentCount).toBe(1); + }); + + it('uses caller-provided strings for text added to imported notes', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: '', + due: { date: '2026-07-15', is_recurring: true, string: 'every day' }, + deadline: { date: '2026-08-01' }, + }, + ]; + raw.notes = [ + { + id: 'n1', + item_id: 't1', + content: 'attachment', + file_attachment: { file_url: 'https://x/file' }, + }, + ]; + + const [taskParsed] = parseSyncResponse(raw, { + untitledProject: 'Projekt ohne Titel', + untitledTask: 'Aufgabe ohne Titel', + repeats: (rule) => `Wiederholt: ${rule}`, + deadline: (date) => `Frist: ${date}`, + comments: 'Kommentare:', + file: 'Datei', + }).tasks; + + expect(taskParsed.title).toBe('Aufgabe ohne Titel'); + expect(taskParsed.notes).toBe( + 'Wiederholt: every day\nFrist: 2026-08-01\n\nKommentare:\n- attachment Datei: https://x/file', + ); + }); + }); + + describe('hostile / malformed payloads', () => { + it('rejects shape-valid but impossible calendar dates', () => { + const raw = baseFixture(); + raw.items = [ + { id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-99-99' } }, + { id: 't2', project_id: 'p1', content: 'b', deadline: { date: '0000-00-00' } }, + ]; + const [t1, t2] = parseSyncResponse(raw).tasks; + expect(t1.dueDay).toBeNull(); + expect(t2.dueDay).toBeNull(); + }); + + it('treats a parent in another project as missing (child becomes root)', () => { + const raw = baseFixture(); + raw.items = [ + { id: 'other', project_id: 'p2', content: 'x' }, + { id: 'child', project_id: 'p1', content: 'y', parent_id: 'other' }, + ]; + const child = parseSyncResponse(raw).tasks.find((t) => t.extId === 'child'); + expect(child?.parentExtId).toBeNull(); + }); + + it('drops unsafe or malformed attachment URLs from notes', () => { + const raw = baseFixture(); + raw.items = [{ id: 't1', project_id: 'p1', content: 'a' }]; + raw.notes = [ + { + id: 'n1', + item_id: 't1', + content: 'evil', + file_attachment: { file_name: 'x', file_url: 'javascript:alert(1)' }, + }, + { + id: 'n2', + item_id: 't1', + content: 'malformed', + file_attachment: { + file_name: 'x', + file_url: 'https://files.example/x\n[bad](javascript:alert(1))', + }, + }, + ]; + const [taskParsed] = parseSyncResponse(raw).tasks; + expect(taskParsed.notes).toBe('Comments:\n- evil\n- malformed'); + expect(taskParsed.attachmentCount).toBe(0); + }); + + it('rejects non-finite or absurd durations', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + duration: { amount: Infinity, unit: 'minute' }, + }, + { + id: 't2', + project_id: 'p1', + content: 'b', + duration: { amount: 9_999_999, unit: 'minute' }, + }, + ]; + const [t1, t2] = parseSyncResponse(raw).tasks; + expect(t1.timeEstimate).toBeNull(); + expect(t2.timeEstimate).toBeNull(); + }); + + it('clamps oversized titles and notes', () => { + const raw = baseFixture(); + raw.projects![0].name = 'p'.repeat(5000); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'x'.repeat(5000), + description: 'y'.repeat(100_000), + }, + ]; + const parsed = parseSyncResponse(raw); + const [taskParsed] = parsed.tasks; + expect(parsed.projects[0].truncatedFieldCount).toBe(1); + expect(taskParsed.title.length).toBeLessThanOrEqual(1001); + expect(taskParsed.notes.length).toBeLessThanOrEqual(50_001); + expect(taskParsed.truncatedFieldCount).toBe(2); + }); + }); + + it('orders nested projects parent-first (children directly after their parent)', () => { + const raw = baseFixture(); + // child_order is per-parent in Todoist: a flat sort would interleave + raw.projects = [ + { id: 'a', name: 'A', child_order: 1 }, + { id: 'b', name: 'B', child_order: 2 }, + { id: 'a1', name: 'A1', parent_id: 'a', child_order: 1 }, + { id: 'b1', name: 'B1', parent_id: 'b', child_order: 1 }, + ]; + const model = parseSyncResponse(raw); + expect(model.projects.map((p) => p.extId)).toEqual(['a', 'a1', 'b', 'b1']); + }); + + it('captures labels and assignee flag', () => { + const raw = baseFixture(); + raw.items = [ + { + id: 't1', + project_id: 'p1', + content: 'a', + labels: ['errand', 'urgent'], + responsible_uid: 'user-2', + priority: 4, + }, + ]; + const [task] = parseSyncResponse(raw).tasks; + expect(task.labels).toEqual(['errand', 'urgent']); + expect(task.hasAssignee).toBe(true); + expect(task.apiPriority).toBe(4); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.ts new file mode 100644 index 0000000000..7d70b4346a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.ts @@ -0,0 +1,482 @@ +import { + TodoistImportModel, + TodoistProject, + TodoistSection, + TodoistTask, +} from './normalized-model'; + +/** + * Raw shapes from `POST https://api.todoist.com/api/v1/sync` (unified v1). + * Only the fields we read; everything is optional so unknown/missing fields + * from API drift degrade gracefully instead of crashing the import. + */ +export interface RawSyncResponse { + sync_token?: string; + full_sync?: boolean; + full_sync_date_utc?: string; + projects?: RawProject[]; + items?: RawItem[]; + sections?: RawSection[]; + notes?: RawNote[]; +} + +interface RawProject { + id?: string | number; + name?: string; + parent_id?: string | number | null; + child_order?: number; + inbox_project?: boolean; + is_archived?: boolean | number; + is_deleted?: boolean | number; +} + +interface RawSection { + id?: string | number; + project_id?: string | number; + name?: string; + section_order?: number; + is_deleted?: boolean | number; + is_archived?: boolean | number; +} + +interface RawDue { + /** YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS (floating/local) | …Z (fixed, UTC instant) */ + date?: string; + timezone?: string | null; + is_recurring?: boolean; + string?: string; +} + +interface RawItem { + id?: string | number; + project_id?: string | number; + section_id?: string | number | null; + parent_id?: string | number | null; + content?: string; + description?: string; + /** 4 = UI p1 (highest) … 1 = default */ + priority?: number; + labels?: string[]; + due?: RawDue | null; + deadline?: { date?: string } | null; + duration?: { amount?: number; unit?: string } | null; + checked?: boolean | number; + is_deleted?: boolean | number; + child_order?: number; + responsible_uid?: string | number | null; +} + +interface RawNote { + id?: string | number; + item_id?: string | number; + content?: string; + is_deleted?: boolean | number; + file_attachment?: { file_name?: string; file_url?: string } | null; +} + +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +// Everything below lands in synced state that every client must replay — +// clamp remote-controlled sizes and reject nonsense dates instead of +// trusting the payload (shared-project collaborators control much of it). +const MAX_TITLE_LEN = 1000; +const MAX_LABEL_LEN = 200; +const MAX_NOTES_LEN = 50_000; +const MIN_DUE_MS = 0; // 1970 +const MAX_DUE_MS = 32_503_680_000_000; // year 3000 + +export interface ParseStrings { + untitledProject: string; + untitledTask: string; + repeats: (rule: string) => string; + deadline: (date: string) => string; + comments: string; + file: string; +} + +const DEFAULT_PARSE_STRINGS: ParseStrings = { + untitledProject: 'Untitled project', + untitledTask: 'Untitled task', + repeats: (rule) => `Repeats: ${rule}`, + deadline: (date) => `Deadline: ${date}`, + comments: 'Comments:', + file: 'file', +}; + +const asId = (v: string | number | null | undefined): string | null => + v === null || v === undefined || v === '' ? null : String(v); + +const mergeResources = ( + full: T[] | undefined, + incremental: T[] | undefined, +): T[] | undefined => { + if (!incremental?.length) { + return full; + } + const changedIds = new Set( + incremental.map((resource) => asId(resource.id)).filter((id): id is string => !!id), + ); + return [ + ...incremental, + ...(full || []).filter((resource) => { + const id = asId(resource.id); + return !id || !changedIds.has(id); + }), + ]; +}; + +/** + * Apply the delta returned by a Todoist incremental sync to its initial full + * snapshot. Incremental tombstones intentionally replace the old resource so + * the normal parser filters deletions instead of resurrecting stale data. + */ +export const mergeSyncResponses = ( + full: RawSyncResponse, + incremental: RawSyncResponse, +): RawSyncResponse => ({ + ...full, + ...incremental, + projects: mergeResources(full.projects, incremental.projects), + items: mergeResources(full.items, incremental.items), + sections: mergeResources(full.sections, incremental.sections), + notes: mergeResources(full.notes, incremental.notes), +}); + +const asStr = (v: unknown): string => (typeof v === 'string' ? v : ''); + +const isSupportedAttachmentUrl = (url: unknown): url is string => { + if (typeof url !== 'string' || /[\s\u0000-\u001f\u007f]/u.test(url)) { + return false; + } + try { + const protocol = new URL(url).protocol; + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const isTruthyFlag = (v: boolean | number | undefined): boolean => !!v; + +const clamp = (s: string, maxLen: number): string => + s.length > maxLen ? `${s.slice(0, maxLen)}…` : s; + +/** Shape AND calendar validity (rejects e.g. 2026-99-99). */ +const isValidDayStr = (s: unknown): s is string => { + if (typeof s !== 'string' || !DATE_ONLY_RE.test(s)) { + return false; + } + const d = new Date(`${s}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s; +}; + +/** + * `new Date('YYYY-MM-DDTHH:MM:SS')` is local time per spec; a trailing `Z` + * makes it a UTC instant — exactly Todoist's floating vs fixed semantics. + */ +const parseDue = ( + due: RawDue | null | undefined, +): { dueDay: string | null; dueWithTime: number | null } => { + const date = due?.date; + if (!date || typeof date !== 'string') { + return { dueDay: null, dueWithTime: null }; + } + if (DATE_ONLY_RE.test(date)) { + return isValidDayStr(date) + ? { dueDay: date, dueWithTime: null } + : { dueDay: null, dueWithTime: null }; + } + const ts = new Date(date).getTime(); + return Number.isNaN(ts) || ts < MIN_DUE_MS || ts > MAX_DUE_MS + ? { dueDay: null, dueWithTime: null } + : { dueDay: null, dueWithTime: ts }; +}; + +const buildNotes = ( + item: RawItem, + comments: RawNote[], + deadlineNoted: string | null, + strings: ParseStrings, +): { notes: string; truncatedFieldCount: number } => { + const parts: string[] = []; + const description = asStr(item.description).trim(); + if (description) { + parts.push(description); + } + const extras: string[] = []; + if (item.due?.is_recurring && asStr(item.due.string)) { + extras.push(strings.repeats(asStr(item.due.string))); + } + if (deadlineNoted) { + extras.push(strings.deadline(deadlineNoted)); + } + if (extras.length) { + parts.push(extras.join('\n')); + } + if (comments.length) { + const lines = comments.map((c) => { + const content = asStr(c.content).trim(); + const att = c.file_attachment; + // scheme-filter remote URLs before they land in markdown-rendered notes + const url = asStr(att?.file_url); + const attLine = isSupportedAttachmentUrl(url) + ? ` ${asStr(att?.file_name) || strings.file}: ${url}` + : ''; + return `- ${content}${attLine}`.trimEnd(); + }); + parts.push(`${strings.comments}\n${lines.join('\n')}`); + } + const notes = parts.join('\n\n'); + return { + notes: clamp(notes, MAX_NOTES_LEN), + truncatedFieldCount: notes.length > MAX_NOTES_LEN ? 1 : 0, + }; +}; + +/** + * Sync payload → normalized model. Pure; safe to unit-test with fixtures. + * + * - completed (`checked`) and deleted items are skipped, as are archived / + * deleted projects (and their items), + * - the item tree is flattened to SP's 2 levels: any task deeper than one + * level is re-parented to its root ancestor in DFS (reading) order, + * - items whose parent is missing (completed/deleted) are treated as roots. + */ +export const parseSyncResponse = ( + raw: RawSyncResponse, + strings: ParseStrings = DEFAULT_PARSE_STRINGS, +): TodoistImportModel => { + const projects: TodoistProject[] = []; + const keptProjectIds = new Set(); + + for (const p of raw.projects || []) { + const extId = asId(p.id); + if (!extId || isTruthyFlag(p.is_archived) || isTruthyFlag(p.is_deleted)) { + continue; + } + const title = asStr(p.name).trim(); + keptProjectIds.add(extId); + projects.push({ + extId, + title: clamp(title, MAX_TITLE_LEN) || strings.untitledProject, + parentExtId: asId(p.parent_id), + isInbox: !!p.inbox_project, + childOrder: p.child_order ?? 0, + truncatedFieldCount: title.length > MAX_TITLE_LEN ? 1 : 0, + }); + } + // parent-first DFS: `child_order` is per-parent in Todoist, so a flat sort + // would interleave unrelated siblings; this keeps children next to their + // (flattened-away) parent in the SP sidebar + const orderedProjects: TodoistProject[] = []; + const projectChildren = new Map(); + for (const p of projects) { + const parentKey = + p.parentExtId && keptProjectIds.has(p.parentExtId) ? p.parentExtId : null; + const list = projectChildren.get(parentKey) || []; + list.push(p); + projectChildren.set(parentKey, list); + } + projectChildren.forEach((list) => list.sort((a, b) => a.childOrder - b.childOrder)); + const visitedProjects = new Set(); + const visitProject = (p: TodoistProject): void => { + if (visitedProjects.has(p.extId)) { + return; + } + visitedProjects.add(p.extId); + orderedProjects.push(p); + (projectChildren.get(p.extId) || []).forEach(visitProject); + }; + (projectChildren.get(null) || []).forEach(visitProject); + // a parent-cycle in a hostile payload would skip its members entirely — + // append anything unvisited so no project is silently lost + for (const p of projects) { + visitProject(p); + } + + const sections: TodoistSection[] = []; + const sectionOrderById = new Map(); + for (const s of raw.sections || []) { + const extId = asId(s.id); + const projectExtId = asId(s.project_id); + if ( + !extId || + !projectExtId || + !keptProjectIds.has(projectExtId) || + isTruthyFlag(s.is_deleted) || + isTruthyFlag(s.is_archived) + ) { + continue; + } + sectionOrderById.set(extId, s.section_order ?? 0); + sections.push({ extId, projectExtId, title: asStr(s.name).trim() }); + } + + const commentsByItemId = new Map(); + for (const n of raw.notes || []) { + const itemId = asId(n.item_id); + if (!itemId || isTruthyFlag(n.is_deleted)) { + continue; + } + const list = commentsByItemId.get(itemId) || []; + list.push(n); + commentsByItemId.set(itemId, list); + } + + const keptItems: RawItem[] = []; + const keptItemIds = new Set(); + for (const item of raw.items || []) { + const extId = asId(item.id); + const projectExtId = asId(item.project_id); + if ( + !extId || + !projectExtId || + !keptProjectIds.has(projectExtId) || + isTruthyFlag(item.checked) || + isTruthyFlag(item.is_deleted) + ) { + continue; + } + keptItems.push(item); + keptItemIds.add(extId); + } + + // parent missing (completed/deleted/filtered) OR in another project (a + // shape Todoist shouldn't produce — its temp ID could never resolve in this + // project's batch) → treat as root + const projectByItemId = new Map( + keptItems.map((i) => [asId(i.id) as string, asId(i.project_id) as string]), + ); + const childrenByParent = new Map(); + const roots: RawItem[] = []; + for (const item of keptItems) { + const parentId = asId(item.parent_id); + if ( + parentId && + keptItemIds.has(parentId) && + projectByItemId.get(parentId) === asId(item.project_id) + ) { + const list = childrenByParent.get(parentId) || []; + list.push(item); + childrenByParent.set(parentId, list); + } else { + roots.push(item); + } + } + childrenByParent.forEach((list) => + list.sort((a, b) => (a.child_order ?? 0) - (b.child_order ?? 0)), + ); + + const projectOrder = new Map(orderedProjects.map((p, i) => [p.extId, i])); + const rootSortKey = (item: RawItem): [number, number, number] => { + const sectionId = asId(item.section_id); + // section-less tasks come first in Todoist, like sections with order -1 + const sectionOrder = sectionId ? (sectionOrderById.get(sectionId) ?? 0) : -1; + return [ + projectOrder.get(asId(item.project_id) as string) ?? 0, + sectionOrder, + item.child_order ?? 0, + ]; + }; + roots.sort((a, b) => { + const [pa, sa, ca] = rootSortKey(a); + const [pb, sb, cb] = rootSortKey(b); + return pa - pb || sa - sb || ca - cb; + }); + + const toTask = ( + item: RawItem, + rootExtId: string | null, + depth: number, + ): TodoistTask => { + const extId = asId(item.id) as string; + const { dueDay: parsedDueDay, dueWithTime } = parseDue(item.due); + const deadlineDay = isValidDayStr(item.deadline?.date) ? item.deadline!.date! : null; + const hasDue = !!parsedDueDay || !!dueWithTime; + // deadline fills in as dueDay when there is no due date; otherwise it is + // preserved as a notes line so nothing is silently dropped + const dueDay = parsedDueDay ?? (!hasDue ? deadlineDay : null); + const deadlineNoted = hasDue && deadlineDay ? deadlineDay : null; + + const duration = item.duration; + // Number.isFinite guards a hostile `1e999` → Infinity, which would diverge + // across clients (Infinity JSON-serializes to null in the op-log) + const isMinuteDuration = + !!duration && + duration.unit === 'minute' && + Number.isFinite(duration.amount) && + (duration.amount as number) > 0 && + (duration.amount as number) <= 525_600; // 1 year in minutes + const isDayDurationSkipped = !!duration && duration.unit === 'day'; + + const title = asStr(item.content).trim(); + const labels = (item.labels || []).filter( + (label): label is string => typeof label === 'string' && label.trim() !== '', + ); + const builtNotes = buildNotes( + item, + commentsByItemId.get(extId) || [], + deadlineNoted, + strings, + ); + + return { + extId, + projectExtId: asId(item.project_id) as string, + parentExtId: rootExtId, + title: clamp(title, MAX_TITLE_LEN) || strings.untitledTask, + notes: builtNotes.notes, + labels: labels.map((label) => clamp(label, MAX_LABEL_LEN)), + apiPriority: item.priority ?? 1, + dueDay, + dueWithTime, + timeEstimate: isMinuteDuration ? (duration.amount as number) * 60_000 : null, + isRecurring: !!item.due?.is_recurring, + wasDemoted: depth >= 2, + isDayDurationSkipped, + hasAssignee: !!asId(item.responsible_uid), + attachmentCount: (commentsByItemId.get(extId) || []).filter((c) => + isSupportedAttachmentUrl(c.file_attachment?.file_url), + ).length, + truncatedFieldCount: + (title.length > MAX_TITLE_LEN ? 1 : 0) + + labels.filter((label) => label.length > MAX_LABEL_LEN).length + + builtNotes.truncatedFieldCount, + }; + }; + + const tasks: TodoistTask[] = []; + const emittedIds = new Set(); + const emitFamily = (root: RawItem): void => { + const rootExtId = asId(root.id) as string; + emittedIds.add(rootExtId); + tasks.push(toTask(root, null, 0)); + // all descendants become direct sub-tasks of the root, DFS keeps reading order + const stack = (childrenByParent.get(rootExtId) || []) + .map((item) => ({ item, depth: 1 })) + .reverse(); + while (stack.length) { + const { item, depth } = stack.pop() as { item: RawItem; depth: number }; + const itemId = asId(item.id) as string; + if (emittedIds.has(itemId)) { + continue; // parent-cycle guard in a hostile payload + } + emittedIds.add(itemId); + tasks.push(toTask(item, rootExtId, depth)); + const children = childrenByParent.get(itemId) || []; + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ item: children[i], depth: depth + 1 }); + } + } + }; + roots.forEach(emitFamily); + // members of a parent-cycle have no root — emit them as roots so nothing + // in the payload is silently dropped + for (const item of keptItems) { + if (!emittedIds.has(asId(item.id) as string)) { + emitFamily(item); + } + } + + return { projects: orderedProjects, sections, tasks }; +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts new file mode 100644 index 0000000000..9c72372289 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.spec.ts @@ -0,0 +1,53 @@ +import { PluginAPI } from '@super-productivity/plugin-api'; +import { loadTodoistData } from './load-todoist-data'; + +describe('loadTodoistData', () => { + it('follows the initial full sync with an incremental sync and merges the delta', async () => { + const request = jest + .fn() + .mockResolvedValueOnce({ + sync_token: 'full-token', + projects: [{ id: 'p1', name: 'Old name' }], + items: [], + sections: [], + notes: [], + }) + .mockResolvedValueOnce({ + sync_token: 'incremental-token', + projects: [ + { id: 'p1', name: 'New name' }, + { id: 'p2', name: 'New project' }, + ], + }); + + const result = await loadTodoistData( + { request } as unknown as Pick, + 'secret-token', + ); + + expect(request).toHaveBeenCalledTimes(2); + expect( + new URLSearchParams(request.mock.calls[0][1].body as string).get('sync_token'), + ).toBe('*'); + expect( + new URLSearchParams(request.mock.calls[1][1].body as string).get('sync_token'), + ).toBe('full-token'); + expect(result.sync_token).toBe('incremental-token'); + expect(result.projects?.map((project) => project.name)).toEqual([ + 'New name', + 'New project', + ]); + }); + + it('fails closed when Todoist omits the incremental sync token', async () => { + const request = jest.fn().mockResolvedValue({ projects: [] }); + + await expect( + loadTodoistData( + { request } as unknown as Pick, + 'secret-token', + ), + ).rejects.toThrow('sync token'); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts new file mode 100644 index 0000000000..c6c9ab7e92 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/load-todoist-data.ts @@ -0,0 +1,43 @@ +import { PluginAPI } from '@super-productivity/plugin-api'; +import { mergeSyncResponses, RawSyncResponse } from './from-api'; + +const SYNC_URL = 'https://api.todoist.com/api/v1/sync'; +const RESOURCE_TYPES = ['projects', 'items', 'sections', 'notes']; + +type TodoistRequestApi = Pick; + +const requestSync = ( + api: TodoistRequestApi, + token: string, + syncToken: string, +): Promise => + api.request(SYNC_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + sync_token: syncToken, + resource_types: JSON.stringify(RESOURCE_TYPES), + }).toString(), + }); + +/** + * Todoist can serve delayed full snapshots for large accounts. Always apply the + * immediately-following incremental delta before previewing data so recent + * creates, updates, and tombstones are represented in the import. + * + * @see https://developer.todoist.com/api/v1/#tag/Sync/Incremental-sync + */ +export const loadTodoistData = async ( + api: TodoistRequestApi, + token: string, +): Promise => { + const full = await requestSync(api, token, '*'); + if (!full.sync_token) { + throw new Error('Todoist full sync did not return an incremental sync token.'); + } + const incremental = await requestSync(api, token, full.sync_token); + return mergeSyncResponses(full, incremental); +}; diff --git a/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts new file mode 100644 index 0000000000..444b1d2966 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts @@ -0,0 +1,60 @@ +/** + * Normalized intermediate shape between the Todoist sync payload and the + * Super Productivity import plan. Everything lossy is flagged per task so the + * preview/summary UI can honestly report what will be / was dropped. + */ + +export interface TodoistProject { + extId: string; + title: string; + parentExtId: string | null; + isInbox: boolean; + childOrder: number; + /** number of imported values shortened to the parser's safe limits */ + truncatedFieldCount?: number; +} + +export interface TodoistSection { + extId: string; + projectExtId: string; + title: string; +} + +export interface TodoistTask { + extId: string; + projectExtId: string; + /** null = root task; set = direct sub-task after depth-flattening (SP nests 2 levels) */ + parentExtId: string | null; + title: string; + notes: string; + labels: string[]; + /** Raw Todoist API value: 4 = UI p1 (highest) … 1 = default/none */ + apiPriority: number; + /** YYYY-MM-DD — mutually exclusive with dueWithTime */ + dueDay: string | null; + /** unix ms — mutually exclusive with dueDay */ + dueWithTime: number | null; + /** ms, from minute-based durations only */ + timeEstimate: number | null; + isRecurring: boolean; + /** original depth was ≥ 2 and the task was re-parented to its root ancestor */ + wasDemoted: boolean; + /** had a day-based duration that is deliberately not imported */ + isDayDurationSkipped: boolean; + hasAssignee: boolean; + /** comment file attachments (URLs kept in notes, files not imported) */ + attachmentCount: number; + /** number of imported values shortened to the parser's safe limits */ + truncatedFieldCount?: number; +} + +export interface TodoistImportModel { + projects: TodoistProject[]; + sections: TodoistSection[]; + /** + * In final creation order: per project, root tasks (sorted by section order, + * then child order) each immediately followed by their sub-tasks in DFS + * order — a parent always precedes its children. + */ + tasks: TodoistTask[]; +} diff --git a/packages/plugin-dev/todoist-import/src/plugin.js b/packages/plugin-dev/todoist-import/src/plugin.js new file mode 100644 index 0000000000..94a9edc51a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/plugin.js @@ -0,0 +1,4 @@ +// Todoist Import plugin — all logic lives in the iframe UI (index.html). +// The plugin is opened via the Import/Export settings screen; it registers no +// hooks, shortcuts or menu entries (isSkipMenuEntry) so it adds zero standing +// weight when idle. diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts new file mode 100644 index 0000000000..7909ef2c35 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts @@ -0,0 +1,100 @@ +import { PriorityMapping } from '../map/plan-import'; +import { TodoistImportModel, TodoistTask } from '../parse/normalized-model'; +import { buildLossyNotes } from './build-lossy-notes'; + +const task = (overrides: Partial): TodoistTask => ({ + extId: 'task', + projectExtId: 'selected', + parentExtId: null, + title: 'Task', + notes: '', + labels: [], + apiPriority: 1, + dueDay: null, + dueWithTime: null, + timeEstimate: null, + isRecurring: false, + wasDemoted: false, + isDayDurationSkipped: false, + hasAssignee: false, + attachmentCount: 0, + ...overrides, +}); + +const model = (): TodoistImportModel => ({ + projects: [ + { + extId: 'selected', + title: 'Child', + parentExtId: 'parent', + isInbox: false, + childOrder: 0, + truncatedFieldCount: 1, + }, + { + extId: 'other', + title: 'Other', + parentExtId: null, + isInbox: false, + childOrder: 1, + }, + ], + sections: [ + { extId: 'section-selected', projectExtId: 'selected', title: 'Section' }, + { extId: 'section-other', projectExtId: 'other', title: 'Other section' }, + ], + tasks: [ + task({ + extId: 'selected-root', + isRecurring: true, + isDayDurationSkipped: true, + hasAssignee: true, + attachmentCount: 2, + truncatedFieldCount: 2, + }), + task({ + extId: 'selected-sub', + parentExtId: 'selected-root', + labels: ['label'], + apiPriority: 4, + wasDemoted: true, + }), + task({ + extId: 'other-root', + projectExtId: 'other', + isRecurring: true, + attachmentCount: 4, + }), + ], +}); + +const keysFor = (priorityMapping: PriorityMapping): string[] => + buildLossyNotes(model(), new Set(['selected']), priorityMapping).map( + (note) => note.key, + ); + +describe('buildLossyNotes', () => { + it('only reports losses for selected projects', () => { + const notes = buildLossyNotes(model(), new Set(['selected']), 'none'); + + expect(notes).toEqual( + expect.arrayContaining([ + { key: 'LOSS.NESTED_PROJECTS', params: { count: 1 } }, + { key: 'LOSS.SECTIONS', params: { count: 1 } }, + { key: 'LOSS.DEMOTED_SUBTASKS', params: { count: 1 } }, + { key: 'LOSS.RECURRING', params: { count: 1 } }, + { key: 'LOSS.DAY_DURATIONS', params: { count: 1 } }, + { key: 'LOSS.SUBTASK_LABELS', params: { count: 1 } }, + { key: 'LOSS.ASSIGNEES', params: { count: 1 } }, + { key: 'LOSS.ATTACHMENTS', params: { count: 2 } }, + { key: 'LOSS.TRUNCATED_FIELDS', params: { count: 3 } }, + ]), + ); + }); + + it('reports prioritized sub-tasks only when priority mapping is enabled', () => { + expect(keysFor('none')).not.toContain('LOSS.SUBTASK_PRIORITIES'); + expect(keysFor('priorityTags')).toContain('LOSS.SUBTASK_PRIORITIES'); + expect(keysFor('eisenhower')).toContain('LOSS.SUBTASK_PRIORITIES'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts new file mode 100644 index 0000000000..eb0f37db2a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts @@ -0,0 +1,61 @@ +import { PriorityMapping } from '../map/plan-import'; +import { TodoistImportModel } from '../parse/normalized-model'; + +export interface LossNote { + key: string; + params?: Record; +} + +export const buildLossyNotes = ( + model: TodoistImportModel, + selected: ReadonlySet, + priorityMapping: PriorityMapping, +): LossNote[] => { + const projects = model.projects.filter((project) => selected.has(project.extId)); + const tasks = model.tasks.filter((task) => selected.has(task.projectExtId)); + const notes: LossNote[] = []; + const addCount = (key: string, count: number): void => { + if (count) { + notes.push({ key, params: { count } }); + } + }; + + addCount( + 'LOSS.NESTED_PROJECTS', + projects.filter((project) => !!project.parentExtId).length, + ); + addCount( + 'LOSS.SECTIONS', + model.sections.filter((section) => selected.has(section.projectExtId)).length, + ); + addCount('LOSS.DEMOTED_SUBTASKS', tasks.filter((task) => task.wasDemoted).length); + addCount('LOSS.RECURRING', tasks.filter((task) => task.isRecurring).length); + addCount( + 'LOSS.DAY_DURATIONS', + tasks.filter((task) => task.isDayDurationSkipped).length, + ); + addCount( + 'LOSS.SUBTASK_LABELS', + tasks.filter((task) => task.parentExtId && task.labels.length).length, + ); + if (priorityMapping !== 'none') { + addCount( + 'LOSS.SUBTASK_PRIORITIES', + tasks.filter((task) => task.parentExtId && task.apiPriority > 1).length, + ); + } + addCount('LOSS.ASSIGNEES', tasks.filter((task) => task.hasAssignee).length); + addCount( + 'LOSS.ATTACHMENTS', + tasks.reduce((count, task) => count + task.attachmentCount, 0), + ); + addCount( + 'LOSS.TRUNCATED_FIELDS', + [...projects, ...tasks].reduce( + (count, item) => count + (item.truncatedFieldCount || 0), + 0, + ), + ); + notes.push({ key: 'LOSS.COMPLETED_AND_REMINDERS' }); + return notes; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts new file mode 100644 index 0000000000..f08133e110 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts @@ -0,0 +1,22 @@ +import { loadTranslations, t } from './i18n'; + +describe('iframe translations', () => { + it('awaits the iframe translation bridge and interpolates dynamic values locally', async () => { + const translate = jest.fn((key: string) => + Promise.resolve(key === 'BUTTON.LOAD_PREVIEW' ? 'Vorschau laden' : key), + ); + + await loadTranslations({ translate }); + + expect(t('BUTTON.LOAD_PREVIEW')).toBe('Vorschau laden'); + expect(t('ERROR.LOAD_FAILED', { error: '401' })).toContain('401'); + }); + + it('falls back to bundled English if the host translation call fails', async () => { + await loadTranslations({ + translate: () => Promise.reject(new Error('bridge unavailable')), + }); + + expect(t('BUTTON.IMPORT')).toBe('Import'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.ts new file mode 100644 index 0000000000..d39134830c --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.ts @@ -0,0 +1,47 @@ +import english from '../../i18n/en.json'; + +type TranslationParams = Record; + +interface TranslationApi { + translate: (key: string, params?: TranslationParams) => string | Promise; +} + +const flatten = (value: Record, prefix = ''): Record => { + const result: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof nestedValue === 'string') { + result[fullKey] = nestedValue; + } else if (nestedValue && typeof nestedValue === 'object') { + Object.assign(result, flatten(nestedValue as Record, fullKey)); + } + } + return result; +}; + +const englishByKey = flatten(english); +let translatedByKey: Record = { ...englishByKey }; + +export const loadTranslations = async (api: TranslationApi): Promise => { + translatedByKey = { ...englishByKey }; + await Promise.all( + Object.keys(englishByKey).map(async (key) => { + try { + const translated = await Promise.resolve(api.translate(key)); + if (translated && translated !== key) { + translatedByKey[key] = translated; + } + } catch { + // Bundled English remains available if the iframe bridge is unavailable. + } + }), + ); +}; + +export const t = (key: string, params: TranslationParams = {}): string => { + let value = translatedByKey[key] || englishByKey[key] || key; + for (const [name, replacement] of Object.entries(params)) { + value = value.split(`{{${name}}}`).join(String(replacement)); + } + return value; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/index.html b/packages/plugin-dev/todoist-import/src/ui/index.html new file mode 100644 index 0000000000..1202698d06 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/index.html @@ -0,0 +1,53 @@ + + + + + Todoist Import + + + + +
+ + + diff --git a/packages/plugin-dev/todoist-import/src/ui/main.ts b/packages/plugin-dev/todoist-import/src/ui/main.ts new file mode 100644 index 0000000000..a1786b2b49 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/main.ts @@ -0,0 +1,355 @@ +import { PluginAPI as PluginApiType, Project } from '@super-productivity/plugin-api'; +import { parseSyncResponse, ParseStrings } from '../parse/from-api'; +import { loadTodoistData } from '../parse/load-todoist-data'; +import { TodoistImportModel } from '../parse/normalized-model'; +import { + buildProjectTitles, + groupTasksByProject, + ImportPlan, + planImport, + PriorityMapping, +} from '../map/plan-import'; +import { runImport, ImportResult } from '../map/run-import'; +import { buildLossyNotes, LossNote } from './build-lossy-notes'; +import { loadTranslations, t } from './i18n'; + +declare global { + interface Window { + PluginAPI: PluginApiType; + } +} + +const api = (): PluginApiType => window.PluginAPI; + +const el = ( + tag: K, + props: Partial & { text?: string } = {}, + children: (HTMLElement | string)[] = [], +): HTMLElementTagNameMap[K] => { + const node = document.createElement(tag); + const { text, ...rest } = props; + Object.assign(node, rest); + if (text !== undefined) { + node.textContent = text; + } + for (const child of children) { + node.append(child); + } + return node; +}; + +const app = (): HTMLElement => document.getElementById('app') as HTMLElement; + +const render = (...children: HTMLElement[]): void => { + const root = app(); + root.replaceChildren(...children); +}; + +const parseStrings = (): ParseStrings => ({ + untitledProject: t('PARSE.UNTITLED_PROJECT'), + untitledTask: t('PARSE.UNTITLED_TASK'), + repeats: (rule) => t('PARSE.REPEATS', { rule }), + deadline: (date) => t('PARSE.DEADLINE', { date }), + comments: t('PARSE.COMMENTS'), + file: t('PARSE.FILE'), +}); + +// --------------------------------------------------------------------------- +// Step 1 — token input +// --------------------------------------------------------------------------- + +const renderTokenStep = (errorMsg?: string, tokenValue?: string): void => { + const tokenInputId = 'todoist-api-token'; + const tokenInput = el('input', { + id: tokenInputId, + type: 'password', + placeholder: t('TOKEN.PLACEHOLDER'), + autocomplete: 'off', + value: tokenValue || '', + }); + const fetchBtn = el('button', { text: t('BUTTON.LOAD_PREVIEW') }); + const errorLine = errorMsg + ? [el('p', { className: 'error', role: 'alert', text: errorMsg })] + : []; + + const submit = async (): Promise => { + const token = tokenInput.value.trim(); + if (!token) { + renderTokenStep(t('TOKEN.REQUIRED')); + return; + } + render( + el('h1', { text: t('TITLE.IMPORT') }), + el('p', { + text: t('TOKEN.LOADING'), + role: 'status', + ariaLive: 'polite', + }), + ); + try { + const raw = await loadTodoistData(api(), token); + const model = parseSyncResponse(raw || {}, parseStrings()); + if (!model.projects.length) { + renderTokenStep(t('TOKEN.NO_PROJECTS'), token); + return; + } + const existingProjects = await api().getAllProjects(); + renderPreviewStep(model, existingProjects); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + renderTokenStep(t('ERROR.LOAD_FAILED', { error: msg }), tokenInput.value); + } + }; + fetchBtn.addEventListener('click', () => void submit()); + tokenInput.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { + void submit(); + } + }); + + render( + el('h1', { text: t('TITLE.IMPORT') }), + el('p', { text: t('TOKEN.INTRO') }), + ...errorLine, + el('label', { htmlFor: tokenInputId, text: t('TOKEN.LABEL') }), + tokenInput, + el('p', { className: 'muted', text: t('TOKEN.HELP') }), + el('div', { className: 'actions' }, [fetchBtn]), + ); +}; + +// --------------------------------------------------------------------------- +// Step 2 — preview with per-project selection +// --------------------------------------------------------------------------- + +const renderPreviewStep = ( + model: TodoistImportModel, + existingProjects: Project[], +): void => { + const existingTitles = new Set(existingProjects.map((p) => p.title.toLowerCase())); + // the exact titles the import will create (Inbox rename, `Parent / Child` + // disambiguation, `(2)` suffixes) — preview and collision check must match + const titleByExtId = buildProjectTitles(model); + const tasksByProject = groupTasksByProject(model); + const checkboxByExtId = new Map(); + const lossyList = el('ul'); + // Priority → tags is a single mutually-exclusive choice (radios): off, the + // p1–p3 tags, or SP's built-in Eisenhower urgent/important tags. + const priorityRadio = (value: PriorityMapping, checked = false): HTMLInputElement => + el('input', { type: 'radio', name: 'todoist-priority-mapping', value, checked }); + const priorityNoneRadio = priorityRadio('none', true); + const priorityTagsRadio = priorityRadio('priorityTags'); + const priorityEisenhowerRadio = priorityRadio('eisenhower'); + const selectedPriorityMapping = (): PriorityMapping => + priorityTagsRadio.checked + ? 'priorityTags' + : priorityEisenhowerRadio.checked + ? 'eisenhower' + : 'none'; + + const selectedIds = (): Set => { + const ids = new Set(); + checkboxByExtId.forEach((box, extId) => { + if (box.checked) { + ids.add(extId); + } + }); + return ids; + }; + + const refreshLossyList = (): void => { + lossyList.replaceChildren( + ...buildLossyNotes(model, selectedIds(), selectedPriorityMapping()).map((note) => + el('li', { text: t(note.key, note.params) }), + ), + ); + }; + + const projectRows = model.projects.map((project) => { + const tasks = tasksByProject.get(project.extId) || []; + const rootCount = tasks.filter((t) => !t.parentExtId).length; + const subCount = tasks.length - rootCount; + const title = titleByExtId.get(project.extId) as string; + const collides = existingTitles.has(title.toLowerCase()); + const checkbox = el('input', { type: 'checkbox', checked: !collides }); + checkbox.addEventListener('change', refreshLossyList); + checkboxByExtId.set(project.extId, checkbox); + return el('label', {}, [ + checkbox, + ` ${t('PREVIEW.PROJECT_COUNTS', { + title, + taskCount: rootCount, + subTaskCount: subCount, + })}`, + ...(collides + ? [ + el('span', { + className: 'warn', + text: ` — ${t('PREVIEW.ALREADY_EXISTS')}`, + }), + ] + : []), + ]); + }); + + for (const radio of [priorityNoneRadio, priorityTagsRadio, priorityEisenhowerRadio]) { + radio.addEventListener('change', refreshLossyList); + } + + const importBtn = el('button', { text: t('BUTTON.IMPORT') }); + const backBtn = el('button', { text: t('BUTTON.BACK') }); + backBtn.addEventListener('click', () => renderTokenStep()); + importBtn.addEventListener('click', () => { + const selected = selectedIds(); + if (!selected.size) { + api().showSnack({ msg: t('PREVIEW.SELECT_PROJECT'), type: 'WARNING' }); + return; + } + const priorityMapping = selectedPriorityMapping(); + const plan = planImport(model, { + priorityMapping, + selectedProjectExtIds: selected, + }); + void executeImport(plan, buildLossyNotes(model, selected, priorityMapping)); + }); + + refreshLossyList(); + render( + el('h1', { text: t('TITLE.PREVIEW') }), + el('p', { text: t('PREVIEW.CHOOSE_PROJECTS') }), + el('div', {}, projectRows), + el('fieldset', {}, [ + el('legend', { text: t('PREVIEW.PRIORITY_LEGEND') }), + el('label', {}, [priorityNoneRadio, ` ${t('PREVIEW.PRIORITY_NONE')}`]), + el('label', {}, [priorityTagsRadio, ` ${t('PREVIEW.PRIORITY_TAGS')}`]), + el('label', {}, [priorityEisenhowerRadio, ` ${t('PREVIEW.PRIORITY_EISENHOWER')}`]), + ]), + el('h2', { text: t('PREVIEW.LOSS_HEADING') }), + lossyList, + el('div', { className: 'actions' }, [importBtn, backBtn]), + ); +}; + +// --------------------------------------------------------------------------- +// Step 3 + 4 — import progress and summary +// --------------------------------------------------------------------------- + +const executeImport = async (plan: ImportPlan, lossyNotes: LossNote[]): Promise => { + const progressLine = el('p', { + text: t('IMPORT.STARTING'), + role: 'status', + ariaLive: 'polite', + }); + render(el('h1', { text: t('TITLE.IMPORTING') }), progressLine); + + const result = await runImport(api(), plan, (progress) => { + const detail = + progress.phase === 'details' && progress.detailTotal + ? t('IMPORT.PHASE_DETAILS', { + current: Math.min((progress.detailIndex ?? 0) + 1, progress.detailTotal), + total: progress.detailTotal, + }) + : t(progress.phase === 'project' ? 'IMPORT.PHASE_PROJECT' : 'IMPORT.PHASE_TASKS'); + progressLine.textContent = t('IMPORT.PROGRESS', { + current: progress.projectIndex + 1, + total: progress.totalProjects, + title: progress.projectTitle, + detail, + }); + }); + renderSummaryStep(result, lossyNotes); +}; + +const projectSummaryLine = (p: ImportResult['imported'][number]): HTMLElement => { + const isShortfall = + p.landedTaskCount < p.plannedTaskCount || + p.landedSubTaskCount < p.plannedSubTaskCount; + return el('li', { + className: isShortfall ? 'warn' : '', + text: + t('SUMMARY.PROJECT_RESULT', { + title: p.title, + landedTasks: p.landedTaskCount, + plannedTasks: p.plannedTaskCount, + landedSubTasks: p.landedSubTaskCount, + plannedSubTasks: p.plannedSubTaskCount, + }) + (isShortfall ? ` — ${t('SUMMARY.SHORTFALL')}` : ''), + }); +}; + +const renderSummaryStep = (result: ImportResult, lossyNotes: LossNote[]): void => { + const items = result.imported.map(projectSummaryLine); + const failure = result.errorMessage + ? [ + el('p', { + className: 'error', + role: 'alert', + text: result.failedProjectTitle + ? t('ERROR.IMPORT_STOPPED', { + project: result.failedProjectTitle, + error: result.errorMessage, + }) + : t('ERROR.IMPORT_FAILED', { error: result.errorMessage }), + }), + ] + : []; + const unverified = result.isCountUnverified + ? [ + el('p', { + className: 'warn', + role: 'status', + text: t('SUMMARY.UNVERIFIED'), + }), + ] + : []; + const tagLine = result.createdTagTitles.length + ? [ + el('p', { + text: t('SUMMARY.CREATED_TAGS', { + tags: result.createdTagTitles.join(', '), + }), + }), + ] + : []; + const lossy = lossyNotes.length + ? [ + el('h2', { text: t('SUMMARY.NOT_CARRIED_OVER') }), + el( + 'ul', + {}, + lossyNotes.map((note) => el('li', { text: t(note.key, note.params) })), + ), + ] + : []; + + if (!result.errorMessage) { + api().showSnack({ msg: t('SUMMARY.SNACK_FINISHED'), type: 'SUCCESS' }); + } + render( + el('h1', { + text: t(result.errorMessage ? 'TITLE.INCOMPLETE' : 'TITLE.FINISHED'), + }), + el('ul', {}, items), + ...unverified, + ...tagLine, + ...failure, + ...lossy, + el('p', { + className: 'muted', + text: t('SUMMARY.UNDO'), + }), + ); +}; + +// The host injects the PluginAPI bridge script at the end of ; wait for +// DOM readiness so it is guaranteed to be defined before first use. +const start = async (): Promise => { + await loadTranslations(api()); + renderTokenStep(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => void start()); +} else { + void start(); +} diff --git a/packages/plugin-dev/todoist-import/tsconfig.json b/packages/plugin-dev/todoist-import/tsconfig.json new file mode 100644 index 0000000000..97f798a881 --- /dev/null +++ b/packages/plugin-dev/todoist-import/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["ES2020", "DOM"], + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index 835011137b..d967d45040 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -127,6 +127,7 @@ export type { ConflictResolutionSuggestion, EntityConflictLike, LwwConflictResolutionPlan, + LwwConflictResolutionReason, LwwResolvedConflict, } from './conflict-resolution'; diff --git a/scripts/fix-duplicate-imports.ts b/scripts/fix-duplicate-imports.ts deleted file mode 100755 index 9ed3110388..0000000000 --- a/scripts/fix-duplicate-imports.ts +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -// Pattern to find duplicate imports -const findDuplicateImports = (content: string): string[] => { - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex) || []; - const seen = new Set(); - const duplicates: string[] = []; - - for (const imp of imports) { - const normalized = imp.trim(); - if (seen.has(normalized)) { - duplicates.push(normalized); - } else { - seen.add(normalized); - } - } - - return duplicates; -}; - -// Remove duplicate imports from content -const removeDuplicateImports = (content: string): string => { - const lines = content.split('\n'); - const seenImports = new Set(); - const resultLines: string[] = []; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Check if it's an import statement - if (trimmedLine.startsWith('import ') && trimmedLine.endsWith(';')) { - if (seenImports.has(trimmedLine)) { - // Skip duplicate import - continue; - } - seenImports.add(trimmedLine); - } - - resultLines.push(line); - } - - return resultLines.join('\n'); -}; - -function processFile(filePath: string): boolean { - try { - const content = fs.readFileSync(filePath, 'utf8'); - const duplicates = findDuplicateImports(content); - - if (duplicates.length > 0) { - console.log( - `Found ${duplicates.length} duplicate imports in ${path.relative(process.cwd(), filePath)}:`, - ); - duplicates.forEach((dup) => console.log(` - ${dup}`)); - - const fixedContent = removeDuplicateImports(content); - fs.writeFileSync(filePath, fixedContent, 'utf8'); - return true; - } - - return false; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return false; - } -} - -function main() { - console.log('Searching for duplicate imports...\n'); - - const files = glob.sync('src/**/*.ts', { - ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'], - absolute: true, - }); - - let fixedCount = 0; - - for (const file of files) { - if (processFile(file)) { - fixedCount++; - } - } - - console.log(`\nFixed duplicate imports in ${fixedCount} files.`); -} - -main(); diff --git a/scripts/log-migration-helper.test.cjs b/scripts/log-migration-helper.test.cjs deleted file mode 100644 index ebe1a963e4..0000000000 --- a/scripts/log-migration-helper.test.cjs +++ /dev/null @@ -1,55 +0,0 @@ -require('ts-node/register/transpile-only'); - -const assert = require('node:assert/strict'); -const test = require('node:test'); -const { migrateLogContent } = require('./log-migration-helper.ts'); - -test('migrates Log calls and updates the import for the target logger', () => { - const input = [ - "import { Log, OtherLog } from '../core/log';", - '', - "Log.log('a');", - "Log.err('b');", - 'OtherLog.log();', - '', - ].join('\n'); - - const result = migrateLogContent(input, 'PluginLog'); - - assert.equal(result.changes, 2); - assert.equal( - result.content, - [ - "import { OtherLog, PluginLog } from '../core/log';", - '', - "PluginLog.log('a');", - "PluginLog.err('b');", - 'OtherLog.log();', - '', - ].join('\n'), - ); -}); - -test('keeps the Log import when non-call Log references remain', () => { - const input = [ - "import { Log } from '../core/log';", - '', - 'const logger = Log;', - "Log.info('a');", - '', - ].join('\n'); - - const result = migrateLogContent(input, 'IssueLog'); - - assert.equal(result.changes, 1); - assert.equal( - result.content, - [ - "import { Log, IssueLog } from '../core/log';", - '', - 'const logger = Log;', - "IssueLog.info('a');", - '', - ].join('\n'), - ); -}); diff --git a/scripts/log-migration-helper.ts b/scripts/log-migration-helper.ts deleted file mode 100644 index 7bd8a5128e..0000000000 --- a/scripts/log-migration-helper.ts +++ /dev/null @@ -1,171 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; -} - -export interface LogMigrationConfig { - description: string; - fileLabel: string; - globPattern: string; - targetLogName: string; - ignore?: string[]; -} - -export interface MigrationResult { - content: string; - changes: number; -} - -export interface FileMigrationResult { - modified: boolean; - changes: number; -} - -const DEFAULT_IGNORE = ['**/*.spec.ts', '**/node_modules/**']; -const LOG_METHODS = ['log', 'err', 'info', 'debug', 'verbose', 'critical']; - -const createReplacements = (targetLogName: string): Replacement[] => - LOG_METHODS.map((method) => ({ - pattern: new RegExp(`\\bLog\\.${method}\\(`, 'g'), - replacement: `${targetLogName}.${method}(`, - })); - -const getTargetLogImportRegex = (targetLogName: string): RegExp => - new RegExp( - `import\\s*{[^}]*\\b${targetLogName}\\b[^}]*}\\s*from\\s*['"][^'"]*\\/log['"]`, - ); - -const LOG_IMPORT_REGEX = /import\s*{([^}]*\bLog\b[^}]*)}\s*from\s*(['"][^'"]*\/log['"])/; - -const updateImports = ( - content: string, - targetLogName: string, - replacements: Replacement[], -): string => { - if (getTargetLogImportRegex(targetLogName).test(content)) { - return content; - } - - const match = content.match(LOG_IMPORT_REGEX); - if (!match) { - return content; - } - - const [fullMatch, imports, importPath] = match; - const importList = imports.split(',').map((s) => s.trim()); - - if (!importList.includes(targetLogName)) { - importList.push(targetLogName); - } - - let tempContent = content; - for (const { pattern, replacement } of replacements) { - tempContent = tempContent.replace(pattern, replacement); - } - - tempContent = tempContent.replace(LOG_IMPORT_REGEX, ''); - - if (!/\bLog\b/.test(tempContent)) { - const logIndex = importList.indexOf('Log'); - if (logIndex > -1) { - importList.splice(logIndex, 1); - } - } - - return content.replace( - fullMatch, - `import { ${importList.join(', ')} } from ${importPath}`, - ); -}; - -export const migrateLogContent = ( - content: string, - targetLogName: string, -): MigrationResult => { - const replacements = createReplacements(targetLogName); - let nextContent = content; - let changeCount = 0; - - for (const { pattern, replacement } of replacements) { - const matches = nextContent.match(pattern); - if (matches) { - changeCount += matches.length; - nextContent = nextContent.replace(pattern, replacement); - } - } - - if (changeCount > 0) { - nextContent = updateImports(nextContent, targetLogName, replacements); - } - - return { - content: nextContent, - changes: changeCount, - }; -}; - -export const migrateLogFile = ( - filePath: string, - targetLogName: string, - dryRun: boolean = false, -): FileMigrationResult => { - try { - const originalContent = fs.readFileSync(filePath, 'utf8'); - const { content, changes } = migrateLogContent(originalContent, targetLogName); - const modified = content !== originalContent; - - if (modified && !dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - - return { modified, changes }; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return { modified: false, changes: 0 }; - } -}; - -export const runLogMigration = (config: LogMigrationConfig): void => { - const dryRun = process.argv.includes('--dry-run'); - - console.log(`${config.description}\n`); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - const files = glob.sync(config.globPattern, { - ignore: config.ignore || DEFAULT_IGNORE, - absolute: true, - }); - - console.log(`Found ${files.length} ${config.fileLabel}\n`); - - const modifiedFiles: { path: string; changes: number }[] = []; - let totalChanges = 0; - - for (const file of files) { - const result = migrateLogFile(file, config.targetLogName, dryRun); - if (result.modified) { - modifiedFiles.push({ path: file, changes: result.changes }); - totalChanges += result.changes; - } - } - - console.log('\nMigration complete!\n'); - console.log(`Total changes: ${totalChanges}`); - console.log(`${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:\n`); - - modifiedFiles - .sort((a, b) => b.changes - a.changes) - .forEach(({ path: filePath, changes }) => { - console.log(` - ${path.relative(process.cwd(), filePath)} (${changes} changes)`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -}; diff --git a/scripts/migrate-console-to-log-force.ts b/scripts/migrate-console-to-log-force.ts deleted file mode 100755 index 40171c4a98..0000000000 --- a/scripts/migrate-console-to-log-force.ts +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; - logMethod: string; -} - -const replacements: Replacement[] = [ - { pattern: /\bconsole\.log\(/g, replacement: 'Log.log(', logMethod: 'log' }, - { pattern: /\bconsole\.info\(/g, replacement: 'Log.info(', logMethod: 'info' }, - { pattern: /\bconsole\.error\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'Log.debug(', logMethod: 'debug' }, -]; - -// Files to exclude -const excludePatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/*.spec.ts', - '**/core/log.ts', - '**/scripts/migrate-console-to-log*.ts', - '**/e2e/**', - '**/coverage/**', - '**/.angular/**', -]; - -function calculateImportPath(filePath: string): string { - const fileDir = path.dirname(filePath); - const logPath = path.join(__dirname, '../src/app/core/log'); - let relativePath = path.relative(fileDir, logPath).replace(/\\/g, '/'); - - // Remove .ts extension if present - relativePath = relativePath.replace(/\.ts$/, ''); - - // Ensure it starts with ./ or ../ - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - - return relativePath; -} - -function hasLogImport(content: string): boolean { - // Check if there's already a Log import from core/log - return /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"][^'"]*\/core\/log['"]/.test(content); -} - -function addLogImport(content: string, importPath: string): string { - // Find the last import statement - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex); - - if (imports && imports.length > 0) { - const lastImport = imports[imports.length - 1]; - const lastImportIndex = content.lastIndexOf(lastImport); - const insertPosition = lastImportIndex + lastImport.length; - - return ( - content.slice(0, insertPosition) + - `\nimport { Log } from '${importPath}';` + - content.slice(insertPosition) - ); - } else { - // No imports found, add at the beginning - return `import { Log } from '${importPath}';\n\n` + content; - } -} - -function processFile( - filePath: string, - dryRun: boolean = false, -): { modified: boolean; changes: number } { - try { - let content = fs.readFileSync(filePath, 'utf8'); - const originalContent = content; - let changeCount = 0; - - // Apply replacements - this will replace even in comments, which is what we want - for (const { pattern, replacement } of replacements) { - const matches = content.match(pattern); - if (matches) { - changeCount += matches.length; - content = content.replace(pattern, replacement); - } - } - - // If we made changes and don't have the correct Log import, add it - if (changeCount > 0 && !hasLogImport(content)) { - const importPath = calculateImportPath(filePath); - content = addLogImport(content, importPath); - } - - const modified = content !== originalContent; - - if (modified && !dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - - return { modified, changes: changeCount }; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return { modified: false, changes: 0 }; - } -} - -function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run'); - - console.log('Starting FORCED console to Log migration...'); - console.log('This will replace ALL console.* calls, including those in comments.'); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - // Find all TypeScript files - const files = glob.sync('src/**/*.ts', { - ignore: excludePatterns, - absolute: true, - }); - - console.log(`Found ${files.length} TypeScript files to check\n`); - - const modifiedFiles: { path: string; changes: number }[] = []; - const stats = { - total: 0, - log: 0, - info: 0, - error: 0, - warn: 0, - debug: 0, - }; - - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - - // Count occurrences before processing (with word boundary) - const logCount = (content.match(/\bconsole\.log\(/g) || []).length; - const infoCount = (content.match(/\bconsole\.info\(/g) || []).length; - const errorCount = (content.match(/\bconsole\.error\(/g) || []).length; - const warnCount = (content.match(/\bconsole\.warn\(/g) || []).length; - const debugCount = (content.match(/\bconsole\.debug\(/g) || []).length; - - stats.log += logCount; - stats.info += infoCount; - stats.error += errorCount; - stats.warn += warnCount; - stats.debug += debugCount; - - const result = processFile(file, dryRun); - if (result.modified) { - modifiedFiles.push({ path: file, changes: result.changes }); - } - } - - stats.total = stats.log + stats.info + stats.error + stats.warn + stats.debug; - - console.log('\nMigration complete!\n'); - console.log('Statistics:'); - console.log(` Total console calls found: ${stats.total}`); - console.log(` - console.log: ${stats.log}`); - console.log(` - console.info: ${stats.info}`); - console.log(` - console.error: ${stats.error}`); - console.log(` - console.warn: ${stats.warn}`); - console.log(` - console.debug: ${stats.debug}`); - console.log( - `\n${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:\n`, - ); - - modifiedFiles - .sort((a, b) => b.changes - a.changes) - .forEach(({ path: filePath, changes }) => { - console.log(` - ${path.relative(process.cwd(), filePath)} (${changes} changes)`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -} - -// Run the migration -main(); diff --git a/scripts/migrate-console-to-log.ts b/scripts/migrate-console-to-log.ts deleted file mode 100755 index 2d571a3836..0000000000 --- a/scripts/migrate-console-to-log.ts +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -interface Replacement { - pattern: RegExp; - replacement: string; - logMethod: string; -} - -const replacements: Replacement[] = [ - { pattern: /\bconsole\.log\(/g, replacement: 'Log.log(', logMethod: 'log' }, - { pattern: /\bconsole\.info\(/g, replacement: 'Log.info(', logMethod: 'info' }, - { pattern: /\bconsole\.error\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'Log.err(', logMethod: 'err' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'Log.debug(', logMethod: 'debug' }, -]; - -// Files to exclude -const excludePatterns = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/*.spec.ts', - '**/core/log.ts', - '**/scripts/migrate-console-to-log.ts', - '**/e2e/**', - '**/coverage/**', - '**/.angular/**', -]; - -function calculateImportPath(filePath: string): string { - const fileDir = path.dirname(filePath); - const logPath = path.join(__dirname, '../src/app/core/log'); - let relativePath = path.relative(fileDir, logPath).replace(/\\/g, '/'); - - // Remove .ts extension if present - relativePath = relativePath.replace(/\.ts$/, ''); - - // Ensure it starts with ./ or ../ - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - - return relativePath; -} - -function hasLogImport(content: string): boolean { - // More flexible pattern that matches any Log import - return /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"].*['"]/.test(content); -} - -function addLogImport(content: string, importPath: string): string { - // Find the last import statement - const importRegex = /^import\s+.*?;$/gm; - const imports = content.match(importRegex); - - if (imports && imports.length > 0) { - const lastImport = imports[imports.length - 1]; - const lastImportIndex = content.lastIndexOf(lastImport); - const insertPosition = lastImportIndex + lastImport.length; - - return ( - content.slice(0, insertPosition) + - `\nimport { Log } from '${importPath}';` + - content.slice(insertPosition) - ); - } else { - // No imports found, add at the beginning - return `import { Log } from '${importPath}';\n\n` + content; - } -} - -function processFile(filePath: string, dryRun: boolean = false): boolean { - try { - let content = fs.readFileSync(filePath, 'utf8'); - const originalContent = content; - let hasChanges = false; - - // Check if file uses any console methods (including in comments) - const usesConsole = replacements.some((r) => r.pattern.test(content)); - - if (!usesConsole) { - return false; - } - - // Apply replacements - this will replace even in comments, which is what we want - for (const { pattern, replacement } of replacements) { - if (pattern.test(content)) { - content = content.replace(pattern, replacement); - hasChanges = true; - } - } - - if (hasChanges && !hasLogImport(content)) { - const importPath = calculateImportPath(filePath); - content = addLogImport(content, importPath); - } - - if (content !== originalContent) { - if (!dryRun) { - fs.writeFileSync(filePath, content, 'utf8'); - } - return true; - } - - return false; - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - return false; - } -} - -function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run'); - - console.log('Starting console to Log migration...'); - if (dryRun) { - console.log('Running in DRY RUN mode - no files will be modified\n'); - } - - // Find all TypeScript files - const files = glob.sync('src/**/*.ts', { - ignore: excludePatterns, - absolute: true, - }); - - console.log(`Found ${files.length} TypeScript files to check\n`); - - const modifiedFiles: string[] = []; - const stats = { - total: 0, - log: 0, - info: 0, - error: 0, - warn: 0, - debug: 0, - }; - - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - - // Count occurrences before processing (with word boundary) - stats.log += (content.match(/\bconsole\.log\(/g) || []).length; - stats.info += (content.match(/\bconsole\.info\(/g) || []).length; - stats.error += (content.match(/\bconsole\.error\(/g) || []).length; - stats.warn += (content.match(/\bconsole\.warn\(/g) || []).length; - stats.debug += (content.match(/\bconsole\.debug\(/g) || []).length; - - const willModify = processFile(file, dryRun); - if (willModify) { - modifiedFiles.push(file); - } - } - - stats.total = stats.log + stats.info + stats.error + stats.warn + stats.debug; - - console.log('\nMigration complete!\n'); - console.log('Statistics:'); - console.log(` Total console calls found: ${stats.total}`); - console.log(` - console.log: ${stats.log}`); - console.log(` - console.info: ${stats.info}`); - console.log(` - console.error: ${stats.error}`); - console.log(` - console.warn: ${stats.warn}`); - console.log(` - console.debug: ${stats.debug}`); - console.log(`\n${dryRun ? 'Would modify' : 'Modified'} ${modifiedFiles.length} files:`); - - modifiedFiles.forEach((file) => { - console.log(` - ${path.relative(process.cwd(), file)}`); - }); - - if (modifiedFiles.length === 0) { - console.log(' No files needed modification.'); - } -} - -// Run the migration -main(); diff --git a/scripts/migrate-to-droid-log.ts b/scripts/migrate-to-droid-log.ts deleted file mode 100755 index ba6fac19e6..0000000000 --- a/scripts/migrate-to-droid-log.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; - -const filesToMigrate = [ - 'src/app/core/persistence/android-db-adapter.service.ts', - 'src/app/features/android/android-interface.ts', - 'src/app/features/android/store/android.effects.ts', -]; - -const CORE_LOG_IMPORT_RE = /from\s*['"][^'"]*core\/log['"]/; - -const migrateFile = (filePath: string): void => { - console.log(`Processing ${filePath}...`); - - let content = fs.readFileSync(filePath, 'utf8'); - let modified = false; - - // Replace Log.* method calls with DroidLog.* - const logPattern = /\bLog\.(log|err|error|info|warn|debug|verbose|critical)\b/g; - if (logPattern.test(content)) { - content = content.replace(logPattern, 'DroidLog.$1'); - modified = true; - } - - // Update imports - if (modified) { - // Check if file already imports from log - if (CORE_LOG_IMPORT_RE.test(content)) { - // Replace Log import with DroidLog - content = content.replace( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/g, - (match, before, after) => { - const imports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - imports.push('DroidLog'); - return `import { ${imports.join(', ')} } from '${match.includes('"') ? match.split('"')[1] : match.split("'")[1]}'`; - }, - ); - } else { - console.log(`Warning: Could not find log import in ${filePath}`); - } - } - - if (modified) { - fs.writeFileSync(filePath, content); - console.log(`✓ Migrated ${filePath}`); - } else { - console.log(`- No changes needed in ${filePath}`); - } -}; - -// Process all files -filesToMigrate.forEach(migrateFile); - -console.log('\nMigration complete!'); diff --git a/scripts/migrate-to-issue-log.ts b/scripts/migrate-to-issue-log.ts deleted file mode 100755 index bd60a16c53..0000000000 --- a/scripts/migrate-to-issue-log.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env ts-node - -import { runLogMigration } from './log-migration-helper'; - -runLogMigration({ - description: 'Migrating Log to IssueLog in features/issue directory...', - fileLabel: 'TypeScript files in features/issue directory', - globPattern: 'src/app/features/issue/**/*.ts', - targetLogName: 'IssueLog', -}); diff --git a/scripts/migrate-to-plugin-log.ts b/scripts/migrate-to-plugin-log.ts deleted file mode 100755 index bd9e90bd9b..0000000000 --- a/scripts/migrate-to-plugin-log.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env ts-node - -import { runLogMigration } from './log-migration-helper'; - -runLogMigration({ - description: 'Migrating Log to PluginLog in plugins directory...', - fileLabel: 'TypeScript files in plugins directory', - globPattern: 'src/app/plugins/**/*.ts', - targetLogName: 'PluginLog', -}); diff --git a/scripts/migrate-to-task-log.ts b/scripts/migrate-to-task-log.ts deleted file mode 100755 index 42906ac2e1..0000000000 --- a/scripts/migrate-to-task-log.ts +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; - -const taskFiles = glob.sync('src/app/features/tasks/**/*.ts', { - ignore: ['**/*.spec.ts', '**/node_modules/**'], -}); - -console.log(`Found ${taskFiles.length} task files to check`); - -let totalChanges = 0; - -function getRelativeImportPath(fromFile: string, toFile: string): string { - const fromDir = path.dirname(fromFile); - let relativePath = path.relative(fromDir, toFile).replace(/\\/g, '/'); - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath; - } - return relativePath.replace(/\.ts$/, ''); -} - -function migrateFile(filePath: string): void { - let content = fs.readFileSync(filePath, 'utf8'); - let modified = false; - let localChanges = 0; - - // Skip if file already uses TaskLog - if (content.includes('TaskLog')) { - return; - } - - // Replace console.* calls - const consolePatterns = [ - { pattern: /\bconsole\.log\(/g, replacement: 'TaskLog.log(' }, - { pattern: /\bconsole\.error\(/g, replacement: 'TaskLog.err(' }, - { pattern: /\bconsole\.info\(/g, replacement: 'TaskLog.info(' }, - { pattern: /\bconsole\.warn\(/g, replacement: 'TaskLog.warn(' }, - { pattern: /\bconsole\.debug\(/g, replacement: 'TaskLog.debug(' }, - ]; - - for (const { pattern, replacement } of consolePatterns) { - const matches = content.match(pattern); - if (matches) { - content = content.replace(pattern, replacement); - modified = true; - localChanges += matches.length; - } - } - - // Replace Log.* method calls with TaskLog.* - const logPattern = /\bLog\.(log|err|error|info|warn|debug|verbose|critical)\b/g; - const logMatches = content.match(logPattern); - if (logMatches) { - content = content.replace(logPattern, 'TaskLog.$1'); - modified = true; - localChanges += logMatches.length; - } - - // Update imports - if (modified) { - const logPath = getRelativeImportPath(filePath, 'src/app/core/log.ts'); - - // Check if file already imports from log - const importRegex = new RegExp( - `import\\s*{([^}]*)}\\s*from\\s*['"]${logPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`, - ); - const existingImport = content.match(importRegex); - - if (existingImport) { - // Update existing import - const imports = existingImport[1] - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - if (!imports.includes('TaskLog')) { - imports.push('TaskLog'); - } - content = content.replace( - importRegex, - `import { ${imports.join(', ')} } from '${logPath}'`, - ); - } else { - // Check for any Log import - const anyLogImport = content.match( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/, - ); - if (anyLogImport) { - // Replace with TaskLog - content = content.replace( - /import\s*{\s*([^}]*)\bLog\b([^}]*)\}\s*from\s*['"][^'"]*core\/log['"]/g, - (match, before, after) => { - const imports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log'); - imports.push('TaskLog'); - const importPath = match.includes('"') - ? match.split('"')[1] - : match.split("'")[1]; - return `import { ${imports.join(', ')} } from '${importPath}'`; - }, - ); - } else if (!content.includes('TaskLog')) { - // Add new import at the top - const importStatement = `import { TaskLog } from '${logPath}';\n`; - - // Find the right place to insert the import - const firstImportMatch = content.match(/^import\s+/m); - if (firstImportMatch) { - const position = firstImportMatch.index!; - content = - content.slice(0, position) + importStatement + content.slice(position); - } else { - // If no imports, add after any leading comments - const afterComments = content.match(/^(\/\*[\s\S]*?\*\/|\/\/.*$)*/m); - if (afterComments) { - const position = afterComments[0].length; - content = - content.slice(0, position) + - (position > 0 ? '\n' : '') + - importStatement + - content.slice(position); - } else { - content = importStatement + content; - } - } - } - } - } - - if (modified) { - fs.writeFileSync(filePath, content); - console.log(`✓ ${filePath} (${localChanges} changes)`); - totalChanges += localChanges; - } -} - -// Process all files -taskFiles.forEach(migrateFile); - -console.log(`\nMigration complete! Total changes: ${totalChanges}`); diff --git a/scripts/remove-unused-log-imports.ts b/scripts/remove-unused-log-imports.ts deleted file mode 100644 index da9b7656b1..0000000000 --- a/scripts/remove-unused-log-imports.ts +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env ts-node - -import * as fs from 'fs'; -import * as path from 'path'; - -const filesToFix = [ - 'src/app/core-ui/drop-list/drop-list.service.ts', - 'src/app/core-ui/side-nav/side-nav.component.ts', - 'src/app/core/banner/banner.service.ts', - 'src/app/features/config/form-cfgs/domina-mode-form.const.ts', - 'src/app/features/focus-mode/focus-mode.service.ts', - 'src/app/features/schedule/create-task-placeholder/create-task-placeholder.component.ts', - 'src/app/features/schedule/map-schedule-data/create-blocked-blocks-by-day-map.ts', - 'src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts', - 'src/app/features/schedule/map-schedule-data/insert-blocked-blocks-view-entries-for-schedule.ts', - 'src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts', - 'src/app/features/task-repeat-cfg/sort-repeatable-task-cfg.ts', - 'src/app/features/tasks/dialog-task-detail-panel/dialog-task-detail-panel.component.ts', - 'src/app/features/work-context/store/work-context-meta.helper.ts', - 'src/app/root-store/index.ts', - 'src/app/ui/duration/input-duration-formly/input-duration-formly.component.ts', - 'src/app/ui/inline-markdown/inline-markdown.component.ts', - 'src/app/util/is-touch-only.ts', -]; - -function removeUnusedLogImport(filePath: string): void { - try { - const fullPath = path.join(process.cwd(), filePath); - let content = fs.readFileSync(fullPath, 'utf8'); - - // Check if Log is actually used in the file (excluding import statement and comments) - const importRegex = /import\s+{[^}]*\bLog\b[^}]*}\s+from\s+['"][^'"]+['"]/g; - let contentWithoutImports = content.replace(importRegex, ''); - - // Remove single line comments - contentWithoutImports = contentWithoutImports.replace(/\/\/.*$/gm, ''); - // Remove multi-line comments - contentWithoutImports = contentWithoutImports.replace(/\/\*[\s\S]*?\*\//g, ''); - - const isLogUsed = /\bLog\./g.test(contentWithoutImports); - - if (!isLogUsed) { - // Remove Log from imports - content = content.replace( - /import\s+{([^}]*)\bLog\b([^}]*)}\s+from\s+(['"][^'"]+['"])/g, - (match, before, after, path) => { - // Clean up the remaining imports - const remainingImports = (before + after) - .split(',') - .map((s) => s.trim()) - .filter((s) => s && s !== 'Log') - .join(', '); - - if (remainingImports) { - return `import { ${remainingImports} } from ${path}`; - } else { - // If Log was the only import, remove the entire import line - return ''; - } - }, - ); - - // Clean up any empty lines left by removed imports - content = content.replace(/^\s*;\s*$/gm, ''); // Remove standalone semicolons - content = content.replace(/\n\n+/g, '\n\n'); // Replace multiple newlines with double newline - - fs.writeFileSync(fullPath, content, 'utf8'); - console.log(`Fixed: ${filePath}`); - } else { - console.log(`Skipped (Log is used): ${filePath}`); - } - } catch (error) { - console.error(`Error processing ${filePath}:`, error); - } -} - -console.log('Removing unused Log imports...\n'); - -filesToFix.forEach(removeUnusedLogImport); - -console.log('\nDone!'); diff --git a/src/app/app.constants.spec.ts b/src/app/app.constants.spec.ts new file mode 100644 index 0000000000..a3dac02cf4 --- /dev/null +++ b/src/app/app.constants.spec.ts @@ -0,0 +1,41 @@ +import { isDonationUiRestricted } from './app.constants'; + +describe('isDonationUiRestricted', () => { + const cases: Array< + [ + label: string, + context: Parameters[0], + expected: boolean, + ] + > = [ + ['native iOS', { isIosNative: true, isElectron: false, isMacOS: false }, true], + ['macOS Electron DMG', { isIosNative: false, isElectron: true, isMacOS: true }, true], + [ + 'macOS Electron App Store', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'macOS Electron development build', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'Windows or Linux Electron', + { isIosNative: false, isElectron: true, isMacOS: false }, + false, + ], + [ + 'macOS web browser', + { isIosNative: false, isElectron: false, isMacOS: true }, + false, + ], + ['native Android', { isIosNative: false, isElectron: false, isMacOS: false }, false], + ]; + + cases.forEach(([label, context, expected]) => { + it(`${expected ? 'restricts' : 'allows'} donation UI on ${label}`, () => { + expect(isDonationUiRestricted(context)).toBe(expected); + }); + }); +}); diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 8f9d7260b6..d1d3dc0b1b 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -23,15 +23,35 @@ export const IS_GNOME_WAYLAND = IS_ELECTRON && window.ea.isGnomeWayland(); // True only inside the Electron build — preload exposes process.arch. // Web builds can't reliably distinguish Apple Silicon from Intel and stay false. export const IS_APPLE_SILICON = IS_ELECTRON && window.ea.isAppleSilicon(); -// True only in a Mac App Store (sandboxed) Electron build. Reuses the existing -// dist-channel bridge (driven by Electron's process.mas); direct-download macOS -// (mac-dmg), other channels and web builds stay false. -export const IS_MAC_APP_STORE = IS_ELECTRON && window.ea.getDistChannel() === 'mac-store'; +interface DonationUiPlatformContext { + isIosNative: boolean; + isElectron: boolean; + isMacOS: boolean; +} + +export const isDonationUiRestricted = ({ + isIosNative, + isElectron, + isMacOS, +}: DonationUiPlatformContext): boolean => isIosNative || (isElectron && isMacOS); + // Apple's App Store guidelines forbid donation/contribution links that route -// around In-App Purchase (Guideline 3.1.1). True for both Apple App Store -// distribution channels (iOS App Store and Mac App Store); direct-download and -// web builds stay false, so they keep the donation UI. -export const IS_APPLE_APP_STORE = IS_IOS_NATIVE || IS_MAC_APP_STORE; +// around In-App Purchase (Guideline 3.1.1). Apply the restriction to every +// macOS Electron build so App Store, direct-download and local review behavior +// cannot diverge based on the unreliable process.mas signal. +export const IS_DONATION_UI_RESTRICTED = isDonationUiRestricted({ + isIosNative: IS_IOS_NATIVE, + isElectron: IS_ELECTRON, + isMacOS: IS_ELECTRON && window.ea.isMacOS(), +}); + +export const IS_DONATION_UI_RESTRICTED_TOKEN = new InjectionToken( + 'IS_DONATION_UI_RESTRICTED', + { + providedIn: 'root', + factory: () => IS_DONATION_UI_RESTRICTED, + }, +); export const TRACKING_INTERVAL = 1000; diff --git a/src/app/app.guard.spec.ts b/src/app/app.guard.spec.ts index 74c77b80ed..6b290e9d55 100644 --- a/src/app/app.guard.spec.ts +++ b/src/app/app.guard.spec.ts @@ -1,13 +1,23 @@ +import { Component } from '@angular/core'; +import { provideLocationMocks } from '@angular/common/testing'; import { TestBed } from '@angular/core/testing'; -import { Router, UrlTree } from '@angular/router'; +import { provideRouter, Router, UrlTree } from '@angular/router'; import { BehaviorSubject, of, throwError } from 'rxjs'; -import { DefaultStartPageGuard } from './app.guard'; +import { DefaultStartPageGuard, DonatePageGuard } from './app.guard'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { ProjectService } from './features/project/project.service'; import { TODAY_TAG } from './features/tag/tag.const'; import { INBOX_PROJECT } from './features/project/project.const'; import { Project } from './features/project/project.model'; +import { APP_ROUTES } from './app.routes'; + +@Component({ standalone: true, template: '' }) +class DonateRouteTestComponent {} + +@Component({ standalone: true, template: '' }) +class HomeRouteTestComponent {} describe('DefaultStartPageGuard', () => { let guard: DefaultStartPageGuard; @@ -164,3 +174,45 @@ describe('DefaultStartPageGuard', () => { expectUrl(await runGuard(), TODAY_URL); }); }); + +describe('DonatePageGuard', () => { + const setup = (isRestricted: boolean): Router => { + TestBed.configureTestingModule({ + providers: [ + provideLocationMocks(), + provideRouter([ + { + path: 'donate', + component: DonateRouteTestComponent, + canActivate: [DonatePageGuard], + }, + { path: '', component: HomeRouteTestComponent }, + ]), + { provide: IS_DONATION_UI_RESTRICTED_TOKEN, useValue: isRestricted }, + ], + }); + return TestBed.inject(Router); + }; + + it('navigates to the donate page on unrestricted platforms', async () => { + const router = setup(false); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/donate'); + }); + + it('redirects donate navigation on restricted platforms', async () => { + const router = setup(true); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/'); + }); + + it('protects the donate route', () => { + const donateRoute = APP_ROUTES.find((route) => route.path === 'donate'); + + expect(donateRoute?.canActivate).toContain(DonatePageGuard); + }); +}); diff --git a/src/app/app.guard.ts b/src/app/app.guard.ts index b9f918db8f..74825679fe 100644 --- a/src/app/app.guard.ts +++ b/src/app/app.guard.ts @@ -17,6 +17,17 @@ import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.sel import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { getStartPageUrlPath } from './features/config/default-start-page.util'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; + +@Injectable({ providedIn: 'root' }) +export class DonatePageGuard { + private _isDonationUiRestricted = inject(IS_DONATION_UI_RESTRICTED_TOKEN); + private _router = inject(Router); + + canActivate(): true | UrlTree { + return this._isDonationUiRestricted ? this._router.parseUrl('/') : true; + } +} @Injectable({ providedIn: 'root' }) export class ActiveWorkContextGuard { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index aabaa27d29..b232ab6fd6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { Routes } from '@angular/router'; import { ActiveWorkContextGuard, DefaultStartPageGuard, + DonatePageGuard, FocusOverlayOpenGuard, ValidProjectIdGuard, ValidTagIdGuard, @@ -43,6 +44,13 @@ export const APP_ROUTES: Routes = [ data: { page: 'config' }, canActivate: [FocusOverlayOpenGuard], }, + { + path: 'sync-conflicts', + loadComponent: () => + import('./routes/pages.routes').then((m) => m.SyncConflictsPageComponent), + data: { page: 'sync-conflicts' }, + canActivate: [FocusOverlayOpenGuard], + }, { path: 'search', loadComponent: () => @@ -94,7 +102,7 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./routes/pages.routes').then((m) => m.DonatePageComponent), data: { page: 'donate' }, - canActivate: [FocusOverlayOpenGuard], + canActivate: [DonatePageGuard, FocusOverlayOpenGuard], }, { path: 'contrast-test', diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index 8d24b098b7..27793fb155 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -36,7 +36,7 @@ import { import { GlobalConfigService } from '../../features/config/global-config.service'; import { AppFeaturesConfig } from '../../features/config/global-config.model'; import { SnackService } from '../../core/snack/snack.service'; -import { IS_APPLE_APP_STORE } from '../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../app.constants'; @Injectable({ providedIn: 'root', @@ -252,9 +252,9 @@ export class MagicNavConfigService { }, // Help Menu (rendered as mat-menu) - // Not allowed to display donation stuff on the iOS or Mac App Store per - // App Store guidelines (Guideline 3.1.1) - ...(this.isDonatePageEnabled() && !IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop build + // to keep App Store review behavior deterministic (Guideline 3.1.1). + ...(this.isDonatePageEnabled() && !IS_DONATION_UI_RESTRICTED ? [ { type: 'route', @@ -293,9 +293,9 @@ export class MagicNavConfigService { icon: 'feedback', href: 'https://github.com/super-productivity/super-productivity/discussions', }, - // Not allowed to display donation stuff on the iOS or Mac App Store - // per App Store guidelines (Guideline 3.1.1) - ...(!IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop + // build to keep App Store review behavior deterministic. + ...(!IS_DONATION_UI_RESTRICTED ? [ { type: 'href' as const, diff --git a/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html b/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html index 8c5ff5df7b..3694392bc6 100644 --- a/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html +++ b/src/app/core-ui/magic-side-nav/nav-item/nav-item.component.html @@ -38,6 +38,7 @@ #settingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert @@ -81,6 +82,7 @@ #folderSettingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert @@ -148,6 +150,7 @@ #featureSettingsBtn class="additional-btn" mat-icon-button + [attr.aria-label]="T.G.MORE_ACTIONS | translate" > more_vert diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index a4558421ce..b4297810e4 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -50,6 +50,18 @@ @if (isSyncIconEnabled()) { - -
- - @for (button of sidePanelButtons(); track button.pluginId) { - - } - - - - - - - @if (isIssuesPanelEnabled()) { - - - } - - @if (isProjectNotesEnabled()) { - - - } -
- - `, - styleUrls: ['./mobile-side-panel-menu.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MobileSidePanelMenuComponent { - readonly T = T; - readonly layoutService = inject(LayoutService); - readonly taskViewCustomizerService = inject(TaskViewCustomizerService); - readonly breakpointObserver = inject(BreakpointObserver); - - private _globalConfigService = inject(GlobalConfigService); - private _pluginBridge = inject(PluginBridgeService); - private _store = inject(Store); - private _router = inject(Router); - - // State signals - readonly isShowMobileMenu = signal(false); - - // Convert observables to signals - readonly isRouteWithSidePanel = toSignal( - this._router.events.pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - map((event) => true), // Always true since right-panel is now global - startWith(true), // Always true since right-panel is now global - ), - { initialValue: true }, - ); - - readonly isWorkViewPage = toSignal( - this._router.events.pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - map((event) => !!event.urlAfterRedirects.match(/tasks$/)), - startWith(!!this._router.url.match(/tasks$/)), - ), - { initialValue: !!this._router.url.match(/tasks$/) }, - ); - - readonly kb: KeyboardConfig = this._globalConfigService.cfg()?.keyboard || {}; - - // Plugin-related signals - readonly sidePanelButtons = this._pluginBridge.sidePanelButtons; - readonly activePluginId = toSignal(this._store.select(selectActivePluginId)); - readonly isShowPluginPanel = toSignal(this._store.select(selectIsShowPluginPanel)); - - // Navigation signals - readonly currentRoute = toSignal( - this._router.events.pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this._router.url), - ), - { initialValue: this._router.url }, - ); - - readonly isWorkView = computed(() => { - const url = this.currentRoute(); - return url.includes('/active/') || url.includes('/tag/') || url.includes('/project/'); - }); - - // Panel state signals - readonly isShowNotes = computed(() => this.layoutService.isShowNotes()); - readonly isShowIssuePanel = computed(() => this.layoutService.isShowIssuePanel()); - readonly isIssuesPanelEnabled = computed( - () => this._globalConfigService.appFeatures().isIssuesPanelEnabled, - ); - readonly isProjectNotesEnabled = computed( - () => this._globalConfigService.appFeatures().isProjectNotesEnabled, - ); - readonly isShowTaskViewCustomizerPanel = computed(() => - this.layoutService.isShowTaskViewCustomizerPanel(), - ); - - // Computed signal for active panel - readonly hasActivePanel = computed(() => { - return !!( - this.isShowNotes() || - this.isShowIssuePanel() || - this.isShowTaskViewCustomizerPanel() || - (this.activePluginId() && this.isShowPluginPanel()) - ); - }); - - toggleMenu(): void { - this.isShowMobileMenu.update((v) => !v); - } - - onPluginButtonClick(button: { - pluginId: string; - onClick?: () => void; - label?: string; - icon?: string; - }): void { - this._store.dispatch(togglePluginPanel(button.pluginId)); - - if (button.onClick) { - button.onClick(); - } - - // Close mobile menu after action - this.isShowMobileMenu.set(false); - } - - toggleIssuePanel(): void { - this.layoutService.toggleAddTaskPanel(); - this.isShowMobileMenu.set(false); - } - - toggleNotes(): void { - this.layoutService.toggleNotes(); - this.isShowMobileMenu.set(false); - } -} diff --git a/src/app/core/banner/banner.model.ts b/src/app/core/banner/banner.model.ts index c1e99f535b..d5e827f1ea 100644 --- a/src/app/core/banner/banner.model.ts +++ b/src/app/core/banner/banner.model.ts @@ -17,6 +17,7 @@ export enum BannerId { SuperSyncEncryptionMigration = 'SuperSyncEncryptionMigration', RatePrompt = 'RatePrompt', SyncConflictContentResolved = 'SyncConflictContentResolved', + SyncConflictsAutoResolved = 'SyncConflictsAutoResolved', UpdateAvailable = 'UpdateAvailable', } @@ -37,6 +38,7 @@ export const BANNER_SORT_PRIO_MAP = { [BannerId.SuperSyncEncryptionMigration]: 0, [BannerId.RatePrompt]: 0, [BannerId.SyncConflictContentResolved]: 1, + [BannerId.SyncConflictsAutoResolved]: 0, [BannerId.UpdateAvailable]: 0, } as const; diff --git a/src/app/core/banner/banner/banner.component.html b/src/app/core/banner/banner/banner.component.html index 0944495651..3d4df3da01 100644 --- a/src/app/core/banner/banner/banner.component.html +++ b/src/app/core/banner/banner/banner.component.html @@ -2,6 +2,8 @@
@if (banner.progress$ && (banner.progress$ | async); as progress) { @if (progress > 0) { @@ -14,7 +16,10 @@
@if (banner.img) {
- +
}
@@ -27,7 +32,10 @@ @if (banner.timer$) {
- {{ banner.timer$ | async | msToMinuteClockString }} – + + – {{ banner.msg | translate: banner.translateParams }}
} @else { diff --git a/src/app/core/base-component/base.component.ts b/src/app/core/base-component/base.component.ts deleted file mode 100644 index 942f47f4fc..0000000000 --- a/src/app/core/base-component/base.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; -import { Subject } from 'rxjs'; - -@Component({ - selector: 'base', - template: '', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class BaseComponent implements OnDestroy { - protected onDestroy$ = new Subject(); - - ngOnDestroy(): void { - this.onDestroy$.next(); - this.onDestroy$.complete(); - } -} diff --git a/src/app/core/clipboard-image/index.ts b/src/app/core/clipboard-image/index.ts deleted file mode 100644 index 3f09a06da4..0000000000 --- a/src/app/core/clipboard-image/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './clipboard-image.service'; -export * from './resolve-clipboard-images.directive'; diff --git a/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts b/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts deleted file mode 100644 index e41f1c5dff..0000000000 --- a/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Pipe, PipeTransform, inject } from '@angular/core'; -import { ClipboardImageService } from './clipboard-image.service'; -import { Observable, from, of, defer } from 'rxjs'; -import { map, shareReplay, catchError } from 'rxjs/operators'; -import { Log } from '../log'; - -@Pipe({ - name: 'resolveClipboardUrl', - standalone: true, -}) -export class ResolveClipboardUrlPipe implements PipeTransform { - private readonly _clipboardImageService = inject(ClipboardImageService); - private readonly _cache = new Map>(); - - transform(url: string | undefined | null): Observable { - if (!url) { - return of(''); - } - - // If it's not an indexeddb URL, return as-is - if (!url.startsWith('indexeddb://clipboard-images/')) { - return of(url); - } - - // Check cache first - if already resolving or resolved, return the cached observable - const cached = this._cache.get(url); - if (cached) { - return cached; - } - - // Use defer to ensure the promise is created fresh and properly handled - const resolved$ = defer(() => - from(this._clipboardImageService.resolveIndexedDbUrl(url)), - ).pipe( - map((resolvedUrl) => { - if (resolvedUrl) { - return resolvedUrl; - } - return url; - }), - catchError((error) => { - Log.err('Error resolving clipboard URL:', error); - return of(url); - }), - shareReplay({ bufferSize: 1, refCount: false }), - ); - - // Cache immediately before returning so concurrent calls will get the same observable - this._cache.set(url, resolved$); - return resolved$; - } -} diff --git a/src/app/core/drop-paste-input/eml-drop.directive.ts b/src/app/core/drop-paste-input/eml-drop.directive.ts index d9a588ba14..ed6f5e36c3 100644 --- a/src/app/core/drop-paste-input/eml-drop.directive.ts +++ b/src/app/core/drop-paste-input/eml-drop.directive.ts @@ -1,5 +1,5 @@ import { Directive, HostListener, inject } from '@angular/core'; -import { isFileEml } from '../../util/eml-parser'; +import { isFileEml } from '../../util/is-file-eml'; import { EmlDropService } from './eml-drop.service'; @Directive({ diff --git a/src/app/core/drop-paste-input/eml-drop.service.spec.ts b/src/app/core/drop-paste-input/eml-drop.service.spec.ts index 7ab0625ff9..d566f8d620 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.spec.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.spec.ts @@ -52,7 +52,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example: Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -65,7 +65,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Hello World', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -77,7 +77,7 @@ describe('EmlDropService', () => { expect(taskService.add).toHaveBeenCalledWith( 'Alice Example', false, - { notes: 'body' }, + { notes: '```\nbody\n```' }, false, true, ); @@ -99,6 +99,67 @@ describe('EmlDropService', () => { ); }); + it('should create a title-only task when the body encoding is unsupported', async () => { + const encodedEml = [ + 'From: Alice ', + 'Subject: Encoded', + 'Content-Type: text/plain', + 'Content-Transfer-Encoding: base64', + '', + 'Ym9keQ==', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(encodedEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Encoded', + false, + { notes: undefined }, + false, + true, + ); + }); + + it('should store imported plain text literally so remote content stays inert', async () => { + const remoteImageEml = [ + 'From: Alice ', + 'Subject: Remote image', + '', + '![pixel](https://attacker.example/pixel)', + '\\![already escaped](https://attacker.example/pixel)', + '![reference image][pixel]', + '[pixel]: https://attacker.example/pixel', + '`C:\\tmp`', + 'ordinary markup', + '', + '```', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(remoteImageEml)); + + expect(taskService.add).toHaveBeenCalledWith( + 'Alice: Remote image', + false, + { + notes: + '~~~\n' + + '![pixel](https://attacker.example/pixel)\n' + + '\\![already escaped](https://attacker.example/pixel)\n' + + '![reference image][pixel]\n' + + '[pixel]: https://attacker.example/pixel\n' + + '`C:\\tmp`\n' + + 'ordinary markup\n' + + '\n' + + '```\n' + + '~~~', + }, + false, + true, + ); + }); + it('should not parse a valid eml or add a task when the file exceeds the size limit', async () => { const bigFile = makeFile(VALID_EML); // Report an oversized file without allocating 10MB. @@ -113,6 +174,39 @@ describe('EmlDropService', () => { }); }); + it('should cap an oversized body so the synced note stays bounded', async () => { + const maxBodyLength = 100_000; + const longBody = 'a'.repeat(maxBodyLength + 50); + const eml = [ + 'From: Alice ', + 'Subject: Long', + '', + longBody, + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const notes = taskService.add.calls.mostRecent().args[2]?.notes; + expect(notes).toBe('```\n' + 'a'.repeat(maxBodyLength) + '…\n```'); + }); + + it('should cap an oversized title', async () => { + const eml = [ + 'From: Alice ', + 'Subject: ' + 'S'.repeat(350), + '', + 'body', + '', + ].join('\n'); + + await service.createTaskFromEml(makeFile(eml)); + + const title = taskService.add.calls.mostRecent().args[0]; + expect(title).toBe('Alice: ' + 'S'.repeat(293) + '…'); + expect(title?.length).toBe(301); + }); + it('should show a warning snack and not add a task when both sender and subject are empty', async () => { await service.createTaskFromEml(makeFile(EMPTY_EML)); @@ -126,13 +220,12 @@ describe('EmlDropService', () => { it('should log and show an error snack without adding a task when parsing fails', async () => { const logErrSpy = spyOn(Log, 'err'); const file = makeFile(VALID_EML); - // postal-mime reads a Blob via arrayBuffer(), so that's the read to fail. - spyOn(file, 'arrayBuffer').and.rejectWith(new Error('read failed')); + spyOn(file, 'text').and.rejectWith(new Error('read failed')); await service.createTaskFromEml(file); expect(taskService.add).not.toHaveBeenCalled(); - expect(logErrSpy).toHaveBeenCalled(); + expect(logErrSpy).toHaveBeenCalledOnceWith('Failed to parse EML file'); expect(snackService.open).toHaveBeenCalledWith({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR, diff --git a/src/app/core/drop-paste-input/eml-drop.service.ts b/src/app/core/drop-paste-input/eml-drop.service.ts index 6d53aefc96..8c55fb3de7 100644 --- a/src/app/core/drop-paste-input/eml-drop.service.ts +++ b/src/app/core/drop-paste-input/eml-drop.service.ts @@ -2,13 +2,18 @@ import { inject, Injectable } from '@angular/core'; import { TaskService } from '../../features/tasks/task.service'; import { SnackService } from '../snack/snack.service'; import { Log } from '../log'; -import { parseEml } from '../../util/eml-parser'; import { T } from '../../t.const'; -// postal-mime parses synchronously on the main thread, and the body becomes a -// note that syncs to every device. Bound the untrusted input so a pathological -// .eml can't freeze the UI or balloon the op-log. +// Parsing runs on the main thread, and the body becomes a note that syncs to every +// device. Bound the untrusted input so a pathological .eml can't freeze the UI or +// balloon the op-log. const MAX_EML_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +// The title and body sync to every device as ops. Bound them independently of the +// file size: the literal fence can up to double the body length, and a crafted +// `.eml` can carry a multi-MB Subject, either of which would bloat the op-log. +// shortcut: fixed caps — make configurable if real emails legitimately exceed them. +const MAX_EML_TITLE_LENGTH = 300; +const MAX_EML_BODY_LENGTH = 100_000; @Injectable({ providedIn: 'root', @@ -24,6 +29,7 @@ export class EmlDropService { } try { + const { parseEml } = await import('../../util/eml-parser'); const data = await parseEml(file); const sender = (data.from?.name || data.from?.address || '').trim(); @@ -35,23 +41,44 @@ export class EmlDropService { return; } - const title = [sender, subject].filter(Boolean).join(': '); - // Keep the email body as notes so the task retains context, not just a - // title. Use the plain-text part only (never data.html) — notes render as - // markdown, so injecting untrusted email HTML would be an XSS vector. - const notes = data.text?.trim() || undefined; + const title = _truncate( + [sender, subject].filter(Boolean).join(': '), + MAX_EML_TITLE_LENGTH, + ); + // A text/plain body is external text, not trusted Markdown. Store it in a + // fence that cannot occur in the body so rendering stays literal and inert. + const plainText = data.text?.trim(); + const notes = plainText + ? _asLiteralMarkdown(_truncate(plainText, MAX_EML_BODY_LENGTH)) + : undefined; // isIgnoreShortSyntax: the subject is untrusted external content — don't // let ShortSyntaxEffects parse #tag/@date/+project tokens out of it. this._taskService.add(title, false, { notes }, false, true); - } catch (e) { - // Log a bounded reason, not the raw error: the source is untrusted email - // content and log history is exportable (rule #9). postal-mime's throw - // messages are structural (no message content), so the reason is safe to keep. - Log.err( - 'Failed to parse EML file', - e instanceof Error ? e.message : 'Unknown error', - ); + } catch { + // Error details cross an untrusted file boundary and may contain user content. + Log.err('Failed to parse EML file'); this._snackService.open({ type: 'ERROR', msg: T.MH.EML_PARSE_ERROR }); } } } + +const _truncate = (text: string, max: number): string => + text.length > max ? `${text.slice(0, max)}…` : text; + +const _asLiteralMarkdown = (text: string): string => { + const backtickFence = '`'.repeat(_getFenceLength(text, /^ {0,3}(`{3,})/gm)); + const tildeFence = '~'.repeat(_getFenceLength(text, /^ {0,3}(~{3,})/gm)); + const fence = backtickFence.length <= tildeFence.length ? backtickFence : tildeFence; + + return `${fence}\n${text}\n${fence}`; +}; + +const _getFenceLength = (text: string, fencePattern: RegExp): number => { + let fenceLength = 3; + + for (const match of text.matchAll(fencePattern)) { + fenceLength = Math.max(fenceLength, match[1].length + 1); + } + + return fenceLength; +}; diff --git a/src/app/core/theme/validated-color-palettes.const.ts b/src/app/core/theme/validated-color-palettes.const.ts deleted file mode 100644 index 87ceb21926..0000000000 --- a/src/app/core/theme/validated-color-palettes.const.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { WorkContextThemeCfg } from '../../features/work-context/work-context.model'; - -/** - * Pre-validated color palette that meets WCAG AA contrast standards (4.5:1) - * for both light and dark themes when used on standard backgrounds. - */ -export interface ValidatedColorPalette { - /** Display name for the palette */ - name: string; - /** Theme configuration with validated colors */ - theme: WorkContextThemeCfg; - /** Description for UI display */ - description?: string; -} - -/** - * Collection of pre-validated color palettes. - * All palettes are tested to meet WCAG AA contrast standards (4.5:1 ratio) - * on both light (#f8f8f7) and dark (#131314) backgrounds. - */ -export const VALIDATED_COLOR_PALETTES: readonly ValidatedColorPalette[] = [ - { - name: 'Purple & Pink (Default)', - description: 'The classic Super Productivity theme', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#8b4a9d', // Darker purple for better contrast (was #a05db1) - huePrimary: '500', - accent: '#d81b60', // Darker pink for better contrast (was #ff4081) - hueAccent: '500', - warn: '#c62828', // Darker red for better contrast (was #e11826) - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Ocean Blue', - description: 'Professional blue tones', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#1976d2', // Material Blue 700 - huePrimary: '500', - accent: '#0277bd', // Light Blue 800 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Forest Green', - description: 'Calming natural greens', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#388e3c', // Green 700 - huePrimary: '500', - accent: '#00796b', // Teal 700 - hueAccent: '500', - warn: '#d32f2f', // Red 700 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Sunset Orange', - description: 'Warm and energetic', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#e64a19', // Deep Orange 700 - huePrimary: '500', - accent: '#f57c00', // Orange 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Deep Teal', - description: 'Modern and sophisticated', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#00796b', // Teal 700 - huePrimary: '500', - accent: '#0097a7', // Cyan 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Ruby Red', - description: 'Bold and striking', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#c62828', // Red 800 - huePrimary: '500', - accent: '#ad1457', // Pink 800 - hueAccent: '500', - warn: '#d84315', // Deep Orange 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Indigo Night', - description: 'Deep and focused', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#283593', // Indigo 800 - huePrimary: '500', - accent: '#303f9f', // Indigo 700 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Amber Glow', - description: 'Bright and cheerful', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#f57f17', // Yellow 800 - huePrimary: '500', - accent: '#ef6c00', // Orange 800 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Slate Gray', - description: 'Neutral and elegant', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#455a64', // Blue Gray 700 - huePrimary: '500', - accent: '#546e7a', // Blue Gray 600 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, - { - name: 'Lime Fresh', - description: 'Vibrant and lively', - theme: { - isAutoContrast: true, - isDisableBackgroundTint: false, - primary: '#689f38', // Light Green 700 - huePrimary: '500', - accent: '#7cb342', // Light Green 600 - hueAccent: '500', - warn: '#c62828', // Red 800 - hueWarn: '500', - backgroundImageDark: null, - backgroundImageLight: null, - }, - }, -] as const; - -/** - * Get a validated palette by name - */ -export const getValidatedPalette = (name: string): ValidatedColorPalette | undefined => { - return VALIDATED_COLOR_PALETTES.find((p) => p.name === name); -}; - -/** - * Get the default validated palette - */ -export const getDefaultValidatedPalette = (): ValidatedColorPalette => { - return VALIDATED_COLOR_PALETTES[0]; -}; diff --git a/src/app/core/update-check/update-check.service.spec.ts b/src/app/core/update-check/update-check.service.spec.ts index 9964a7d2b5..bb8d6227d8 100644 --- a/src/app/core/update-check/update-check.service.spec.ts +++ b/src/app/core/update-check/update-check.service.spec.ts @@ -60,7 +60,6 @@ describe('UpdateCheckService', () => { describe('checkForUpdate()', () => { it('should open a banner when a newer version is available', async () => { - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v99.0.0' }); expect(bannerService.open).toHaveBeenCalledTimes(1); const banner: Banner = bannerService.open.calls.mostRecent().args[0]; @@ -70,21 +69,18 @@ describe('UpdateCheckService', () => { }); it('should do nothing when already on the latest version', async () => { - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: `v${environment.version}` }); expect(bannerService.open).not.toHaveBeenCalled(); expect(snackService.open).not.toHaveBeenCalled(); }); it('should do nothing when the latest release is older (dev build)', async () => { - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v0.0.1' }); expect(bannerService.open).not.toHaveBeenCalled(); }); it('should show an up-to-date snack for a user-triggered check', async () => { await checkAndRespond( - // eslint-disable-next-line @typescript-eslint/naming-convention { tag_name: `v${environment.version}` }, { isUserTriggered: true }, ); @@ -96,21 +92,18 @@ describe('UpdateCheckService', () => { it('should not re-show the banner for a dismissed version', async () => { localStorage.setItem(LS.UPDATE_CHECK_DISMISSED_VERSION, 'v99.0.0'); - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v99.0.0' }); expect(bannerService.open).not.toHaveBeenCalled(); }); it('should show the banner for a dismissed version when user-triggered', async () => { localStorage.setItem(LS.UPDATE_CHECK_DISMISSED_VERSION, 'v99.0.0'); - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v99.0.0' }, { isUserTriggered: true }); expect(bannerService.open).toHaveBeenCalledTimes(1); }); it('should show the banner again for a newer version than the dismissed one', async () => { localStorage.setItem(LS.UPDATE_CHECK_DISMISSED_VERSION, 'v99.0.0'); - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v99.0.1' }); expect(bannerService.open).toHaveBeenCalledTimes(1); }); @@ -159,7 +152,6 @@ describe('UpdateCheckService', () => { it('should reject a tag that is not strictly a version tag', async () => { // lenient parsing is fine for the LOCAL version, but remote tags are // untrusted input and must match exactly - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: '99.0.0' }, { isUserTriggered: true }); expect(bannerService.open).not.toHaveBeenCalled(); expect(snackService.open).toHaveBeenCalledWith( @@ -171,7 +163,7 @@ describe('UpdateCheckService', () => { const p1 = service.checkForUpdate(); const p2 = service.checkForUpdate(); // expectOne throws if the guard failed and two requests were made - httpMock.expectOne(RELEASES_API_URL).flush({ tag_name: 'v99.0.0' }); // eslint-disable-line @typescript-eslint/naming-convention + httpMock.expectOne(RELEASES_API_URL).flush({ tag_name: 'v99.0.0' }); await Promise.all([p1, p2]); expect(bannerService.open).toHaveBeenCalledTimes(1); }); @@ -200,7 +192,6 @@ describe('UpdateCheckService', () => { expect(localStorage.getItem(LS.UPDATE_CHECK_DISMISSED_VERSION)).toBe('v99.0.0'); bannerService.open.calls.reset(); - // eslint-disable-next-line @typescript-eslint/naming-convention await checkAndRespond({ tag_name: 'v99.0.0' }); expect(bannerService.open).not.toHaveBeenCalled(); }); diff --git a/src/app/features/archive/store/archive-old.reducer.ts b/src/app/features/archive/store/archive-old.reducer.ts deleted file mode 100644 index f81b4b07a6..0000000000 --- a/src/app/features/archive/store/archive-old.reducer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createFeatureSelector, createReducer, on } from '@ngrx/store'; -import { loadAllData } from '../../../root-store/meta/load-all-data.action'; -import { ArchiveModel } from '../archive.model'; - -export const ARCHIVE_OLD_FEATURE_NAME = 'archiveOld'; - -export const initialArchiveOldState: ArchiveModel | null = null; - -export const archiveOldReducer = createReducer( - initialArchiveOldState, - on( - loadAllData, - (_state, { appDataComplete }) => - (appDataComplete as { archiveOld?: ArchiveModel | null }).archiveOld ?? null, - ), -); - -export const selectArchiveOldFeatureState = createFeatureSelector( - ARCHIVE_OLD_FEATURE_NAME, -); diff --git a/src/app/features/archive/store/archive-young.reducer.ts b/src/app/features/archive/store/archive-young.reducer.ts deleted file mode 100644 index e6d67da612..0000000000 --- a/src/app/features/archive/store/archive-young.reducer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createFeatureSelector, createReducer, on } from '@ngrx/store'; -import { loadAllData } from '../../../root-store/meta/load-all-data.action'; -import { ArchiveModel } from '../archive.model'; - -export const ARCHIVE_YOUNG_FEATURE_NAME = 'archiveYoung'; - -export const initialArchiveYoungState: ArchiveModel | null = null; - -export const archiveYoungReducer = createReducer( - initialArchiveYoungState, - on( - loadAllData, - (_state, { appDataComplete }) => - (appDataComplete as { archiveYoung?: ArchiveModel | null }).archiveYoung ?? null, - ), -); - -export const selectArchiveYoungFeatureState = createFeatureSelector( - ARCHIVE_YOUNG_FEATURE_NAME, -); diff --git a/src/app/features/boards/board-panel/board-panel.component.html b/src/app/features/boards/board-panel/board-panel.component.html index 2f21d1cd57..5954954d53 100644 --- a/src/app/features/boards/board-panel/board-panel.component.html +++ b/src/app/features/boards/board-panel/board-panel.component.html @@ -12,6 +12,7 @@ diff --git a/src/app/features/config/form-cfgs/app-features-form.const.ts b/src/app/features/config/form-cfgs/app-features-form.const.ts index 774eef6c17..e1d1431564 100644 --- a/src/app/features/config/form-cfgs/app-features-form.const.ts +++ b/src/app/features/config/form-cfgs/app-features-form.const.ts @@ -1,6 +1,6 @@ import { ConfigFormSection, AppFeaturesConfig } from '../global-config.model'; import { T } from '../../../t.const'; -import { IS_APPLE_APP_STORE } from '../../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../../app.constants'; export const EXPERIMENTAL_APP_FEATURE_KEYS: ReadonlyArray = [ 'isEnableUserProfiles', @@ -94,9 +94,9 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection = { { key: 'isDonatePageEnabled', type: 'slide-toggle', - // Donations are fully hidden on Apple App Store builds (Guideline 3.1.1), - // so this toggle would be inert there — hide it to avoid a dead control. - hideExpression: () => IS_APPLE_APP_STORE, + // Donations are fully hidden on native iOS and macOS desktop builds, so + // this toggle would be inert there — hide it to avoid a dead control. + hideExpression: () => IS_DONATION_UI_RESTRICTED, templateOptions: { label: T.GCF.APP_FEATURES.DONATE_PAGE, icon: 'favorite', diff --git a/src/app/features/config/form-cfgs/keyboard-form.const.ts b/src/app/features/config/form-cfgs/keyboard-form.const.ts index f864326d6c..1ff0bcd7cf 100644 --- a/src/app/features/config/form-cfgs/keyboard-form.const.ts +++ b/src/app/features/config/form-cfgs/keyboard-form.const.ts @@ -56,26 +56,12 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection = { T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL, ), kbField('toggleIssuePanel', T.GCF.KEYBOARD.TOGGLE_ISSUE_PANEL), - // { - // key: 'showHelp', - // type: 'keyboard', - // templateOptions: { - // label: T.GCF.KEYBOARD.SHOW_HELP - // }, - // }, kbField('showSearchBar', T.GCF.KEYBOARD.SHOW_SEARCH_BAR), kbField('toggleBacklog', T.GCF.KEYBOARD.TOGGLE_BACKLOG), kbField('goToWorkView', T.GCF.KEYBOARD.GO_TO_WORK_VIEW), kbField('goToFocusMode', T.GCF.KEYBOARD.GO_TO_FOCUS_MODE), kbField('goToTimeline', T.GCF.KEYBOARD.GO_TO_SCHEDULE), kbField('goToScheduledView', T.GCF.KEYBOARD.GO_TO_SCHEDULED_VIEW), - // { - // key: 'goToDailyAgenda', - // type: 'keyboard', - // templateOptions: { - // label: T.GCF.KEYBOARD.GO_TO_DAILY_AGENDA - // }, - // }, kbField('goToSettings', T.GCF.KEYBOARD.GO_TO_SETTINGS), kbField('zoomIn', T.GCF.KEYBOARD.ZOOM_IN), kbField('zoomOut', T.GCF.KEYBOARD.ZOOM_OUT), diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 787b495661..6b97bce35d 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -80,7 +80,7 @@ const createWebdavFormFields = (options: { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.WebDAV, + isSyncProvider(field, 2, SyncProviderId.WebDAV), }, }, { @@ -92,7 +92,7 @@ const createWebdavFormFields = (options: { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.WebDAV, + isSyncProvider(field, 2, SyncProviderId.WebDAV), }, }, { @@ -105,7 +105,7 @@ const createWebdavFormFields = (options: { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.WebDAV, + isSyncProvider(field, 2, SyncProviderId.WebDAV), }, }, { @@ -117,14 +117,42 @@ const createWebdavFormFields = (options: { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.WebDAV, + isSyncProvider(field, 2, SyncProviderId.WebDAV), }, }, ]; }; +/** + * Reads the sync form's root `syncProvider`, given a field some number of + * `.parent` hops below the root. Centralized because Formly's parent-depth + * is otherwise easy to get wrong when copy-pasting a predicate to a field at + * a different nesting level: `depth: 1` for a field placed directly in + * `SYNC_FORM.items` (typically a provider's `fieldGroup` wrapper), `depth: 2` + * for a field nested one level inside such a fieldGroup. + */ +const rootSyncProvider = ( + field: FormlyFieldConfig | undefined, + depth: 1 | 2, +): SyncProviderId | null | undefined => { + const root = depth === 1 ? field?.parent : field?.parent?.parent; + return root?.model?.syncProvider; +}; + +const isSyncProvider = ( + field: FormlyFieldConfig | undefined, + depth: 1 | 2, + providerId: SyncProviderId | null, +): boolean => rootSyncProvider(field, depth) === providerId; + +const isNotSyncProvider = ( + field: FormlyFieldConfig | undefined, + depth: 1 | 2, + providerId: SyncProviderId | null, +): boolean => rootSyncProvider(field, depth) !== providerId; + const isOneDriveClientIdRequired = (field: FormlyFieldConfig): boolean => { - if (field?.parent?.parent?.model?.syncProvider !== SyncProviderId.OneDrive) { + if (!isSyncProvider(field, 2, SyncProviderId.OneDrive)) { return false; } @@ -182,8 +210,7 @@ export const SYNC_FORM: ConfigFormSection = { }, { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.LocalFile || - IS_ANDROID_WEB_VIEW, + isNotSyncProvider(field, 1, SyncProviderId.LocalFile) || IS_ANDROID_WEB_VIEW, resetOnHide: false, key: 'localFileSync', fieldGroup: [ @@ -216,8 +243,7 @@ export const SYNC_FORM: ConfigFormSection = { }, { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.LocalFile || - !IS_ANDROID_WEB_VIEW, + isNotSyncProvider(field, 1, SyncProviderId.LocalFile) || !IS_ANDROID_WEB_VIEW, resetOnHide: false, key: 'localFileSync', fieldGroup: [ @@ -246,7 +272,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.LocalFile, + isSyncProvider(field, 2, SyncProviderId.LocalFile), }, }, ], @@ -255,7 +281,7 @@ export const SYNC_FORM: ConfigFormSection = { // Nextcloud provider form fields { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.Nextcloud, + isNotSyncProvider(field, 1, SyncProviderId.Nextcloud), resetOnHide: false, key: 'nextcloud', fieldGroup: [ @@ -280,7 +306,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.Nextcloud, + isSyncProvider(field, 2, SyncProviderId.Nextcloud), }, }, { @@ -292,7 +318,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.Nextcloud, + isSyncProvider(field, 2, SyncProviderId.Nextcloud), }, }, { @@ -313,7 +339,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.Nextcloud, + isSyncProvider(field, 2, SyncProviderId.Nextcloud), }, }, { @@ -324,7 +350,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.Nextcloud, + isSyncProvider(field, 2, SyncProviderId.Nextcloud), }, }, ], @@ -332,8 +358,7 @@ export const SYNC_FORM: ConfigFormSection = { // WebDAV provider form fields { - hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.WebDAV, + hideExpression: (m, v, field) => isNotSyncProvider(field, 1, SyncProviderId.WebDAV), resetOnHide: false, key: 'webDav', fieldGroup: createWebdavFormFields({ @@ -348,7 +373,7 @@ export const SYNC_FORM: ConfigFormSection = { // Note: No key needed - Dropbox credentials stored privately via SyncCredentialStore { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.Dropbox, + isNotSyncProvider(field, 1, SyncProviderId.Dropbox), resetOnHide: false, fieldGroup: [ { @@ -363,7 +388,7 @@ export const SYNC_FORM: ConfigFormSection = { // OneDrive provider form fields { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.OneDrive, + isNotSyncProvider(field, 1, SyncProviderId.OneDrive), resetOnHide: false, key: 'oneDrive', fieldGroup: [ @@ -431,7 +456,7 @@ export const SYNC_FORM: ConfigFormSection = { // OneDrive provider authentication panel { hideExpression: (m, v, field) => - field?.parent?.model.syncProvider !== SyncProviderId.OneDrive, + isNotSyncProvider(field, 1, SyncProviderId.OneDrive), resetOnHide: false, props: {}, fieldGroup: [ @@ -468,8 +493,7 @@ export const SYNC_FORM: ConfigFormSection = { btnType: 'primary', btnStyle: 'stroked', onClick: async (field: FormlyFieldConfig) => { - const isSuperSync = - field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync; + const isSuperSync = isSyncProvider(field, 2, SyncProviderId.SuperSync); await (isSuperSync ? openEncryptionPasswordChangeDialog() : openEncryptionPasswordChangeDialogForFileBased()); @@ -479,7 +503,7 @@ export const SYNC_FORM: ConfigFormSection = { // Hide disable encryption for SuperSync — encryption is mandatory { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync, + isSyncProvider(field, 2, SyncProviderId.SuperSync), type: 'btn', className: 'e2e-disable-encryption-btn', templateOptions: { @@ -510,8 +534,7 @@ export const SYNC_FORM: ConfigFormSection = { // The dialog component drops this hide in edit mode and appends action buttons. { type: 'collapsible', - hideExpression: (m, v, field) => - field?.parent?.model.syncProvider === SyncProviderId.SuperSync, + hideExpression: (m, v, field) => isSyncProvider(field, 1, SyncProviderId.SuperSync), // syncRole is a stable structural marker the dialog routes on, so a // future global rename of T.G.ADVANCED_CFG cannot silently break it. props: { label: T.G.ADVANCED_CFG, syncRole: 'advanced' } as SyncCollapsibleProps, @@ -534,8 +557,7 @@ export const SYNC_FORM: ConfigFormSection = { key: 'isManualSyncOnly', type: 'checkbox', // Only show for file-based providers (Dropbox, WebDAV, LocalFile, Nextcloud) - hideExpression: (m, v, field) => - field?.parent?.parent?.model?.syncProvider === null, + hideExpression: (m, v, field) => isSyncProvider(field, 2, null), templateOptions: { label: T.F.SYNC.FORM.L_MANUAL_SYNC_ONLY, }, @@ -553,8 +575,8 @@ export const SYNC_FORM: ConfigFormSection = { key: 'isUseSplitSyncFiles', type: 'checkbox', hideExpression: (m, v, field) => - field?.parent?.parent?.model?.syncProvider === null || - field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync, + isSyncProvider(field, 2, null) || + isSyncProvider(field, 2, SyncProviderId.SuperSync), templateOptions: { label: T.F.SYNC.FORM.L_USE_SPLIT_SYNC_FILES, }, @@ -562,8 +584,7 @@ export const SYNC_FORM: ConfigFormSection = { // Enable encryption button for file-based providers (shown when encryption is disabled) { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => - field?.parent?.parent?.model.syncProvider === SyncProviderId.SuperSync || - m.isEncryptionEnabled, + isSyncProvider(field, 2, SyncProviderId.SuperSync) || m.isEncryptionEnabled, type: 'btn', className: 'e2e-file-based-enable-encryption-btn', templateOptions: { @@ -594,7 +615,7 @@ export const SYNC_FORM: ConfigFormSection = { // above already shows "Encryption password is set" in that case). { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync || + isNotSyncProvider(field, 2, SyncProviderId.SuperSync) || (field?.parent?.parent?.model?.isEncryptionEnabled ?? false), type: 'tpl', templateOptions: { @@ -605,7 +626,7 @@ export const SYNC_FORM: ConfigFormSection = { }, { hideExpression: (m, v, field) => - field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync, + isNotSyncProvider(field, 2, SyncProviderId.SuperSync), type: 'btn', templateOptions: { text: T.F.SYNC.FORM.SUPER_SYNC.BTN_GET_TOKEN, @@ -621,7 +642,7 @@ export const SYNC_FORM: ConfigFormSection = { }, { hideExpression: (m, v, field) => - field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync, + isNotSyncProvider(field, 2, SyncProviderId.SuperSync), key: 'accessToken', type: 'textarea', className: 'e2e-accessToken', @@ -632,7 +653,7 @@ export const SYNC_FORM: ConfigFormSection = { }, expressions: { 'props.required': (field: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync, + isSyncProvider(field, 2, SyncProviderId.SuperSync), }, }, // Encryption encouragement warning (shown when encryption is NOT enabled) @@ -642,7 +663,7 @@ export const SYNC_FORM: ConfigFormSection = { // the root flag from SuperSync's privateCfg.isEncryptionEnabled). { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync || + isNotSyncProvider(field, 2, SyncProviderId.SuperSync) || (field?.parent?.parent?.model?.isEncryptionEnabled ?? false) || field?.parent?.parent?.model?._isInitialSetup === true, type: 'tpl', @@ -656,7 +677,7 @@ export const SYNC_FORM: ConfigFormSection = { // Hidden during initial setup (encryption dialog opens automatically after save) { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => - field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync || + isNotSyncProvider(field, 2, SyncProviderId.SuperSync) || (field?.parent?.parent?.model?.isEncryptionEnabled ?? false) || field?.parent?.parent?.model?._isInitialSetup === true, type: 'btn', @@ -694,7 +715,7 @@ export const SYNC_FORM: ConfigFormSection = { { type: 'collapsible', hideExpression: (m, v, field) => - field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync, + isNotSyncProvider(field, 2, SyncProviderId.SuperSync), props: { label: T.G.ADVANCED_CFG, syncRole: 'advanced', diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 11e6835a32..3bc2910532 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -28,6 +28,7 @@ import { updateGlobalConfigSection } from './global-config.actions'; import { selectConfigFeatureState, selectLocalizationConfig, + selectMiscConfig, } from './global-config.reducer'; import { mapKeyboardConfigToQwerty } from '../keyboard-shortcut.util'; import { AppFeaturesConfig, MiscConfig } from '../global-config.model'; @@ -37,6 +38,8 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions' import { selectAllTasks } from '../../tasks/store/task.selectors'; import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config'; import { Log } from '../../../core/log'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { FULL_STATE_OP_TYPES } from '../../../op-log/core/operation.types'; const LAYOUT_DETECTION_TIMEOUT_MS = 1000; @@ -220,6 +223,34 @@ export class GlobalConfigEffects { ), ); + // Bulk replay intentionally hides its inner actions from effects. Reconcile this + // local, non-persistent runtime state after reducers finish, but keep the persistent + // dueDay migration in the direct local-change effect above. + setStartOfNextDayDiffOnBulkApply = createEffect(() => + this._actions$.pipe( + ofType(bulkApplyOperations), + filter(({ operations }) => + operations.some( + (op) => + (op.entityType === 'GLOBAL_CONFIG' && op.entityId === 'misc') || + FULL_STATE_OP_TYPES.has(op.opType), + ), + ), + withLatestFrom(this._store.select(selectMiscConfig)), + map(([, misc]) => { + const normalizedMisc = normalizeStartOfNextDayConfig(misc); + this._dateService.setStartOfNextDayDiff( + normalizedMisc.startOfNextDayTime, + normalizedMisc.startOfNextDay, + ); + return AppStateActions.setTodayString({ + todayStr: this._dateService.todayStr(), + startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(), + }); + }), + ), + ); + notifyElectronAboutCfgChange = createEffect( () => this._actions$.pipe( diff --git a/src/app/features/config/store/global-config.reducer.spec.ts b/src/app/features/config/store/global-config.reducer.spec.ts index c7b736fb4d..acc3464f69 100644 --- a/src/app/features/config/store/global-config.reducer.spec.ts +++ b/src/app/features/config/store/global-config.reducer.spec.ts @@ -20,6 +20,7 @@ import { import { updateGlobalConfigSection } from './global-config.actions'; import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { GlobalConfigState } from '../global-config.model'; +import { MemoizedSelector } from '@ngrx/store'; import { SyncProviderId } from '../../../op-log/sync-providers/provider.const'; import { AppDataComplete } from '../../../op-log/model/model-config'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; @@ -1053,124 +1054,63 @@ describe('GlobalConfigReducer', () => { }); describe('Selectors', () => { - describe('selectLocalizationConfig', () => { + // Shared contract of every `createConfigSectionSelector` output: falls back to + // the baked-in default when state is undefined, otherwise passes the slice + // through unchanged. Keeps the per-selector blocks below to only what's + // beyond that shared contract (e.g. selectFocusModeConfig's extra regression). + const itBehavesLikeConfigSectionSelector = ( + selector: MemoizedSelector, + key: K, + ): void => { it('should return default config when state is undefined', () => { - const result = selectLocalizationConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.localization); + const result = selector.projector(undefined as any); + expect(result).toEqual(DEFAULT_GLOBAL_CONFIG[key]); }); - it('should return localization config when state is defined', () => { - const result = selectLocalizationConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.localization); + it(`should return ${key} config when state is defined`, () => { + const result = selector.projector(initialGlobalConfigState); + expect(result).toEqual(initialGlobalConfigState[key]); }); + }; + + describe('selectLocalizationConfig', () => { + itBehavesLikeConfigSectionSelector(selectLocalizationConfig, 'localization'); }); describe('selectMiscConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectMiscConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.misc); - }); - - it('should return misc config when state is defined', () => { - const result = selectMiscConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.misc); - }); + itBehavesLikeConfigSectionSelector(selectMiscConfig, 'misc'); }); describe('selectShortSyntaxConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectShortSyntaxConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.shortSyntax); - }); - - it('should return shortSyntax config when state is defined', () => { - const result = selectShortSyntaxConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.shortSyntax); - }); + itBehavesLikeConfigSectionSelector(selectShortSyntaxConfig, 'shortSyntax'); }); describe('selectSoundConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectSoundConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.sound); - }); - - it('should return sound config when state is defined', () => { - const result = selectSoundConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.sound); - }); + itBehavesLikeConfigSectionSelector(selectSoundConfig, 'sound'); }); describe('selectEvaluationConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectEvaluationConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.evaluation); - }); - - it('should return evaluation config when state is defined', () => { - const result = selectEvaluationConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.evaluation); - }); + itBehavesLikeConfigSectionSelector(selectEvaluationConfig, 'evaluation'); }); describe('selectIdleConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectIdleConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.idle); - }); - - it('should return idle config when state is defined', () => { - const result = selectIdleConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.idle); - }); + itBehavesLikeConfigSectionSelector(selectIdleConfig, 'idle'); }); describe('selectSyncConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectSyncConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.sync); - }); - - it('should return sync config when state is defined', () => { - const result = selectSyncConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.sync); - }); + itBehavesLikeConfigSectionSelector(selectSyncConfig, 'sync'); }); describe('selectTakeABreakConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectTakeABreakConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.takeABreak); - }); - - it('should return takeABreak config when state is defined', () => { - const result = selectTakeABreakConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.takeABreak); - }); + itBehavesLikeConfigSectionSelector(selectTakeABreakConfig, 'takeABreak'); }); describe('selectTimelineConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectTimelineConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.schedule); - }); - - it('should return schedule config when state is defined', () => { - const result = selectTimelineConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.schedule); - }); + itBehavesLikeConfigSectionSelector(selectTimelineConfig, 'schedule'); }); describe('selectIsDominaModeConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectIsDominaModeConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.dominaMode); - }); - - it('should return dominaMode config when state is defined', () => { - const result = selectIsDominaModeConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.dominaMode); - }); + itBehavesLikeConfigSectionSelector(selectIsDominaModeConfig, 'dominaMode'); }); describe('selectFocusModeConfig', () => { @@ -1180,39 +1120,15 @@ describe('GlobalConfigReducer', () => { expect(DEFAULT_GLOBAL_CONFIG.focusMode.isPauseTrackingDuringBreak).toBe(true); }); - it('should return default config when state is undefined', () => { - const result = selectFocusModeConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.focusMode); - }); - - it('should return focusMode config when state is defined', () => { - const result = selectFocusModeConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.focusMode); - }); + itBehavesLikeConfigSectionSelector(selectFocusModeConfig, 'focusMode'); }); describe('selectPomodoroConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectPomodoroConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.pomodoro); - }); - - it('should return pomodoro config when state is defined', () => { - const result = selectPomodoroConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.pomodoro); - }); + itBehavesLikeConfigSectionSelector(selectPomodoroConfig, 'pomodoro'); }); describe('selectReminderConfig', () => { - it('should return default config when state is undefined', () => { - const result = selectReminderConfig.projector(undefined as any); - expect(result).toEqual(DEFAULT_GLOBAL_CONFIG.reminder); - }); - - it('should return reminder config when state is defined', () => { - const result = selectReminderConfig.projector(initialGlobalConfigState); - expect(result).toEqual(initialGlobalConfigState.reminder); - }); + itBehavesLikeConfigSectionSelector(selectReminderConfig, 'reminder'); }); describe('selectIsFocusModeEnabled', () => { diff --git a/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts new file mode 100644 index 0000000000..e54e92c01e --- /dev/null +++ b/src/app/features/config/store/global-config.start-of-next-day-hydration.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, Store, StoreModule } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; +import { Subscription, take } from 'rxjs'; +import { DateService } from '../../../core/date/date.service'; +import { GlobalConfigEffects } from './global-config.effects'; +import { + CONFIG_FEATURE_NAME, + globalConfigReducer, + selectMiscConfig, +} from './global-config.reducer'; +import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; +import { GlobalConfigState, MiscConfig } from '../global-config.model'; +import { loadAllData } from '../../../root-store/meta/load-all-data.action'; +import { AppDataComplete } from '../../../op-log/model/model-config'; +import { bulkApplyOperations } from '../../../op-log/apply/bulk-hydration.action'; +import { bulkOperationsMetaReducer } from '../../../op-log/apply/bulk-hydration.meta-reducer'; +import { ActionType, Operation, OpType } from '../../../op-log/core/operation.types'; +import { initialTaskState, TASK_FEATURE_NAME } from '../../tasks/store/task.reducer'; +import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; +import { + appStateFeatureKey, + appStateReducer, +} from '../../../root-store/app-state/app-state.reducer'; +import { AppStateActions } from '../../../root-store/app-state/app-state.actions'; +import { selectStartOfNextDayDiffMs } from '../../../root-store/app-state/app-state.selectors'; +import { LanguageService } from '../../../core/language/language.service'; +import { SnackService } from '../../../core/snack/snack.service'; +import { UserProfileService } from '../../user-profile/user-profile.service'; +import { KeyboardLayoutService } from '../../../core/keyboard-layout/keyboard-layout.service'; +import { IS_ELECTRON_TOKEN } from '../../../app.constants'; +import { IS_MAC_TOKEN } from '../../../util/is-mac'; + +describe('start-of-next-day offset across operation replay', () => { + const SIX_AM_MS = 6 * 60 * 60 * 1000; + + const misc = (startOfNextDay: number, startOfNextDayTime: string): MiscConfig => ({ + ...DEFAULT_GLOBAL_CONFIG.misc, + startOfNextDay, + startOfNextDayTime, + }); + + const appDataWithMisc = (miscCfg: MiscConfig): AppDataComplete => + ({ + globalConfig: { + ...DEFAULT_GLOBAL_CONFIG, + misc: miscCfg, + } as GlobalConfigState, + }) as unknown as AppDataComplete; + + const configOp = (miscCfg: MiscConfig, clientId = 'client1'): Operation => ({ + id: `op-cfg-misc-${clientId}`, + opType: OpType.Update, + entityType: 'GLOBAL_CONFIG', + entityId: 'misc', + actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION, + payload: { + actionPayload: { sectionKey: 'misc', sectionCfg: miscCfg }, + entityChanges: [], + }, + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const fullStateOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + id: `op-full-state-${clientId}`, + opType: OpType.SyncImport, + entityType: 'ALL', + actionType: ActionType.LOAD_ALL_DATA, + payload: appDataWithMisc(miscCfg), + vectorClock: { [clientId]: 1 }, + clientId, + timestamp: 1_700_000_000_000, + schemaVersion: 1, + }); + + const repairOp = (miscCfg: MiscConfig, clientId = 'client2'): Operation => ({ + ...fullStateOp(miscCfg, clientId), + id: `op-repair-${clientId}`, + opType: OpType.Repair, + actionType: ActionType.REPAIR_AUTO, + payload: { + appDataComplete: appDataWithMisc(miscCfg), + repairSummary: { + entityStateFixed: 1, + orphanedEntitiesRestored: 0, + invalidReferencesRemoved: 0, + relationshipsFixed: 0, + structureRepaired: 0, + typeErrorsFixed: 0, + }, + }, + }); + + let store: Store; + let dateService: DateService; + let observedActions: Action[]; + let actionsSubscription: Subscription; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot( + { + [CONFIG_FEATURE_NAME]: globalConfigReducer, + [TASK_FEATURE_NAME]: () => initialTaskState, + [appStateFeatureKey]: appStateReducer, + }, + { metaReducers: [bulkOperationsMetaReducer] }, + ), + EffectsModule.forRoot([GlobalConfigEffects]), + ], + providers: [ + { + provide: LanguageService, + useValue: { setLng: (): void => undefined, tryAutoswitch: (): boolean => true }, + }, + { provide: SnackService, useValue: { open: (): void => undefined } }, + { + provide: UserProfileService, + useValue: { migrateOnFirstEnable: (): Promise => Promise.resolve() }, + }, + { provide: KeyboardLayoutService, useValue: new KeyboardLayoutService() }, + { provide: IS_ELECTRON_TOKEN, useValue: false }, + { provide: IS_MAC_TOKEN, useValue: false }, + ], + }); + + store = TestBed.inject(Store); + dateService = TestBed.inject(DateService); + observedActions = []; + actionsSubscription = TestBed.inject(Actions).subscribe((action) => + observedActions.push(action), + ); + }); + + afterEach(() => actionsSubscription.unsubscribe()); + + const readStoreHour = (): number => { + let hour = -1; + store + .select(selectMiscConfig) + .pipe(take(1)) + .subscribe((cfg) => (hour = cfg.startOfNextDay)); + return hour; + }; + + const readAppStateOffset = (): number => { + let offset = -1; + store + .select(selectStartOfNextDayDiffMs) + .pipe(take(1)) + .subscribe((value) => (offset = value)); + return offset; + }; + + const bootFromSnapshotAt = (miscCfg: MiscConfig): void => { + store.dispatch(loadAllData({ appDataComplete: appDataWithMisc(miscCfg) })); + observedActions = []; + }; + + const replay = (operations: Operation[], localClientId = 'client1'): void => { + store.dispatch(bulkApplyOperations({ operations, localClientId })); + }; + + it('installs the offset when the config op is replayed at startup', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client1')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when the config change arrives from another device', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'), 'client2')]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a full-state operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([fullStateOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('installs the offset when a repair operation is replayed', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([repairOp(misc(6, '06:00'))]); + + expect(readStoreHour()).toBe(6); + expect(dateService.getStartOfNextDayDiffMs()).toBe(SIX_AM_MS); + expect(readAppStateOffset()).toBe(SIX_AM_MS); + }); + + it('does not re-mint task updates while replaying the config operation', () => { + bootFromSnapshotAt(misc(0, '00:00')); + + replay([configOp(misc(6, '06:00'))]); + + expect( + observedActions.some( + (action) => action.type === TaskSharedActions.updateTasks.type, + ), + ).toBeFalse(); + }); + + it('ignores bulk operations that cannot change the day-start config', () => { + bootFromSnapshotAt(misc(0, '00:00')); + const unrelatedOp: Operation = { + ...configOp(misc(6, '06:00')), + id: 'op-cfg-sound-client1', + entityId: 'sound', + payload: { + actionPayload: { sectionKey: 'sound', sectionCfg: {} }, + entityChanges: [], + }, + }; + + replay([unrelatedOp]); + + expect(dateService.getStartOfNextDayDiffMs()).toBe(0); + expect( + observedActions.some( + (action) => action.type === AppStateActions.setTodayString.type, + ), + ).toBeFalse(); + }); +}); diff --git a/src/app/features/dialog-please-rate/dialog-please-rate.component.html b/src/app/features/dialog-please-rate/dialog-please-rate.component.html index 40dba3a88b..b25d910f43 100644 --- a/src/app/features/dialog-please-rate/dialog-please-rate.component.html +++ b/src/app/features/dialog-please-rate/dialog-please-rate.component.html @@ -79,7 +79,7 @@ - @if (!IS_APPLE_APP_STORE) { + @if (!IS_DONATION_UI_RESTRICTED) {