From 051ef3024e5e5bd2a277ba4069ecfc2542ea82c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?WangZixu=5F=E6=97=AD?= Date: Mon, 3 Aug 2026 22:58:52 +0800 Subject: [PATCH] feat(wakatime): report Studio authoring time to WakaTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One toolbar button, one dialog, three settings: a switch, the API key, and the project name. No panel, no menu group, no runtime entry — nothing this plugin does reaches a shipped game. The button is icon-only and the command palette falls back to its tooltip, so it is also reachable by typing "WakaTime". Studio's plugin API has no editing signal, so the tracker reads the workspace window's DOM — keystrokes, clicks, wheel, IME commits — for their timing only, and heartbeats carry nothing but the project name, the fixed category/language labels and a timestamp. `type: "app"` rather than "file": the active document is not knowable through the API, and a guessed path would be a lie the dashboard cannot tell from a fact. The project name is typed rather than derived for the same reason — there is no project name or path anywhere in PluginServices. The API key lives in the workspace window's localStorage, never in `services.storage` — that writes into `editor/services/`, inside the versioned working tree, so a credential there is a credential in the repository. Only the project name is project data, on purpose: it is the thing collaborators should share. No `wakatime-cli`: the privileged facade exposes `bash.execute` but the main process handler answers "not implemented yet", so this speaks the HTTP API directly and reimplements the offline queue. Endpoints were checked against a CORS preflight — `GET /users/current` answers its errors without `Access-Control-Allow-Origin`, which is why the connection test calls `statusbar/today`. The icon is a line-art stopwatch generated by tools/make-icon.mjs, not WakaTime's logo: a thumbnail carrying their mark would read as an official integration. Same call narraleaf.steam-achievements made about Valve's. permissions: []. Co-Authored-By: Claude Opus 5 --- index.json | 43 + plugins/wangzixu.wakatime-plugin/.yarnrc.yml | 4 + plugins/wangzixu.wakatime-plugin/README.md | 182 ++ plugins/wangzixu.wakatime-plugin/build.mjs | 82 + plugins/wangzixu.wakatime-plugin/icon.png | Bin 0 -> 6369 bytes .../wangzixu.wakatime-plugin/manifest.json | 13 + plugins/wangzixu.wakatime-plugin/package.json | 44 + plugins/wangzixu.wakatime-plugin/src/i18n.ts | 95 + plugins/wangzixu.wakatime-plugin/src/main.tsx | 561 ++++++ .../src/projectStore.ts | 65 + .../src/settings.test.ts | 48 + .../wangzixu.wakatime-plugin/src/settings.ts | 162 ++ .../src/tracker.test.ts | 147 ++ .../wangzixu.wakatime-plugin/src/tracker.ts | 226 +++ .../src/wakatime.test.ts | 103 ++ .../wangzixu.wakatime-plugin/src/wakatime.ts | 266 +++ .../tools/make-icon.mjs | 241 +++ .../wangzixu.wakatime-plugin/tsconfig.json | 17 + plugins/wangzixu.wakatime-plugin/yarn.lock | 1587 +++++++++++++++++ 19 files changed, 3886 insertions(+) create mode 100644 plugins/wangzixu.wakatime-plugin/.yarnrc.yml create mode 100644 plugins/wangzixu.wakatime-plugin/README.md create mode 100644 plugins/wangzixu.wakatime-plugin/build.mjs create mode 100644 plugins/wangzixu.wakatime-plugin/icon.png create mode 100644 plugins/wangzixu.wakatime-plugin/manifest.json create mode 100644 plugins/wangzixu.wakatime-plugin/package.json create mode 100644 plugins/wangzixu.wakatime-plugin/src/i18n.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/main.tsx create mode 100644 plugins/wangzixu.wakatime-plugin/src/projectStore.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/settings.test.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/settings.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/tracker.test.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/tracker.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/wakatime.test.ts create mode 100644 plugins/wangzixu.wakatime-plugin/src/wakatime.ts create mode 100644 plugins/wangzixu.wakatime-plugin/tools/make-icon.mjs create mode 100644 plugins/wangzixu.wakatime-plugin/tsconfig.json create mode 100644 plugins/wangzixu.wakatime-plugin/yarn.lock diff --git a/index.json b/index.json index cfdeee7..a32f4f1 100644 --- a/index.json +++ b/index.json @@ -95,6 +95,49 @@ "description": "在 Studio 里编写 Steam 成就与统计量,并用蓝图节点解锁。Steam 不可用时写入本地镜像,itch 版、web 版与 Dev Mode 下同一套脚本照常工作。" } } + }, + { + "id": "wangzixu.wakatime-plugin", + "name": "WakaTime", + "version": "1.0.0", + "description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)", + "publisher": "WangZixu", + "path": "plugins/wangzixu.wakatime-plugin", + "targets": [ + "studio" + ], + "categories": [ + "integration", + "workflow" + ], + "keywords": [ + "narraleaf", + "narraleaf-studio-plugin", + "wakatime", + "time-tracking", + "heartbeats", + "productivity" + ], + "license": "MPL-2.0", + "contributes": { + "blueprintNodes": [], + "widgets": [], + "locales": [] + }, + "permissions": [], + "release": { + "tag": "wangzixu.wakatime-plugin@1.0.0", + "page": "https://github.com/NarraLeaf/Plugins/releases/tag/wangzixu.wakatime-plugin%401.0.0", + "download": "https://github.com/NarraLeaf/Plugins/releases/download/wangzixu.wakatime-plugin%401.0.0/wangzixu.wakatime-plugin-1.0.0.zip" + }, + "icon": "https://raw.githubusercontent.com/NarraLeaf/Plugins/wangzixu.wakatime-plugin%401.0.0/plugins/wangzixu.wakatime-plugin/icon.png", + "studioVersion": ">=0.5.0", + "locales": { + "en": { + "name": "WakaTime", + "description": "Lets NarraLeaf Studio talk to WakaTime, reporting the hours as you put them in — so the effort is visible. (Type WakaTime in the command palette to change the settings.)" + } + } } ] } diff --git a/plugins/wangzixu.wakatime-plugin/.yarnrc.yml b/plugins/wangzixu.wakatime-plugin/.yarnrc.yml new file mode 100644 index 0000000..f0e72bb --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/.yarnrc.yml @@ -0,0 +1,4 @@ +# Matches NarraLeaf-Studio: real node_modules, no PnP. +# Each plugin resolves independently — this directory is not part of any workspace. +nodeLinker: node-modules +enableScripts: true diff --git a/plugins/wangzixu.wakatime-plugin/README.md b/plugins/wangzixu.wakatime-plugin/README.md new file mode 100644 index 0000000..1346ad4 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/README.md @@ -0,0 +1,182 @@ +# WakaTime + +Reports the time you spend authoring a project in NarraLeaf Studio to +[WakaTime](https://wakatime.com). + +The whole plugin is **one icon button** beside the Run control, opening a dialog +with three settings: an on/off switch, your API key, and the project name. +Nothing else is added to the workspace — no rail icon, no panel, no menu. + +Editor-side only. There is no `runtime` entry, no blueprint node and no widget: +nothing this plugin does reaches a shipped game. + +## Why a button and not a Settings page + +Because a plugin cannot reach the Settings window. A studio entry loads in the +**workspace window only** — never Launcher, Settings, Project Wizard or Dev Mode +— and `PluginServices` has no settings contribution point, in the published +`narraleaf-studio@0.5.0` types or in Studio's current source. A standalone +registered action, which Studio draws as a single icon button next to Run, is the +lightest surface the plugin API actually has. + +The dialog mounts its own React root (`react-dom/client` is published to plugins +through the workspace import map for exactly this). Studio's `ui` kit reads its +translations from a module store rather than a React provider, so `ui.Modal` and +the inputs render there identically. The one thing that does need workspace +context — `ui.useFreezeGuard` — is replaced by the documented service half, +`services.workspace.frozen` / `onFreezeChange`. + +## What it sees, and what it does not + +Studio's plugin API has no editing signal — no "document changed", no "the active +editor is now this scene", no save hook. What a studio entry *does* have is the +workspace window's DOM, so that is what the tracker reads: keystrokes, clicks, +wheel and IME commits, in the capture phase, for their **timing only**. No key, +no coordinate and no target element is inspected, stored or sent. + +| WakaTime concept | What this plugin reports | +| ---------------- | ------------------------ | +| Project | The name you type in the dialog. | +| Entity / file | `NarraLeaf Studio`, always. The active document is not knowable through the plugin API, and a guessed file path would be a lie the dashboard cannot tell from a fact. | +| Language | `NarraLeaf`, fixed. | +| Category | `designing`, fixed. | +| Branch, lines, cursor | Not sent. | + +So: correct per-project totals, correct per-day totals, no per-file breakdown. + +**The project name has to be typed.** There is no project name or project path +anywhere in `PluginServices`, the workspace title bar is a hardcoded +`"NarraLeaf Studio"`, and no DOM element carries it — so there is nothing to read +it from. It is asked once per project and then versioned with the project, which +also means collaborators inherit it instead of each inventing their own. + +## What leaves your machine + +One `POST` to `…/users/current/heartbeats.bulk` per two minutes of activity, each +heartbeat carrying: the project name you chose, the fixed category and language +labels, a timestamp, the constant entity string above, and the plugin's +user-agent. **No story text, no scene or asset names, no file paths, no +keystrokes.** `buildHeartbeat` in `src/wakatime.ts` is the whole payload, and a +test asserts its field list so it cannot quietly grow. + +Nothing is sent at all until the switch is on *and* an API key is entered *and* +the project is named. + +## Where the API key lives + +**In the workspace window's `localStorage`, not in the project.** + +`app.services.storage` — the storage a Studio plugin is handed — writes into +`editor/services/`, inside the project's versioned working tree: the same tree +you commit and push. A credential written there is a credential in your +repository, and on a public repository that is a leak with no undo. So the key +and the on/off switch are machine-scoped and live outside the project; only the +project name is project data. + +It is plaintext, which is exactly what `~/.wakatime.cfg` is. Studio exposes no +secret store to plugins, so the real choice here is not "encrypted or plaintext" +but "outside the project or in git". + +## Offline + +Heartbeats go into a local queue and are flushed in batches of 25, retried once a +minute. Being offline, or the server being down or rate-limiting, keeps the +queue; only a response that will never become valid drops a batch. A rejected +**API key** parks sending entirely rather than retrying forever, and raises one +notification so a plugin with no permanent UI does not fail silently — changing +the key resumes it. + +The queue holds 1000 heartbeats, about 33 hours of unsent work, and drops its +oldest entries first. + +Editor plugins normally get all of this for free by shelling out to +`wakatime-cli`. This one cannot: Studio's privileged facade exposes +`bash.execute`, but the main process handler answers *"Bash execution is not +implemented yet"*. So the plugin speaks the documented HTTP API directly and +reimplements the queue. + +## Permissions + +`"permissions": []` — none. + +Network access is not a declared Studio permission: a studio entry runs in the +workspace renderer and uses `fetch` like any other renderer code. The two +endpoints used here (`heartbeats.bulk`, `statusbar/today`) were checked against +api.wakatime.com and pass a CORS preflight with an `Authorization` header. +`GET /users/current` does **not** — it answers its error responses without +`Access-Control-Allow-Origin` — which is why the dialog's **Test** button calls +`statusbar/today` instead. + +Only wakatime.com is supported. A self-hosted server (Wakapi, Hackatime) would +need a fourth setting, and three is the budget. + +## Setting it up + +1. **Launcher → Plugins → Install from folder**, then approve and enable it. +2. Click the timer icon beside the Run button — or type **WakaTime** in the + command palette. (An icon-only action carries a tooltip rather than a label, + and the palette falls back to it, so the button is reachable by name.) +3. Click the `wakatime.com/settings/api-key` address under the key field to copy + it, open it in your browser, and paste the key back. Name the project, leave + the switch on. +4. **Test** — it records one heartbeat, sends it, and reports today's total, so a + success there means the write path works and not just the key. + +That address copies rather than opens, because a plugin cannot open a browser: +the workspace window denies every `setWindowOpenHandler` request and blocks +`will-navigate` to anything outside its own entry, and `shell.openExternal` sits +behind an IPC the plugin facade does not carry. An `` there would be a +control that visibly does nothing. + +The switch defaults to on, which is safe because it is inert: with no key and no +project name, nothing is recorded and nothing is sent. + +## Frozen projects + +While Studio's version control has the project frozen — restoring a past version, +or showing one read-only — every plugin toolbar action is disabled by Studio +itself (the exemption list is a table in Studio's source, not a flag a plugin can +set), so the button cannot be opened until the project thaws. If a freeze begins +while the dialog is open, the project-name field greys out and its write bails +before touching memory; the key and the switch stay live, because they are not +project data. The store re-reads through `registerReloader` after a restore. + +The tracker keeps running while frozen. Time spent reading an old version is +still time spent on the project. + +## Files + +| File | Purpose | +| ---------------------- | ------- | +| `src/wakatime.ts` | The wire format and the HTTP client. No Studio types, no DOM. | +| `src/settings.ts` | The three settings' shapes, normalization, and the `localStorage` half. | +| `src/projectStore.ts` | The project-storage half: freeze bail-out and reloader. | +| `src/tracker.ts` | Activity detection, heartbeat rhythm, queue flushing. | +| `src/i18n.ts` | The dialog's own messages (`en`, `zh`). | +| `src/main.tsx` | Studio entry: the action, the dialog and its root, and the wiring. | +| `tools/make-icon.mjs` | Renders `icon.png` — a line-art stopwatch, drawn here rather than downloaded. | + +## The icon + +`icon.png` is generated by `yarn icon`, and it is **not WakaTime's logo**. A +plugin icon is the most prominent brand slot a package has, and this one is +published by someone other than WakaTime — their mark on it would read as an +official integration, which this is not. The plugin's *name* says WakaTime, +which is ordinary descriptive use of the service it talks to, and that is where +the association belongs. `narraleaf.steam-achievements` made the same call about +Valve's logo. + +The drawing is a stopwatch, matching the lucide `Timer` the plugin puts on the +toolbar, so the thumbnail and the button are the same idea. Studio requires a +square PNG/WebP/JPEG between 64x64 and 512x512 and under 512 KB; this ships at +256x256 and about 6 KB. Render it at the size a list actually shows with +`node tools/make-icon.mjs icon.png --size=64`. + +## Scripts + +```bash +yarn build # bundle to dist/ +yarn dev # same, unminified with sourcemaps +yarn test # vitest +yarn typecheck # tsc --noEmit +``` diff --git a/plugins/wangzixu.wakatime-plugin/build.mjs b/plugins/wangzixu.wakatime-plugin/build.mjs new file mode 100644 index 0000000..39560f2 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/build.mjs @@ -0,0 +1,82 @@ +/** + * Bundles each entry declared in manifest.json into dist/. + * + * Mirrors how NarraLeaf-Studio builds its own built-in plugins: one prebundled + * ESM file per entry, with the host modules left external. The host resolves + * `narraleaf-studio/*`, `react` and `react-dom` through an import map at load + * time — bundling them would produce a second, broken React instance. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(root, "dist"); +const dev = process.argv.includes("--dev"); + +const EXTERNALS = [ + "narraleaf-studio/plugin", + "narraleaf-studio/runtime", + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", +]; + +const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf-8")); + +/** Map a declared entry (`main.js`) onto its source file (`src/main.ts`). */ +function resolveSource(entry) { + const { name } = path.parse(entry); + for (const candidate of [`${name}.tsx`, `${name}.ts`, `${name}.jsx`, `${name}.js`]) { + const full = path.join(root, "src", candidate); + if (fs.existsSync(full)) { + return full; + } + } + throw new Error(`No source file found for declared entry "${entry}" (looked for src/${name}.{tsx,ts,jsx,js})`); +} + +fs.rmSync(distDir, { recursive: true, force: true }); +fs.mkdirSync(distDir, { recursive: true }); + +for (const target of ["studio", "runtime"]) { + const entry = manifest.entries?.[target]; + if (typeof entry !== "string" || !entry.trim()) { + continue; + } + const outfile = path.join(distDir, entry); + fs.mkdirSync(path.dirname(outfile), { recursive: true }); + + await esbuild.build({ + entryPoints: [resolveSource(entry)], + outfile, + bundle: true, + platform: "browser", + format: "esm", + target: ["chrome114"], + jsx: "automatic", + sourcemap: dev, + minify: !dev, + external: EXTERNALS, + }); + console.log(`built ${target} -> dist/${entry}`); +} + +// Studio reads manifest.json from the installed directory, so it ships too. +fs.copyFileSync(path.join(root, "manifest.json"), path.join(distDir, "manifest.json")); +console.log("copied manifest.json"); + +// Same for a declared icon (the starter has none — add `"icon": "icon.png"` to +// manifest.json and drop the file next to it). Studio refuses a package whose +// declared icon is missing, so anything the manifest points at ships too. +if (typeof manifest.icon === "string" && manifest.icon.trim()) { + const relative = manifest.icon.trim().split(/[\\/]+/); + const target = path.join(distDir, ...relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(root, ...relative), target); + console.log(`copied ${manifest.icon}`); +} diff --git a/plugins/wangzixu.wakatime-plugin/icon.png b/plugins/wangzixu.wakatime-plugin/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..85370d2e9ccf68ceabf230c5c4247ba2ec7ae50b GIT binary patch literal 6369 zcmb7JbyU;u+x~1Y7(Ggn8c0Zf5s?-c9ZHClfTYrmgh~t;DJY@>5`q$fNJ$9@Y%oAY z7~L^IN?K}+dWYZNIq&bB_pkTtoW)(w?)y2rp6j_5Z)BiNN6k(R005n?j)pM+fJkpa z0F;9CV(nMt1ORMBx*Dn`!Q<=M@jShLTVC&i1LqVYRB*HeP0nSkvAxCpY#x?| zHYq6d5I)wCph`+$&iQx`#HFtj&vJuHG+vM{L?IOx+A2)WNkCbMkQeBs!Mn9X)g1n~ zT~$~P3hh~BW;z;*!t(U24alPi+D=9kDrvjW_=5&(o{SVNm<`y=Uae7+x$BfnW%=C6hZcjN2;#{Uq{X=fqUmqhL3eK!j7n zxb_`@1yEbN0F<3EJFJEQ)BtZF4PXvHD+2_OT0Gdf*8qD2jATWD5zu!4Oic*j59UuT z{d8*C?8c)yvCKyeY^Psa_*evr(k|k9;x|}-b7Ux5^ic%4r#eTUS@oOdJRn&}*u1jA zveE118L3J~r$vb36ti;LA6u0bkX;Lh+%f&NJF|y!J#NB{2nzur?x=zaH#?)5n+r zy@9(C&G`TQNW z9N-!AjID(GgcvTti6+o0uP(B?aEyQJ45J39*!Bi8;vqmzLAMYf zAP>DBz=2=$pewxLJa4G;HHmnD^Lay?!6 z#uH@n)^~X!$S-WWd|IKowVTSh8U4L4^RV{`U(dZlU{T^v4&W zAP;gYNhgxgsL_V6#-p9xPIn297n_-9lHBo@VNfy;N)A7NMhK6I9hy@iP@yo^ zJL`u3_j-Q@&TGcf9x3mBn10%@I`a{?W**Vv=uNfYwgg+X7AA^QBXWza#Wh1e2ft}J zTRuqtneIJSX`aNcs9GTX-6z2OIjalJII8wtQl-ThQs3TcG`&&_1m!Fb48Fsq5-`0J zG`rroU&7ngS@deW_+2IB?~d8vLeU)+(vHu-u=K>Q@pqhx8yUj(yz+jwySowBAR?u{ zkBidUNxunDsA|sF&kP=PnNrLzz_0_wd;5!AZ^@;peb>6>3yJ%1w_j#=cd0_v;R4aR z{OEoA$JgEiY~(VfS$p`hn6ceb#e?Dz#TM401ENLG#oY4cs*9pjTc8k3dOkqdw|HtY z;&P=SS)tK=lXuT@sqWJa{fnVmGxkv~cd}+_2&hJ=Fd$iQt6s#7`04Pr#3TE$PeCo1 zo`b$ZQ%)EnV3MYb^07kQ1ulE&qqv)=aTY>>KBQnbhn?4&F*%JqrIgkvx*4- z1ih!Yaxf9@qr_X|^4(9cGWeAV#Ht}XIGRCzNe$Hdc1zvu(}ecvX;h5A?vMEb^U=X| zQBcVDcvWg7;l#f^ZRX3wE5F%~ZexXH>uh{>R->ay-uOE`ZAO4i@P{{2;gu!8MEEi} zjRnEP4pJL9&qqi#bYej|T#|A4R1&1Wrdw{AcfoU+xuPbMla_EnxsXm62!B7oF=Y~l zVP4O*+|if0ax`~c9y+l~q*0D3cU7lG!v~9JEbe_x`M`Bc$n{>Wnly!R7IJ-->|P6I zWJ}&t2W0d>)XZEl5Z+6FIV+_L1E*y~N<2KF%t>4LLF%|We}=eO>w8z&uB&z{K2w*} zN_+O^WKCM&Nwd;w5a)mp8*NZQ>w%Vs^U8-Gt33PkdnyP&wf9=7w2H1uQQY zUw--_tSYRkmV1?F(~5zo(`t?Op0-6s0vJuR*=t~#+&D5E3-1j!jiS%5ab;{cg> z>&8cE#ZAibfA*{nuwj>E!xC0m90(@PRcYciWS?Nj4W%PjE@er-pTYFKYy(v|V4Vps z=h(Zxhrg4pZUvKK0n|56kLbBaMAk6sR~);L7(Q#RLWPF}wviE(VE}bJkCR6*^;H|7 z;o&A<^U?gt^ZL2UsR(($-PrNNi}=NC%G+>q>KVPI2$)nH7d7IW^J&0vwNZJE8BrKQ z2?M}M}$$=7yfYN!!);TSyQx}mI zYPU#bTRUme5~93xSTb*Z%aJ1bg@0WpT1dAT8pmuFtgD|#Tw4txu00r=U+ixDXld+8 zZ${`{|s>Wf#rJNK?QL07OZyXlv{UURvoo$Z{q_%<#gL_iVlc| zOW(^!n5hvgKhv8KoFS9@tKnL<_E&GSZ6LHbr%sV9CsM_GNb>*&!7oYmj2V;v}gNi0@1y^3!*HJ8n=`w_FO< zfl|-CrNKr%<}J1ou@a}y5u`Bo;q4NBRn!5qTG&&0B5(7sNeL5 zoKOtN>t8#6sK%$;09a$eO?XZUAJZcHENU`0QCULOLkE|r@ss3YsqQ~Mt6jHQLdXi` z;cmh>`iX%3X31@EZ<(X>b_&b$<7*OmxUSM){anxD(?GMC-g(E4H}mLE{&L>?Ne^)$?L}Db0L~LIB7f^Iq)5K<(OSm#VjmR*pFjDMR~j z#z*PZ;BQe2q&mDxw`R2N5 zDFy`G$^010&Kyq4e3}|mKyomV{5mhqx47L)0F$CD{^^Aai;2xHs);)O@bfQ|UU30` zL;>Fsn|&$Jm4qXu9YlBj_&bbQ^sM!Z0YUvt#~Q#Syxt(*!BtJmwwg4PN>q9tK#uZk z{UKeo80K)ImHW$*ZZM-AHP!A z<0$F5_e35aH2$mQe@hjpec*kPq-vn2W=S4U%qz>xM{d`!rGe*EpPtZJ`^MOFMPK$; zz8xPqo^;r28Yl*BXY|F3@hiYM;uv+sc+aQDTlEC~V!&%b#e{U}U|Y-A`7hr)-JRh% z>%fpyj6uRvzLjm~LhK7haI#5N^OsMi<;_w5hWB$)ozD~T#>*MC@aY3C17~+d(@+qX zA(6m~%@7V?Oq9R0e6+3-msRJxLrB-zQG3R#L z!J-zzfc^{Sv{H+jKAR=Z$XpMb1|k zke%!~3h_I^tO~PZ)9o_U3!EFLo)9wiJO?AA(0(woIEWQ4^|}G@xJk*#n#itw&lfgI z=YU|Bh$AsywCZfv*BlxRdZmmfse2$X+?h75jsrw8dh|VjLVC-3F&h4^ULAcCg)zoI zS;?1l_@TkUCJZ!1yJp)J@GgC24#hul&Dxc@o;0ZgDk8DylZW%qAf5pOUpP;oVo{ zlgVTgDV4L14v{#8pr#8lsGDcaN~D@paGvtM$0^S1M!TfR4V0%7Vbz)jAulVK(9 zWY?=(;vqjdA~nItDXS5$X;#Hh-C=6_3+fTx1-{61N#CWM0I-o>-lRXjZqb8Gdd{mc zpd7uIVICrI?`Qz=y?k-uX?6>VS)0+zKnYCBuZi$VhqkF1c2KV{ajSFox#Ja|EjDmS z@%)p_gmw!)VD!h~{9z7O6}eVE*s;Qiu*V&EXZ_Pd4~b*EQ+Hx14ivmi4NK)dh1RZ* z+Mo_d0*|twEuE5R9YN^r=CGxX`unhekp0 zV-Z`{;H{G1gvaLeMaiI?sevsihs$lPLpkIeQI6BC1(rb+S(iFr&H5A&-wup|sA0@9 z<3IF3XntJ$z{P4X`DRL?DN34=ptIBj=egjKL8!6;E87X~U^ZNF!}PSxeNz~GQ~RXCNDL#mkxuSRl;-f%LVgN=v> zc{VX-33SX`5j~&1ehEJV8-Rji>N9pgJT3V6$4A+`)wciP(cLx2O*vqrpS8DFA}-@p zsA#!m5@dESjanb{z~%8#6OF=vrm=W&twPAL_IhqB-vT9EU#cPaz7aCX-hIkk2AFB> ztmcE{TKq1M8JgC-An)YiJ4|g#qUz(4TIkw}%3Zngr6B*-I#Lim`Z_LHGvsscPRPND z!Jc0?u{y64U06}dk%La#5?@ruXf2sL} zgs;;68&^bXZqqn=pqg_Hmov=+BfIUNbnrlUU_rIG)N*46?z4d^K)Y~av9xh$;w1Lt zM`^d{9zhqW@n_0kzK@I2kFwP+@{2%tjE)5=BOjNHDBc!lO?_yx_q&F}q`Z!j;_-W$ zzk*pd-&jB@!On3sMzs^A z4aRorqbZV_AShRnoZ^1lcgaVb|LC%KO*WSsWY_1D_)W=Yje@KtlY$Qh4oSM>K*WGx$f8dml9WVggb#DFYH=-7bj`r{;%5$@2T#4(yI2ex#Roq1u<2)f2c|L?8^ zr!lM?h4V7z-d3a4bGb5NV7gK|q1Y}XsGnV$dcV}iZH-GVtHdC?>C->f-|v|3{K>XF z9QBF9(Ei_nV}s_|#R9x~N##KJFrW?dE?ozYX%4%;Au!^GNB*T)+*z*QiQ$Z3^a!Bl z5ppcS4~AT)=heECY&6MWP6n-X*>8b@bn+vhk{MW3N*I9!5f>d4t_*=W98TpJf>0JA z2jLsSNKr6~?b?n+^U+17h`#HbyjoWkFx?DQLP1W)x~ATLHiY`%EQ zKN`q*H55u|Huf?CN*R8A2V~98kF6F^NdS0B!YfEkjvx$1`D}G*Aep03*hj6n0}O5m zq(+;)zU0KKCN}iCh8iWH_W=YvBuM0p|(?Fw4%`WnP04B)8kN^Mx literal 0 HcmV?d00001 diff --git a/plugins/wangzixu.wakatime-plugin/manifest.json b/plugins/wangzixu.wakatime-plugin/manifest.json new file mode 100644 index 0000000..b42fed5 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/manifest.json @@ -0,0 +1,13 @@ +{ + "manifestVersion": 2, + "id": "wangzixu.wakatime-plugin", + "name": "WakaTime", + "version": "1.0.0", + "description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)", + "publisher": "WangZixu", + "icon": "icon.png", + "entries": { + "studio": "main.js" + }, + "permissions": [] +} diff --git a/plugins/wangzixu.wakatime-plugin/package.json b/plugins/wangzixu.wakatime-plugin/package.json new file mode 100644 index 0000000..0a002ba --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/package.json @@ -0,0 +1,44 @@ +{ + "name": "@narraleaf-plugins/wakatime-plugin", + "version": "1.0.0", + "private": true, + "description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)", + "license": "MPL-2.0", + "type": "module", + "keywords": [ + "narraleaf", + "narraleaf-studio-plugin", + "wakatime", + "time-tracking", + "heartbeats", + "productivity" + ], + "packageManager": "yarn@4.10.3", + "scripts": { + "build": "node build.mjs", + "dev": "node build.mjs --dev", + "icon": "node tools/make-icon.mjs icon.png", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/react-dom": "^19.0.0", + "esbuild": "^0.25.0", + "narraleaf-studio": "^0.5.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "narraleaf": { + "categories": [ + "integration", + "workflow" + ], + "studioVersion": ">=0.5.0", + "locales": { + "en": { + "name": "WakaTime", + "description": "Lets NarraLeaf Studio talk to WakaTime, reporting the hours as you put them in — so the effort is visible. (Type WakaTime in the command palette to change the settings.)" + } + } + } +} diff --git a/plugins/wangzixu.wakatime-plugin/src/i18n.ts b/plugins/wangzixu.wakatime-plugin/src/i18n.ts new file mode 100644 index 0000000..941a78d --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/i18n.ts @@ -0,0 +1,95 @@ +/** + * The plugin's own message tables, handed to `app.services.i18n.createTranslator`. + * + * Unrelated to Studio's translations and to a game's player-facing localization: + * this catalog only covers strings this dialog draws. Studio's built-in locale + * codes are `en` and `zh`; the extra aliases exist so a plugin-provided Chinese + * locale (a language pack keyed `zh-CN`, or the catgirl one) still lands on the + * Chinese table instead of falling all the way back to English. + */ + +import type { PluginMessageBundle } from "narraleaf-studio/plugin"; + +const en: Record = { + "action.open": "WakaTime", + "dialog.title": "WakaTime", + + "field.enabled": "Track my authoring time with WakaTime", + "field.enabledHint": "Nothing is reported while this is off, or while either field below is not filled in correctly", + "field.apiKey": "API key", + // `{url}` is substituted with the click-to-copy address, not with a string — + // `t()` leaves the token in place and `ApiKeyHint` splits the sentence on it. + "field.apiKeyHint": "Get one at {url}. Stored on this device only — set it up again on another device", + "field.apiKeyPlaceholder": "waka_…", + "field.copyLink": "Click to copy — Studio does not let a plugin open a browser", + "field.copied": "Copied — paste it in your browser", + "field.copyFailed": "Could not copy. Select the address and copy it by hand.", + "field.reveal": "Show the API key", + "field.hide": "Hide the API key", + "field.project": "Project name", + "field.projectHint": "Saved inside the project, so collaborators report to the same WakaTime project", + "field.projectPlaceholder": "My NarraLeaf Project", + + "status.tracking": "Tracking · {total} today", + "status.trackingUnknown": "Tracking", + "status.disabled": "Off", + "status.needsKey": "Waiting for an API key", + "status.needsProject": "Waiting for a project name", + "status.pausedForAuth": "Stopped — the server rejected the key", + "status.queued": "{count} heartbeat(s) waiting to send", + "status.frozen": "The project is read-only right now, so the project name cannot be changed.", + + "action.test": "Test", + "action.testing": "Testing…", + "action.close": "Close", + "test.ok": "Connected. {total} today.", + "test.failed": "Could not connect: {message}", + "test.auth": "The server rejected that API key.", + "notify.auth": "WakaTime stopped: the server rejected the API key. Open the WakaTime dialog to fix it.", +}; + +const zh: Record = { + "action.open": "WakaTime", + "dialog.title": "WakaTime", + + "field.enabled": "使用WakaTime记录创作时间", + "field.enabledHint": "关闭或下面两项未正确填写时,将不会上报任何数据", + "field.apiKey": "API Key", + "field.apiKeyHint": "在 {url} 获取,仅存储在本设备上,切换设备时请重新配置", + "field.apiKeyPlaceholder": "waka_…", + "field.copyLink": "点击复制 —— Studio 不允许插件打开浏览器", + "field.copied": "已复制,去浏览器里粘贴", + "field.copyFailed": "复制失败,请手动选中地址复制。", + "field.reveal": "显示 API Key", + "field.hide": "隐藏 API Key", + "field.project": "项目名", + "field.projectHint": "将会保存在项目内,因此协作者会被上报到同一个WakaTime项目", + "field.projectPlaceholder": "My NarraLeaf Project", + + "status.tracking": "正在记录 · 今日 {total}", + "status.trackingUnknown": "正在记录", + "status.disabled": "已关闭", + "status.needsKey": "等待填写 API Key", + "status.needsProject": "等待填写项目名", + "status.pausedForAuth": "已停止 —— 服务器拒绝了这个 Key", + "status.queued": "{count} 条心跳待发送", + "status.frozen": "项目当前是只读状态,项目名改不了。", + + "action.test": "测试", + "action.testing": "测试中…", + "action.close": "关闭", + "test.ok": "连接成功,今日 {total}。", + "test.failed": "连接失败:{message}", + "test.auth": "服务器拒绝了这个 API Key。", + "notify.auth": "WakaTime 已停止:服务器拒绝了这个 API Key。打开 WakaTime 对话框修改。", +}; + +export const MESSAGES: PluginMessageBundle = { + messages: { + en, + zh, + "zh-CN": zh, + "zh-x-neko": zh, + }, + fallbackLocale: "en", +}; diff --git a/plugins/wangzixu.wakatime-plugin/src/main.tsx b/plugins/wangzixu.wakatime-plugin/src/main.tsx new file mode 100644 index 0000000..82db856 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/main.tsx @@ -0,0 +1,561 @@ +/** + * Studio entry: one action-bar button, one dialog, and a tracker behind them. + * + * ## Why this shape, and not a Settings page + * + * The obvious home for three settings is Studio's Settings window, and a plugin + * cannot reach it: a studio entry loads in the **workspace window only** (never + * Launcher, Settings, Project Wizard or Dev Mode), and `PluginServices` has no + * settings contribution point — checked against both `narraleaf-studio@0.5.0` + * and Studio's current source. So the nearest thing a plugin can build is this: + * one standalone action, which Studio renders as a single icon button beside the + * Run control, opening a dialog with the three fields in it. + * + * ## Why the dialog gets its own React root + * + * An action's `onClick` is imperative — there is no React tree to render a modal + * into, and the alternative surfaces (a sidebar panel, an editor tab) are the + * permanent chrome this plugin is meant not to have. The workspace import map + * publishes `react-dom/client` to plugins precisely so one can be mounted (the + * prohibition on plugin-owned roots is on the *runtime* entry, which is game + * code and gets no such external). Studio's `ui` kit reads its translations from + * a module-level store rather than a React provider, so `ui.Modal` and friends + * render identically outside the workspace tree. + * + * The one thing that does need workspace context is `ui.useFreezeGuard`, so this + * file uses the documented non-React half instead: `services.workspace.frozen` + * and `onFreezeChange`. + * + * Style note: a third-party plugin bundle is not scanned by Studio's Tailwind + * build, so only utilities Studio itself already emits would have any effect. + * Everything cosmetic here is therefore an inline style, deliberately. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { createRoot } from "react-dom/client"; +import { Eye, EyeOff, Timer } from "lucide-react"; +import { definePlugin, ui, type PluginApp } from "narraleaf-studio/plugin"; +import { MESSAGES } from "./i18n"; +import { createProjectStore, type ProjectStore } from "./projectStore"; +import { + ENTITY, + PLUGIN_ID, + createMachineStore, + readQueue, + toCredentials, + writeQueue, + type MachineStore, +} from "./settings"; +import { createTracker, type Tracker } from "./tracker"; +import { fetchToday, type TodayTotal } from "./wakatime"; + +const ACTION_ID = `${PLUGIN_ID}.open`; +const API_KEY_URL = "https://wakatime.com/settings/api-key"; + +const LABEL_STYLE = { fontSize: "0.8125rem", fontWeight: 500 } as const; +const HINT_STYLE = { fontSize: "0.7rem", lineHeight: 1.5, opacity: 0.65, marginTop: "0.125rem" } as const; + +type Translate = (key: string, params?: Record) => string; + +type Deps = { + app: PluginApp; + machine: MachineStore; + project: ProjectStore; + tracker: Tracker; +}; + +export default definePlugin({ + setup(app) { + // Indirection, not laziness: the settings store's change hook has to be + // able to reach the tracker, and the tracker has to be able to read the + // settings store. A tracker parked on an auth failure should wake the + // moment the key it objected to is replaced. + const pending: { tracker: Tracker | null } = { tracker: null }; + + const machine = createMachineStore(() => pending.tracker?.resume()); + const project = createProjectStore(app); + + const tracker = createTracker({ + readConfig: () => { + const settings = machine.get(); + return { + ...toCredentials(settings.apiKey, app.manifest.version), + enabled: settings.enabled, + entity: ENTITY, + project: project.get().projectName, + }; + }, + readQueue, + writeQueue, + }); + pending.tracker = tracker; + const stopTracker = tracker.start(); + + // Version control replaces the working tree under us; a store that kept + // its pre-restore copy would write it back on the author's next edit. + app.services.workspace.registerReloader(() => project.load()); + void project.load(); + + // With no permanent UI, a rejected key would otherwise fail in total + // silence until the author next opened the dialog. One toast per + // transition into the failure — not per retry, which would be a stream. + const translator = app.services.i18n.createTranslator(MESSAGES); + let announced = false; + const stopWatching = tracker.subscribe(() => { + const paused = tracker.status().pausedForAuth; + if (paused && !announced) { + announced = true; + app.services.ui.notifications.error(translator.t("notify.auth")); + } else if (!paused) { + announced = false; + } + }); + + const dialog = createDialog({ app, machine, project, tracker }); + + // Studio renders a group as a top-bar dropdown and a standalone action as + // a single icon button beside Run. This is the second kind on purpose. + const stopAction = app.services.ui.actions.register({ + id: ACTION_ID, + icon: , + tooltip: "WakaTime", + onClick: () => dialog.open(), + }); + + return () => { + stopWatching(); + void stopAction(); + dialog.dispose(); + stopTracker(); + }; + }, +}); + +/* ------------------------------------------------------------------ dialog */ + +type DialogController = { + subscribe(listener: () => void): () => void; + isOpen(): boolean; + set(open: boolean): void; +}; + +function createDialog(deps: Deps): { open(): void; dispose(): void } { + const container = document.createElement("div"); + container.dataset.plugin = PLUGIN_ID; + document.body.appendChild(container); + const root = createRoot(container); + + let open = false; + const listeners = new Set<() => void>(); + const controller: DialogController = { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + isOpen: () => open, + set(next) { + open = next; + for (const listener of listeners) { + listener(); + } + }, + }; + + // Mounted once and left mounted, rendering nothing while closed. Mounting + // per open would mean unmounting from inside the dialog's own click handler, + // which React refuses to do synchronously. + root.render(); + + return { + open: () => controller.set(true), + dispose() { + // Safe here, and only here: plugin cleanup runs outside rendering. + root.unmount(); + container.remove(); + }, + }; +} + +function SettingsDialog({ deps, controller }: { deps: Deps; controller: DialogController }) { + const isOpen = useSyncExternalStore(controller.subscribe, controller.isOpen); + const t = useTranslate(deps.app); + const close = useCallback(() => controller.set(false), [controller]); + + return ( + + {/* Modal renders null while closed, so the body's hooks — including the + deferred commits, which flush on unmount — never run behind it. */} + + + ); +} + +function DialogBody({ deps, onClose }: { deps: Deps; onClose: () => void }) { + const { app, machine, project, tracker } = deps; + const t = useTranslate(app); + const version = app.manifest.version; + + const settings = useSyncExternalStore(machine.subscribe, machine.get); + const projectSettings = useSyncExternalStore(project.subscribe, project.get); + const status = useSyncExternalStore(tracker.subscribe, tracker.status); + const frozen = useFrozen(app); + + const [reveal, setReveal] = useState(false); + const [today, setToday] = useState(null); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + const apiKey = settings.apiKey.trim(); + const credentials = useMemo(() => toCredentials(apiKey, version), [apiKey, version]); + + const apiKeyField = useDeferredCommit(settings.apiKey, value => machine.set({ apiKey: value })); + const projectField = useDeferredCommit( + projectSettings.projectName, + value => void project.patch({ projectName: value }), + ); + + /* Today's total is read from the server rather than derived: the heartbeats + this plugin sent are only part of it. Only while the dialog is open. */ + useEffect(() => { + if (!credentials.apiKey) { + setToday(null); + return; + } + let cancelled = false; + void (async () => { + const result = await fetchToday(credentials); + if (!cancelled && "text" in result) { + setToday(result); + } + })(); + return () => { + cancelled = true; + }; + }, [credentials]); + + const runTest = useCallback(async () => { + apiKeyField.flush(); + projectField.flush(); + setTesting(true); + setMessage(null); + // Record one first, so the button proves the *write* path — the half a + // setup actually gets wrong. No-ops until the key and name are both in. + await tracker.recordNow(); + // Read back through the store: the flushes above are what just wrote it, + // and this closure predates them. + const result = await fetchToday(toCredentials(machine.get().apiKey, version)); + if ("text" in result) { + setToday(result); + setMessage({ ok: true, text: t("test.ok", { total: result.text }) }); + tracker.resume(); + } else { + setMessage({ + ok: false, + text: result.kind === "auth" ? t("test.auth") : t("test.failed", { message: result.message }), + }); + } + setTesting(false); + }, [apiKeyField, machine, projectField, t, tracker, version]); + + const state = !settings.enabled ? "disabled" + : !apiKey ? "needsKey" + : !projectSettings.projectName.trim() ? "needsProject" + : status.pausedForAuth ? "pausedForAuth" + : "tracking"; + const statusText = state !== "tracking" + ? t(`status.${state}`) + : today + ? t("status.tracking", { total: today.text }) + : t("status.trackingUnknown"); + + return ( + +
+ machine.set({ enabled: checked })} + /> + )} + /> + + }> + apiKeyField.set(event.target.value)} + onBlur={apiKeyField.flush} + rightIcon={reveal ? : } + rightIconLabel={reveal ? t("field.hide") : t("field.reveal")} + onRightIconClick={() => setReveal(current => !current)} + /> + + + + projectField.set(event.target.value)} + onBlur={projectField.flush} + disabled={frozen} + title={frozen ? t("status.frozen") : undefined} + /> + + +
+
+
{statusText}
+ {status.queued > 0 && ( +
+ {t("status.queued", { count: status.queued })} +
+ )} + {message && ( +
+ {message.text} +
+ )} +
+ void runTest()}> + {testing ? t("action.testing") : t("action.test")} + + {t("action.close")} +
+
+
+ ); +} + +function Row({ label, hint, control }: { label: string; hint: string; control: ReactNode }) { + return ( +
+
+
{label}
+
{hint}
+
+
{control}
+
+ ); +} + +/** + * Label above, control below, hint under that — written out rather than using + * `ui.InputGroup` because its `helper` is typed `string`, and the API key's hint + * has to carry an interactive element inside the sentence. + */ +function Field({ label, hint, children }: { + label: string; + hint: ReactNode; + children: ReactNode; +}) { + return ( +
+
{label}
+ {children} +
{hint}
+
+ ); +} + +/** + * The API key hint, with the address inside it click-to-copy. + * + * Not a link, because a plugin cannot open one: the workspace window denies + * every `setWindowOpenHandler` request and blocks `will-navigate` to anything + * outside its own entry, and `shell.openExternal` lives behind an IPC the plugin + * facade does not expose (it carries `fs`, `permissions` and `bash`, nothing + * else). An `
` here would be a control that visibly does nothing, which + * is worse than plain text. Copying is the part the author actually needs. + * + * The address is spliced into the sentence rather than appended to it, so a + * translation can put it where its own grammar wants it — `{url}` survives `t()` + * untouched because no `url` param is passed. + */ +function ApiKeyHint({ t }: { t: Translate }) { + const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); + const timer = useRef(null); + + useEffect(() => () => { + if (timer.current !== null) { + window.clearTimeout(timer.current); + } + }, []); + + const copy = useCallback(async () => { + setState(await copyText(API_KEY_URL) ? "copied" : "failed"); + if (timer.current !== null) { + window.clearTimeout(timer.current); + } + timer.current = window.setTimeout(() => setState("idle"), 2500); + }, []); + + const [before, after = ""] = t("field.apiKeyHint").split("{url}"); + + return ( + <> + {before} + + {after} + {state !== "idle" && ( +
+ {t(state === "copied" ? "field.copied" : "field.copyFailed")} +
+ )} + + ); +} + +/** Clipboard API first; the `execCommand` path is for a context that refuses it. */ +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + /* Fall through. */ + } + try { + const scratch = document.createElement("textarea"); + scratch.value = text; + scratch.style.position = "fixed"; + scratch.style.opacity = "0"; + document.body.appendChild(scratch); + scratch.select(); + const copied = document.execCommand("copy"); + scratch.remove(); + return copied; + } catch { + return false; + } +} + +/* ------------------------------------------------------------------- hooks */ + +function useTranslate(app: PluginApp): Translate { + const translator = useMemo(() => app.services.i18n.createTranslator(MESSAGES), [app]); + const subscribe = useCallback((listener: () => void) => { + const cleanup = app.services.i18n.onLocaleChange(() => listener()); + return () => { + void cleanup(); + }; + }, [app]); + // Read for its re-render, not its value: the translator resolves against the + // editor language at call time, so it only needs to be called again. + useSyncExternalStore(subscribe, () => app.services.i18n.locale); + return useCallback((key, params) => translator.t(key, params), [translator]); +} + +/** + * `ui.useFreezeGuard` needs the workspace React context this dialog's own root + * does not have, so the freeze state comes from the service half instead. + */ +function useFrozen(app: PluginApp): boolean { + const subscribe = useCallback((listener: () => void) => { + const cleanup = app.services.workspace.onFreezeChange(() => listener()); + return () => { + void cleanup(); + }; + }, [app]); + return useSyncExternalStore(subscribe, () => app.services.workspace.frozen); +} + +/** + * A text field that keeps its own draft and commits behind it. + * + * Committing per keystroke would mean a project write — and a tracker wake-up — + * per keystroke; committing only on blur would lose whatever was typed into a + * dialog closed with Escape. So: debounce, flush on blur, and flush again on + * unmount, which is what closing the dialog does to this component. + */ +function useDeferredCommit(external: string, commit: (value: string) => void) { + const [value, setValue] = useState(external); + const [dirty, setDirty] = useState(false); + + const commitRef = useRef(commit); + commitRef.current = commit; + const pending = useRef(null); + const timer = useRef(null); + + // An external change (a reload after a version restore) wins over a draft + // nobody is editing, and never over one somebody is. + useEffect(() => { + if (!dirty) { + setValue(external); + } + }, [external, dirty]); + + const flush = useCallback(() => { + if (timer.current !== null) { + window.clearTimeout(timer.current); + timer.current = null; + } + if (pending.current !== null) { + const next = pending.current; + pending.current = null; + setDirty(false); + commitRef.current(next); + } + }, []); + + const set = useCallback((next: string) => { + setValue(next); + setDirty(true); + pending.current = next; + if (timer.current !== null) { + window.clearTimeout(timer.current); + } + timer.current = window.setTimeout(flush, 500); + }, [flush]); + + useEffect(() => flush, [flush]); + + return { value, set, flush }; +} diff --git a/plugins/wangzixu.wakatime-plugin/src/projectStore.ts b/plugins/wangzixu.wakatime-plugin/src/projectStore.ts new file mode 100644 index 0000000..0d0799d --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/projectStore.ts @@ -0,0 +1,65 @@ +/** + * The project-scoped half of the settings, over `app.services.storage`. + * + * Two obligations come with writing project data, and both are met here rather + * than in the panel: + * + * - **Bail before touching memory when the project is frozen.** A frozen + * project discards writes at the boundary and reports success, so a store + * that mutated first and wrote second would keep a copy the disk does not + * have — and hand it back over the restored version on the next edit. + * - **Re-read when Studio replaces the working tree.** `registerReloader` runs + * after a restore or a thaw with the new bytes already at the read boundary. + */ + +import type { PluginApp } from "narraleaf-studio/plugin"; +import { + DEFAULT_PROJECT_SETTINGS, + SETTINGS_NAMESPACE, + normalizeProjectSettings, + type ProjectSettings, +} from "./settings"; + +export type ProjectStore = { + get(): ProjectSettings; + subscribe(listener: () => void): () => void; + /** Reads storage into memory. Also the reloader. */ + load(): Promise; + /** No-ops while frozen; resolves to whether the write actually happened. */ + patch(patch: Partial): Promise; +}; + +export function createProjectStore(app: PluginApp): ProjectStore { + let settings: ProjectSettings = DEFAULT_PROJECT_SETTINGS; + const listeners = new Set<() => void>(); + + const notify = () => { + for (const listener of listeners) { + listener(); + } + }; + + return { + get: () => settings, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async load() { + settings = normalizeProjectSettings(await app.services.storage.readJson(SETTINGS_NAMESPACE)); + notify(); + }, + async patch(patch) { + if (app.services.workspace.frozen) { + return false; + } + const next = normalizeProjectSettings({ ...settings, ...patch }); + settings = next; + notify(); + await app.services.storage.writeJson(SETTINGS_NAMESPACE, next); + return true; + }, + }; +} diff --git a/plugins/wangzixu.wakatime-plugin/src/settings.test.ts b/plugins/wangzixu.wakatime-plugin/src/settings.test.ts new file mode 100644 index 0000000..1e3537a --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/settings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_PROJECT_SETTINGS, + normalizeMachineSettings, + normalizeProjectSettings, + toCredentials, +} from "./settings"; + +describe("normalizeMachineSettings", () => { + it("survives an absent or corrupt store", () => { + for (const raw of [null, undefined, 42, "nope", []]) { + expect(normalizeMachineSettings(raw)).toEqual({ apiKey: "", enabled: true }); + } + }); + + it("trims a pasted key, which usually arrives with a newline", () => { + expect(normalizeMachineSettings({ apiKey: " waka_abc\n" }).apiKey).toBe("waka_abc"); + }); + + it("keeps an explicit opt-out", () => { + expect(normalizeMachineSettings({ enabled: false }).enabled).toBe(false); + }); +}); + +describe("toCredentials", () => { + it("cleans at the point of use, so an edit in progress is never rewritten", () => { + expect(toCredentials(" waka_abc ", "0.1.0")).toEqual({ + apiKey: "waka_abc", + userAgent: "narraleaf-studio/unknown narraleaf-wakatime/0.1.0", + }); + }); +}); + +describe("normalizeProjectSettings", () => { + it("defaults an unvisited project to unnamed, which is what makes it inert", () => { + expect(normalizeProjectSettings(null)).toEqual(DEFAULT_PROJECT_SETTINGS); + expect(normalizeProjectSettings(null).projectName).toBe(""); + }); + + it("keeps the author's text exactly, so a debounced commit cannot edit it back", () => { + // Trimming here would eat the space half a second after it was typed. + expect(normalizeProjectSettings({ projectName: "My Novel " }).projectName).toBe("My Novel "); + }); + + it("ignores a corrupt value rather than propagating it", () => { + expect(normalizeProjectSettings({ projectName: 42 }).projectName).toBe(""); + }); +}); diff --git a/plugins/wangzixu.wakatime-plugin/src/settings.ts b/plugins/wangzixu.wakatime-plugin/src/settings.ts new file mode 100644 index 0000000..9b908f6 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/settings.ts @@ -0,0 +1,162 @@ +/** + * Three settings, in two places, because they are not the same kind of thing and + * Studio only offers a home for one of them. + * + * **The API key is not project data.** `app.services.storage` writes into + * `editor/services/`, which is inside the project's versioned working tree — the + * same tree the author commits and shares. A credential written there is a + * credential in the repository, and on a public repository that is a leak with + * no undo. So the key, and the on/off switch that goes with it, live in the + * workspace window's `localStorage`: machine-scoped, outside the project, never + * committed. It is plaintext, which is exactly what `~/.wakatime.cfg` is; Studio + * exposes no secret store, and the alternative here is not "encrypted" but "in + * git". + * + * **The project name is project data**, and belongs in project storage on + * purpose: everyone working on the same project should report to the same + * WakaTime project, and the way to make that true is to version the answer + * alongside the story rather than ask each collaborator to retype it. + */ + +import { trimQueue, userAgent, type Credentials, type Heartbeat } from "./wakatime"; + +export const PLUGIN_ID = "wangzixu.wakatime-plugin"; + +/** Project storage namespace. Prefixed with the plugin id, like every other contributed name. */ +export const SETTINGS_NAMESPACE = `${PLUGIN_ID}.settings`; + +const MACHINE_KEY = `${PLUGIN_ID}.account`; +const QUEUE_KEY = `${PLUGIN_ID}.queue`; + +/** What the heartbeats say they are about. See the note on `Heartbeat`. */ +export const ENTITY = "NarraLeaf Studio"; + +/* ------------------------------------------------- machine-scoped settings */ + +export type MachineSettings = { + apiKey: string; + enabled: boolean; +}; + +/** + * Load-time only. Cleaning a value on the way *in* is safe; doing it on the way + * out of an edit is not — a debounced commit that trimmed would rewrite the text + * under the cursor. So editing stores the string verbatim and + * {@link toCredentials} trims at the point of use. + */ +export function normalizeMachineSettings(raw: unknown): MachineSettings { + const record = isRecord(raw) ? raw : {}; + return { + apiKey: typeof record.apiKey === "string" ? record.apiKey.trim() : "", + // Defaults to on, which is safe precisely because it is inert: no key, + // no project name, nothing sent. + enabled: typeof record.enabled === "boolean" ? record.enabled : true, + }; +} + +export function toCredentials(apiKey: string, pluginVersion: string): Credentials { + return { apiKey: apiKey.trim(), userAgent: userAgent(pluginVersion) }; +} + +/* --------------------------------------------------- project-scoped setting */ + +export type ProjectSettings = { + version: 1; + /** Empty means "not named yet"; nothing is sent until it has one. */ + projectName: string; +}; + +export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { version: 1, projectName: "" }; + +export function normalizeProjectSettings(raw: unknown): ProjectSettings { + const record = isRecord(raw) ? raw : {}; + return { + version: 1, + // Verbatim, for the reason on normalizeMachineSettings. `buildHeartbeat` + // trims what it actually sends. + projectName: typeof record.projectName === "string" ? record.projectName : "", + }; +} + +/* ------------------------------------------------------------ local storage */ + +/** + * `localStorage` can throw outright (a partitioned or disabled store), and a + * time tracker is not worth taking the workspace down for. Every access here + * fails to a default instead. + */ +function readLocal(key: string): unknown { + try { + const raw = window.localStorage.getItem(key); + return raw === null ? null : JSON.parse(raw); + } catch { + return null; + } +} + +function writeLocal(key: string, value: unknown): void { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* Quota or a disabled store. The in-memory copy still works this session. */ + } +} + +/* -------------------------------------------------------------------- store */ + +export type MachineStore = { + get(): MachineSettings; + subscribe(listener: () => void): () => void; + set(patch: Partial): void; +}; + +export function createMachineStore(onChange?: () => void): MachineStore { + let settings = normalizeMachineSettings(readLocal(MACHINE_KEY)); + const listeners = new Set<() => void>(); + + return { + get: () => settings, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set(patch) { + // Verbatim. Cleaning happens on load and at `toCredentials`. + settings = { ...settings, ...patch }; + writeLocal(MACHINE_KEY, settings); + for (const listener of listeners) { + listener(); + } + // A changed key is the one event that makes a paused-on-auth-failure + // tracker worth waking: the reason it stopped may no longer hold. + onChange?.(); + }, + }; +} + +/* ---------------------------------------------------------- heartbeat queue */ + +export function readQueue(): Heartbeat[] { + const raw = readLocal(QUEUE_KEY); + if (!Array.isArray(raw)) { + return []; + } + return trimQueue(raw.filter(isHeartbeat)); +} + +export function writeQueue(queue: readonly Heartbeat[]): void { + writeLocal(QUEUE_KEY, trimQueue(queue)); +} + +function isHeartbeat(value: unknown): value is Heartbeat { + return isRecord(value) + && typeof value.entity === "string" + && typeof value.time === "number" + && Number.isFinite(value.time); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/wangzixu.wakatime-plugin/src/tracker.test.ts b/plugins/wangzixu.wakatime-plugin/src/tracker.test.ts new file mode 100644 index 0000000..78505bf --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/tracker.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTracker, shouldRecord, type TrackerConfig } from "./tracker"; +import { HEARTBEAT_INTERVAL_MS, type Heartbeat } from "./wakatime"; + +describe("shouldRecord", () => { + it("records the first activity of a session immediately", () => { + expect(shouldRecord(null, 1_000, true)).toBe(true); + }); + + it("holds off until a full interval has passed", () => { + expect(shouldRecord(1_000, 1_000 + HEARTBEAT_INTERVAL_MS - 1, true)).toBe(false); + expect(shouldRecord(1_000, 1_000 + HEARTBEAT_INTERVAL_MS, true)).toBe(true); + }); + + it("records nothing for a window that is not on screen", () => { + expect(shouldRecord(null, 1_000, false)).toBe(false); + }); +}); + +const CONFIG: TrackerConfig = { + enabled: true, + apiKey: "waka_test", + entity: "NarraLeaf Studio", + project: "My Visual Novel", + userAgent: "narraleaf-studio/unknown narraleaf-wakatime/0.1.0", +}; + +function harness(config: Partial = {}, initial: Heartbeat[] = []) { + let queue = [...initial]; + const tracker = createTracker({ + readConfig: () => ({ ...CONFIG, ...config }), + readQueue: () => [...queue], + writeQueue: next => { + queue = [...next]; + }, + now: () => 1_700_000_000_000, + isVisible: () => true, + }); + return { tracker, queue: () => queue }; +} + +function beat(time: number): Heartbeat { + return { entity: "NarraLeaf Studio", type: "app", time, is_write: false }; +} + +function respond(status: number) { + return vi.fn(async () => new Response("{}", { status })); +} + +let fetchSpy: ReturnType; + +beforeEach(() => { + fetchSpy = respond(201); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("flush", () => { + it("drains an accepted queue", async () => { + const { tracker, queue } = harness({}, [beat(1), beat(2)]); + await tracker.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(queue()).toEqual([]); + expect(tracker.status().error).toBeNull(); + }); + + it("splits a long queue into bulk-sized requests", async () => { + const { tracker, queue } = harness({}, Array.from({ length: 60 }, (_, index) => beat(index))); + await tracker.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(queue()).toEqual([]); + }); + + it("keeps the queue and parks on a rejected key, and stays parked", async () => { + fetchSpy = respond(401); + vi.stubGlobal("fetch", fetchSpy); + const { tracker, queue } = harness({}, [beat(1)]); + + await tracker.flush(); + expect(queue()).toHaveLength(1); + expect(tracker.status().pausedForAuth).toBe(true); + expect(tracker.status().error?.kind).toBe("auth"); + + // A wrong key does not become right on a timer. + await tracker.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("tries again once the author resumes it", async () => { + fetchSpy = respond(401); + vi.stubGlobal("fetch", fetchSpy); + const { tracker } = harness({}, [beat(1)]); + await tracker.flush(); + + tracker.resume(); + await tracker.flush(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(tracker.status().pausedForAuth).toBe(false); + }); + + it("drops a batch the server refused outright, rather than retrying it forever", async () => { + fetchSpy = respond(400); + vi.stubGlobal("fetch", fetchSpy); + const { tracker, queue } = harness({}, [beat(1)]); + await tracker.flush(); + expect(queue()).toEqual([]); + expect(tracker.status().error?.kind).toBe("rejected"); + }); + + it("keeps the queue through a server outage", async () => { + fetchSpy = respond(503); + vi.stubGlobal("fetch", fetchSpy); + const { tracker, queue } = harness({}, [beat(1)]); + await tracker.flush(); + expect(queue()).toHaveLength(1); + expect(tracker.status().pausedForAuth).toBe(false); + }); + + it("sends nothing while the setup is incomplete", async () => { + for (const config of [{ apiKey: "" }, { project: "" }, { enabled: false }]) { + const { tracker, queue } = harness(config, [beat(1)]); + await tracker.flush(); + expect(queue()).toHaveLength(1); + } + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("recordNow", () => { + it("records and sends one heartbeat regardless of the interval", async () => { + const { tracker, queue } = harness(); + await tracker.recordNow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(queue()).toEqual([]); + expect(tracker.status().lastHeartbeatAt).toBe(1_700_000_000_000); + }); + + it("records nothing when there is no project to record against", async () => { + const { tracker, queue } = harness({ project: "" }); + await tracker.recordNow(); + expect(queue()).toEqual([]); + expect(tracker.status().lastHeartbeatAt).toBeNull(); + }); +}); diff --git a/plugins/wangzixu.wakatime-plugin/src/tracker.ts b/plugins/wangzixu.wakatime-plugin/src/tracker.ts new file mode 100644 index 0000000..dcbe9c6 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/tracker.ts @@ -0,0 +1,226 @@ +/** + * When a heartbeat happens, and what happens to it afterwards. + * + * ## What this can actually see + * + * Studio's plugin API has no editing signal: no "document changed", no "active + * editor is now this scene", no save hook. What a studio entry *does* have is + * the workspace window's DOM, and that is enough for the only question WakaTime + * asks — was the author working just now? Keystrokes, clicks, wheel and IME + * commits are that answer, and they are collected in the capture phase so a + * surface that stops propagation (the blueprint canvas does) still counts. + * + * The events are read for their *timing only*. No key, no coordinate, no target + * element is inspected, stored or sent; see `buildHeartbeat` for the entire + * payload. + * + * ## Rhythm + * + * Event-driven, not polled. WakaTime's server closes a session after 15 minutes + * of silence, so one heartbeat per two minutes of activity is enough to + * reconstruct it — and an author who stops typing generates nothing, which is + * the correct answer rather than a gap to paper over. The interval below exists + * only to retry the offline queue. + */ + +import { + BULK_CHUNK, + HEARTBEAT_INTERVAL_MS, + buildHeartbeat, + chunk, + sendHeartbeats, + shouldRetry, + trimQueue, + type Credentials, + type Heartbeat, + type SendFailureKind, +} from "./wakatime"; + +const FLUSH_INTERVAL_MS = 60_000; + +/** Timing only — never the key, the target or the coordinates. */ +const ACTIVITY_EVENTS = ["keydown", "pointerdown", "wheel", "input", "compositionend"] as const; + +export type TrackerConfig = { + enabled: boolean; + apiKey: string; + entity: string; + project: string; + userAgent: string; +}; + +export type TrackerStatus = { + queued: number; + sending: boolean; + /** Epoch ms of the last heartbeat recorded locally. */ + lastHeartbeatAt: number | null; + /** Epoch ms of the last batch the server accepted. */ + lastSentAt: number | null; + error: { kind: SendFailureKind; message: string } | null; + /** + * Sending is parked until the credentials change or the author retries by + * hand. Only ever set by an auth failure: a wrong key does not become right + * on a timer, and hammering the endpoint every minute would be rude. + */ + pausedForAuth: boolean; +}; + +export type TrackerDeps = { + readConfig(): TrackerConfig; + readQueue(): Heartbeat[]; + writeQueue(queue: readonly Heartbeat[]): void; + now?: () => number; + /** Present in the workspace; injectable so the scheduling logic is testable. */ + isVisible?: () => boolean; +}; + +export type Tracker = { + /** Attaches the listeners and the retry timer. Returns the cleanup. */ + start(): () => void; + status(): TrackerStatus; + subscribe(listener: () => void): () => void; + /** Record one now, ignoring the two-minute gate. Used by "Send a heartbeat now". */ + recordNow(): Promise; + /** Clear a parked auth failure and try again. */ + resume(): void; + flush(): Promise; +}; + +/** + * The whole rhythm rule, as one testable function: record when the window is on + * screen and the last heartbeat is at least an interval old. + */ +export function shouldRecord(lastHeartbeatAt: number | null, at: number, visible: boolean): boolean { + if (!visible) { + return false; + } + return lastHeartbeatAt === null || at - lastHeartbeatAt >= HEARTBEAT_INTERVAL_MS; +} + +export function createTracker(deps: TrackerDeps): Tracker { + const now = deps.now ?? (() => Date.now()); + const isVisible = deps.isVisible ?? (() => document.visibilityState === "visible"); + + let status: TrackerStatus = { + queued: deps.readQueue().length, + sending: false, + lastHeartbeatAt: null, + lastSentAt: null, + error: null, + pausedForAuth: false, + }; + const listeners = new Set<() => void>(); + + const update = (patch: Partial) => { + status = { ...status, ...patch }; + for (const listener of listeners) { + listener(); + } + }; + + /** Everything needed to send, or null when the setup is incomplete. */ + const ready = (config: TrackerConfig): Credentials | null => { + if (!config.enabled || !config.apiKey || !config.project) { + return null; + } + return { apiKey: config.apiKey, userAgent: config.userAgent }; + }; + + const record = (force: boolean) => { + const config = deps.readConfig(); + if (!ready(config)) { + return false; + } + const at = now(); + if (!force && !shouldRecord(status.lastHeartbeatAt, at, isVisible())) { + return false; + } + const queue = trimQueue([ + ...deps.readQueue(), + buildHeartbeat({ entity: config.entity, timeMs: at, project: config.project }), + ]); + deps.writeQueue(queue); + update({ lastHeartbeatAt: at, queued: queue.length }); + return true; + }; + + const flush = async (): Promise => { + if (status.sending || status.pausedForAuth) { + return; + } + const config = deps.readConfig(); + const credentials = ready(config); + if (!credentials) { + return; + } + let queue = deps.readQueue(); + if (!queue.length) { + return; + } + + update({ sending: true }); + try { + for (const batch of chunk(queue, BULK_CHUNK)) { + const outcome = await sendHeartbeats(credentials, batch); + if (!outcome.ok && shouldRetry(outcome)) { + update({ + error: { kind: outcome.kind, message: outcome.message }, + pausedForAuth: outcome.kind === "auth", + }); + return; + } + // Accepted, or refused in a way that will not improve — either + // way the batch leaves the queue. Re-read rather than splice: + // an activity event may have appended while this was in flight. + queue = deps.readQueue().slice(batch.length); + deps.writeQueue(queue); + update({ + queued: queue.length, + lastSentAt: outcome.ok ? now() : status.lastSentAt, + error: outcome.ok ? null : { kind: outcome.kind, message: outcome.message }, + }); + } + } finally { + update({ sending: false }); + } + }; + + const onActivity = () => { + if (record(false)) { + void flush(); + } + }; + + return { + start() { + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, onActivity, { capture: true, passive: true }); + } + const timer = window.setInterval(() => void flush(), FLUSH_INTERVAL_MS); + void flush(); + + return () => { + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, onActivity, { capture: true }); + } + window.clearInterval(timer); + }; + }, + status: () => status, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async recordNow() { + record(true); + await flush(); + }, + resume() { + update({ pausedForAuth: false, error: null }); + void flush(); + }, + flush, + }; +} diff --git a/plugins/wangzixu.wakatime-plugin/src/wakatime.test.ts b/plugins/wangzixu.wakatime-plugin/src/wakatime.test.ts new file mode 100644 index 0000000..a5bb470 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/wakatime.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + QUEUE_LIMIT, + buildHeartbeat, + chunk, + classifyStatus, + encodeApiKey, + shouldRetry, + trimQueue, + userAgent, + type Heartbeat, +} from "./wakatime"; + +const base = { + entity: "NarraLeaf Studio", + timeMs: 1_700_000_000_000, + project: "My Visual Novel", +} as const; + +describe("buildHeartbeat", () => { + it("sends seconds, not milliseconds", () => { + expect(buildHeartbeat(base).time).toBe(1_700_000_000); + }); + + it("omits an empty project rather than filing time under a nameless one", () => { + expect(buildHeartbeat({ ...base, project: " " })).not.toHaveProperty("project"); + }); + + it("trims what it does send", () => { + expect(buildHeartbeat({ ...base, project: " Spaced " }).project).toBe("Spaced"); + }); + + it("carries nothing about the project but its name", () => { + expect(Object.keys(buildHeartbeat(base)).sort()).toEqual([ + "category", + "entity", + "is_write", + "language", + "project", + "time", + "type", + ]); + }); +}); + +describe("classifyStatus", () => { + it("treats 401 and 403 as an auth failure worth pausing on", () => { + for (const status of [401, 403]) { + const outcome = classifyStatus(status); + expect(outcome).toMatchObject({ ok: false, kind: "auth" }); + expect(shouldRetry(outcome)).toBe(true); + } + }); + + it("drops a batch the server merely refused", () => { + const outcome = classifyStatus(400, "bad heartbeat"); + expect(outcome).toMatchObject({ ok: false, kind: "rejected", message: "bad heartbeat" }); + expect(shouldRetry(outcome)).toBe(false); + }); + + it("keeps a batch through rate limits and server trouble", () => { + expect(shouldRetry(classifyStatus(429))).toBe(true); + expect(shouldRetry(classifyStatus(503))).toBe(true); + }); + + it("accepts every 2xx, including the 201/202 the bulk endpoint answers", () => { + for (const status of [200, 201, 202]) { + expect(classifyStatus(status)).toEqual({ ok: true }); + } + }); +}); + +describe("queue maintenance", () => { + const beat = (time: number): Heartbeat => ({ ...buildHeartbeat(base), time }); + + it("chunks to the bulk limit", () => { + expect(chunk(Array.from({ length: 60 }, (_, index) => index), 25).map(part => part.length)) + .toEqual([25, 25, 10]); + }); + + it("leaves a short queue alone", () => { + const queue = [beat(1), beat(2)]; + expect(trimQueue(queue)).toEqual(queue); + }); + + it("drops the oldest when full, never the newest", () => { + const queue = Array.from({ length: QUEUE_LIMIT + 3 }, (_, index) => beat(index)); + const trimmed = trimQueue(queue); + expect(trimmed).toHaveLength(QUEUE_LIMIT); + expect(trimmed[0].time).toBe(3); + expect(trimmed[trimmed.length - 1].time).toBe(QUEUE_LIMIT + 2); + }); +}); + +describe("identification", () => { + it("names the editor before the plugin, which is how the dashboard reads it", () => { + expect(userAgent("0.1.0")).toBe("narraleaf-studio/unknown narraleaf-wakatime/0.1.0"); + }); + + it("base64-encodes the key for Basic auth", () => { + expect(encodeApiKey("waka_abc")).toBe("d2FrYV9hYmM="); + }); +}); diff --git a/plugins/wangzixu.wakatime-plugin/src/wakatime.ts b/plugins/wangzixu.wakatime-plugin/src/wakatime.ts new file mode 100644 index 0000000..68f3ea9 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/src/wakatime.ts @@ -0,0 +1,266 @@ +/** + * The WakaTime wire format, and everything about talking to wakatime.com. + * + * Deliberately free of Studio types and of DOM state, so the parts that decide + * *what gets sent* and *what a failure means* can be tested without a browser. + * The only impure things here are the two `fetch` calls at the bottom. + * + * Why the plugin speaks HTTP itself rather than shelling out to `wakatime-cli`, + * which is what every editor plugin WakaTime ships does: Studio's privileged + * facade exposes `bash.execute`, but the main process handler answers + * "Bash execution is not implemented yet" — there is no way to run the CLI. The + * API is documented and stable, so this speaks it directly and reimplements the + * one thing the CLI would have given us for free: an offline queue. + */ + +export const WAKATIME_API_URL = "https://api.wakatime.com/api/v1"; + +/** + * Fixed, not configurable. Three settings is what this plugin has room for, and + * a dropdown nobody changes is not one of them — `designing` is what authoring a + * visual novel is, and `NarraLeaf` is what it is authored in. + */ +export const CATEGORY = "designing"; +export const LANGUAGE = "NarraLeaf"; + +/** + * How long one heartbeat is allowed to stand for. WakaTime's own convention, + * shared by every official editor plugin: while you keep working, one heartbeat + * every two minutes is enough for the server to reconstruct a session (it closes + * a session after 15 minutes of silence). + */ +export const HEARTBEAT_INTERVAL_MS = 2 * 60 * 1000; + +/** `heartbeats.bulk` accepts at most 25 per request. */ +export const BULK_CHUNK = 25; + +/** + * Hard cap on the offline queue. At one heartbeat per two minutes this is about + * 33 hours of unsent work — far past the point where a server is "briefly down" + * and into "this key is wrong and nobody noticed". Oldest goes first. + */ +export const QUEUE_LIMIT = 1000; + +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * One heartbeat, as the API takes it. + * + * `type: "app"` rather than `"file"`, and that is a statement about what this + * plugin honestly knows. Studio's plugin API has no "active document" signal — + * no open-editor event, no focused-asset accessor — so a file path would be a + * guess dressed as a fact. The project is real (the author names it); the + * application is real; the file is not knowable, so it is not claimed. + */ +export type Heartbeat = { + entity: string; + type: "app"; + /** Epoch **seconds**, fractional. Not milliseconds — the API reads seconds. */ + time: number; + project?: string; + language?: string; + category?: string; + is_write: false; +}; + +export type HeartbeatInput = { + entity: string; + /** Epoch milliseconds, as `Date.now()` produces it. */ + timeMs: number; + project: string; +}; + +export function buildHeartbeat(input: HeartbeatInput): Heartbeat { + const project = input.project.trim(); + return { + entity: input.entity, + type: "app", + time: input.timeMs / 1000, + // Omitted rather than sent empty: WakaTime files an empty string under a + // project literally named "", which is worse than "Unknown Project". + ...(project ? { project } : {}), + language: LANGUAGE, + category: CATEGORY, + is_write: false, + }; +} + +/* --------------------------------------------------------------- transport */ + +export type SendFailureKind = + /** The key is wrong or revoked. Retrying on a timer just burns the queue's clock. */ + | "auth" + /** The server understood and refused. The batch is not going to become valid. */ + | "rejected" + | "rateLimit" + | "server" + | "network"; + +export type SendFailure = { + ok: false; + kind: SendFailureKind; + status?: number; + message: string; +}; + +export type SendOutcome = { ok: true } | SendFailure; + +/** Whether a failure is worth keeping the batch queued for. */ +export function shouldRetry(outcome: SendOutcome): boolean { + return !outcome.ok && outcome.kind !== "rejected"; +} + +export function classifyStatus(status: number, body?: string): SendOutcome { + return status >= 200 && status < 300 ? { ok: true } : failureFromStatus(status, body); +} + +/** + * The non-2xx half, split out so a caller that already knows the response failed + * gets a type that says so — a `TodayTotal | SendOutcome` union would leave every + * error branch pretending `{ ok: true }` were still possible. + */ +export function failureFromStatus(status: number, body?: string): SendFailure { + if (status === 401 || status === 403) { + return { ok: false, kind: "auth", status, message: body || "API key rejected" }; + } + if (status === 429) { + return { ok: false, kind: "rateLimit", status, message: body || "Rate limited" }; + } + if (status >= 500) { + return { ok: false, kind: "server", status, message: body || `Server error ${status}` }; + } + return { ok: false, kind: "rejected", status, message: body || `Request rejected (${status})` }; +} + +export function chunk(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let index = 0; index < items.length; index += size) { + out.push(items.slice(index, index + size)); + } + return out; +} + +/** Keep the newest `limit`; a full queue drops its oldest entries, not its newest. */ +export function trimQueue(queue: readonly Heartbeat[], limit: number = QUEUE_LIMIT): Heartbeat[] { + return queue.length <= limit ? [...queue] : queue.slice(queue.length - limit); +} + +/** + * The `plugin` field is how WakaTime names the editor on the dashboard: it reads + * the second-to-last `name/version` pair. Studio's version is genuinely not + * exposed to plugins, so it is reported as `unknown` rather than invented. + */ +export function userAgent(pluginVersion: string, studioVersion = "unknown"): string { + return `narraleaf-studio/${studioVersion} narraleaf-wakatime/${pluginVersion}`; +} + +export function encodeApiKey(apiKey: string): string { + const bytes = new TextEncoder().encode(apiKey); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export type Credentials = { + apiKey: string; + userAgent: string; +}; + +function headers(credentials: Credentials): Record { + return { + "Content-Type": "application/json", + Authorization: `Basic ${encodeApiKey(credentials.apiKey)}`, + }; +} + +/** + * Studio's `app://` scheme is registered `corsEnabled`, so a plugin's `fetch` is + * an ordinary cross-origin request and every endpoint used here has to survive a + * CORS preflight. Measured against api.wakatime.com: `heartbeats.bulk` and + * `statusbar/today` both pass with an `Authorization` header — but + * `GET /users/current` answers its error responses without + * `Access-Control-Allow-Origin`, so the browser eats them and the plugin sees an + * opaque "Failed to fetch". That is why the connection test below is a + * `statusbar/today` call and not the obvious `/users/current` one. + */ +async function request(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** Truncated: a proxy that answers an HTML error page must not become the toast. */ +async function readError(response: Response): Promise { + try { + return (await response.text()).trim().slice(0, 200); + } catch { + return ""; + } +} + +export async function sendHeartbeats( + credentials: Credentials, + batch: readonly Heartbeat[], +): Promise { + if (!batch.length) { + return { ok: true }; + } + try { + const response = await request(`${WAKATIME_API_URL}/users/current/heartbeats.bulk`, { + method: "POST", + headers: headers(credentials), + // `plugin` is a per-heartbeat field, not a header: `User-Agent` is a + // forbidden header name in a renderer and would be dropped silently. + body: JSON.stringify(batch.map(beat => ({ ...beat, plugin: credentials.userAgent }))), + }); + return classifyStatus(response.status, response.ok ? "" : await readError(response)); + } catch (error) { + // Offline, DNS failure, aborted timeout. Keep the batch. + return { ok: false, kind: "network", message: errorMessage(error) }; + } +} + +export type TodayTotal = { + /** Preformatted by the server ("2 hrs 14 mins"); shown as-is. */ + text: string; + seconds: number; +}; + +/** + * Today's total — and, because it is the cheapest call that proves the key is + * good, also what the dialog's "Test" button runs. + */ +export async function fetchToday(credentials: Credentials): Promise { + try { + const response = await request(`${WAKATIME_API_URL}/users/current/statusbar/today`, { + method: "GET", + headers: headers(credentials), + }); + if (!response.ok) { + return failureFromStatus(response.status, await readError(response)); + } + const payload = (await response.json()) as { + data?: { grand_total?: { text?: string; digital?: string; total_seconds?: number } }; + }; + const total = payload.data?.grand_total; + return { + text: total?.text ?? total?.digital ?? "0 mins", + seconds: typeof total?.total_seconds === "number" ? total.total_seconds : 0, + }; + } catch (error) { + return { ok: false, kind: "network", message: errorMessage(error) }; + } +} + +export function errorMessage(error: unknown): string { + if (error instanceof DOMException && error.name === "AbortError") { + return "Request timed out"; + } + return error instanceof Error ? error.message : String(error); +} diff --git a/plugins/wangzixu.wakatime-plugin/tools/make-icon.mjs b/plugins/wangzixu.wakatime-plugin/tools/make-icon.mjs new file mode 100644 index 0000000..6e44da3 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/tools/make-icon.mjs @@ -0,0 +1,241 @@ +/** + * Render this plugin's thumbnail: a line-art stopwatch, one ink colour on one + * ground. 256x256 PNG, encoded by hand — a store thumbnail is not worth pulling + * an image library into a plugin that otherwise builds with esbuild alone. + * + * node tools/make-icon.mjs icon.png [--size=64] + * + * **Not WakaTime's logo, and not a derivative of one.** A plugin icon is the + * most prominent brand slot a package has, and this package is published by + * someone other than WakaTime — a thumbnail carrying their mark would read as + * an official integration, which this is not. The plugin's *name* says WakaTime, + * which is ordinary descriptive use of the service it talks to, and that is + * where the association belongs. Same call `narraleaf.steam-achievements` made + * about Valve's logo, for the same reason. + * + * The drawing is a stopwatch, matching the lucide `Timer` this plugin already + * puts on Studio's toolbar, so the store thumbnail and the in-app button are the + * same idea. + * + * The drawing is defined as strokes — segments and arcs — not as a silhouette to + * be outlined. Ink is the band within half a stroke of the nearest primitive, so + * the weight is uniform by construction and antialiasing is just the distance + * ramp. + * + * The output has to stay square, 64-512px, and under 512 KB — Studio refuses the + * package otherwise (see scripts/lib/image.mjs in the registry root). + */ +import fs from "node:fs"; +import zlib from "node:zlib"; + +// 256 is what ships. `--size=64` renders it at the size a store row actually +// shows, which is the only size worth judging line art at. +const sizeArg = process.argv.find(arg => arg.startsWith("--size=")); +const SIZE = sizeArg ? Number.parseInt(sizeArg.slice("--size=".length), 10) : 256; +if (!Number.isInteger(SIZE) || SIZE < 64 || SIZE > 512) { + console.error(`--size must be an integer in 64..512 (got ${SIZE})`); + process.exit(1); +} + +/** Mask resolution per output pixel, per axis. Also the antialiasing budget. */ +const SS = 3; +const N = SIZE * SS; + +/** + * Stroke weight as a fraction of the icon's width, so it scales with --size. + * Below about 0.03 the line starts washing out at 64px, which is the size that + * decides it. + */ +const strokeArg = process.argv.find(arg => arg.startsWith("--stroke=")); +const STROKE = strokeArg ? Number.parseFloat(strokeArg.slice("--stroke=".length)) : 0.040; +if (!Number.isFinite(STROKE) || STROKE <= 0 || STROKE > 0.2) { + console.error(`--stroke must be a fraction in (0, 0.2] (got ${STROKE})`); + process.exit(1); +} + +/* ------------------------------------------------------------------ palette */ +// Two tones, no gradients: ink on ground. +const GROUND = [24, 27, 33]; +const INK = [232, 236, 242]; + +/* ------------------------------------------------------------------ drawing */ +// Normalized coords: x in [-0.5, 0.5], y in [0, 1] top-down. + +const DIAL_CY = 0.575; // centre of the face +const DIAL_R = 0.288; // face radius +const CROWN_TOP = 0.200; // the cap bar across the top of the stem +const CROWN_HALF = 0.075; // half-width of that bar +const HAND_TIP = [0.150, 0.435]; // where the single hand points + +/** Distance from a point to a line segment. */ +function distSegment(x, y, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.min(1, Math.max(0, ((x - x1) * dx + (y - y1) * dy) / len2)); + return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)); +} + +/** + * Distance to a full circle. + * + * Separate from {@link distArc} rather than expressed as one: an arc's span is + * `norm(a1 - a0)`, and a full turn normalizes to zero, so `distArc(…, 0, 2π)` + * degenerates to a single point on the rim. Measured the hard way — the first + * render had a stopwatch with no dial and two hands that read as a checkmark. + */ +function distCircle(x, y, cx, cy, r) { + return Math.abs(Math.hypot(x - cx, y - cy) - r); +} + +/** + * Distance to the stopwatch: the dial, a cap bar on a short stem, and one hand. + * + * **One hand, not two.** Two hands meeting at the centre at any readable angle + * draw a V, and a V inside a circle is a checkmark — which is what the first + * render turned out to be. Lucide's `Timer`, the icon this plugin already puts + * on Studio's toolbar, has the same single diagonal for the same reason, so the + * thumbnail and the button now read as one idea. + * + * The dial is a full circle rather than one broken for the stem: a gap that + * small closes up under antialiasing at 64px and reads as a dent. The stem + * simply meets the rim. + */ +function distStopwatch(x, y) { + const ax = Math.abs(x); + const dialTop = DIAL_CY - DIAL_R; + + return Math.min( + distCircle(x, y, 0, DIAL_CY, DIAL_R), + // Cap bar and the stem joining it to the rim. + distSegment(ax, y, 0, CROWN_TOP, CROWN_HALF, CROWN_TOP), + distSegment(x, y, 0, CROWN_TOP, 0, dialTop), + // The hand. + distSegment(x, y, 0, DIAL_CY, HAND_TIP[0], HAND_TIP[1]), + ); +} + +/** Rounded-square ground, so the thumbnail has a shape of its own in a list. */ +function inCard(px, py) { + const radius = 0.17; + const x = Math.min(px, 1 - px); + const y = Math.min(py, 1 - py); + if (x >= radius || y >= radius) return true; + const dx = radius - x; + const dy = radius - y; + return dx * dx + dy * dy <= radius * radius; +} + +/* -------------------------------------------------------------------- ink */ + +const halfStroke = STROKE / 2; +/** Feather over roughly one output pixel, so the line is crisp but not jagged. */ +const feather = 0.6 / SIZE; + +const coverage = new Float64Array(N * N); +for (let gy = 0; gy < N; gy += 1) { + for (let gx = 0; gx < N; gx += 1) { + const x = (gx + 0.5) / N - 0.5; + const y = (gy + 0.5) / N; + const d = distStopwatch(x, y); + coverage[gy * N + gx] = Math.min(1, Math.max(0, (halfStroke - d) / feather + 0.5)); + } +} + +/* -------------------------------------------------------------------- render */ + +function mix(a, b, t) { + const k = Math.min(1, Math.max(0, t)); + return [ + Math.round(a[0] + (b[0] - a[0]) * k), + Math.round(a[1] + (b[1] - a[1]) * k), + Math.round(a[2] + (b[2] - a[2]) * k), + ]; +} + +const rgba = Buffer.alloc(SIZE * SIZE * 4); +for (let py = 0; py < SIZE; py += 1) { + for (let px = 0; px < SIZE; px += 1) { + let ink = 0; + let card = 0; + for (let sy = 0; sy < SS; sy += 1) { + for (let sx = 0; sx < SS; sx += 1) { + const gx = px * SS + sx; + const gy = py * SS + sy; + const u = (gx + 0.5) / N; + const v = (gy + 0.5) / N; + if (!inCard(u, v)) continue; + card += 1; + ink += coverage[gy * N + gx]; + } + } + const samples = SS * SS; + const i = (py * SIZE + px) * 4; + if (card === 0) { + rgba[i] = rgba[i + 1] = rgba[i + 2] = rgba[i + 3] = 0; + continue; + } + const colour = mix(GROUND, INK, ink / card); + rgba[i] = colour[0]; + rgba[i + 1] = colour[1]; + rgba[i + 2] = colour[2]; + rgba[i + 3] = Math.round((card / samples) * 255); + } +} + +/* -------------------------------------------------------------------- encode */ + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buffer) { + let c = 0xffffffff; + for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(SIZE, 0); +ihdr.writeUInt32BE(SIZE, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 6; // colour type: RGBA +ihdr[10] = 0; // deflate +ihdr[11] = 0; // adaptive filtering +ihdr[12] = 0; // no interlace + +const raw = Buffer.alloc(SIZE * (SIZE * 4 + 1)); +for (let y = 0; y < SIZE; y += 1) { + raw[y * (SIZE * 4 + 1)] = 0; + rgba.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4); +} + +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), +]); + +const out = process.argv.find(arg => !arg.startsWith("--") && arg.endsWith(".png")); +if (!out) { + console.error("Usage: node tools/make-icon.mjs [--size=64]"); + process.exit(1); +} +fs.writeFileSync(out, png); +console.log(`wrote ${out} — ${SIZE}x${SIZE}, ${png.length} bytes`); diff --git a/plugins/wangzixu.wakatime-plugin/tsconfig.json b/plugins/wangzixu.wakatime-plugin/tsconfig.json new file mode 100644 index 0000000..a0087d2 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "build.mjs"] +} diff --git a/plugins/wangzixu.wakatime-plugin/yarn.lock b/plugins/wangzixu.wakatime-plugin/yarn.lock new file mode 100644 index 0000000..a56ef17 --- /dev/null +++ b/plugins/wangzixu.wakatime-plugin/yarn.lock @@ -0,0 +1,1587 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@esbuild/aix-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/aix-ppc64@npm:0.25.12" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm64@npm:0.25.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm@npm:0.25.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-x64@npm:0.25.12" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-arm64@npm:0.25.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-x64@npm:0.25.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-arm64@npm:0.25.12" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-x64@npm:0.25.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm64@npm:0.25.12" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm@npm:0.25.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ia32@npm:0.25.12" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-loong64@npm:0.25.12" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-mips64el@npm:0.25.12" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ppc64@npm:0.25.12" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-riscv64@npm:0.25.12" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-s390x@npm:0.25.12" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-x64@npm:0.25.12" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-arm64@npm:0.25.12" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-x64@npm:0.25.12" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-arm64@npm:0.25.12" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-x64@npm:0.25.12" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openharmony-arm64@npm:0.25.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/sunos-x64@npm:0.25.12" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-arm64@npm:0.25.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-ia32@npm:0.25.12" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-x64@npm:0.25.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@napi-rs/lzma-linux-x64-gnu@npm:1.5.1": + version: 1.5.1 + resolution: "@napi-rs/lzma-linux-x64-gnu@npm:1.5.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@narraleaf-plugins/wakatime-plugin@workspace:.": + version: 0.0.0-use.local + resolution: "@narraleaf-plugins/wakatime-plugin@workspace:." + dependencies: + "@types/react-dom": "npm:^19.0.0" + esbuild: "npm:^0.25.0" + narraleaf-studio: "npm:^0.5.0" + typescript: "npm:^5.7.0" + vitest: "npm:^3.0.0" + languageName: unknown + linkType: soft + +"@rollup/rollup-android-arm-eabi@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.62.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-android-arm64@npm:4.62.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-darwin-arm64@npm:4.62.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-darwin-x64@npm:4.62.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.62.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-freebsd-x64@npm:4.62.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.62.4" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.62.4" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.62.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.62.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.62.4" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.62.4" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.62.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.62.4" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.62.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.62.4" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.62.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.62.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.62.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-openbsd-x64@npm:4.62.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.62.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.62.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.62.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.62.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.62.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:1.0.9, @types/estree@npm:^1.0.0": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@types/node@npm:^24.8.1": + version: 24.13.3 + resolution: "@types/node@npm:24.13.3" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/a5bc08f49b9581dcdca90e02cd77197a3c799840807664942e5cd5161a553d4d2ae8064f7e3294410d748d7d098b5c1848be7fa50c06f29c4193ab16be4e59eb + languageName: node + linkType: hard + +"@types/react-dom@npm:^19.0.0": + version: 19.2.4 + resolution: "@types/react-dom@npm:19.2.4" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10c0/b7d854ce17bb51a3a067168268a90f0123d10dc490f63f1ff5409f07ac715febeb8f5b7b473404a9d437106571e0312fc2937a945634d590abfc9aa46a14b01b + languageName: node + linkType: hard + +"@types/react@npm:^19.2.0": + version: 19.2.18 + resolution: "@types/react@npm:19.2.18" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/d04216172b4b4362b310017c210dfbe019fb4f4e7dffd0313e70b3acb3051d5c3e76e7e84e3cf4c6a3c824993ffadfe3949e589a6398a09d4200e7670e2962de + languageName: node + linkType: hard + +"@vitest/expect@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/expect@npm:3.2.7" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.7" + "@vitest/utils": "npm:3.2.7" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/5a13fee261d1020d47a3517f4d9a2cb209d7475399c815f6ebe22be116ddb0c6c401592f07d24f9a8b1c192155d11f651397ee28061f886ea61f1f9181a8fd48 + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/mocker@npm:3.2.7" + dependencies: + "@vitest/spy": "npm:3.2.7" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/e8d9a155723e77b3e14c5a1eba35d8966b94420ebc733ffd7119b7bede22006580c468eec2aec6612fb77852cbcdfa94c6f49218f3ca1427aa9363d545c624fd + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:3.2.7, @vitest/pretty-format@npm:^3.2.7": + version: 3.2.7 + resolution: "@vitest/pretty-format@npm:3.2.7" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/f556dd5f9e5240c7ab054d795622292c1b23f6aad3332d1e250f33d801783efbb7883387640b76d22cc91b81f30cb735b40371ec3e4f5293e735495f45d624df + languageName: node + linkType: hard + +"@vitest/runner@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/runner@npm:3.2.7" + dependencies: + "@vitest/utils": "npm:3.2.7" + pathe: "npm:^2.0.3" + strip-literal: "npm:^3.0.0" + checksum: 10c0/d73ba6df95175ea2b545d37fde3e743d113ecd608693b444af1d4dc27956abe4d5b500e25e09ffe46ce720de6b7cec68c264fca20366fc6da0d9ce75c52047cc + languageName: node + linkType: hard + +"@vitest/snapshot@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/snapshot@npm:3.2.7" + dependencies: + "@vitest/pretty-format": "npm:3.2.7" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10c0/c58a17a2c98788e77d30d39837c024852eca91618efe9b53ec399cf76332365b8cc592a69617e0f7d696df716dcc341dac61c6cbea86ae98cdb49a4a84f48d19 + languageName: node + linkType: hard + +"@vitest/spy@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/spy@npm:3.2.7" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10c0/cf2728b4ddc6137e0ca749215c7714845e8221a5f799e933bc623216a9958fd8b032cdd7f033ed8375df9e6ed84a18b64687ca2530f50e8a76bc3b1d16d88c42 + languageName: node + linkType: hard + +"@vitest/utils@npm:3.2.7": + version: 3.2.7 + resolution: "@vitest/utils@npm:3.2.7" + dependencies: + "@vitest/pretty-format": "npm:3.2.7" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/fea57d6cf66853926f54ea6e9bd249b707120b0abba565d96a3880c7f04d5c5cd4778546879b9074cc73c0b81b475c1873791d4b3b3e5a324115b5bcfd3a46bb + languageName: node + linkType: hard + +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + +"chai@npm:^5.2.0": + version: 5.3.3 + resolution: "chai@npm:5.3.3" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 + languageName: node + linkType: hard + +"check-error@npm:^2.1.1": + version: 2.1.3 + resolution: "check-error@npm:2.1.3" + checksum: 10c0/878e99038fb6476316b74668cd6a498c7e66df3efe48158fa40db80a06ba4258742ac3ee2229c4a2a98c5e73f5dff84eb3e50ceb6b65bbd8f831eafc8338607d + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"debug@npm:^4.4.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + +"esbuild@npm:^0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.12" + "@esbuild/android-arm": "npm:0.25.12" + "@esbuild/android-arm64": "npm:0.25.12" + "@esbuild/android-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.25.12" + "@esbuild/darwin-x64": "npm:0.25.12" + "@esbuild/freebsd-arm64": "npm:0.25.12" + "@esbuild/freebsd-x64": "npm:0.25.12" + "@esbuild/linux-arm": "npm:0.25.12" + "@esbuild/linux-arm64": "npm:0.25.12" + "@esbuild/linux-ia32": "npm:0.25.12" + "@esbuild/linux-loong64": "npm:0.25.12" + "@esbuild/linux-mips64el": "npm:0.25.12" + "@esbuild/linux-ppc64": "npm:0.25.12" + "@esbuild/linux-riscv64": "npm:0.25.12" + "@esbuild/linux-s390x": "npm:0.25.12" + "@esbuild/linux-x64": "npm:0.25.12" + "@esbuild/netbsd-arm64": "npm:0.25.12" + "@esbuild/netbsd-x64": "npm:0.25.12" + "@esbuild/openbsd-arm64": "npm:0.25.12" + "@esbuild/openbsd-x64": "npm:0.25.12" + "@esbuild/openharmony-arm64": "npm:0.25.12" + "@esbuild/sunos-x64": "npm:0.25.12" + "@esbuild/win32-arm64": "npm:0.25.12" + "@esbuild/win32-ia32": "npm:0.25.12" + "@esbuild/win32-x64": "npm:0.25.12" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/c205357531423220a9de8e1e6c6514242bc9b1666e762cd67ccdf8fdfdc3f1d0bd76f8d9383958b97ad4c953efdb7b6e8c1f9ca5951cd2b7c5235e8755b34a6b + languageName: node + linkType: hard + +"esbuild@npm:^0.27.0 || ^0.28.0": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"expect-type@npm:^1.2.1": + version: 1.4.0 + resolution: "expect-type@npm:1.4.0" + checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 10c0/68dcab8f233dde211a6b5fd98079783cbcd04b53617c1250e3553ee16ab3e6134f5e65478e41d82f6d351a052a63d71024553933808570f04dbf828d7921e80e + languageName: node + linkType: hard + +"loupe@npm:^3.1.0, loupe@npm:^3.1.4": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 + languageName: node + linkType: hard + +"lucide-react@npm:^0.546.0": + version: 0.546.0 + resolution: "lucide-react@npm:0.546.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/42ee0bd358517f012297aefb69b54da0b3c62f1ac8485ffa24141b63f05c9f4a682eacc2637c4b13f597aed11f9a8c79627af0c17717400bebb25581daeaad80 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.17": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.16": + version: 3.3.17 + resolution: "nanoid@npm:3.3.17" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/06f7949c7cce5c92c6aea66022f29a1eae7f56e05acbfddf1e049e819b4bf234c44a765cc44da7163482b111677948514012e27acdfa56f95309295768bdae32 + languageName: node + linkType: hard + +"narraleaf-studio@npm:^0.5.0": + version: 0.5.0 + resolution: "narraleaf-studio@npm:0.5.0" + dependencies: + "@types/node": "npm:^24.8.1" + "@types/react": "npm:^19.2.0" + lucide-react: "npm:^0.546.0" + checksum: 10c0/b7231ba8029289d49e1ac7488cbc3a0243d80c063e054141cfe0d16a5c62b5797eb1e16a58c1acdb0bbbb31478c5866fe02eaa737826964c9e5a8bae3880db26 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 13.0.1 + resolution: "node-gyp@npm:13.0.1" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^8.4.1" + which: "npm:^7.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/424077bc9e9bbe953a8e86db473ba818cbc6a121714008c977fd589e21e5f0c811fbf22faac730dc7182450b5e52df301811d01ae3373898658d999b7710f4e6 + languageName: node + linkType: hard + +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.1 + resolution: "pathval@npm:2.0.1" + checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + +"postcss@npm:^8.5.6": + version: 8.5.25 + resolution: "postcss@npm:8.5.25" + dependencies: + nanoid: "npm:^3.3.16" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/0a12c1e74b456c57122e81f684e02fd98ff4d57526f794d10c996df1147158808f5ae373ac82b988c8de6cbbaa82dbd7b13803b14f5dd3cf7cc6a42ccad5c9f2 + languageName: node + linkType: hard + +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b + languageName: node + linkType: hard + +"rollup@npm:^4.43.0": + version: 4.62.4 + resolution: "rollup@npm:4.62.4" + dependencies: + "@napi-rs/lzma-linux-x64-gnu": "npm:1.5.1" + "@rollup/rollup-android-arm-eabi": "npm:4.62.4" + "@rollup/rollup-android-arm64": "npm:4.62.4" + "@rollup/rollup-darwin-arm64": "npm:4.62.4" + "@rollup/rollup-darwin-x64": "npm:4.62.4" + "@rollup/rollup-freebsd-arm64": "npm:4.62.4" + "@rollup/rollup-freebsd-x64": "npm:4.62.4" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.62.4" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.62.4" + "@rollup/rollup-linux-arm64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-arm64-musl": "npm:4.62.4" + "@rollup/rollup-linux-loong64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-loong64-musl": "npm:4.62.4" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-ppc64-musl": "npm:4.62.4" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-riscv64-musl": "npm:4.62.4" + "@rollup/rollup-linux-s390x-gnu": "npm:4.62.4" + "@rollup/rollup-linux-x64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-x64-musl": "npm:4.62.4" + "@rollup/rollup-openbsd-x64": "npm:4.62.4" + "@rollup/rollup-openharmony-arm64": "npm:4.62.4" + "@rollup/rollup-win32-arm64-msvc": "npm:4.62.4" + "@rollup/rollup-win32-ia32-msvc": "npm:4.62.4" + "@rollup/rollup-win32-x64-gnu": "npm:4.62.4" + "@rollup/rollup-win32-x64-msvc": "npm:4.62.4" + "@types/estree": "npm:1.0.9" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@napi-rs/lzma-linux-x64-gnu": + optional: true + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/d83bcc89f02db337963dcd0b0d16620129cee263f8437464c02d782dde4e1bb89a6b5b511cd230317ce2c5959fb858884305a9a1a33d1d3da4eef9fe6368414b + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.9.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + +"strip-literal@npm:^3.0.0": + version: 3.1.0 + resolution: "strip-literal@npm:3.1.0" + dependencies: + js-tokens: "npm:^9.0.1" + checksum: 10c0/50918f669915d9ad0fe4b7599902b735f853f2201c97791ead00104a654259c0c61bc2bc8fa3db05109339b61f4cf09e47b94ecc874ffbd0e013965223893af8 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.22 + resolution: "tar@npm:7.5.22" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.2": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tinypool@npm:^1.1.1": + version: 1.1.1 + resolution: "tinypool@npm:1.1.1" + checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b + languageName: node + linkType: hard + +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f + languageName: node + linkType: hard + +"tinyspy@npm:^4.0.3": + version: 4.0.4 + resolution: "tinyspy@npm:4.0.4" + checksum: 10c0/a8020fc17799251e06a8398dcc352601d2770aa91c556b9531ecd7a12581161fd1c14e81cbdaff0c1306c93bfdde8ff6d1c1a3f9bbe6d91604f0fd4e01e2f1eb + languageName: node + linkType: hard + +"typescript@npm:^5.7.0": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.7.0#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + +"undici@npm:^8.4.1": + version: 8.9.0 + resolution: "undici@npm:8.9.0" + checksum: 10c0/e3d9fa35a9aa8360d9f56e66bd372f451ee053e25066a97fd9fab3816267035660f67870c6d709a58a5411551657af78c4475be5b31c113b04f70251309900d0 + languageName: node + linkType: hard + +"vite-node@npm:3.2.4": + version: 3.2.4 + resolution: "vite-node@npm:3.2.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.4.1" + es-module-lexer: "npm:^1.7.0" + pathe: "npm:^2.0.3" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b + languageName: node + linkType: hard + +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": + version: 7.3.6 + resolution: "vite@npm:7.3.6" + dependencies: + esbuild: "npm:^0.27.0 || ^0.28.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/c6d359f84ad362f5c97ff7988b77c90add04162c60cdf6059375a7d9e3217848c53a8cb6b6c48517bef84b6bba4c9bc85319a889b5236acb7d5a2bc8cb7b9a8d + languageName: node + linkType: hard + +"vitest@npm:^3.0.0": + version: 3.2.7 + resolution: "vitest@npm:3.2.7" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/expect": "npm:3.2.7" + "@vitest/mocker": "npm:3.2.7" + "@vitest/pretty-format": "npm:^3.2.7" + "@vitest/runner": "npm:3.2.7" + "@vitest/snapshot": "npm:3.2.7" + "@vitest/spy": "npm:3.2.7" + "@vitest/utils": "npm:3.2.7" + chai: "npm:^5.2.0" + debug: "npm:^4.4.1" + expect-type: "npm:^1.2.1" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.2" + std-env: "npm:^3.9.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.14" + tinypool: "npm:^1.1.1" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node: "npm:3.2.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.7 + "@vitest/ui": 3.2.7 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: ./vitest.mjs + checksum: 10c0/4eb7a63a1d62b88c425bcbc609835055a5644a1994d6f47605fe396dddf732e3c0277c9ec89a028fe81557dc4051dd15fc15b9eba6a51d1466cd5c682ddc1261 + languageName: node + linkType: hard + +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard