commit 4af453d1e7a68507efb0444d8e86502278c72f8c Author: Dominik Bartosik Date: Sat Jun 13 19:58:53 2026 +0200 Initial commit: Unabyss Obsidian plugin. Two-way OAuth sync between Obsidian vaults and Unabyss memory, with manifest-first delta upload, exports puller, and GitHub release workflow. Co-authored-by: Cursor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f2de676 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release Obsidian Plugin + +# Triggered by pushing a SemVer tag (v*). Builds the production bundle +# and attaches main.js, manifest.json, and logo SVGs to the GitHub +# Release - the same artefacts the Obsidian Community Plugins store +# expects to download from the GitHub release. + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Run unit tests + run: pnpm test + + - name: Build production bundle + run: pnpm run build + + - name: Extract version from tag + id: version + run: | + TAG="${GITHUB_REF##*/}" + VERSION="${TAG#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Verify manifest version matches tag + run: | + MANIFEST_VERSION="$(node -e 'process.stdout.write(require("./manifest.json").version)')" + if [ "$MANIFEST_VERSION" != "${{ steps.version.outputs.version }}" ]; then + echo "manifest.json version ($MANIFEST_VERSION) does not match tag (${{ steps.version.outputs.version }})" + exit 1 + fi + + - name: Collect release artefacts + run: | + mkdir -p release + cp main.js manifest.json logo-dark.svg logo-light.svg release/ + if [ -f styles.css ]; then cp styles.css release/; fi + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Unabyss Obsidian Plugin ${{ steps.version.outputs.version }} + draft: false + prerelease: false + files: | + release/main.js + release/manifest.json + release/logo-dark.svg + release/logo-light.svg + release/styles.css + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..664d553 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output (committed for releases; gitignored during dev iteration) +main.js +*.js.map + +# Local plugin state if the folder is symlinked into a vault during dev +data.json + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2e6b5dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Unabyss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LOCAL_INSTALL.md b/LOCAL_INSTALL.md new file mode 100644 index 0000000..ee9ccf2 --- /dev/null +++ b/LOCAL_INSTALL.md @@ -0,0 +1,120 @@ +# Local install guide + +A focused guide for installing the current branch's build of the +Unabyss Obsidian plugin into a local Obsidian vault. For end-user / +release-channel install paths (Community Plugins store, BRAT), see +`README.md`. + +## Prerequisites + +- Obsidian Desktop installed. +- A vault you can test against (real or a throwaway one - recommended + for first install since the plugin's `data.json` ends up under + `.obsidian/plugins/unabyss/` and outbound sync starts pushing notes + once you connect). +- `pnpm` available locally if you want to rebuild (the repo has + `pnpm-lock.yaml` and uses `pnpm@11.1.3`). + +## Option A - Drop-in install (fastest) + +`main.js` is built on release tags (~16 KB, generated alongside +`manifest.json`), so you can install without any tooling after +downloading a GitHub release or running `pnpm run build` locally. + +1. Find your vault path. In Obsidian: **Settings -> About -> Show + vault folder** (or just remember where you created it). Call it + ``. +2. Create the plugin folder: + + ```bash + mkdir -p "/.obsidian/plugins/unabyss" + ``` + +3. Copy the artefacts from this repository: + + ```bash + cp main.js manifest.json logo-dark.svg logo-light.svg \ + "/.obsidian/plugins/unabyss/" + ``` + +4. Open Obsidian, go to **Settings -> Community plugins**. + - If you see "Restricted mode is on", click **Turn on community + plugins** (Obsidian's safety prompt for third-party code). +5. In the same panel, scroll to **Installed plugins**, find + **Unabyss**, and toggle it on. If it doesn't appear, hit the + refresh icon next to "Installed plugins". +6. The plugin's settings tab will appear under **Community plugins -> + Unabyss**. Configure per `README.md` (API base URL, include + folders, export target folder, etc.). + +If Obsidian was already open before you copied the files, either +restart it or use the **Reload app without saving** command from the +command palette so it picks up the new plugin directory. + +## Option B - Rebuild from source before installing + +Only needed if you've changed `src/**` since the last build, or you +want sourcemaps for debugging. + +```bash +pnpm install +pnpm run build # tsc -noEmit + production esbuild bundle +``` + +Then run the copy step from Option A. + +## Option C - Live development symlink + +If you'll iterate on the plugin and want changes to land in the vault +without re-copying: + +1. Make sure the plugin folder does not already exist as a real + directory, then symlink the repo folder in: + + ```bash + ln -s "$(pwd)" "/.obsidian/plugins/unabyss" + ``` + +2. Run the esbuild watcher so `main.js` rebuilds on save: + + ```bash + pnpm install + pnpm run dev + ``` + +3. In Obsidian, install the **Hot Reload** community plugin (by + `pjeby`) to auto-reload Unabyss on each rebuild - or just + disable/enable the toggle under **Settings -> Community plugins** + after each change. + +Notes on this option: + +- `.gitignore` ignores `data.json`, so your local OAuth tokens won't + be staged accidentally. +- The symlink approach means Obsidian will write `data.json` directly + into the repo working tree (still gitignored), so be aware if + you're switching branches. + +## Verifying the install + +- Open Obsidian's developer console (`Ctrl/Cmd+Shift+I`). On enable, + you should see no red errors from `plugin:unabyss`. +- In **Settings -> Community plugins -> Unabyss**, the settings tab + should render with **Connect**, the outbound / inbound toggles, and + the **Force full resync** button described in `README.md`. +- If you're pointing at a non-prod backend, set **API base URL** (under + Advanced) before clicking **Connect** (the OAuth consent page is + derived from this host as `https://app.`). + +## Uninstall / reset + +```bash +rm -rf "/.obsidian/plugins/unabyss" +``` + +Then in Obsidian, **Settings -> Community plugins** -> refresh. If +you had connected, also click **Disconnect** before deleting the +folder (or revoke the session from the Unabyss web app's +connected-accounts page) so the refresh tokens are blacklisted +server-side - otherwise the leftover tokens in `data.json` remain +valid until expiry. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bd57b7 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# Unabyss Obsidian Plugin + +Two-way OAuth sync between your Obsidian vault and your Unabyss memory. + +- Push your notes into Unabyss as incremental, change-aware deltas. +- Pull Unabyss-generated exports back into a folder you pick inside the vault. +- File-change-driven outbound sync with a 5-second debounce, plus an + hourly safety-net timer that runs both directions regardless of + events. + +## Features + +- OAuth 2.0 + PKCE connect / disconnect against the Unabyss backend. +- Manifest-first delta protocol: only files whose content hash the + server doesn't already have are uploaded. +- Inbound puller: every export in `GET /api/exports/changed-since/` + is mirrored as `.md` in your chosen folder. +- Configurable behaviour when an export is deleted in Unabyss: + leave the local file, delete it, or move it to a `Deleted/` + subfolder. +- Per-direction enable / disable toggles - either direction can be + turned off without uninstalling the plugin. +- Per-direction live progress indicator inside the settings tab. +- Force-full-resync button that wipes the local hash cache + inbound + watermark and re-checks every file with the server. +- Per-note 1 MiB size cap (oversize notes are skipped with a notice). +- Local hash + mtime cache to skip re-hashing unchanged files. + +## Installation + +### Obsidian Community Plugins (recommended) + +1. Open Obsidian -> Settings -> Community plugins -> Browse. +2. Search for **Unabyss**, click **Install**, then **Enable**. + +### BRAT sideload (beta releases) + +1. Install the [BRAT](https://github.com/TfTHacker/obsidian42-brat) + community plugin. +2. Open Obsidian -> Settings -> Community plugins -> BRAT -> + **Add Beta plugin** and paste this repository's URL. +3. Enable the **Unabyss** plugin in Settings -> Community plugins. + +### Manual install + +1. Clone this repository. +2. From the repository root run: + + ```bash + pnpm install + pnpm build + ``` + +3. Copy `main.js`, `manifest.json`, `logo-dark.svg`, and + `logo-light.svg` into `/.obsidian/plugins/unabyss/` + (create the folder if it doesn't exist). +4. Enable the plugin in Settings -> Community plugins. + +## Configuration + +Open Settings -> Community plugins -> Unabyss. + +| Setting | Default | Notes | +|------------------------------------------|---------------------------|-------| +| API base URL | `https://api.unabyss.com` | Set to your self-hosted backend if applicable. The plugin opens `https://app.` in your browser for the consent page; point at the API host you have if your deploy doesn't follow the `api.` / `app.` convention. | +| Account | (empty until you connect) | Click **Connect** to start the OAuth PKCE flow. | +| Sync outbound | on | Master switch for the Obsidian -> Unabyss direction. When off, neither file-change events, the hourly timer, nor the manual button sends notes to Unabyss. | +| Include folders | (empty = whole vault) | Vault folders included by outbound sync. Click **Add folder** to pick from a list of every folder in the vault. Empty = sync everything. | +| Sync inbound | on | Master switch for the Unabyss -> Obsidian direction. When off, the hourly timer skips inbound and the manual button refuses to run it. | +| Export target folder | (empty) | Vault folder where Unabyss exports are written. Inbound sync requires this to be set. Auto-completes vault folders as you type. | +| When an export is deleted in Unabyss | Leave the local file alone | Choose between **Leave**, **Delete (system trash)**, and **Move to a `Deleted/` subfolder** inside your target folder. | +| Force full resync | - | Wipes the local manifest cache + inbound watermark and immediately runs an outbound sync so the server's hash-diff guard re-establishes the truth. | + +## Usage + +### First connect + +1. Click **Connect** in the settings tab. Your browser opens the + Unabyss consent page; sign in if needed and click **Allow**. +2. Obsidian focuses back to your vault. The settings tab now reads + "Connected as ``". + +### Outbound (Obsidian -> Unabyss) + +- Once connected and with the include-folder list configured to your + taste, every modify / create / delete on a markdown file kicks off a + debounced outbound sync after **5 seconds** of quiet. +- Manual: **Settings -> Unabyss -> Manual sync -> Sync now** (or the + command palette: `Unabyss: Sync outbound now`). +- The hourly safety-net timer fires both directions regardless of + file events, so the plugin recovers from missed events and external + file-system writes. + +### Inbound (Unabyss -> Obsidian) + +- Pick an **Export target folder** in settings. +- Each export gets written as `.md` (e.g. + `daily-summary-2026-01-15.md`). On slug collision with a different + export, the plugin appends `-` to the filename so + no unrelated note is overwritten. +- Every written file carries a stable trailer line at the bottom: + ``. Do not delete this line if + you want the plugin to recognise the file as the same export on the + next sync. +- Soft-deleted exports honour your **When an export is deleted in + Unabyss** setting. +- Manual: **Settings -> Unabyss -> Manual sync -> Sync now** or the + command palette: `Unabyss: Sync inbound now`. + +### Disconnecting + +Click **Disconnect** in the settings tab. The plugin calls +`POST /api/oauth/revoke/` to blacklist its refresh tokens server-side +and clears the local `data.json` token entries. You can also revoke +from the Unabyss web app's connected-accounts page if your machine is +inaccessible. + +## Security and threat model + +This plugin stores OAuth tokens in +`/.obsidian/plugins/unabyss/data.json`, which is **plaintext +JSON on disk**. + +- Anyone with read access to your vault folder can read these tokens + and use them to access your Unabyss account until you click + **Disconnect** (which revokes them server-side). +- Tokens are also synced by Obsidian Sync, iCloud, Dropbox, or any + other sync layer you configure for your `.obsidian/` directory. +- If you suspect your tokens were leaked, click **Disconnect** in the + plugin settings; the OAuth refresh tokens are blacklisted + immediately on the server. You can also revoke from the Unabyss web + app's connected accounts page. + +OS-keychain integration is on the roadmap. If plaintext token storage +is unacceptable for your threat model, do not install this plugin yet. + +## Troubleshooting + +- **"Connect failed: state_mismatch"** - the browser tab took too + long to complete the consent flow, or you opened multiple Connect + tabs. Click Connect again. +- **"Authentication has expired"** - the refresh-token rotation + failed. Click Disconnect (or Connect again, which clears the stale + state). +- **Sync rejects notes with `hash_mismatch`** - this is the server + rejecting a row whose content changed between the manifest scan + and the body upload. Re-running Sync resolves it. +- **Inbound sync skips everything** - the **Export target folder** + setting is empty. Pick a folder via the type-ahead in settings. +- **"Inbound target X exists but is not a folder"** - the chosen + target path is a file. Pick a different folder. +- **Exports keep coming back after I delete them locally** - your + **When an export is deleted in Unabyss** setting is "Leave"; the + plugin won't touch local files even when the server soft-deletes + the source export, but it will keep re-writing the file if the + export is *not* deleted server-side. Delete the export in the web + app first. + +## Development + +```bash +pnpm install +pnpm run dev # esbuild watch mode +pnpm run typecheck # tsc -noEmit +pnpm test # jest unit suite +pnpm run build # production bundle +``` + +The unit suite covers: + +- PKCE S256 round-trip (`tests/oauth.test.ts`). +- Manifest cache load/save/clear semantics + (`tests/manifestCache.test.ts`). +- Slugify + collision-suffix policy (`tests/slugify.test.ts`). +- Inbound watermark advancement on partial-page failure + (`tests/syncInbound.test.ts`). + +Anything that touches the Obsidian runtime (Vault, App, Plugin, +`requestUrl`) is dependency-injected at the call site, so the tests +do not require Electron. + +## License + +MIT. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..ccbdc0d --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,47 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +If you want to view the source, please visit the GitHub repository of this plugin. +*/ +`; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { js: banner }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..10e7e26 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,24 @@ +/** + * Jest configuration for the Unabyss Obsidian plugin's unit tests. + * + * Tests live under ``tests/`` and target the side-effect-free helpers + * (PKCE, manifest cache, slug + collision policy, inbound watermark + * advancement). Anything that touches the Obsidian runtime (the App, + * Plugin lifecycle, Vault, requestUrl) is dependency-injected at the + * call site so the tests never need a real Electron host. + * + * The ``obsidian`` module is mocked via the ``moduleNameMapper`` shim + * in ``tests/__mocks__/obsidian.ts``. + */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + rootDir: ".", + testMatch: ["/tests/**/*.test.ts"], + moduleNameMapper: { + "^obsidian$": "/tests/__mocks__/obsidian.ts", + }, + transform: { + "^.+\\.ts$": ["ts-jest", { tsconfig: "/tests/tsconfig.json" }], + }, +}; diff --git a/logo-dark.svg b/logo-dark.svg new file mode 100755 index 0000000..e49f98c --- /dev/null +++ b/logo-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/logo-light.svg b/logo-light.svg new file mode 100755 index 0000000..8260672 --- /dev/null +++ b/logo-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2d29377 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "unabyss", + "name": "Unabyss", + "version": "1.0.0", + "minAppVersion": "1.5.0", + "description": "Two-way OAuth sync between your Obsidian vault and your Unabyss memory.", + "author": "Unabyss", + "authorUrl": "https://unabyss.com", + "fundingUrl": "https://unabyss.com", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d83ac27 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "obsidian-plugin-unabyss", + "version": "1.0.0", + "description": "Two-way OAuth sync between your Obsidian vault and Unabyss.", + "main": "main.js", + "private": true, + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "typecheck": "tsc -noEmit -skipLibCheck", + "test": "jest", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [ + "obsidian", + "plugin", + "unabyss" + ], + "author": "Unabyss", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.11.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.5", + "jest": "^29.7.0", + "obsidian": "^1.5.7", + "ts-jest": "^29.1.5", + "tslib": "^2.6.2", + "typescript": "^5.4.5" + }, + "packageManager": "pnpm@11.1.3" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..74e4444 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2925 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.0 + version: 20.19.41 + builtin-modules: + specifier: ^3.3.0 + version: 3.3.0 + esbuild: + specifier: ^0.21.5 + version: 0.21.5 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.41) + obsidian: + specifier: ^1.5.7 + version: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6) + ts-jest: + specifier: ^29.1.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.41))(typescript@5.9.3) + tslib: + specifier: ^2.6.2 + version: 2.8.1 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@codemirror/state@6.5.0': + resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==} + + '@codemirror/view@6.38.6': + resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/codemirror@5.60.8': + resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tern@0.23.9': + resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.37: + resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + electron-to-chromium@1.5.372: + resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob@7.2.3: + resolution: {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 + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {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. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + moment@2.29.4: + resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + obsidian@1.12.3: + resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==} + peerDependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.38.6 + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-jest@29.4.11: + resolution: {integrity: sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + 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 + esbuild: '*' + 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 + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + 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/core@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.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + 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.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@codemirror/state@6.5.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.38.6': + dependencies: + '@codemirror/state': 6.5.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.41) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.41 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.41 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.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: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.41 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@marijn/find-cluster-break@1.0.2': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/codemirror@5.60.8': + dependencies: + '@types/tern': 0.23.9 + + '@types/estree@1.0.9': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.41 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/stack-utils@2.0.3': {} + + '@types/tern@0.23.9': + dependencies: + '@types/estree': 1.0.9 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.10.37: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.372 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + builtin-modules@3.3.0: {} + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001799: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + create-jest@29.7.0(@types/node@20.19.41): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.41) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + crelt@1.0.6: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + electron-to-chromium@1.5.372: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-errors@1.3.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + esprima@4.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + fast-json-stable-stringify@2.1.0: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-package-type@0.1.0: {} + + get-stream@6.0.1: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-number@7.0.0: {} + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.19.41): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.41) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.41) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.19.41): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.41 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.41 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.41 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.41 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.19.41): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.41) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + kleur@3.0.3: {} + + leven@3.1.0: {} + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.memoize@4.1.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.4 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimist@1.2.8: {} + + moment@2.29.4: {} + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-int64@0.4.0: {} + + node-releases@2.0.47: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6): + dependencies: + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.38.6 + '@types/codemirror': 5.60.8 + moment: 2.29.4 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + pure-rand@6.1.0: {} + + react-is@18.3.1: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + semver@6.3.1: {} + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + style-mod@4.1.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.41))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.41) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + esbuild: 0.21.5 + jest-util: 29.7.0 + + tslib@2.8.1: {} + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + w3c-keyname@2.2.8: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/apiClient.ts b/src/apiClient.ts new file mode 100644 index 0000000..37a9281 --- /dev/null +++ b/src/apiClient.ts @@ -0,0 +1,245 @@ +/** + * Typed HTTP client for the Unabyss backend. + * + * Wraps the manifest-first plugin endpoints + * (`/api/ingest/obsidian/manifest-chunks/`, `.../notes/upload/`, + * `.../sync-finalize/`), the dashboard endpoints + * (`/api/ingest/obsidian/vaults/...`), the inbound exports listing + * (`/api/exports/changed-since/`), and the JWT refresh endpoint + * (`/api/auth/token/refresh/`). + * + * Authentication: every authenticated call attaches + * ``Authorization: Bearer ``. On a 401 response, the client + * runs `rotateRefreshToken` once, swaps in the new access/refresh + * pair, and retries the original request. A second 401 propagates + * upward so the caller can surface a "Reconnect" UI affordance. + */ + +import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian"; +import { normalizeApiBaseUrl, rotateRefreshToken } from "./oauth"; +import { + AuthState, + ExportRow, + ManifestChunkRequest, + ManifestChunkResponse, + NoteUploadRequest, + NoteUploadResponse, + PaginatedResponse, + SyncFinalizeRequest, + SyncFinalizeResponse, + VaultListResponse, + VaultRow, +} from "./types"; + +/** Saver hook so the apiClient can persist refreshed tokens without owning settings. */ +export type AuthSaver = (auth: AuthState) => Promise; + +/** Reset hook called when refresh fails terminally so the plugin can prompt re-auth. */ +export type AuthClearHook = (reason: string) => Promise; + +export class ApiError extends Error { + status: number; + bodyText: string; + + constructor(status: number, message: string, bodyText: string) { + super(message); + this.status = status; + this.bodyText = bodyText; + this.name = "ApiError"; + } +} + +/** Thrown when the refresh-on-401 path also fails (the user must reconnect). */ +export class AuthExpiredError extends Error { + constructor(message = "Authentication has expired. Reconnect to continue syncing.") { + super(message); + this.name = "AuthExpiredError"; + } +} + +export interface ApiClientOptions { + apiBaseUrl: string; + auth: AuthState; + saveAuth: AuthSaver; + clearAuth: AuthClearHook; +} + +export class UnabyssApiClient { + private apiBaseUrl: string; + private auth: AuthState; + private readonly saveAuth: AuthSaver; + private readonly clearAuth: AuthClearHook; + private refreshInFlight: Promise | null = null; + + constructor(opts: ApiClientOptions) { + this.apiBaseUrl = normalizeApiBaseUrl(opts.apiBaseUrl); + this.auth = opts.auth; + this.saveAuth = opts.saveAuth; + this.clearAuth = opts.clearAuth; + } + + updateBaseUrl(apiBaseUrl: string): void { + this.apiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); + } + + updateAuth(auth: AuthState): void { + this.auth = auth; + } + + async postManifestChunk(body: ManifestChunkRequest): Promise { + return this.requestJson({ + method: "POST", + path: "/api/ingest/obsidian/manifest-chunks/", + body, + }); + } + + async postNoteUpload(body: NoteUploadRequest): Promise { + return this.requestJson({ + method: "POST", + path: "/api/ingest/obsidian/notes/upload/", + body, + }); + } + + async postSyncFinalize(body: SyncFinalizeRequest): Promise { + return this.requestJson({ + method: "POST", + path: "/api/ingest/obsidian/sync-finalize/", + body, + }); + } + + async listVaults(): Promise { + const response = await this.requestJson({ + method: "GET", + path: "/api/ingest/obsidian/vaults/", + }); + return response.results ?? []; + } + + async getChangedExports(updatedAfter: string, limit = 100, offset = 0): Promise> { + const params = new URLSearchParams({ + updated_after: updatedAfter, + limit: String(limit), + offset: String(offset), + }); + return this.requestJson>({ + method: "GET", + path: `/api/exports/changed-since/?${params.toString()}`, + }); + } + + private async requestJson(opts: { + method: "GET" | "POST" | "PUT" | "DELETE"; + path: string; + body?: unknown; + }): Promise { + const response = await this.sendWithRefresh(opts); + return response.json as T; + } + + private async sendWithRefresh(opts: { + method: "GET" | "POST" | "PUT" | "DELETE"; + path: string; + body?: unknown; + }): Promise { + const initial = await this.sendOnce(opts); + if (initial.status !== 401) { + ensureSuccess(initial); + return initial; + } + try { + await this.refreshTokens(); + } catch (err) { + await this.clearAuth("refresh_failed"); + throw new AuthExpiredError(); + } + const retried = await this.sendOnce(opts); + if (retried.status === 401) { + await this.clearAuth("retry_unauthorized"); + throw new AuthExpiredError(); + } + ensureSuccess(retried); + return retried; + } + + private async sendOnce(opts: { + method: "GET" | "POST" | "PUT" | "DELETE"; + path: string; + body?: unknown; + }): Promise { + const params: RequestUrlParam = { + url: `${this.apiBaseUrl}${opts.path}`, + method: opts.method, + headers: { + Authorization: `Bearer ${this.auth.accessToken}`, + }, + throw: false, + }; + if (opts.body !== undefined) { + params.contentType = "application/json"; + params.body = JSON.stringify(opts.body); + } + return requestUrl(params); + } + + /** + * Serialize concurrent 401-driven refreshes so only one rotation + * runs at a time. Concurrent callers await the same promise; once + * it resolves, ``this.auth`` carries the new pair for every + * waiting request. + */ + private async refreshTokens(): Promise { + if (this.refreshInFlight) { + return this.refreshInFlight; + } + this.refreshInFlight = (async () => { + try { + const tokens = await rotateRefreshToken(this.apiBaseUrl, this.auth.refreshToken); + this.auth = { + ...this.auth, + accessToken: tokens.access, + refreshToken: tokens.refresh, + }; + await this.saveAuth(this.auth); + } finally { + this.refreshInFlight = null; + } + })(); + return this.refreshInFlight; + } +} + +function ensureSuccess(response: RequestUrlResponse): void { + if (response.status < 400) { + return; + } + const body = describeApiError(response); + throw new ApiError(response.status, body.message, body.text); +} + +function describeApiError(response: RequestUrlResponse): { message: string; text: string } { + const raw = response.text ?? ""; + let message = `HTTP ${response.status}`; + try { + const parsed = response.json as Record | undefined; + if (parsed) { + const detail = parsed.detail; + const error = parsed.error; + if (typeof detail === "string") { + message = detail; + } else if (error && typeof error === "object" && "message" in error) { + const innerMessage = (error as { message?: unknown }).message; + if (typeof innerMessage === "string") { + message = innerMessage; + } + } else if (typeof error === "string") { + message = error; + } + } + } catch { + /* swallow JSON parse errors; the HTTP-status message is fine */ + } + return { message, text: raw }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..87ec3d3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,525 @@ +/** + * Plugin entry for the Unabyss Obsidian plugin (Phase 5 - feature complete). + * + * Responsibilities (Phase 5 superset of Phase 4): + * + * - Load and persist settings + auth + manifest cache + inbound + * watermark to ``data.json``. + * - Register the ``obsidian://unabyss/auth-callback`` protocol + * handler so the OAuth PKCE flow can deep-link back into the + * plugin after the user clicks Allow in their browser. + * - Wire commands ("Sync now", "Sync outbound now", "Sync inbound + * now", "Force full resync") and the settings tab. + * - File-change driven outbound sync with a 5s debounce window + * (per ``OUTBOUND_DEBOUNCE_MS``). The handler walks every modify / + * create / delete on ``.md`` files inside the user's include scope. + * - Hourly safety-net timer (per ``SAFETY_NET_INTERVAL_MS``) that + * runs both directions regardless of file events. + * - Per-direction enable/disable toggles - when off, neither timer + * nor command engages that direction. + * + * Lifecycle: every owned resource is created in ``onload`` and + * disposed in ``onunload`` (timer cleared, debounce cancelled, + * protocol-handler registration auto-removed by Obsidian). + */ + +import { Notice, Plugin, TAbstractFile, TFile, normalizePath } from "obsidian"; +import { UnabyssApiClient } from "./apiClient"; +import { EMPTY_MANIFEST_CACHE, ManifestCache, normalizeManifestCacheData } from "./manifestCache"; +import { OAuthClient, revokeTokens } from "./oauth"; +import { ProgressTracker } from "./progress"; +import { UnabyssSettingTab } from "./settings"; +import { runInboundSync } from "./syncInbound"; +import { runOutboundSync } from "./syncOutbound"; +import { + AuthState, + DEFAULT_SETTINGS, + ManifestCacheData, + OAUTH_PROTOCOL_ACTION, + OUTBOUND_DEBOUNCE_MS, + PluginSettings, + SAFETY_NET_INTERVAL_MS, + SyncInboundReport, + SyncOutboundReport, +} from "./types"; + +/** + * On-disk shape of ``data.json``: plugin settings (with ``auth`` + * nested) plus the manifest cache. Kept flat so a hand-edit doesn't + * require deep-merge logic on the next load. + */ +interface PluginData { + settings: PluginSettings; + manifestCache: ManifestCacheData; +} + +export default class UnabyssPlugin extends Plugin { + settings!: PluginSettings; + readonly outboundProgress = new ProgressTracker("outbound"); + readonly inboundProgress = new ProgressTracker("inbound"); + + private manifestCacheData: ManifestCacheData = { ...EMPTY_MANIFEST_CACHE }; + private manifestCache!: ManifestCache; + private api: UnabyssApiClient | null = null; + private oauthClient: OAuthClient = new OAuthClient(); + private outboundDebounceTimer: number | null = null; + private safetyNetIntervalHandle: number | null = null; + private outboundInFlight = false; + private inboundInFlight = false; + + async onload(): Promise { + await this.loadPluginData(); + this.manifestCache = new ManifestCache(this.manifestCacheData, async (data) => { + this.manifestCacheData = data; + await this.savePluginData(); + }); + this.rebuildApiClient(); + + this.registerObsidianProtocolHandler(OAUTH_PROTOCOL_ACTION, async (params) => { + await this.handleOAuthCallback(params); + }); + + this.addCommand({ + id: "unabyss-sync-now", + name: "Sync now (both directions)", + callback: () => { + this.runManualSync().catch((err) => { + new Notice(`Unabyss sync failed: ${describeError(err)}`); + }); + }, + }); + + this.addCommand({ + id: "unabyss-sync-outbound", + name: "Sync outbound now", + callback: () => { + this.runOutboundSync(true) + .then((report) => { + new Notice( + `Unabyss outbound \u2014 ${report.uploaded} uploaded, ` + + `${report.deleted} deleted, ${report.restored} restored.`, + ); + }) + .catch((err) => { + new Notice(`Unabyss outbound failed: ${describeError(err)}`); + }); + }, + }); + + this.addCommand({ + id: "unabyss-sync-inbound", + name: "Sync inbound now", + callback: () => { + this.runInboundSync(true) + .then((report) => { + new Notice( + `Unabyss inbound \u2014 ${report.written} written, ` + + `${report.deleted} deleted, ${report.moved} moved.`, + ); + }) + .catch((err) => { + new Notice(`Unabyss inbound failed: ${describeError(err)}`); + }); + }, + }); + + this.addCommand({ + id: "unabyss-force-full-resync", + name: "Force full resync", + callback: () => { + this.forceFullResync() + .then(() => new Notice("Unabyss: force full resync complete.")) + .catch((err) => new Notice(`Force full resync failed: ${describeError(err)}`)); + }, + }); + + this.addSettingTab(new UnabyssSettingTab(this.app, this)); + + this.registerVaultWatcher(); + this.installSafetyNetTimer(); + } + + onunload(): void { + this.oauthClient.abort(); + this.api = null; + this.cancelOutboundDebounce(); + this.clearSafetyNetTimer(); + } + + async saveSettings(): Promise { + await this.savePluginData(); + } + + /** + * Build a fresh {@link UnabyssApiClient} from the current settings + * + auth state. Called on load, after the OAuth callback succeeds, + * after the user edits the API base URL, and after disconnect. + */ + rebuildApiClient(): void { + if (!this.settings.auth) { + this.api = null; + return; + } + this.api = new UnabyssApiClient({ + apiBaseUrl: this.settings.apiBaseUrl, + auth: this.settings.auth, + saveAuth: async (next) => { + this.settings.auth = next; + await this.savePluginData(); + }, + clearAuth: async (reason) => { + console.warn(`Unabyss: clearing auth (${reason}).`); + await this.clearAuth(); + }, + }); + } + + async beginConnect(): Promise { + await this.oauthClient.beginAuthorize(this.settings.apiBaseUrl); + } + + async disconnect(): Promise { + if (this.settings.auth) { + try { + await revokeTokens(this.settings.apiBaseUrl, this.settings.auth.accessToken); + } catch (err) { + console.warn("Unabyss: revoke call failed; clearing local tokens anyway.", err); + } + } + await this.clearAuth(); + } + + /** + * Wipe the manifest cache (paths + inbound watermark) and + * immediately re-run outbound sync so the server's hash-diff + * guard re-establishes the truth. If the user has outbound + * disabled or isn't connected, the cache is still wiped. + */ + async forceFullResync(): Promise { + this.manifestCache.clear(); + await this.manifestCache.flush(); + if (this.api && this.settings.outboundEnabled) { + await this.runOutboundSync(true); + } + } + + /** + * Manual ``Sync now`` from the settings tab / command palette: + * runs both directions in sequence, honouring each direction's + * enable toggle. Errors in one direction are surfaced but do not + * block the other. + */ + async runManualSync(): Promise<{ outbound: SyncOutboundReport | null; inbound: SyncInboundReport | null }> { + let outbound: SyncOutboundReport | null = null; + let inbound: SyncInboundReport | null = null; + let outboundErr: unknown = null; + let inboundErr: unknown = null; + + if (this.settings.outboundEnabled) { + try { + outbound = await this.runOutboundSync(true); + } catch (err) { + outboundErr = err; + } + } + if (this.settings.inboundEnabled) { + try { + inbound = await this.runInboundSync(true); + } catch (err) { + inboundErr = err; + } + } + + if (outbound) { + new Notice( + `Outbound \u2014 ${outbound.uploaded} uploaded, ${outbound.deleted} deleted, ${outbound.restored} restored.`, + ); + } + if (inbound) { + new Notice( + `Inbound \u2014 ${inbound.written} written, ${inbound.deleted} deleted, ${inbound.moved} moved.`, + ); + } + if (outboundErr) { + new Notice(`Outbound failed: ${describeError(outboundErr)}`); + } + if (inboundErr) { + new Notice(`Inbound failed: ${describeError(inboundErr)}`); + } + return { outbound, inbound }; + } + + async runOutboundSync(force = false): Promise { + if (!force && !this.settings.outboundEnabled) { + throw new Error("Outbound sync is disabled in settings."); + } + if (!this.api) { + throw new Error("Connect to Unabyss before running outbound sync."); + } + if (this.outboundInFlight) { + throw new Error("Outbound sync is already running."); + } + if (!this.settings.vaultId) { + this.settings.vaultId = generateVaultId(); + await this.savePluginData(); + } + this.outboundInFlight = true; + this.outboundProgress.start("Scanning vault..."); + try { + const report = await runOutboundSync({ + app: this.app, + api: this.api, + cache: this.manifestCache, + vaultId: this.settings.vaultId, + vaultDisplayName: this.app.vault.getName(), + includeFolders: this.settings.includeFolders, + progress: this.outboundProgress, + }); + this.outboundProgress.succeed( + `Outbound done - ${report.uploaded} uploaded, ${report.deleted} deleted.`, + ); + return report; + } catch (err) { + this.outboundProgress.fail(describeError(err)); + throw err; + } finally { + this.outboundInFlight = false; + } + } + + async runInboundSync(force = false): Promise { + if (!force && !this.settings.inboundEnabled) { + throw new Error("Inbound sync is disabled in settings."); + } + if (!this.api) { + throw new Error("Connect to Unabyss before running inbound sync."); + } + if (!this.settings.exportTargetFolder.trim()) { + throw new Error("Pick an export target folder in settings before running inbound sync."); + } + if (this.inboundInFlight) { + throw new Error("Inbound sync is already running."); + } + this.inboundInFlight = true; + try { + return await runInboundSync({ + app: this.app, + api: this.api, + cache: this.manifestCache, + targetFolder: this.settings.exportTargetFolder, + deleteBehaviour: this.settings.exportDeleteBehaviour, + progress: this.inboundProgress, + }); + } finally { + this.inboundInFlight = false; + } + } + + /** + * Reset the per-direction progress tracker when the user toggles a + * direction off so a stale "Synced X notes" label doesn't keep + * sticking around. + */ + onDirectionToggleChanged(): void { + if (!this.settings.outboundEnabled) { + this.cancelOutboundDebounce(); + this.outboundProgress.reset(); + } + if (!this.settings.inboundEnabled) { + this.inboundProgress.reset(); + } + } + + private registerVaultWatcher(): void { + const handler = (file: TAbstractFile): void => { + if (!this.settings.outboundEnabled) { + return; + } + if (!(file instanceof TFile) || file.extension !== "md") { + return; + } + if (!this.isInOutboundScope(file.path)) { + return; + } + this.scheduleOutboundDebounce(); + }; + this.registerEvent(this.app.vault.on("modify", handler)); + this.registerEvent(this.app.vault.on("create", handler)); + this.registerEvent(this.app.vault.on("delete", handler)); + this.registerEvent( + this.app.vault.on("rename", (file) => handler(file)), + ); + } + + private installSafetyNetTimer(): void { + this.clearSafetyNetTimer(); + const handle = window.setInterval(() => { + this.runSafetyNetPass().catch((err) => { + console.warn("Unabyss: safety-net pass failed", err); + }); + }, SAFETY_NET_INTERVAL_MS); + this.safetyNetIntervalHandle = handle; + this.registerInterval(handle); + } + + private clearSafetyNetTimer(): void { + if (this.safetyNetIntervalHandle !== null) { + window.clearInterval(this.safetyNetIntervalHandle); + this.safetyNetIntervalHandle = null; + } + } + + private async runSafetyNetPass(): Promise { + if (!this.api) { + return; + } + if (this.settings.outboundEnabled && !this.outboundInFlight) { + try { + await this.runOutboundSync(false); + } catch (err) { + console.warn("Unabyss safety-net outbound failed", err); + } + } + if ( + this.settings.inboundEnabled && + this.settings.exportTargetFolder.trim() !== "" && + !this.inboundInFlight + ) { + try { + await this.runInboundSync(false); + } catch (err) { + console.warn("Unabyss safety-net inbound failed", err); + } + } + } + + private scheduleOutboundDebounce(): void { + if (this.outboundDebounceTimer !== null) { + window.clearTimeout(this.outboundDebounceTimer); + } + this.outboundDebounceTimer = window.setTimeout(() => { + this.outboundDebounceTimer = null; + this.runOutboundSync(false).catch((err) => { + console.warn("Unabyss debounced outbound failed", err); + }); + }, OUTBOUND_DEBOUNCE_MS); + } + + private cancelOutboundDebounce(): void { + if (this.outboundDebounceTimer !== null) { + window.clearTimeout(this.outboundDebounceTimer); + this.outboundDebounceTimer = null; + } + } + + /** + * Decide whether a file path falls inside the user's outbound + * include-folder scope. Empty list = whole vault. Matches the + * predicate logic in ``syncOutbound.buildIncludeFilter`` so a + * file-change event only triggers a debounce when the file would + * actually be picked up by the next sync. + */ + private isInOutboundScope(filePath: string): boolean { + const folders = this.settings.includeFolders + .map((folder) => folder.trim()) + .filter((folder) => folder.length > 0) + .map((folder) => normalizePath(folder)); + if (folders.length === 0) { + return true; + } + const normalizedPath = normalizePath(filePath); + return folders.some((prefix) => { + if (prefix === "/" || prefix === "") { + return true; + } + return normalizedPath === prefix || normalizedPath.startsWith(prefix + "/"); + }); + } + + private async loadPluginData(): Promise { + const raw = (await this.loadData()) as Partial | null; + const incoming = raw ?? {}; + const settings: PluginSettings = { + ...DEFAULT_SETTINGS, + ...(incoming.settings ?? {}), + includeFolders: + incoming.settings && Array.isArray(incoming.settings.includeFolders) + ? [...incoming.settings.includeFolders] + : [...DEFAULT_SETTINGS.includeFolders], + auth: incoming.settings?.auth ?? null, + }; + if (!settings.vaultId) { + settings.vaultId = generateVaultId(); + } + this.settings = settings; + this.manifestCacheData = normalizeManifestCacheData(incoming.manifestCache); + await this.savePluginData(); + } + + private async savePluginData(): Promise { + const data: PluginData = { + settings: this.settings, + manifestCache: this.manifestCacheData, + }; + await this.saveData(data); + } + + private async clearAuth(): Promise { + this.settings.auth = null; + await this.savePluginData(); + this.rebuildApiClient(); + } + + private async handleOAuthCallback(params: Record): Promise { + try { + const auth: AuthState = await this.oauthClient.handleCallback({ + code: params.code, + state: params.state, + error: params.error, + }); + this.settings.auth = auth; + await this.savePluginData(); + this.rebuildApiClient(); + new Notice(`Connected to Unabyss as ${auth.userEmail || "(unknown)"}.`); + } catch (err) { + new Notice(`Connect failed: ${describeError(err)}`); + } + } +} + +/** + * Mint a fresh UUIDv4 for use as the plugin's stable ``vault_id``. + * + * The value is opaque to the server (per tech-decisions §Decision 3) + * and lives in ``data.json``. ``crypto.randomUUID()`` is available + * inside Electron's renderer for modern Obsidian versions; we fall + * back to a manual v4 builder for the edge case where it isn't. + */ +function generateVaultId(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return ( + hex.slice(0, 8) + + "-" + + hex.slice(8, 12) + + "-" + + hex.slice(12, 16) + + "-" + + hex.slice(16, 20) + + "-" + + hex.slice(20, 32) + ); +} + +function describeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} diff --git a/src/manifestCache.ts b/src/manifestCache.ts new file mode 100644 index 0000000..2ece142 --- /dev/null +++ b/src/manifestCache.ts @@ -0,0 +1,174 @@ +/** + * Local manifest cache backing the manifest-first sync. + * + * Two responsibilities, persisted side-by-side in the plugin's + * `data.json`: + * + * - **Path index** (``ManifestPathIndex``): ``vault_path -> + * { content_hash, mtime }``. Lets the outbound scanner short-circuit + * rehashing files whose ``mtime`` matches. + * - **Exports watermark** (``exportsUpdatedAfter``): the latest + * ``updated_at`` we've successfully reconciled inbound from the + * Unabyss exports stream. Persisted so a fresh plugin boot resumes + * from the same offset instead of redoing every export ever. + * + * Mutations write through to disk via ``flush()`` so the caller can + * batch many updates inside one sync pass. The server-side hash diff + * remains the source of truth for outbound correctness; this cache is + * a best-effort optimisation that ``clear()`` (Force full resync) can + * wipe without risking divergence. + */ + +import { EMPTY_MANIFEST_CACHE, ManifestCacheData, ManifestCacheEntry, ManifestPathIndex } from "./types"; + +export type ManifestCachePersistor = (data: ManifestCacheData) => Promise; + +/** + * Normalise an arbitrary stored shape into the current + * {@link ManifestCacheData} contract. + * + * Tolerates v1 (flat ``Record``) and v2 (the structured + * shape) so users upgrading from Phase 4's plugin don't lose their + * existing cache on the first Phase 5 boot. + */ +export function normalizeManifestCacheData(raw: unknown): ManifestCacheData { + if (raw === null || raw === undefined) { + return { paths: {}, exportsUpdatedAfter: "" }; + } + if (typeof raw !== "object") { + return { paths: {}, exportsUpdatedAfter: "" }; + } + const record = raw as Record; + if (isStructuredShape(record)) { + return { + paths: normalizePathIndex(record.paths), + exportsUpdatedAfter: + typeof record.exportsUpdatedAfter === "string" ? record.exportsUpdatedAfter : "", + }; + } + return { + paths: normalizePathIndex(record), + exportsUpdatedAfter: "", + }; +} + +function isStructuredShape(record: Record): record is { + paths?: unknown; + exportsUpdatedAfter?: unknown; +} { + return "paths" in record || "exportsUpdatedAfter" in record; +} + +function normalizePathIndex(raw: unknown): ManifestPathIndex { + if (!raw || typeof raw !== "object") { + return {}; + } + const next: ManifestPathIndex = {}; + for (const [path, value] of Object.entries(raw as Record)) { + if (!value || typeof value !== "object") { + continue; + } + const candidate = value as Partial; + if (typeof candidate.contentHash !== "string" || typeof candidate.mtime !== "number") { + continue; + } + next[path] = { contentHash: candidate.contentHash, mtime: candidate.mtime }; + } + return next; +} + +export class ManifestCache { + private paths: ManifestPathIndex; + private exportsUpdatedAfter: string; + private readonly persist: ManifestCachePersistor; + private dirty = false; + + constructor(initial: ManifestCacheData, persist: ManifestCachePersistor) { + this.paths = { ...initial.paths }; + this.exportsUpdatedAfter = initial.exportsUpdatedAfter; + this.persist = persist; + } + + get(path: string): ManifestCacheEntry | undefined { + return this.paths[path]; + } + + set(path: string, entry: ManifestCacheEntry): void { + const existing = this.paths[path]; + if (existing && existing.contentHash === entry.contentHash && existing.mtime === entry.mtime) { + return; + } + this.paths[path] = { contentHash: entry.contentHash, mtime: entry.mtime }; + this.dirty = true; + } + + /** + * Drop entries whose path is no longer in ``presentPaths``. Called + * at the end of an outbound pass so the cache doesn't grow forever + * with files the user has since deleted from the vault. + */ + retainOnly(presentPaths: Iterable): void { + const present = new Set(presentPaths); + const next: ManifestPathIndex = {}; + for (const [path, entry] of Object.entries(this.paths)) { + if (present.has(path)) { + next[path] = entry; + } else { + this.dirty = true; + } + } + this.paths = next; + } + + /** Wipe every cached path. Wired to "Force full resync". */ + clear(): void { + if (Object.keys(this.paths).length > 0) { + this.paths = {}; + this.dirty = true; + } + if (this.exportsUpdatedAfter !== "") { + this.exportsUpdatedAfter = ""; + this.dirty = true; + } + } + + getExportsWatermark(): string { + return this.exportsUpdatedAfter; + } + + /** + * Advance the inbound watermark. + * + * Monotonic-by-default: a smaller watermark replaces a larger one + * only if the caller passes an empty string (reset). This matches + * the contract documented for ``syncInbound`` - the watermark + * never rewinds on partial failure. + */ + setExportsWatermark(value: string): void { + if (value === this.exportsUpdatedAfter) { + return; + } + if (value !== "" && this.exportsUpdatedAfter !== "" && value < this.exportsUpdatedAfter) { + return; + } + this.exportsUpdatedAfter = value; + this.dirty = true; + } + + snapshot(): ManifestCacheData { + return { + paths: { ...this.paths }, + exportsUpdatedAfter: this.exportsUpdatedAfter, + }; + } + + async flush(): Promise { + if (!this.dirty) { + return; + } + await this.persist(this.snapshot()); + this.dirty = false; + } +} + +export { EMPTY_MANIFEST_CACHE }; diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..802526d --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,337 @@ +/** + * OAuth 2.0 + PKCE (S256) client for the Unabyss plugin. + * + * Drives the full PKCE dance against the Phase 1 backend + * (`/api/oauth/authorize/`, `/api/oauth/token/`, `/api/oauth/revoke/`): + * + * 1. {@link beginAuthorize} mints a fresh code-verifier / code-challenge + * pair, stashes the verifier under `state`, and opens the user's + * browser at the frontend's consent URL. + * 2. The user clicks "Allow" in the browser; the frontend redirects to + * {@link OAUTH_REDIRECT_URI}, which Obsidian's protocol handler + * routes back into {@link handleCallback}. + * 3. {@link handleCallback} validates `state`, exchanges the code for an + * access/refresh JWT pair, fetches the user's email, and persists + * the {@link AuthState} via the supplied saver. + * + * The plaintext-on-disk token storage is intentional and documented in + * the README's threat-model section (per requirements §NFR). + */ + +import { Notice, requestUrl, RequestUrlResponse } from "obsidian"; +import { + AuthState, + OAUTH_CLIENT_ID, + OAUTH_REDIRECT_URI, + OAuthErrorBody, + TokenResponse, + UserMeResponse, +} from "./types"; + +const PKCE_VERIFIER_BYTES = 32; +const PKCE_STATE_BYTES = 16; +const PKCE_METHOD = "S256"; +const RESPONSE_TYPE = "code"; +const GRANT_TYPE = "authorization_code"; + +interface PendingAuthorization { + verifier: string; + state: string; + redirectUri: string; + apiBaseUrl: string; +} + +export interface AuthorizeCallbackParams { + code?: string; + state?: string; + error?: string; +} + +/** + * Build the user-facing consent URL. + * + * Convention (per user decision): when ``apiBaseUrl`` has an ``api.`` + * subdomain, swap it for ``app.`` to land on the SvelteKit consent + * route; otherwise fall back to the same origin (covers self-hosted + * deployments where API and frontend share a host). + */ +export function deriveConsentUrl(apiBaseUrl: string): string { + try { + const url = new URL(apiBaseUrl); + if (url.hostname.startsWith("api.")) { + url.hostname = "app." + url.hostname.slice("api.".length); + } + url.pathname = "/oauth/authorize"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return apiBaseUrl.replace(/\/$/, "") + "/oauth/authorize"; + } +} + +/** Strip the trailing slash so callers can always append `/api/...` cleanly. */ +export function normalizeApiBaseUrl(apiBaseUrl: string): string { + return apiBaseUrl.replace(/\/$/, ""); +} + +/** + * Orchestrates one in-flight PKCE flow. Stateful inside the plugin + * process; a new instance is created on every "Connect" click and + * disposed once the callback runs (or on plugin unload). + */ +export class OAuthClient { + private pending: PendingAuthorization | null = null; + + /** + * Mint a verifier/challenge pair, persist the verifier in memory + * keyed by ``state``, and open the user's browser at the consent + * URL. Returns the URL so callers can override the open mechanism + * during tests. + */ + async beginAuthorize(apiBaseUrl: string): Promise { + const verifier = generatePkceVerifier(); + const challenge = await pkceChallenge(verifier); + const state = randomBase64Url(PKCE_STATE_BYTES); + const consentBase = deriveConsentUrl(apiBaseUrl); + const params = new URLSearchParams({ + response_type: RESPONSE_TYPE, + client_id: OAUTH_CLIENT_ID, + redirect_uri: OAUTH_REDIRECT_URI, + code_challenge: challenge, + code_challenge_method: PKCE_METHOD, + state: state, + }); + const url = `${consentBase}?${params.toString()}`; + this.pending = { + verifier, + state, + redirectUri: OAUTH_REDIRECT_URI, + apiBaseUrl: normalizeApiBaseUrl(apiBaseUrl), + }; + window.open(url); + return url; + } + + /** + * Handle the `obsidian://unabyss/auth-callback?code=...&state=...` + * deep-link. Validates `state` against the pending request, + * exchanges the code for tokens, fetches the user's email, and + * returns the assembled {@link AuthState}. The caller is + * responsible for persisting it via `Plugin.saveData()`. + */ + async handleCallback(params: AuthorizeCallbackParams): Promise { + if (!this.pending) { + throw new OAuthFlowError( + "no_pending_authorization", + "No in-flight authorization. Click Connect first.", + ); + } + const pending = this.pending; + this.pending = null; + + if (params.error) { + throw new OAuthFlowError(params.error, `Authorization rejected: ${params.error}`); + } + if (!params.code) { + throw new OAuthFlowError( + "missing_code", + "Authorization callback was missing the code parameter.", + ); + } + if (!params.state || params.state !== pending.state) { + throw new OAuthFlowError( + "state_mismatch", + "Authorization callback state does not match. Restart the flow.", + ); + } + + const tokens = await exchangeCodeForTokens({ + apiBaseUrl: pending.apiBaseUrl, + code: params.code, + codeVerifier: pending.verifier, + redirectUri: pending.redirectUri, + }); + const email = await fetchUserEmail(pending.apiBaseUrl, tokens.access); + return { + accessToken: tokens.access, + refreshToken: tokens.refresh, + userEmail: email, + }; + } + + abort(): void { + this.pending = null; + } + + hasPending(): boolean { + return this.pending !== null; + } +} + +/** + * Refresh-token rotation against `/api/auth/token/refresh/` (the + * existing simplejwt rotation endpoint, which honours the OAuth + * `client_type` claim minted by Phase 1). The returned pair MUST + * replace the stored access+refresh tokens; the old refresh token is + * blacklisted server-side as part of rotation. + */ +export async function rotateRefreshToken( + apiBaseUrl: string, + refreshToken: string, +): Promise { + const base = normalizeApiBaseUrl(apiBaseUrl); + const response = await requestUrl({ + url: `${base}/api/auth/token/refresh/`, + method: "POST", + contentType: "application/json", + body: JSON.stringify({ refresh: refreshToken }), + throw: false, + }); + assertOk(response, "refresh-token rotation"); + const body = response.json as Partial & { access?: string; refresh?: string }; + if (!body.access || !body.refresh) { + throw new OAuthFlowError( + "malformed_refresh_response", + "Refresh endpoint returned no access/refresh pair.", + ); + } + return { access: body.access, refresh: body.refresh }; +} + +/** + * Revoke every outstanding OAuth refresh token for ``(user, obsidian + * client)`` against `/api/oauth/revoke/`. Idempotent server-side; a + * second call after disconnect returns 204 with no effect. + */ +export async function revokeTokens( + apiBaseUrl: string, + accessToken: string, +): Promise { + const base = normalizeApiBaseUrl(apiBaseUrl); + const response = await requestUrl({ + url: `${base}/api/oauth/revoke/`, + method: "POST", + contentType: "application/json", + headers: { Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ client_id: OAUTH_CLIENT_ID }), + throw: false, + }); + if (response.status >= 400) { + const detail = describeError(response, "revoke"); + new Notice(detail); + } +} + +export class OAuthFlowError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + this.name = "OAuthFlowError"; + } +} + +interface ExchangeOptions { + apiBaseUrl: string; + code: string; + codeVerifier: string; + redirectUri: string; +} + +interface OAuthTokenEndpointResponse { + access_token: string; + refresh_token: string; +} + +async function exchangeCodeForTokens(opts: ExchangeOptions): Promise { + const response = await requestUrl({ + url: `${opts.apiBaseUrl}/api/oauth/token/`, + method: "POST", + contentType: "application/json", + body: JSON.stringify({ + grant_type: GRANT_TYPE, + code: opts.code, + code_verifier: opts.codeVerifier, + client_id: OAUTH_CLIENT_ID, + redirect_uri: opts.redirectUri, + }), + throw: false, + }); + if (response.status >= 400) { + const body = response.json as OAuthErrorBody | undefined; + const description = body?.error_description || body?.error || `HTTP ${response.status}`; + throw new OAuthFlowError(body?.error || "token_exchange_failed", description); + } + const body = response.json as Partial; + if (!body.access_token || !body.refresh_token) { + throw new OAuthFlowError( + "malformed_token_response", + "Token endpoint returned no access/refresh pair.", + ); + } + return { access: body.access_token, refresh: body.refresh_token }; +} + +async function fetchUserEmail(apiBaseUrl: string, accessToken: string): Promise { + const response = await requestUrl({ + url: `${apiBaseUrl}/api/users/me/`, + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + throw: false, + }); + if (response.status >= 400) { + throw new OAuthFlowError( + "fetch_user_failed", + `Could not fetch user identity (HTTP ${response.status}).`, + ); + } + const body = response.json as Partial; + return body.email || ""; +} + +/** Exported for unit-test round-tripping (see `tests/oauth.test.ts`). */ +export function generatePkceVerifier(): string { + return randomBase64Url(PKCE_VERIFIER_BYTES); +} + +/** Exported for unit-test round-tripping. */ +export async function pkceChallenge(verifier: string): Promise { + const encoded = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Exported for unit-test round-tripping. */ +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function randomBase64Url(byteLength: number): string { + const buffer = new Uint8Array(byteLength); + crypto.getRandomValues(buffer); + return base64UrlEncode(buffer); +} + +function assertOk(response: RequestUrlResponse, context: string): void { + if (response.status >= 400) { + const description = describeError(response, context); + throw new OAuthFlowError("http_error", description); + } +} + +function describeError(response: RequestUrlResponse, context: string): string { + const body = response.json as Partial | undefined; + if (body?.error_description) { + return `${context} failed: ${body.error_description}`; + } + if (body?.error) { + return `${context} failed: ${body.error}`; + } + return `${context} failed: HTTP ${response.status}`; +} diff --git a/src/progress.ts b/src/progress.ts new file mode 100644 index 0000000..86d4622 --- /dev/null +++ b/src/progress.ts @@ -0,0 +1,134 @@ +/** + * Minimal per-direction progress observable shared by the sync engines + * and the settings tab. + * + * Each direction owns one {@link ProgressTracker} instance. The settings + * tab subscribes to render live "uploaded / written" counters; the sync + * engines call {@link ProgressTracker.report} as they advance. Reset on + * each new pass so the indicator reflects the current run only. + * + * No third-party reactive primitive - a pub/sub of unknown size is + * overkill for two consumers (settings tab + status notice). A flat + * listener list plus an immutable snapshot is enough. + */ + +export type SyncDirection = "outbound" | "inbound"; + +export type SyncPhase = "idle" | "running" | "ok" | "error"; + +export interface ProgressSnapshot { + direction: SyncDirection; + phase: SyncPhase; + /** Total items planned for this pass (notes scanned / exports paged). */ + total: number; + /** Items finalised so far. */ + done: number; + /** Free-form status line ("Uploading bodies", "Polling page 3"). */ + label: string; + /** Last error message when ``phase === "error"``. */ + error: string; + /** Wall-clock of the last update. */ + updatedAt: number; +} + +export type ProgressListener = (snapshot: ProgressSnapshot) => void; + +const INITIAL_LABELS: Record = { + outbound: "Idle.", + inbound: "Idle.", +}; + +export class ProgressTracker { + private snapshot: ProgressSnapshot; + private readonly listeners = new Set(); + + constructor(direction: SyncDirection) { + this.snapshot = { + direction, + phase: "idle", + total: 0, + done: 0, + label: INITIAL_LABELS[direction], + error: "", + updatedAt: Date.now(), + }; + } + + get value(): ProgressSnapshot { + return this.snapshot; + } + + subscribe(listener: ProgressListener): () => void { + this.listeners.add(listener); + listener(this.snapshot); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Mark this direction as actively syncing and zero the counters. + * Callers pass an initial label and (when known up-front) a total. + */ + start(label: string, total = 0): void { + this.update({ + phase: "running", + label, + total, + done: 0, + error: "", + }); + } + + report(delta: Partial>): void { + this.update(delta); + } + + succeed(label: string): void { + this.update({ + phase: "ok", + label, + error: "", + }); + } + + fail(error: string): void { + this.update({ + phase: "error", + label: "Failed.", + error, + }); + } + + /** + * Reset to the idle phase. Called when the user disables a direction + * so stale "Synced X notes" labels don't keep showing. + */ + reset(): void { + this.snapshot = { + ...this.snapshot, + phase: "idle", + label: INITIAL_LABELS[this.snapshot.direction], + total: 0, + done: 0, + error: "", + updatedAt: Date.now(), + }; + this.emit(); + } + + private update(delta: Partial): void { + this.snapshot = { + ...this.snapshot, + ...delta, + updatedAt: Date.now(), + }; + this.emit(); + } + + private emit(): void { + for (const listener of this.listeners) { + listener(this.snapshot); + } + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..f089c75 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,455 @@ +/** + * Settings tab for the Unabyss plugin (Phase 5). + * + * Adds onto the Phase 4 shell: + * + * - Native folder picker for the include-folder list AND for the + * inbound exports target folder (``FolderSuggestModal`` pattern). + * - Per-direction enable / disable toggles. + * - "Delete behaviour when an export is deleted in Unabyss" + * dropdown (leave / delete / move). + * - Per-direction live progress indicator subscribed to the + * {@link ProgressTracker} owned by {@link UnabyssPlugin}. + * - Working "Force full resync" button that clears the local cache + * and immediately re-runs an outbound sync. + * + * The tab continues to delegate every action back to the host plugin + * so authentication state, api client mutation, persistence, and + * sync orchestration stay in one place. + */ + +import { + AbstractInputSuggest, + App, + Notice, + PluginSettingTab, + Setting, + SuggestModal, + TFolder, +} from "obsidian"; +import type UnabyssPlugin from "./main"; +import { ProgressSnapshot, ProgressTracker } from "./progress"; +import { ExportDeleteBehaviour } from "./types"; + +type SubscriptionDisposer = () => void; + +export class UnabyssSettingTab extends PluginSettingTab { + private readonly plugin: UnabyssPlugin; + private readonly disposers: SubscriptionDisposer[] = []; + + constructor(app: App, plugin: UnabyssPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + this.unsubscribeAll(); + const { containerEl } = this; + containerEl.empty(); + this.renderHeader(containerEl); + this.renderAuthState(containerEl); + this.renderOutboundSection(containerEl); + this.renderInboundSection(containerEl); + this.renderSyncActions(containerEl); + this.renderAdvancedSection(containerEl); + } + + hide(): void { + this.unsubscribeAll(); + } + + private renderHeader(containerEl: HTMLElement): void { + const header = containerEl.createDiv({ cls: "unabyss-settings-header" }); + header.style.display = "flex"; + header.style.alignItems = "center"; + header.style.gap = "10px"; + header.style.marginBottom = "18px"; + + const logo = header.createEl("img", { cls: "unabyss-settings-logo" }); + logo.alt = "Unabyss"; + logo.src = this.pluginLogoResourcePath(); + logo.style.width = "32px"; + logo.style.height = "32px"; + logo.style.flexShrink = "0"; + + const title = header.createEl("h2", { text: "Unabyss" }); + title.style.margin = "0"; + } + + private pluginLogoResourcePath(): string { + const logoFile = document.body.classList.contains("theme-dark") + ? "logo-dark.svg" + : "logo-light.svg"; + return this.app.vault.adapter.getResourcePath( + `.obsidian/plugins/${this.plugin.manifest.id}/${logoFile}`, + ); + } + + private renderApiBaseUrl(containerEl: HTMLElement): void { + new Setting(containerEl) + .setName("API base URL") + .setDesc( + "Unabyss API origin. The plugin opens the matching consent page in your browser " + + "(api. is rewritten to app. automatically).", + ) + .addText((text) => { + text.setPlaceholder("https://api.unabyss.com") + .setValue(this.plugin.settings.apiBaseUrl) + .onChange(async (value) => { + const trimmed = value.trim() || "https://api.unabyss.com"; + this.plugin.settings.apiBaseUrl = trimmed; + await this.plugin.saveSettings(); + this.plugin.rebuildApiClient(); + }); + }); + } + + private renderAuthState(containerEl: HTMLElement): void { + const auth = this.plugin.settings.auth; + const setting = new Setting(containerEl).setName("Account"); + + if (auth) { + setting.setDesc(`Connected as ${auth.userEmail || "(unknown)"}`); + setting.addButton((btn) => + btn.setButtonText("Disconnect").onClick(async () => { + btn.setDisabled(true); + try { + await this.plugin.disconnect(); + new Notice("Disconnected from Unabyss."); + } catch (err) { + new Notice(`Disconnect failed: ${describeError(err)}`); + } finally { + btn.setDisabled(false); + this.display(); + } + }), + ); + return; + } + + setting.setDesc("Not connected."); + setting.addButton((btn) => + btn.setCta().setButtonText("Connect").onClick(async () => { + btn.setDisabled(true); + try { + await this.plugin.beginConnect(); + new Notice("Opened consent page in your browser."); + } catch (err) { + new Notice(`Connect failed: ${describeError(err)}`); + } finally { + btn.setDisabled(false); + } + }), + ); + } + + private renderOutboundSection(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Obsidian \u2192 Unabyss (outbound)" }); + + new Setting(containerEl) + .setName("Sync outbound") + .setDesc( + "When off, neither file-change events, the hourly timer, nor the manual button " + + "send notes to Unabyss.", + ) + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.outboundEnabled).onChange(async (value) => { + this.plugin.settings.outboundEnabled = value; + await this.plugin.saveSettings(); + this.plugin.onDirectionToggleChanged(); + this.display(); + }), + ); + + new Setting(containerEl) + .setName("Include folders") + .setDesc( + "Vault-relative folder paths to sync. Leave empty to sync the whole vault. " + + "Use the picker to add folders one at a time.", + ) + .addButton((btn) => + btn.setButtonText("Add folder").onClick(() => { + new FolderSuggestModal(this.app, async (folder) => { + const next = [...this.plugin.settings.includeFolders]; + if (!next.includes(folder.path)) { + next.push(folder.path); + this.plugin.settings.includeFolders = next; + await this.plugin.saveSettings(); + this.display(); + } + }).open(); + }), + ); + + this.renderFolderChipList(containerEl, this.plugin.settings.includeFolders, async (next) => { + this.plugin.settings.includeFolders = next; + await this.plugin.saveSettings(); + this.display(); + }); + + this.renderProgressRow(containerEl, "Outbound status", this.plugin.outboundProgress); + } + + private renderInboundSection(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Unabyss \u2192 Obsidian (inbound)" }); + + new Setting(containerEl) + .setName("Sync inbound") + .setDesc( + "When off, exports are not written back into the vault and the hourly timer " + + "skips this direction.", + ) + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.inboundEnabled).onChange(async (value) => { + this.plugin.settings.inboundEnabled = value; + await this.plugin.saveSettings(); + this.plugin.onDirectionToggleChanged(); + this.display(); + }), + ); + + new Setting(containerEl) + .setName("Export target folder") + .setDesc("Vault folder where Unabyss exports are written. Pick a folder to enable inbound sync.") + .addText((text) => { + const inputEl = text.inputEl; + text.setPlaceholder("Unabyss/Exports") + .setValue(this.plugin.settings.exportTargetFolder) + .onChange(async (value) => { + this.plugin.settings.exportTargetFolder = value.trim(); + await this.plugin.saveSettings(); + }); + new FolderInputSuggest(this.app, inputEl, async (folder) => { + this.plugin.settings.exportTargetFolder = folder.path; + await this.plugin.saveSettings(); + this.display(); + }); + }); + + new Setting(containerEl) + .setName("When an export is deleted in Unabyss") + .setDesc( + "Controls what happens locally when Unabyss soft-deletes an export the plugin " + + "previously wrote into your vault.", + ) + .addDropdown((dropdown) => + dropdown + .addOption("leave", "Leave the local file alone (default)") + .addOption("delete", "Delete the local file (system trash)") + .addOption("move", "Move to a Deleted/ subfolder") + .setValue(this.plugin.settings.exportDeleteBehaviour) + .onChange(async (value) => { + this.plugin.settings.exportDeleteBehaviour = value as ExportDeleteBehaviour; + await this.plugin.saveSettings(); + }), + ); + + this.renderProgressRow(containerEl, "Inbound status", this.plugin.inboundProgress); + } + + private renderSyncActions(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Manual sync" }); + + new Setting(containerEl) + .setName("Sync now (both directions)") + .setDesc("Run both enabled directions in sequence, same as the hourly timer fires.") + .addButton((btn) => + btn + .setCta() + .setButtonText("Sync now") + .setDisabled(this.plugin.settings.auth === null) + .onClick(async () => { + btn.setDisabled(true); + try { + await this.plugin.runManualSync(); + } catch (err) { + new Notice(`Sync failed: ${describeError(err)}`); + } finally { + btn.setDisabled(this.plugin.settings.auth === null); + this.display(); + } + }), + ); + } + + private renderAdvancedSection(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Advanced" }); + this.renderApiBaseUrl(containerEl); + new Setting(containerEl) + .setName("Force full resync") + .setDesc( + "Clears the local manifest cache + inbound watermark, then runs an outbound sync " + + "so the server's hash-diff guard re-establishes the truth.", + ) + .addButton((btn) => + btn + .setWarning() + .setButtonText("Force full resync") + .setDisabled(this.plugin.settings.auth === null) + .onClick(async () => { + btn.setDisabled(true); + try { + await this.plugin.forceFullResync(); + new Notice("Force full resync complete."); + } catch (err) { + new Notice(`Force full resync failed: ${describeError(err)}`); + } finally { + btn.setDisabled(this.plugin.settings.auth === null); + this.display(); + } + }), + ); + } + + private renderFolderChipList( + containerEl: HTMLElement, + folders: string[], + save: (next: string[]) => Promise, + ): void { + if (folders.length === 0) { + return; + } + const chipsRow = containerEl.createDiv({ cls: "unabyss-folder-chip-row" }); + chipsRow.style.display = "flex"; + chipsRow.style.flexWrap = "wrap"; + chipsRow.style.gap = "8px"; + chipsRow.style.marginBottom = "12px"; + for (const folder of folders) { + const chip = chipsRow.createDiv({ cls: "unabyss-folder-chip" }); + chip.style.padding = "4px 8px"; + chip.style.border = "1px solid var(--background-modifier-border)"; + chip.style.borderRadius = "4px"; + chip.style.display = "flex"; + chip.style.alignItems = "center"; + chip.style.gap = "6px"; + chip.createSpan({ text: folder }); + const remove = chip.createEl("button", { text: "x" }); + remove.style.background = "transparent"; + remove.style.border = "none"; + remove.style.cursor = "pointer"; + remove.addEventListener("click", async () => { + const next = folders.filter((entry) => entry !== folder); + await save(next); + }); + } + } + + private renderProgressRow( + containerEl: HTMLElement, + name: string, + tracker: ProgressTracker, + ): void { + const setting = new Setting(containerEl).setName(name); + const valueEl = setting.descEl.createSpan(); + const dispose = tracker.subscribe((snapshot) => { + valueEl.setText(formatProgress(snapshot)); + }); + this.disposers.push(dispose); + } + + private unsubscribeAll(): void { + for (const dispose of this.disposers) { + dispose(); + } + this.disposers.length = 0; + } +} + +function formatProgress(snapshot: ProgressSnapshot): string { + const head = `${snapshot.label}`; + if (snapshot.phase === "running" && snapshot.total > 0) { + return `${head} (${snapshot.done}/${snapshot.total})`; + } + if (snapshot.phase === "error" && snapshot.error) { + return `${head} - ${snapshot.error}`; + } + return head; +} + +function describeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +/** + * Modal that suggests every folder in the vault. Used by the include-list + * "Add folder" button. Mirrors the official ``FolderSuggestModal`` + * pattern (Obsidian's own UX for the "Move file to..." flow). + */ +class FolderSuggestModal extends SuggestModal { + private readonly onChoose: (folder: TFolder) => void | Promise; + + constructor(app: App, onChoose: (folder: TFolder) => void | Promise) { + super(app); + this.onChoose = onChoose; + this.setPlaceholder("Pick a vault folder..."); + } + + getSuggestions(query: string): TFolder[] { + const folders: TFolder[] = []; + const root = this.app.vault.getRoot(); + const walk = (folder: TFolder): void => { + folders.push(folder); + for (const child of folder.children) { + if (child instanceof TFolder) { + walk(child); + } + } + }; + walk(root); + const needle = query.toLowerCase(); + return folders.filter((folder) => folder.path.toLowerCase().includes(needle)); + } + + renderSuggestion(folder: TFolder, el: HTMLElement): void { + el.setText(folder.path === "/" ? "(vault root)" : folder.path); + } + + onChooseSuggestion(folder: TFolder): void { + Promise.resolve(this.onChoose(folder)).catch((err) => { + console.warn("Unabyss: folder pick failed", err); + }); + } +} + +/** + * Inline ``AbstractInputSuggest`` attached to the target-folder text + * input so users can type-ahead instead of opening a separate modal. + */ +class FolderInputSuggest extends AbstractInputSuggest { + private readonly inputEl: HTMLInputElement; + + constructor(app: App, inputEl: HTMLInputElement, onPick: (folder: TFolder) => Promise) { + super(app, inputEl); + this.inputEl = inputEl; + this.onSelect((folder) => { + this.inputEl.value = folder.path; + this.close(); + onPick(folder).catch((err) => { + console.warn("Unabyss: folder suggest pick failed", err); + }); + }); + } + + protected getSuggestions(query: string): TFolder[] { + const folders: TFolder[] = []; + const root = this.app.vault.getRoot(); + const walk = (folder: TFolder): void => { + folders.push(folder); + for (const child of folder.children) { + if (child instanceof TFolder) { + walk(child); + } + } + }; + walk(root); + const needle = query.toLowerCase(); + return folders.filter((folder) => folder.path.toLowerCase().includes(needle)); + } + + renderSuggestion(folder: TFolder, el: HTMLElement): void { + el.setText(folder.path === "/" ? "(vault root)" : folder.path); + } +} diff --git a/src/syncInbound.ts b/src/syncInbound.ts new file mode 100644 index 0000000..d3d4c3d --- /dev/null +++ b/src/syncInbound.ts @@ -0,0 +1,379 @@ +/** + * Inbound sync engine (Unabyss -> Obsidian). + * + * Polls ``GET /api/exports/changed-since/?updated_after=``, + * paginates through the response set, and writes each row into the + * user-configured ``exportTargetFolder`` as + * ``.md``. The bottom of every written file carries a + * stable trailer line ```` so the + * next sync can recognise a same-slug existing file as the same export + * (idempotent overwrite) versus an unrelated user-authored note + * (suffix-on-write with ``-``). + * + * Delete behaviour per export ``is_deleted=true`` row is governed by + * the per-user ``exportDeleteBehaviour`` setting: + * + * - ``leave`` : do nothing (default; the on-disk file is treated as + * the user's now-detached copy). + * - ``delete`` : trash the export file if we own it (trailer matches). + * - ``move`` : relocate the file into ``Deleted/`` underneath the + * target folder. The trailer stays so a subsequent + * restore can find it again. + * + * Watermark: ``updated_after`` advances **only** after a page is fully + * applied. A mid-page failure leaves the watermark at the boundary of + * the last fully-applied page so the next pass retries the same rows. + */ + +import { App, normalizePath, TFile, TFolder, Vault } from "obsidian"; +import { UnabyssApiClient } from "./apiClient"; +import { ManifestCache } from "./manifestCache"; +import { ProgressTracker } from "./progress"; +import { + DELETED_EXPORTS_SUBFOLDER, + EXPORT_TRAILER_PREFIX, + EXPORT_TRAILER_SUFFIX, + ExportDeleteBehaviour, + ExportRow, + InboundFileOutcome, + SyncInboundReport, +} from "./types"; + +const INBOUND_PAGE_SIZE = 100; +const FALLBACK_WATERMARK = "1970-01-01T00:00:00Z"; + +export interface InboundSyncDeps { + app: App; + api: UnabyssApiClient; + cache: ManifestCache; + targetFolder: string; + deleteBehaviour: ExportDeleteBehaviour; + progress?: ProgressTracker; +} + +export async function runInboundSync(deps: InboundSyncDeps): Promise { + const target = normalizePath(deps.targetFolder.trim()); + if (!target) { + throw new Error("Pick a vault folder for incoming exports before running inbound sync."); + } + await ensureFolderExists(deps.app.vault, target); + + const report: SyncInboundReport = { + polled: 0, + written: 0, + deleted: 0, + moved: 0, + skipped: 0, + errors: [], + watermarkAdvancedTo: deps.cache.getExportsWatermark() || FALLBACK_WATERMARK, + }; + + deps.progress?.start("Polling exports...", 0); + + let pageOffset = 0; + let pageCount = 0; + const watermarkInitial = deps.cache.getExportsWatermark() || FALLBACK_WATERMARK; + while (true) { + const page = await deps.api.getChangedExports(watermarkInitial, INBOUND_PAGE_SIZE, pageOffset); + const rows = page.results ?? []; + if (rows.length === 0) { + break; + } + pageCount += 1; + deps.progress?.report({ + label: `Polling page ${pageCount}...`, + total: report.polled + rows.length, + }); + + let pageHighest = ""; + let pageFailed = false; + for (const row of rows) { + const outcome = await applyExportRow(deps, target, row); + report.polled += 1; + absorbOutcome(report, outcome); + if (outcome.error) { + pageFailed = true; + } + if (!outcome.error && row.updated_at > pageHighest) { + pageHighest = row.updated_at; + } + deps.progress?.report({ + label: outcome.error ? `Error on ${row.title}` : `Synced "${row.title}"`, + done: report.polled, + }); + } + + if (!pageFailed && pageHighest) { + deps.cache.setExportsWatermark(pageHighest); + report.watermarkAdvancedTo = pageHighest; + await deps.cache.flush(); + } else if (pageFailed) { + break; + } + + if (rows.length < INBOUND_PAGE_SIZE) { + break; + } + pageOffset += INBOUND_PAGE_SIZE; + } + + if (report.errors.length === 0) { + deps.progress?.succeed( + `Inbound done - ${report.written} written, ${report.deleted} deleted, ${report.moved} moved.`, + ); + } else { + deps.progress?.fail(`${report.errors.length} row(s) failed; will retry next sync.`); + } + return report; +} + +async function applyExportRow( + deps: InboundSyncDeps, + targetFolder: string, + row: ExportRow, +): Promise { + try { + if (row.is_deleted) { + return await applyDeletion(deps, targetFolder, row); + } + return await applyUpsert(deps, targetFolder, row); + } catch (err) { + return { + exportId: row.id, + title: row.title, + vaultPath: "", + action: "skipped", + error: describeError(err), + }; + } +} + +async function applyUpsert( + deps: InboundSyncDeps, + targetFolder: string, + row: ExportRow, +): Promise { + const baseSlug = slugifyTitle(row.title) || "untitled"; + const primaryPath = joinPath(targetFolder, `${baseSlug}.md`); + const ownPath = await locateOwnedExport(deps.app.vault, targetFolder, row.id); + const targetPath = ownPath ?? (await resolveCollisionPath(deps.app.vault, primaryPath, row.id)); + const body = appendTrailer(row.markdown, row.id); + + const existing = deps.app.vault.getAbstractFileByPath(targetPath); + if (existing && existing instanceof TFile) { + await deps.app.vault.modify(existing, body); + } else { + await deps.app.vault.create(targetPath, body); + } + return { + exportId: row.id, + title: row.title, + vaultPath: targetPath, + action: "written", + }; +} + +async function applyDeletion( + deps: InboundSyncDeps, + targetFolder: string, + row: ExportRow, +): Promise { + if (deps.deleteBehaviour === "leave") { + return { + exportId: row.id, + title: row.title, + vaultPath: "", + action: "skipped", + }; + } + const ownPath = await locateOwnedExport(deps.app.vault, targetFolder, row.id); + if (!ownPath) { + return { + exportId: row.id, + title: row.title, + vaultPath: "", + action: "skipped", + }; + } + const existing = deps.app.vault.getAbstractFileByPath(ownPath); + if (!(existing instanceof TFile)) { + return { + exportId: row.id, + title: row.title, + vaultPath: ownPath, + action: "skipped", + }; + } + if (deps.deleteBehaviour === "delete") { + await deps.app.vault.trash(existing, true); + return { + exportId: row.id, + title: row.title, + vaultPath: ownPath, + action: "deleted", + }; + } + const trashFolder = joinPath(targetFolder, DELETED_EXPORTS_SUBFOLDER); + await ensureFolderExists(deps.app.vault, trashFolder); + const movedPath = await resolveCollisionPath( + deps.app.vault, + joinPath(trashFolder, existing.name), + row.id, + ); + await deps.app.fileManager.renameFile(existing, movedPath); + return { + exportId: row.id, + title: row.title, + vaultPath: movedPath, + action: "moved", + }; +} + +function absorbOutcome(report: SyncInboundReport, outcome: InboundFileOutcome): void { + switch (outcome.action) { + case "written": + report.written += 1; + break; + case "deleted": + report.deleted += 1; + break; + case "moved": + report.moved += 1; + break; + case "skipped": + report.skipped += 1; + break; + } + if (outcome.error) { + report.errors.push(outcome); + } +} + +/** + * Walk the export target folder (and the optional ``Deleted/`` + * subfolder) looking for a file whose body carries this export's + * trailer. The lookup is O(N-files-in-target-folder), bounded by what + * a user would realistically put there. + */ +async function locateOwnedExport( + vault: Vault, + targetFolder: string, + exportId: string, +): Promise { + const folders = [targetFolder, joinPath(targetFolder, DELETED_EXPORTS_SUBFOLDER)]; + const trailer = trailerLineFor(exportId); + for (const folder of folders) { + const node = vault.getAbstractFileByPath(folder); + if (!(node instanceof TFolder)) { + continue; + } + for (const child of node.children) { + if (!(child instanceof TFile) || child.extension !== "md") { + continue; + } + const text = await vault.cachedRead(child); + if (text.includes(trailer)) { + return child.path; + } + } + } + return null; +} + +function appendTrailer(body: string, exportId: string): string { + const trailer = trailerLineFor(exportId); + const trimmed = body.replace(/\s+$/u, ""); + return `${trimmed}\n\n${trailer}\n`; +} + +export function trailerLineFor(exportId: string): string { + return `${EXPORT_TRAILER_PREFIX} ${exportId} ${EXPORT_TRAILER_SUFFIX}`; +} + +/** + * Slugify an export title for use as a filename. + * + * Rules (kept simple to avoid pulling in a runtime dependency): + * + * - Lowercase ASCII alphanumerics and dashes. + * - Non-ASCII letters and punctuation collapse to dashes. + * - Repeated dashes collapse to one; leading / trailing dashes drop. + * - Truncate to 80 characters so the final ``-.md`` path + * fits comfortably inside Obsidian's ``normalizePath`` limit. + */ +export function slugifyTitle(title: string): string { + const lowered = title.toLowerCase(); + const stripped = lowered.replace(/[^a-z0-9]+/gu, "-"); + const collapsed = stripped.replace(/-+/gu, "-").replace(/^-|-$/gu, ""); + return collapsed.slice(0, 80); +} + +/** + * Suffix policy for slug collisions. + * + * The collision target is "a different file already exists at this + * vault-relative path that doesn't carry our trailer". The plugin + * appends ``-`` to the slug body (before ``.md``) + * and rechecks. With 16^6 = ~16M possible suffixes and a slug that + * already differs by title, a second collision is statistically + * negligible; the function falls back to a UUID-only filename in the + * pathological case rather than looping. + */ +export function buildCollisionPath(primaryPath: string, exportId: string): string { + const ext = ".md"; + const dotIndex = primaryPath.lastIndexOf("."); + const body = dotIndex === -1 ? primaryPath : primaryPath.slice(0, dotIndex); + const suffix = collisionSuffixFor(exportId); + return `${body}-${suffix}${ext}`; +} + +export function collisionSuffixFor(exportId: string): string { + const stripped = exportId.replace(/-/gu, ""); + return stripped.slice(0, 6) || exportId.slice(0, 6); +} + +async function resolveCollisionPath( + vault: Vault, + desiredPath: string, + exportId: string, +): Promise { + if (!vault.getAbstractFileByPath(desiredPath)) { + return desiredPath; + } + const candidate = buildCollisionPath(desiredPath, exportId); + if (!vault.getAbstractFileByPath(candidate)) { + return candidate; + } + const ext = ".md"; + const dotIndex = candidate.lastIndexOf("."); + const body = dotIndex === -1 ? candidate : candidate.slice(0, dotIndex); + return `${body}-${exportId.slice(0, 8)}${ext}`; +} + +function joinPath(folder: string, name: string): string { + if (!folder || folder === "/") { + return normalizePath(name); + } + return normalizePath(`${folder.replace(/\/$/u, "")}/${name}`); +} + +async function ensureFolderExists(vault: Vault, folder: string): Promise { + if (!folder) { + return; + } + const existing = vault.getAbstractFileByPath(folder); + if (existing instanceof TFolder) { + return; + } + if (existing) { + throw new Error(`Inbound target "${folder}" exists but is not a folder.`); + } + await vault.createFolder(folder); +} + +function describeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} diff --git a/src/syncOutbound.ts b/src/syncOutbound.ts new file mode 100644 index 0000000..70e6a27 --- /dev/null +++ b/src/syncOutbound.ts @@ -0,0 +1,269 @@ +/** + * Outbound sync engine (Obsidian -> Unabyss) - minimum-viable Phase 4 + * implementation. + * + * Flow per ``run()`` (manual "Sync now" button): + * + * 1. Walk every markdown file in the vault via + * ``app.vault.getMarkdownFiles()``. + * 2. Apply the user's include-folder filter (empty list = whole vault). + * 3. Hash each in-scope file with SHA-256 (matching the server's + * ``compute_hash(raw_bytes)``). Skip oversize files + * (> ``MAX_NOTE_BYTES``). + * 4. Persist the computed hashes back into the local manifest cache. + * 5. Chunk the hash set into ``MANIFEST_MAX_HASHES_PER_CHUNK`` batches + * and POST each to ``manifest-chunks/``. + * 6. For every hash the server reports as missing, upload the body + * via ``notes/upload/`` in batches of + * ``NOTE_BODIES_MAX_PER_REQUEST``. + * 7. POST the full hash set to ``sync-finalize/`` and bump the local + * ``last_synced_at``. + * + * The manifest cache is best-effort. Files whose ``mtime`` matches the + * cache row skip hash recomputation; the server-side hash diff is the + * ultimate source of truth, so a stale or wiped cache produces extra + * hashing work but never bad data. + */ + +import { App, normalizePath, TFile } from "obsidian"; +import { UnabyssApiClient } from "./apiClient"; +import { ManifestCache } from "./manifestCache"; +import { ProgressTracker } from "./progress"; +import { + MAX_NOTE_BYTES, + MANIFEST_MAX_HASHES_PER_CHUNK, + NOTE_BODIES_MAX_PER_REQUEST, + NoteEnvelope, + NoteUploadRejection, + SyncOutboundReport, +} from "./types"; + +interface ScannedNote { + path: string; + content: string; + contentHash: string; + sizeBytes: number; + mtime: number; +} + +export interface OutboundSyncDeps { + app: App; + api: UnabyssApiClient; + cache: ManifestCache; + vaultId: string; + vaultDisplayName: string; + includeFolders: string[]; + progress?: ProgressTracker; +} + +export async function runOutboundSync(deps: OutboundSyncDeps): Promise { + if (!deps.vaultId) { + throw new Error("Outbound sync requires a vault_id. Connect to Unabyss first."); + } + const filter = buildIncludeFilter(deps.includeFolders); + const allMarkdown = deps.app.vault.getMarkdownFiles(); + const inScope = allMarkdown.filter((file) => filter(file.path)); + + deps.progress?.report({ label: "Hashing notes...", total: inScope.length, done: 0 }); + + const scanned: ScannedNote[] = []; + let skippedOversize = 0; + let scannedCount = 0; + for (const file of inScope) { + const note = await scanNote(deps.app, deps.cache, file); + scannedCount += 1; + deps.progress?.report({ done: scannedCount }); + if (note === null) { + skippedOversize += 1; + continue; + } + scanned.push(note); + } + + deps.cache.retainOnly(scanned.map((row) => row.path)); + await deps.cache.flush(); + + const allHashes = uniqueHashes(scanned); + deps.progress?.report({ label: "Diffing against server..." }); + const missing = await diffAgainstServer(deps.api, deps.vaultId, allHashes); + deps.progress?.report({ + label: `Uploading ${missing.size} missing body(s)...`, + total: missing.size, + done: 0, + }); + const uploadOutcome = await uploadMissingBodies( + deps.api, + deps.vaultId, + scanned, + missing, + deps.progress, + ); + deps.progress?.report({ label: "Finalising sync..." }); + const finalize = await deps.api.postSyncFinalize({ + vault_id: deps.vaultId, + hashes: allHashes, + vault_display_name: deps.vaultDisplayName || undefined, + }); + return { + scanned: scanned.length, + skippedOversize, + uploaded: uploadOutcome.uploaded, + rejected: uploadOutcome.rejected, + deleted: finalize.deleted, + restored: finalize.restored, + lastSyncedAt: finalize.last_synced_at, + }; +} + +async function scanNote(app: App, cache: ManifestCache, file: TFile): Promise { + const cached = cache.get(file.path); + if (cached && cached.mtime === file.stat.mtime) { + const content = await app.vault.cachedRead(file); + const sizeBytes = byteLength(content); + if (sizeBytes > MAX_NOTE_BYTES) { + return null; + } + return { + path: file.path, + content, + contentHash: cached.contentHash, + sizeBytes, + mtime: file.stat.mtime, + }; + } + const content = await app.vault.cachedRead(file); + const sizeBytes = byteLength(content); + if (sizeBytes > MAX_NOTE_BYTES) { + return null; + } + const contentHash = await computeSha256Hex(content); + cache.set(file.path, { contentHash, mtime: file.stat.mtime }); + return { + path: file.path, + content, + contentHash, + sizeBytes, + mtime: file.stat.mtime, + }; +} + +/** + * Build a predicate matching the user's include-folder list. Empty + * list = include everything; otherwise vault-relative paths must be + * under one of the listed folder prefixes (case-sensitive, matching + * Obsidian's path semantics). + */ +function buildIncludeFilter(includeFolders: string[]): (path: string) => boolean { + const normalized = includeFolders + .map((folder) => folder.trim()) + .filter((folder) => folder.length > 0) + .map((folder) => normalizePath(folder)); + if (normalized.length === 0) { + return () => true; + } + return (path: string) => { + const normalizedPath = normalizePath(path); + return normalized.some((prefix) => { + if (prefix === "/" || prefix === "") { + return true; + } + return ( + normalizedPath === prefix || + normalizedPath.startsWith(prefix + "/") + ); + }); + }; +} + +function uniqueHashes(rows: ScannedNote[]): string[] { + const set = new Set(); + for (const row of rows) { + set.add(row.contentHash); + } + return [...set].sort(); +} + +async function diffAgainstServer( + api: UnabyssApiClient, + vaultId: string, + allHashes: string[], +): Promise> { + const missing = new Set(); + for (let i = 0; i < allHashes.length; i += MANIFEST_MAX_HASHES_PER_CHUNK) { + const chunk = allHashes.slice(i, i + MANIFEST_MAX_HASHES_PER_CHUNK); + const response = await api.postManifestChunk({ + vault_id: vaultId, + hashes: chunk, + }); + for (const hash of response.missing_hashes ?? []) { + missing.add(hash); + } + } + if (allHashes.length === 0) { + await api.postManifestChunk({ vault_id: vaultId, hashes: [] }); + } + return missing; +} + +interface UploadOutcome { + uploaded: number; + rejected: NoteUploadRejection[]; +} + +async function uploadMissingBodies( + api: UnabyssApiClient, + vaultId: string, + scanned: ScannedNote[], + missing: Set, + progress?: ProgressTracker, +): Promise { + if (missing.size === 0) { + return { uploaded: 0, rejected: [] }; + } + const envelopes: NoteEnvelope[] = []; + const seen = new Set(); + for (const row of scanned) { + if (!missing.has(row.contentHash) || seen.has(row.contentHash)) { + continue; + } + seen.add(row.contentHash); + envelopes.push({ + vault_path: row.path, + content: row.content, + vault_id: vaultId, + content_hash: row.contentHash, + }); + } + let uploaded = 0; + const rejected: NoteUploadRejection[] = []; + for (let i = 0; i < envelopes.length; i += NOTE_BODIES_MAX_PER_REQUEST) { + const batch = envelopes.slice(i, i + NOTE_BODIES_MAX_PER_REQUEST); + const response = await api.postNoteUpload({ notes: batch }); + uploaded += response.accepted ?? 0; + for (const rejection of response.rejected ?? []) { + rejected.push(rejection); + } + progress?.report({ done: uploaded }); + } + return { uploaded, rejected }; +} + +function byteLength(content: string): number { + return new TextEncoder().encode(content).byteLength; +} + +async function computeSha256Hex(content: string): Promise { + const encoded = new TextEncoder().encode(content); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return bytesToHex(new Uint8Array(digest)); +} + +function bytesToHex(bytes: Uint8Array): string { + const HEX = "0123456789abcdef"; + let out = ""; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + out += HEX[byte >> 4] + HEX[byte & 0x0f]; + } + return out; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5924f2f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,246 @@ +/** + * Shared types for the Unabyss Obsidian plugin. + * + * The wire shapes here mirror the backend serializers documented in + * `dev-docs/backend/obsidian-plugin/01/tech-decisions.md` so the + * client and server stay byte-aligned without an OpenAPI codegen step. + * + * Persistence: every field marked "persisted" lands in + * `/.obsidian/plugins/unabyss/data.json` as plaintext JSON + * (see README threat model). + */ + +export const DEFAULT_API_BASE_URL = "https://api.unabyss.com"; +export const OAUTH_CLIENT_ID = "obsidian"; +export const OAUTH_REDIRECT_URI = "obsidian://unabyss/auth-callback"; +export const OAUTH_PROTOCOL_ACTION = "unabyss/auth-callback"; + +/** + * Hard upper bounds enforced by the backend serializers. Kept in sync + * with `OBSIDIAN_MANIFEST_MAX_HASHES_PER_CHUNK` and + * `OBSIDIAN_NOTE_BODIES_MAX_PER_REQUEST` in `settings/base/integrations.py`. + * Mirroring the value locally lets the plugin pre-chunk before hitting + * a 400 from the server. + */ +export const MANIFEST_MAX_HASHES_PER_CHUNK = 1000; +export const NOTE_BODIES_MAX_PER_REQUEST = 100; + +/** + * Per-note size cap. Mirrors `_MAX_NOTE_BYTES = 1 MiB` in + * `ingest/obsidian/services/obsidian_import.py`. Notes above this cap + * are skipped client-side so the plugin never wastes a round-trip on + * a row the server will reject. + */ +export const MAX_NOTE_BYTES = 1024 * 1024; + +export interface AuthState { + accessToken: string; + refreshToken: string; + userEmail: string; +} + +/** + * Behaviour when an export is soft-deleted on the server side. + * Mirrors the requirements §Configuration choices. + */ +export type ExportDeleteBehaviour = "leave" | "delete" | "move"; + +/** Subfolder used when {@link ExportDeleteBehaviour} is "move". */ +export const DELETED_EXPORTS_SUBFOLDER = "Deleted"; + +/** + * Debounce window applied to Obsidian file-change events before kicking + * off an outbound sync. Aligns with phases.md §"main.ts deltas". + */ +export const OUTBOUND_DEBOUNCE_MS = 5_000; + +/** + * Safety-net timer cadence. Runs both directions regardless of file + * events so the plugin recovers from missed events / external writes. + */ +export const SAFETY_NET_INTERVAL_MS = 60 * 60 * 1_000; + +/** + * Wire-format trailer appended to the bottom of every Unabyss export + * we write into the vault so subsequent syncs can detect that a slug + * collision is *the same* export (idempotent overwrite) versus a + * collision with an unrelated user-authored note (suffix-on-write). + */ +export const EXPORT_TRAILER_PREFIX = ""; + +export interface PluginSettings { + apiBaseUrl: string; + vaultId: string; + includeFolders: string[]; + /** Vault-relative folder where inbound exports are written. */ + exportTargetFolder: string; + /** Action to take when a synced export is deleted server-side. */ + exportDeleteBehaviour: ExportDeleteBehaviour; + /** Master enable switch for the Obsidian -> Unabyss direction. */ + outboundEnabled: boolean; + /** Master enable switch for the Unabyss -> Obsidian direction. */ + inboundEnabled: boolean; + auth: AuthState | null; +} + +export const DEFAULT_SETTINGS: PluginSettings = { + apiBaseUrl: DEFAULT_API_BASE_URL, + vaultId: "", + includeFolders: [], + exportTargetFolder: "", + exportDeleteBehaviour: "leave", + outboundEnabled: true, + inboundEnabled: true, + auth: null, +}; + +/** + * One row inside the local manifest cache (``). `mtime` is the Obsidian-reported + * `stat.mtime` in milliseconds since epoch and lets us short-circuit + * the hash recomputation on files that did not change. + */ +export interface ManifestCacheEntry { + contentHash: string; + mtime: number; +} + +/** On-disk shape of the path -> hash cache, keyed by vault-relative path. */ +export type ManifestPathIndex = Record; + +/** + * Full on-disk shape of the manifest cache. v1 was a flat + * ``Record``; the cache now also persists the per-vault + * inbound exports watermark used by ``syncInbound`` so a fresh plugin + * boot resumes from the same offset. + */ +export interface ManifestCacheData { + paths: ManifestPathIndex; + /** ISO-8601 high-watermark for ``GET /api/exports/changed-since/``. */ + exportsUpdatedAfter: string; +} + +export const EMPTY_MANIFEST_CACHE: ManifestCacheData = { + paths: {}, + exportsUpdatedAfter: "", +}; + +export interface ManifestChunkRequest { + vault_id: string; + hashes: string[]; +} + +export interface ManifestChunkResponse { + missing_hashes: string[]; +} + +export interface NoteEnvelope { + vault_path: string; + content: string; + vault_id: string; + content_hash: string; +} + +export interface NoteUploadRequest { + notes: NoteEnvelope[]; +} + +export interface NoteUploadRejection { + vault_path: string; + reason: string; +} + +export interface NoteUploadResponse { + accepted: number; + rejected: NoteUploadRejection[]; +} + +export interface SyncFinalizeRequest { + vault_id: string; + hashes: string[]; + vault_display_name?: string; +} + +export interface SyncFinalizeResponse { + deleted: number; + restored: number; + last_synced_at: string; +} + +export interface VaultRow { + vault_id: string; + display_name: string; + is_export_target: boolean; + connected_at: string; + last_synced_at: string | null; + last_sync_error: string; + notes_count: number; + notes_soft_deleted_count: number; +} + +export interface VaultListResponse { + results: VaultRow[]; +} + +export interface ExportRow { + id: string; + title: string; + topic_text: string; + preset_slug: string; + status: string; + is_deleted: boolean; + markdown: string; + created_at: string; + updated_at: string; +} + +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface TokenResponse { + access: string; + refresh: string; +} + +export interface OAuthErrorBody { + error: string; + error_description?: string; +} + +export interface UserMeResponse { + email: string; +} + +export interface SyncOutboundReport { + scanned: number; + skippedOversize: number; + uploaded: number; + rejected: NoteUploadRejection[]; + deleted: number; + restored: number; + lastSyncedAt: string; +} + +/** Per-row outcome surfaced from one inbound sync pass. */ +export interface InboundFileOutcome { + exportId: string; + title: string; + vaultPath: string; + action: "written" | "skipped" | "deleted" | "moved"; + error?: string; +} + +export interface SyncInboundReport { + polled: number; + written: number; + deleted: number; + moved: number; + skipped: number; + errors: InboundFileOutcome[]; + watermarkAdvancedTo: string; +} diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts new file mode 100644 index 0000000..e6c68d2 --- /dev/null +++ b/tests/__mocks__/obsidian.ts @@ -0,0 +1,98 @@ +/** + * Test-time stub of the ``obsidian`` runtime module. + * + * Only the symbols actually reached by unit tests are implemented; the + * rest are inert placeholders that exist so TypeScript ``import`` + * statements resolve. Tests that need vault behaviour build their own + * tiny fakes per-file and inject them into the unit under test. + */ + +export class TFile { + path = ""; + name = ""; + extension = "md"; + stat = { mtime: 0, ctime: 0, size: 0 }; +} + +export class TFolder { + path = ""; + name = ""; + children: Array = []; +} + +export type TAbstractFile = TFile | TFolder; + +export class Plugin { + app: unknown = {}; + addCommand(): void {} + addSettingTab(): void {} + registerObsidianProtocolHandler(): void {} + registerEvent(): void {} + registerInterval(): void {} + async loadData(): Promise { + return null; + } + async saveData(): Promise {} +} + +export class PluginSettingTab { + containerEl = { empty: () => undefined, createEl: () => ({}) }; + constructor(_app: unknown, _plugin: unknown) {} + display(): void {} +} + +export class Setting { + constructor(_containerEl: unknown) {} + setName(): this { + return this; + } + setDesc(): this { + return this; + } + addText(): this { + return this; + } + addButton(): this { + return this; + } + addToggle(): this { + return this; + } + addDropdown(): this { + return this; + } + descEl = { createSpan: () => ({ setText: () => undefined }) }; +} + +export class SuggestModal<_T> { + constructor(_app: unknown) {} + setPlaceholder(): void {} + open(): void {} +} + +export class AbstractInputSuggest<_T> { + constructor(_app: unknown, _inputEl: unknown) {} + onSelect(): this { + return this; + } + close(): void {} +} + +export class Notice { + constructor(_message?: string) {} +} + +export const App = class {} as unknown; + +export function normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); +} + +export function requestUrl(): never { + throw new Error("requestUrl is not available in unit tests; inject your own client."); +} + +export type RequestUrlParam = unknown; +export type RequestUrlResponse = unknown; + +export const Vault = class {} as unknown; diff --git a/tests/manifestCache.test.ts b/tests/manifestCache.test.ts new file mode 100644 index 0000000..56ea9f1 --- /dev/null +++ b/tests/manifestCache.test.ts @@ -0,0 +1,98 @@ +/** + * Manifest cache load / save / clear round-trips. + * + * The cache is the seam between the outbound scanner's "have I seen + * this file before?" cheap path and the on-disk ``data.json``. A + * regression here either over-uploads (cache thinks it has no entries + * after a reload) or under-uploads (cache claims it knows about a + * file it hasn't yet hashed). + */ + +import { + ManifestCache, + normalizeManifestCacheData, +} from "../src/manifestCache"; +import { EMPTY_MANIFEST_CACHE, ManifestCacheData } from "../src/types"; + +function makeCache(): { cache: ManifestCache; saved: ManifestCacheData[] } { + const saved: ManifestCacheData[] = []; + const cache = new ManifestCache({ ...EMPTY_MANIFEST_CACHE }, async (data) => { + saved.push(data); + }); + return { cache, saved }; +} + +describe("ManifestCache", () => { + it("round-trips set -> snapshot -> normalize via persistence", async () => { + const { cache, saved } = makeCache(); + cache.set("Notes/Daily.md", { contentHash: "aaaa", mtime: 42 }); + cache.setExportsWatermark("2026-01-01T00:00:00Z"); + await cache.flush(); + + expect(saved).toHaveLength(1); + const round = normalizeManifestCacheData(saved[0]); + expect(round.paths["Notes/Daily.md"]).toEqual({ contentHash: "aaaa", mtime: 42 }); + expect(round.exportsUpdatedAfter).toEqual("2026-01-01T00:00:00Z"); + }); + + it("clear wipes paths AND the watermark", async () => { + const { cache, saved } = makeCache(); + cache.set("a.md", { contentHash: "abc", mtime: 1 }); + cache.setExportsWatermark("2026-01-02T00:00:00Z"); + await cache.flush(); + + cache.clear(); + await cache.flush(); + + const restored = normalizeManifestCacheData(saved[saved.length - 1]); + expect(restored.paths).toEqual({}); + expect(restored.exportsUpdatedAfter).toEqual(""); + }); + + it("set is a no-op when the existing entry matches exactly", async () => { + const { cache, saved } = makeCache(); + cache.set("a.md", { contentHash: "abc", mtime: 1 }); + await cache.flush(); + const flushesAfterFirst = saved.length; + + cache.set("a.md", { contentHash: "abc", mtime: 1 }); + await cache.flush(); + expect(saved.length).toEqual(flushesAfterFirst); + }); + + it("retainOnly drops paths absent from the live set", async () => { + const { cache, saved } = makeCache(); + cache.set("a.md", { contentHash: "1", mtime: 1 }); + cache.set("b.md", { contentHash: "2", mtime: 2 }); + await cache.flush(); + + cache.retainOnly(["a.md"]); + await cache.flush(); + + const restored = normalizeManifestCacheData(saved[saved.length - 1]); + expect(Object.keys(restored.paths)).toEqual(["a.md"]); + }); + + it("normalizes the legacy flat v1 cache shape", () => { + const legacy = { + "old.md": { contentHash: "deadbeef", mtime: 99 }, + }; + const normalized = normalizeManifestCacheData(legacy); + expect(normalized.paths["old.md"]).toEqual({ contentHash: "deadbeef", mtime: 99 }); + expect(normalized.exportsUpdatedAfter).toEqual(""); + }); + + it("setExportsWatermark refuses to rewind to an earlier value", () => { + const { cache } = makeCache(); + cache.setExportsWatermark("2026-02-01T00:00:00Z"); + cache.setExportsWatermark("2026-01-15T00:00:00Z"); + expect(cache.getExportsWatermark()).toEqual("2026-02-01T00:00:00Z"); + }); + + it("setExportsWatermark to empty string is treated as a reset", () => { + const { cache } = makeCache(); + cache.setExportsWatermark("2026-02-01T00:00:00Z"); + cache.setExportsWatermark(""); + expect(cache.getExportsWatermark()).toEqual(""); + }); +}); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 0000000..e3acb62 --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,55 @@ +/** + * PKCE S256 round-trip: the challenge MUST equal + * ``base64url(SHA-256(verifier))`` and the verifier MUST satisfy the + * RFC-7636 character + length bounds. + * + * These guarantees are the smallest correctness contract the backend + * checks at ``/api/oauth/token/``; a regression in the helper would + * silently break every Connect attempt. + */ + +import { createHash, webcrypto } from "node:crypto"; + +if (typeof globalThis.crypto === "undefined") { + Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true }); +} + +import { + base64UrlEncode, + generatePkceVerifier, + pkceChallenge, +} from "../src/oauth"; + +const PKCE_CHARSET = /^[A-Za-z0-9\-_]+$/u; + +function expectedChallenge(verifier: string): string { + const digest = createHash("sha256").update(verifier).digest(); + return base64UrlEncode(new Uint8Array(digest)); +} + +describe("oauth pkce helpers", () => { + it("verifier is RFC-7636 character-safe and at least 43 chars", () => { + const verifier = generatePkceVerifier(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier).toMatch(PKCE_CHARSET); + }); + + it("each call mints a fresh verifier", () => { + const a = generatePkceVerifier(); + const b = generatePkceVerifier(); + expect(a).not.toEqual(b); + }); + + it("challenge equals base64url(SHA-256(verifier))", async () => { + const verifier = generatePkceVerifier(); + const actual = await pkceChallenge(verifier); + expect(actual).toEqual(expectedChallenge(verifier)); + }); + + it("known-vector RFC-7636 Appendix B round-trips", async () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const actual = await pkceChallenge(verifier); + expect(actual).toEqual(expected); + }); +}); diff --git a/tests/slugify.test.ts b/tests/slugify.test.ts new file mode 100644 index 0000000..cecbec7 --- /dev/null +++ b/tests/slugify.test.ts @@ -0,0 +1,68 @@ +/** + * Slug + collision-suffix policy for inbound exports. + * + * The slug rules are conservative (lowercase ASCII alphanumerics + + * dashes only) so the resulting filename round-trips through + * Obsidian's path normaliser regardless of host filesystem. The + * collision suffix MUST be deterministic so a second pass against an + * unchanged server state writes back to the same on-disk path. + */ + +import { + buildCollisionPath, + collisionSuffixFor, + slugifyTitle, + trailerLineFor, +} from "../src/syncInbound"; + +describe("slugifyTitle", () => { + it("lowercases and dashifies a typical title", () => { + expect(slugifyTitle("Hello World")).toEqual("hello-world"); + }); + + it("collapses sequences of non-alphanumeric chars into a single dash", () => { + expect(slugifyTitle("Foo --- Bar / Baz!")).toEqual("foo-bar-baz"); + }); + + it("strips diacritics by collapsing them into dashes (lossy)", () => { + const slug = slugifyTitle("\u00e9diteur \u00e0 La Mode"); + expect(slug).toEqual("diteur-la-mode"); + }); + + it("returns an empty string for all-punctuation titles", () => { + expect(slugifyTitle("!!!---???")).toEqual(""); + }); + + it("truncates very long titles to 80 chars", () => { + const slug = slugifyTitle("a".repeat(120)); + expect(slug.length).toEqual(80); + }); +}); + +describe("collision suffix", () => { + it("uses the first 6 hex chars of the uuid stripped of dashes", () => { + expect(collisionSuffixFor("11111111-2222-3333-4444-555555555555")).toEqual("111111"); + }); + + it("handles uuids that have already been stripped of dashes", () => { + expect(collisionSuffixFor("abcdef0123456789")).toEqual("abcdef"); + }); +}); + +describe("buildCollisionPath", () => { + it("inserts the suffix before the .md extension", () => { + const path = buildCollisionPath("Folder/hello-world.md", "11111111-2222-3333-4444-555555555555"); + expect(path).toEqual("Folder/hello-world-111111.md"); + }); + + it("handles paths without a folder prefix", () => { + const path = buildCollisionPath("alone.md", "deadbeef-aaaa-bbbb-cccc-dddddddddddd"); + expect(path).toEqual("alone-deadbe.md"); + }); +}); + +describe("trailer line", () => { + it("returns a stable comment that identifies the export uuid", () => { + expect(trailerLineFor("uuid-123")).toEqual(""); + }); +}); diff --git a/tests/syncInbound.test.ts b/tests/syncInbound.test.ts new file mode 100644 index 0000000..e4df758 --- /dev/null +++ b/tests/syncInbound.test.ts @@ -0,0 +1,205 @@ +/** + * Watermark advancement on partial inbound failure. + * + * The contract documented in ``syncInbound.ts``: the + * ``exportsUpdatedAfter`` watermark only advances past a row that was + * fully applied. If any row inside a page errors, the watermark + * remains at whatever the last fully-applied page boundary was so the + * next sync replays the failed rows. + */ + +import { ManifestCache } from "../src/manifestCache"; +import { runInboundSync } from "../src/syncInbound"; +import { + EMPTY_MANIFEST_CACHE, + ExportRow, + PaginatedResponse, +} from "../src/types"; +import { TFile, TFolder } from "./__mocks__/obsidian"; + +type ApiStub = { + getChangedExports: ( + updatedAfter: string, + limit: number, + offset: number, + ) => Promise>; +}; + +function makeRow(id: string, title: string, updatedAt: string): ExportRow { + return { + id, + title, + topic_text: "", + preset_slug: "", + status: "completed", + is_deleted: false, + markdown: `# ${title}\n\nbody for ${id}`, + created_at: updatedAt, + updated_at: updatedAt, + }; +} + +class FakeVault { + files = new Map(); + folders = new Map(); + createCalls: Array<{ path: string; data: string }> = []; + + constructor(rootFolders: string[]) { + for (const folder of rootFolders) { + this.addFolder(folder); + } + } + + addFolder(path: string): TFolder { + const folder = new TFolder(); + folder.path = path; + this.folders.set(path, folder); + return folder; + } + + getAbstractFileByPath(path: string): TFile | TFolder | null { + return this.files.get(path) ?? this.folders.get(path) ?? null; + } + + getRoot(): TFolder { + return this.folders.get("/") ?? this.addFolder("/"); + } + + async createFolder(path: string): Promise { + return this.addFolder(path); + } + + async cachedRead(file: TFile): Promise { + return (file as TFile & { __body?: string }).__body ?? ""; + } + + async modify(file: TFile, data: string): Promise { + (file as TFile & { __body?: string }).__body = data; + } + + async create(path: string, data: string): Promise { + this.createCalls.push({ path, data }); + if (this.failOnCreate?.test(path)) { + throw new Error(`simulated create failure on ${path}`); + } + const file = new TFile(); + file.path = path; + file.name = path.split("/").pop() ?? path; + (file as TFile & { __body?: string }).__body = data; + this.files.set(path, file); + return file; + } + + async trash(_file: TFile, _system: boolean): Promise {} + + failOnCreate: RegExp | null = null; +} + +function setupApp(vault: FakeVault) { + return { + vault, + fileManager: { async renameFile() {} }, + } as unknown as Parameters[0]["app"]; +} + +function setupCache(): { cache: ManifestCache } { + const cache = new ManifestCache({ ...EMPTY_MANIFEST_CACHE }, async () => { + /* persistence side-effects are uninteresting to these tests */ + }); + return { cache }; +} + +describe("runInboundSync watermark advancement", () => { + it("advances the watermark to the highest updated_at of a fully-applied page", async () => { + const vault = new FakeVault(["Exports"]); + const app = setupApp(vault); + const { cache } = setupCache(); + const rows = [ + makeRow("11111111-aaaa-bbbb-cccc-dddddddddddd", "Note A", "2026-01-01T00:00:00Z"), + makeRow("22222222-aaaa-bbbb-cccc-dddddddddddd", "Note B", "2026-01-02T00:00:00Z"), + makeRow("33333333-aaaa-bbbb-cccc-dddddddddddd", "Note C", "2026-01-03T00:00:00Z"), + ]; + let calls = 0; + const api: ApiStub = { + async getChangedExports() { + calls += 1; + if (calls === 1) { + return { count: rows.length, next: null, previous: null, results: rows }; + } + return { count: 0, next: null, previous: null, results: [] }; + }, + }; + + const report = await runInboundSync({ + app, + api: api as unknown as Parameters[0]["api"], + cache, + targetFolder: "Exports", + deleteBehaviour: "leave", + }); + + expect(report.errors).toHaveLength(0); + expect(report.written).toEqual(3); + expect(cache.getExportsWatermark()).toEqual("2026-01-03T00:00:00Z"); + expect(vault.createCalls.map((row) => row.path)).toEqual([ + "Exports/note-a.md", + "Exports/note-b.md", + "Exports/note-c.md", + ]); + }); + + it("does NOT advance the watermark when a row inside the page errors", async () => { + const vault = new FakeVault(["Exports"]); + vault.failOnCreate = /note-b\.md$/u; + const app = setupApp(vault); + const { cache } = setupCache(); + + const rows = [ + makeRow("11111111-aaaa-bbbb-cccc-dddddddddddd", "Note A", "2026-01-01T00:00:00Z"), + makeRow("22222222-aaaa-bbbb-cccc-dddddddddddd", "Note B", "2026-01-02T00:00:00Z"), + makeRow("33333333-aaaa-bbbb-cccc-dddddddddddd", "Note C", "2026-01-03T00:00:00Z"), + ]; + const api: ApiStub = { + async getChangedExports() { + return { count: rows.length, next: null, previous: null, results: rows }; + }, + }; + + const report = await runInboundSync({ + app, + api: api as unknown as Parameters[0]["api"], + cache, + targetFolder: "Exports", + deleteBehaviour: "leave", + }); + + expect(report.errors).toHaveLength(1); + expect(report.errors[0].title).toEqual("Note B"); + expect(report.written).toEqual(2); + expect(cache.getExportsWatermark()).toEqual(""); + }); + + it("appends the stable trailer line to every written export", async () => { + const vault = new FakeVault(["Exports"]); + const app = setupApp(vault); + const { cache } = setupCache(); + const row = makeRow("uuid-abc", "Daily Log", "2026-01-01T00:00:00Z"); + const api: ApiStub = { + async getChangedExports() { + return { count: 1, next: null, previous: null, results: [row] }; + }, + }; + + await runInboundSync({ + app, + api: api as unknown as Parameters[0]["api"], + cache, + targetFolder: "Exports", + deleteBehaviour: "leave", + }); + + expect(vault.createCalls).toHaveLength(1); + expect(vault.createCalls[0].data).toContain(""); + expect(vault.createCalls[0].data.startsWith("# Daily Log")).toBe(true); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..9206398 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "types": ["node", "jest"], + "isolatedModules": false, + "noEmit": true + }, + "include": [ + "../src/**/*.ts", + "./**/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..18a7961 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7", + "ES2018", + "DOM.Iterable" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..edad0bf --- /dev/null +++ b/versions.json @@ -0,0 +1,4 @@ +{ + "0.1.0": "1.5.0", + "1.0.0": "1.5.0" +}