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 0000000..85370d2 Binary files /dev/null and b/plugins/wangzixu.wakatime-plugin/icon.png differ 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