diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 00000000..3b683d21 --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,89 @@ +name: Desktop build + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: write + +jobs: + desktop: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS arm64 + os: macos-14 + - name: Windows x64 + os: windows-2022 + - name: Linux x64 + os: ubuntu-24.04 + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.19 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Web renderer + run: bun run build:web + + - name: Verify Web precache budget + run: bun run verify:web-performance + + - name: Run project type checks + run: | + bun run typecheck + bun run typecheck:mobile + + - name: Build debug sidecar for integration tests + run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml + + - name: Run desktop regression tests + run: bun run test:desktop + + - name: Build Rust sidecar + run: | + cargo fmt --manifest-path crates/desktop-sidecar/Cargo.toml -- --check + cargo clippy --manifest-path crates/desktop-sidecar/Cargo.toml --all-targets -- -D warnings + cargo test --manifest-path crates/desktop-sidecar/Cargo.toml + cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release + + - name: Package desktop installer + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ secrets.EDGEEVER_MAC_CERTIFICATE_BASE64 != '' && 'true' || 'false' }} + CSC_LINK: ${{ secrets.EDGEEVER_MAC_CERTIFICATE_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.EDGEEVER_MAC_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.EDGEEVER_APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.EDGEEVER_APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.EDGEEVER_APPLE_TEAM_ID }} + WIN_CSC_LINK: ${{ secrets.EDGEEVER_WINDOWS_CERTIFICATE_BASE64 }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.EDGEEVER_WINDOWS_CERTIFICATE_PASSWORD }} + run: bun run --cwd apps/desktop dist -- --publish ${{ github.event_name == 'release' && 'always' || 'never' }} + + - name: Verify desktop installer + run: bun scripts/verify-desktop-package.mjs + + - name: Upload desktop installer and metadata + uses: actions/upload-artifact@v4 + with: + name: edgeever-desktop-${{ matrix.name }} + path: | + release/desktop/**/*.{dmg,exe,AppImage,blockmap,yml} + release/desktop/**/*.zip + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index f64e40e8..e0c47f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,12 @@ node_modules/ .idea/ dist/ build/ +release/desktop/ coverage/ +# Rust sidecar build artifacts. +target/ + .wrangler/ .wrangler.generated-migrations/ .wrangler.generated*.toml diff --git a/README.md b/README.md index d797f093..22161d6c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The public demo resets every Monday at 1:00 AM (China Standard Time) and restore - **Batch Operations & Flexible Sorting**: Easily merge or relocate multiple notes, with drag-and-drop notebook reordering. - **Offline Drafts & Queueing**: Draft and edit uninterrupted while offline; changes automatically sync once reconnected. - **Multi-Tenant Account Isolation**: Host multiple user accounts on a single instance with strictly partitioned spaces and clean admin account management. -- **Everywhere You Need It**: Chrome/Edge Web Clipper published on Chrome Web Store; installable as a PWA; native Android app available on [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile), with APKs also downloadable from GitHub Releases; iOS app currently under App Store review; native desktop app development is planned with Electron and a Rust sidecar. +- **Everywhere You Need It**: Chrome/Edge Web Clipper published on Chrome Web Store; installable as a PWA; native Android app available on [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile), with APKs also downloadable from GitHub Releases; iOS app currently under App Store review; native desktop builds use Electron and a Rust sidecar. ## Deployment @@ -105,7 +105,7 @@ The Chrome/Edge web clipper is officially published. You can install it directly The Android app is now available on [Google Play](https://play.google.com/store/apps/details?id=org.edgeever.mobile), with signed APKs also available from [GitHub Releases](https://github.com/tianma-if/edgeever/releases). The iOS app has been submitted and is currently under App Store review. -The native desktop app is planned to be built with Electron and a Rust sidecar. +The native desktop app is built with Electron and a Rust sidecar. Development and unsigned installer instructions are available in [`apps/desktop/README.md`](apps/desktop/README.md); signed release artifacts are produced by the desktop CI workflow. ## Tech Stack diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index fc9e9de2..270dc2e1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -439,7 +439,7 @@ const app = new Hono<{ Bindings: Bindings; Variables: { auth: AuthContext } }>() app.use( "/api/*", cors({ - origin: ["http://127.0.0.1:5173", "http://localhost:5173"], + origin: ["http://127.0.0.1:5173", "http://localhost:5173", "null"], allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], credentials: true, @@ -449,7 +449,7 @@ app.use( app.use( "/mcp", cors({ - origin: ["http://127.0.0.1:5173", "http://localhost:5173"], + origin: ["http://127.0.0.1:5173", "http://localhost:5173", "null"], allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "OPTIONS"], credentials: true, @@ -1228,6 +1228,30 @@ app.delete("/api/v1/notebooks/:id", async (c) => { return c.json({ ok: true }); }); +app.post("/api/v1/notebooks/:id/restore", async (c) => { + const denied = requireScopes(c, "write:notebooks"); + if (denied) return denied; + + const id = c.req.param("id"); + const workspaceId = getWorkspaceId(c); + const actor = getAuditActor(c); + const current = await c.env.storage.db.prepare( + `SELECT id FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 1` + ).bind(id, workspaceId).first<{ id: string }>(); + if (!current) return notFound(c, "Deleted notebook not found"); + + const now = isoNow(); + await c.env.storage.db.batch([ + c.env.storage.db.prepare( + `UPDATE notebooks SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ? AND workspace_id = ?` + ).bind(now, id, workspaceId), + auditStatement(c.env.storage.db, actor.actorType, actor.actorId, "notebook.restore", "notebook", id, {}), + ]); + const notebook = await getNotebook(c.env.storage.db, workspaceId, id); + if (!notebook) return notFound(c, "Notebook not found after restore"); + return c.json({ notebook }); +}); + app.get("/api/v1/tags", async (c) => { const denied = requireScopes(c, "read:tags"); diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000..c34f1ded --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,69 @@ +# EdgeEver Desktop + +The desktop application deliberately reuses the Web renderer from `apps/web`. +Electron owns the window and OS lifecycle; the Rust sidecar owns local SQLite +and native data services. The renderer never receives Node.js access. + +Development prerequisites: + +- Bun dependencies installed with `bun install` +- Rust toolchain (`cargo`) installed +- Web dev server available at `http://127.0.0.1:5173` +- Debug sidecar built at `crates/desktop-sidecar/target/debug/edgeever-sidecar` + +Run the shell with: + +```sh +bun run dev:desktop +``` + +The sidecar can be overridden for development with +`EDGE_EVER_SIDECAR_PATH=/absolute/path/to/edgeever-sidecar`. + +Build an unsigned installer for the current platform with: + +```sh +bun run build:web +bun run build:desktop:sidecar +CSC_IDENTITY_AUTO_DISCOVERY=false bun run --cwd apps/desktop dist -- --publish never +``` + +The resulting DMG, NSIS installer, or AppImage is written under +`release/desktop`. The CI workflow accepts optional signing secrets: + +- `EDGEEVER_MAC_CERTIFICATE_BASE64` and `EDGEEVER_MAC_CERTIFICATE_PASSWORD` +- `EDGEEVER_APPLE_ID`, `EDGEEVER_APPLE_APP_SPECIFIC_PASSWORD`, and `EDGEEVER_APPLE_TEAM_ID` +- `EDGEEVER_WINDOWS_CERTIFICATE_BASE64` and `EDGEEVER_WINDOWS_CERTIFICATE_PASSWORD` + +When these secrets are absent, CI produces unsigned verification artifacts. +Private signing material is never committed to the repository. + +The workflow keeps `workflow_dispatch` builds as non-publishing verification +runs. When a GitHub Release is published, each platform job publishes its +installer and update metadata to that release through electron-builder. + +The desktop Settings page exposes the sidecar's local backup list. Restoring a +backup creates an additional protective backup first, restores the SQLite +database in place, restores the staged offline attachment directory when the +snapshot contains one, applies any newer migrations, and reloads the workspace. + +Local SQLite data, backups, staged attachments, and resource cache are scoped +by the configured instance and authenticated user. Existing pre-scope data is +migrated to the first authenticated account that opens the upgraded desktop +app. + +On Unix-like systems the sidecar also enforces private permissions for its data +directory (0700) and SQLite/backup files (0600); Windows uses the platform's +user-data ACLs. + +On startup the sidecar receives the repository `migrations/` directory and +applies unapplied SQL files into the per-user SQLite database. Migration files +are never copied into the user data directory or modified in place. + +Main-process diagnostics are written to `userData/logs/desktop.log` and rotate +at 5 MiB, retaining one `.1` archive so long-running installations cannot grow +logs without bound. + +If the sidecar exits unexpectedly, Electron records the exit and retries it +with exponential backoff (up to 30 seconds). Intentional shutdowns and account +or instance switches stop the process without scheduling a restart. diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 00000000..eb1caef3 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,57 @@ +appId: org.edgeever.desktop +productName: EdgeEver +executableName: EdgeEver +publish: + provider: github + owner: tianma-if + repo: edgeever + releaseType: release +protocols: + - name: EdgeEver Deep Link + schemes: + - edgeever +fileAssociations: + - ext: + - md + - markdown + name: Markdown document + description: Open a Markdown document in EdgeEver + role: Editor +asar: true +icon: ../web/public/pwa-512x512.png +directories: + output: ../../release/desktop +files: + - src/** + - package.json +extraResources: + - from: ../web/dist + to: web + filter: + - "**/*" + - from: ../../migrations + to: migrations + filter: + - "**/*.sql" + - from: ../../crates/desktop-sidecar/target/release + to: sidecar + filter: + - edgeever-sidecar + - edgeever-sidecar.exe +mac: + category: public.app-category.productivity + target: + - target: dmg + arch: + - arm64 + artifactName: EdgeEver-${version}-mac-${arch}.${ext} +win: + target: + - nsis + artifactName: EdgeEver-${version}-windows-${arch}.${ext} +linux: + category: Utility + syncDesktopName: true + target: + - AppImage + artifactName: EdgeEver-${version}-linux-${arch}.${ext} diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..9c411eed --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,22 @@ +{ + "name": "@edgeever/desktop", + "version": "1.6.25", + "desktopName": "EdgeEver", + "private": true, + "description": "Native EdgeEver desktop client", + "author": "EdgeEver contributors", + "type": "module", + "main": "src/main/index.mjs", + "scripts": { + "dev": "electron .", + "start": "electron .", + "dist": "electron-builder --config electron-builder.yml" + }, + "devDependencies": { + "electron": "^38.0.0", + "electron-builder": "^26.0.12" + }, + "dependencies": { + "electron-updater": "^6.6.2" + } +} diff --git a/apps/desktop/src/main/account-scope.mjs b/apps/desktop/src/main/account-scope.mjs new file mode 100644 index 00000000..5ccaf9e2 --- /dev/null +++ b/apps/desktop/src/main/account-scope.mjs @@ -0,0 +1,10 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +export const accountScopeKey = (apiBaseUrl, accountId) => { + if (!accountId) return "anonymous"; + return createHash("sha256").update(`${apiBaseUrl}\0${accountId}`).digest("hex").slice(0, 24); +}; + +export const accountDataDirectory = (userDataDirectory, apiBaseUrl, accountId) => + join(userDataDirectory, "accounts", accountScopeKey(apiBaseUrl, accountId)); diff --git a/apps/desktop/src/main/account-scope.test.mjs b/apps/desktop/src/main/account-scope.test.mjs new file mode 100644 index 00000000..4f492423 --- /dev/null +++ b/apps/desktop/src/main/account-scope.test.mjs @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { accountDataDirectory, accountScopeKey } from "./account-scope.mjs"; + +describe("desktop account scopes", () => { + test("keeps anonymous scope separate and stable user scopes deterministic", () => { + expect(accountScopeKey("https://notes.example.com", null)).toBe("anonymous"); + expect(accountScopeKey("https://notes.example.com", "user-1")).toBe(accountScopeKey("https://notes.example.com", "user-1")); + expect(accountScopeKey("https://notes.example.com", "user-1")).not.toBe(accountScopeKey("https://notes.example.com", "user-2")); + expect(accountScopeKey("https://notes.example.com", "user-1")).not.toBe(accountScopeKey("https://other.example.com", "user-1")); + }); + + test("stores accounts below the managed user-data directory", () => { + const directory = accountDataDirectory("/tmp/edgeever", "https://notes.example.com", "user-1"); + expect(directory.startsWith("/tmp/edgeever/accounts/")).toBe(true); + expect(directory.endsWith("/user-1")).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/diagnostic-log.mjs b/apps/desktop/src/main/diagnostic-log.mjs new file mode 100644 index 00000000..2101e207 --- /dev/null +++ b/apps/desktop/src/main/diagnostic-log.mjs @@ -0,0 +1,15 @@ +import { rename, stat, unlink } from "node:fs/promises"; + +export const rotateDiagnosticLog = async (path, maxBytes = 5 * 1024 * 1024) => { + try { + const metadata = await stat(path); + if (metadata.size <= maxBytes) return false; + } catch { + return false; + } + + const archivePath = `${path}.1`; + await unlink(archivePath).catch(() => {}); + await rename(path, archivePath); + return true; +}; diff --git a/apps/desktop/src/main/diagnostic-log.test.mjs b/apps/desktop/src/main/diagnostic-log.test.mjs new file mode 100644 index 00000000..2070dba5 --- /dev/null +++ b/apps/desktop/src/main/diagnostic-log.test.mjs @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rotateDiagnosticLog } from "./diagnostic-log.mjs"; + +test("diagnostic logs rotate after reaching the size limit", async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-diagnostic-")); + const path = join(directory, "desktop.log"); + await writeFile(path, "x".repeat(11)); + + expect(await rotateDiagnosticLog(path, 10)).toBe(true); + expect((await readFile(`${path}.1`, "utf8")).length).toBe(11); + await expect(stat(path)).rejects.toThrow(); + expect(await rotateDiagnosticLog(path, 10)).toBe(false); +}); diff --git a/apps/desktop/src/main/file-association.mjs b/apps/desktop/src/main/file-association.mjs new file mode 100644 index 00000000..57b4e35d --- /dev/null +++ b/apps/desktop/src/main/file-association.mjs @@ -0,0 +1,9 @@ +import { extname } from "node:path"; + +const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]); + +export const isSupportedAssociatedFile = (filePath) => + typeof filePath === "string" && MARKDOWN_EXTENSIONS.has(extname(filePath).toLowerCase()); + +export const markdownTitleFromFileName = (fileName) => + typeof fileName === "string" ? fileName.trim().replace(/\.(?:md|markdown)$/i, "").trim() : ""; diff --git a/apps/desktop/src/main/file-association.test.mjs b/apps/desktop/src/main/file-association.test.mjs new file mode 100644 index 00000000..a33412f4 --- /dev/null +++ b/apps/desktop/src/main/file-association.test.mjs @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { isSupportedAssociatedFile, markdownTitleFromFileName } from "./file-association.mjs"; + +describe("desktop file associations", () => { + test("accepts Markdown extensions case-insensitively", () => { + expect(isSupportedAssociatedFile("/tmp/notes/today.md")).toBe(true); + expect(isSupportedAssociatedFile("C:\\Notes\\Today.MARKDOWN")).toBe(true); + expect(isSupportedAssociatedFile("/tmp/notes/today.txt")).toBe(false); + expect(isSupportedAssociatedFile("/tmp/notes/.md")).toBe(false); + }); + + test("derives a clean note title from the associated file name", () => { + expect(markdownTitleFromFileName(" Today.markdown ")).toBe("Today"); + expect(markdownTitleFromFileName("meeting.md")).toBe("meeting"); + expect(markdownTitleFromFileName("README")).toBe("README"); + }); +}); diff --git a/apps/desktop/src/main/file-permissions.mjs b/apps/desktop/src/main/file-permissions.mjs new file mode 100644 index 00000000..fa299675 --- /dev/null +++ b/apps/desktop/src/main/file-permissions.mjs @@ -0,0 +1,9 @@ +import { chmod } from "node:fs/promises"; + +export const restrictDirectory = async (path) => { + try { await chmod(path, 0o700); } catch { /* Best effort on platforms without POSIX modes. */ } +}; + +export const restrictFile = async (path) => { + try { await chmod(path, 0o600); } catch { /* Best effort on platforms without POSIX modes. */ } +}; diff --git a/apps/desktop/src/main/file-permissions.test.mjs b/apps/desktop/src/main/file-permissions.test.mjs new file mode 100644 index 00000000..394c1aff --- /dev/null +++ b/apps/desktop/src/main/file-permissions.test.mjs @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { restrictDirectory, restrictFile } from "./file-permissions.mjs"; + +let directory; + +afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("desktop private file permissions", () => { + test("restricts directories and files on POSIX hosts", async () => { + directory = await mkdtemp(join(tmpdir(), "edgeever-permissions-")); + const nested = join(directory, "private"); + const file = join(nested, "payload.bin"); + await mkdir(nested); + await writeFile(file, "secret"); + await restrictDirectory(nested); + await restrictFile(file); + + if (process.platform !== "win32") { + const directoryMode = (await stat(nested)).mode & 0o777; + const fileMode = (await stat(file)).mode & 0o777; + expect(directoryMode).toBe(0o700); + expect(fileMode).toBe(0o600); + } + expect(await readFile(file, "utf8")).toBe("secret"); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs new file mode 100644 index 00000000..7a2f5b62 --- /dev/null +++ b/apps/desktop/src/main/index.mjs @@ -0,0 +1,567 @@ +import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, session, net, protocol, shell } from "electron"; +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { SidecarRpcClient } from "./rpc.mjs"; +import { isSafeResourceId, resourceIdFromRequest } from "./resource-url.mjs"; +import { isSupportedAssociatedFile } from "./file-association.mjs"; +import { accountDataDirectory, accountScopeKey } from "./account-scope.mjs"; +import { rotateDiagnosticLog } from "./diagnostic-log.mjs"; +import { restrictDirectory, restrictFile } from "./file-permissions.mjs"; +import { normalizeStagedResourceInput } from "./staged-resource.mjs"; +import { autoUpdater } from "electron-updater"; + +const currentDirectory = fileURLToPath(new URL(".", import.meta.url)); +const projectRoot = join(currentDirectory, "../../.."); +const webUrl = process.env.EDGE_EVER_DESKTOP_WEB_URL || "http://127.0.0.1:5173"; +const apiBaseUrl = (process.env.EDGE_EVER_API_URL || (process.env.EDGE_EVER_DESKTOP_WEB_URL ? webUrl : "")).replace(/\/$/, ""); +let configuredApiBaseUrl = apiBaseUrl; +const packagedSidecarName = process.platform === "win32" ? "edgeever-sidecar.exe" : "edgeever-sidecar"; +const sidecarPath = process.env.EDGE_EVER_SIDECAR_PATH || (app.isPackaged + ? join(process.resourcesPath, "sidecar", packagedSidecarName) + : join(projectRoot, "crates/desktop-sidecar/target/debug", packagedSidecarName)); + +let mainWindow; +let sidecarProcess; +let sidecar; +let tray; +let isQuitting = false; +let updateState = "idle"; +let sidecarScopeKey = "anonymous"; +let activeAccountId = null; +let shutdownCleanupStarted = false; +let sidecarRestartTimer = null; +let sidecarRestartAttempts = 0; +let sidecarRestartInFlight = false; +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +const windowStatePath = () => join(app.getPath("userData"), "window-state.json"); +const instanceUrlPath = () => join(app.getPath("userData"), "instance-url"); +const crashMarkerPath = () => join(app.getPath("userData"), "last-session-active"); +const logPath = () => join(app.getPath("userData"), "logs", "desktop.log"); +const sidecarDataDirectory = (accountId = null) => { + return accountId + ? accountDataDirectory(app.getPath("userData"), configuredApiBaseUrl, accountId) + : app.getPath("userData"); +}; +const legacyDataDirectory = () => app.getPath("userData"); +const stagedResourceDirectory = () => join(sidecarDataDirectory(activeAccountId), "resource-outbox"); +const resourceCacheDirectory = () => join(sidecarDataDirectory(activeAccountId), "resource-cache"); + +const migrateLegacyAccountData = async (accountId) => { + if (!accountId) return; + const source = legacyDataDirectory(); + const destination = sidecarDataDirectory(accountId); + if (existsSync(join(destination, "edgeever.sqlite")) || !existsSync(join(source, "edgeever.sqlite"))) return; + await mkdir(destination, { recursive: true }); + await restrictDirectory(destination); + for (const name of ["edgeever.sqlite", "edgeever.sqlite-wal", "edgeever.sqlite-shm", "backups", "resource-outbox", "resource-cache"]) { + const sourcePath = join(source, name); + if (existsSync(sourcePath)) await rename(sourcePath, join(destination, name)); + } + void writeDiagnostic("sidecar.legacy-data-migrated", { scope: accountScopeKey(configuredApiBaseUrl, accountId) }); +}; + +protocol.registerSchemesAsPrivileged([{ + scheme: "edgeever-resource", + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true }, +}, { + scheme: "edgeever-staged", + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true }, +}]); + +const writeDiagnostic = async (event, details = {}) => { + try { + const path = logPath(); + await mkdir(join(app.getPath("userData"), "logs"), { recursive: true }); + await restrictDirectory(join(app.getPath("userData"), "logs")); + await rotateDiagnosticLog(path); + await appendFile(path, `${JSON.stringify({ at: new Date().toISOString(), event, ...details })}\n`); + await restrictFile(path); + } catch { + // Diagnostics must never prevent the desktop app from starting or quitting. + } +}; + +process.on("uncaughtException", (error) => { void writeDiagnostic("main.uncaught-exception", { message: error.message, stack: error.stack }); }); +process.on("unhandledRejection", (reason) => { void writeDiagnostic("main.unhandled-rejection", { message: reason instanceof Error ? reason.message : String(reason) }); }); + +const readWindowState = async () => { + try { + const state = JSON.parse(await readFile(windowStatePath(), "utf8")); + return { width: Number(state.width) || 1440, height: Number(state.height) || 960, x: Number.isFinite(state.x) ? state.x : undefined, y: Number.isFinite(state.y) ? state.y : undefined, isMaximized: Boolean(state.isMaximized) }; + } catch { + return { width: 1440, height: 960, x: undefined, y: undefined, isMaximized: false }; + } +}; + +const saveWindowState = async () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + const bounds = mainWindow.getBounds(); + await writeFile(windowStatePath(), JSON.stringify({ ...bounds, isMaximized: mainWindow.isMaximized() })); +}; + +const loadConfiguredApiBaseUrl = async () => { + try { + const stored = (await readFile(instanceUrlPath(), "utf8")).trim().replace(/\/$/, ""); + if (stored.startsWith("http://") || stored.startsWith("https://")) configuredApiBaseUrl = stored; + } catch { + // A first-run desktop app has no configured instance yet. + } +}; + +const pendingDesktopCommands = []; + +const sendDesktopCommand = (command) => { + if (!mainWindow || mainWindow.isDestroyed() || !rendererReady) { + pendingDesktopCommands.push(command); + return; + } + mainWindow.webContents.send("desktop:command", command); +}; + +const importMarkdownFile = async (filePath) => { + if (!isSupportedAssociatedFile(filePath)) return; + try { + const content = await readFile(filePath, "utf8"); + if (Buffer.byteLength(content, "utf8") > 8 * 1024 * 1024) { + void writeDiagnostic("file-import-rejected", { filePath, reason: "file-too-large" }); + return; + } + const payload = { name: basename(filePath), content }; + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoading() || !rendererReady) { + pendingMarkdownImport = payload; + return; + } + mainWindow.webContents.send("desktop:import-markdown", payload); + } catch (error) { + void writeDiagnostic("file-import-failed", { filePath, message: error.message }); + } +}; + +let pendingMarkdownImport = null; +let rendererReady = false; + +const flushPendingMarkdownImport = () => { + if (!pendingMarkdownImport || !rendererReady || !mainWindow || mainWindow.isDestroyed()) return; + const payload = pendingMarkdownImport; + pendingMarkdownImport = null; + mainWindow.webContents.send("desktop:import-markdown", payload); +}; + +const flushPendingDesktopCommands = () => { + if (!rendererReady || !mainWindow || mainWindow.isDestroyed()) return; + while (pendingDesktopCommands.length > 0) { + mainWindow.webContents.send("desktop:command", pendingDesktopCommands.shift()); + } +}; + +const handleOpenTarget = (commandLine) => { + const target = commandLine.find((value) => value.startsWith("edgeever://")); + if (target) { + try { + const url = new URL(target); + const memoMatch = url.pathname.match(/^\/memo\/([^/]+)$/); + if (memoMatch) sendDesktopCommand(`open-memo:${decodeURIComponent(memoMatch[1])}`); + } catch { + // Ignore malformed protocol invocations. + } + } + const associatedFile = commandLine.find((value) => !value.startsWith("-") && isSupportedAssociatedFile(value)); + if (associatedFile) void importMarkdownFile(associatedFile); +}; + +const buildApplicationMenu = () => { + const template = [ + ...(process.platform === "darwin" ? [{ label: app.name, submenu: [{ role: "about" }, { type: "separator" }, { role: "hide" }, { role: "quit" }] }] : []), + { + label: "File", + submenu: [ + { label: "New Note", accelerator: "CmdOrCtrl+N", click: () => sendDesktopCommand("new-memo") }, + { label: "New Notebook", accelerator: "CmdOrCtrl+Shift+N", click: () => sendDesktopCommand("new-notebook") }, + { type: "separator" }, + { role: "close" }, + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, { role: "redo" }, { type: "separator" }, + { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { label: "Focus Search", accelerator: "CmdOrCtrl+K", click: () => sendDesktopCommand("focus-search") }, + { label: "Toggle Focus Mode", accelerator: "CmdOrCtrl+Shift+F", click: () => sendDesktopCommand("toggle-focus-mode") }, + { type: "separator" }, + { role: "togglefullscreen" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, + ], + }, + { + label: "Window", + submenu: [{ role: "minimize" }, { role: "zoom" }, ...(process.platform === "darwin" ? [{ role: "front" }] : [])], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +}; + +const createTray = () => { + const iconPath = app.isPackaged + ? join(process.resourcesPath, "web", "pwa-192x192.png") + : join(projectRoot, "apps/web/public/pwa-192x192.png"); + const icon = existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty(); + tray = new Tray(icon); + tray.setToolTip("EdgeEver"); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: "Show EdgeEver", click: () => { mainWindow?.show(); mainWindow?.focus(); } }, + { label: "Sync now", click: () => sendDesktopCommand("sync-now") }, + { label: "Backup now", click: () => sendDesktopCommand("backup-now") }, + ...(updateState === "available" ? [{ label: "Download update", click: () => void autoUpdater.downloadUpdate() }] : []), + ...(updateState === "downloaded" ? [{ label: "Restart to update", click: () => autoUpdater.quitAndInstall() }] : []), + { type: "separator" }, + { label: "Quit EdgeEver", click: () => { isQuitting = true; app.quit(); } }, + ])); + tray.on("double-click", () => { mainWindow?.show(); mainWindow?.focus(); }); +}; + +const registerResourceProtocol = () => { + protocol.handle("edgeever-resource", async (request) => { + const resourceId = resourceIdFromRequest(request.url); + if (!resourceId) return new Response("Invalid resource", { status: 400 }); + + const directory = resourceCacheDirectory(); + const bytesPath = join(directory, `${resourceId}.bin`); + const metadataPath = join(directory, `${resourceId}.json`); + + try { + const bytes = await readFile(bytesPath); + let metadata = {}; + try { metadata = JSON.parse(await readFile(metadataPath, "utf8")); } catch {} + return new Response(bytes, { headers: { "Content-Type": metadata.contentType || "application/octet-stream", "Cache-Control": "no-store" } }); + } catch { + // Fall through to the instance while online, then persist the response. + } + + if (!configuredApiBaseUrl) return new Response("Resource is not cached", { status: 504 }); + const sourceUrl = `${configuredApiBaseUrl}/api/v1/resources/${encodeURIComponent(resourceId)}/blob`; + try { + const cookies = await session.defaultSession.cookies.get({ url: sourceUrl }); + const cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); + const response = await net.fetch(sourceUrl, cookieHeader ? { headers: { Cookie: cookieHeader } } : undefined); + if (!response.ok) return new Response("Resource request failed", { status: response.status }); + const body = Buffer.from(await response.arrayBuffer()); + await mkdir(directory, { recursive: true }); + await restrictDirectory(directory); + await writeFile(bytesPath, body, { mode: 0o600 }); + await writeFile(metadataPath, JSON.stringify({ contentType: response.headers.get("content-type") || "application/octet-stream" }), { mode: 0o600 }); + await restrictFile(bytesPath); + await restrictFile(metadataPath); + return new Response(body, { headers: { "Content-Type": response.headers.get("content-type") || "application/octet-stream", "Cache-Control": "no-store" } }); + } catch (error) { + void writeDiagnostic("resource.cache-failed", { resourceId, message: error.message }); + return new Response("Resource unavailable", { status: 504 }); + } + }); + + protocol.handle("edgeever-staged", async (request) => { + const stagedId = resourceIdFromRequest(request.url); + if (!stagedId) return new Response("Invalid staged resource", { status: 400 }); + + const directory = stagedResourceDirectory(); + try { + const metadata = JSON.parse(await readFile(join(directory, `${stagedId}.json`), "utf8")); + const bytes = await readFile(join(directory, `${stagedId}.bin`)); + return new Response(bytes, { + headers: { + "Content-Type": metadata.type || "application/octet-stream", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + void writeDiagnostic("resource.staged-read-failed", { stagedId, message: error.message }); + return new Response("Staged resource unavailable", { status: 404 }); + } + }); +}; + +const refreshTrayMenu = () => { + if (!tray) return; + tray.destroy(); + createTray(); +}; + +const configureAutoUpdater = () => { + if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1") return; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.on("update-available", () => { updateState = "available"; refreshTrayMenu(); void writeDiagnostic("update.available"); }); + autoUpdater.on("download-progress", (progress) => { void writeDiagnostic("update.download-progress", { percent: progress.percent }); }); + autoUpdater.on("update-downloaded", () => { updateState = "downloaded"; refreshTrayMenu(); void writeDiagnostic("update.downloaded"); }); + autoUpdater.on("error", (error) => { void writeDiagnostic("update.error", { message: error.message }); }); + void autoUpdater.checkForUpdates().catch((error) => writeDiagnostic("update.check-failed", { message: error.message })); +}; + +const startSidecar = async (accountId = null) => { + if (!existsSync(sidecarPath)) { + console.warn(`[desktop] sidecar not found: ${sidecarPath}`); + void writeDiagnostic("sidecar.missing", { sidecarPath }); + return null; + } + + await migrateLegacyAccountData(accountId); + const migrationsPath = app.isPackaged ? join(process.resourcesPath, "migrations") : join(projectRoot, "migrations"); + sidecarScopeKey = accountScopeKey(configuredApiBaseUrl, accountId); + activeAccountId = accountId; + sidecarProcess = spawn(sidecarPath, ["--data-dir", sidecarDataDirectory(accountId), "--migrations-dir", migrationsPath], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + sidecarProcess.stderr.on("data", (chunk) => { + const message = chunk.toString().trimEnd(); + console.error(`[sidecar] ${message}`); + void writeDiagnostic("sidecar.stderr", { message }); + }); + const processForExitHandler = sidecarProcess; + sidecarProcess.on("exit", (code, signal) => { + void writeDiagnostic("sidecar.exit", { code, signal }); + if (sidecarProcess !== processForExitHandler || isQuitting) return; + sidecarProcess = null; + sidecar = null; + scheduleSidecarRestart(); + }); + sidecar = new SidecarRpcClient(sidecarProcess); + return sidecar; +}; + +const stopSidecar = async () => { + const processToStop = sidecarProcess; + sidecar = null; + sidecarProcess = null; + if (!processToStop) return; + const exited = new Promise((resolve) => processToStop.once("exit", resolve)); + processToStop.stdin.end(); + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1000))]); + if (!processToStop.killed) processToStop.kill(); +}; + +const scheduleSidecarRestart = () => { + if (isQuitting || shutdownCleanupStarted || sidecarRestartTimer || sidecarRestartInFlight) return; + sidecarRestartAttempts += 1; + const delayMs = Math.min(30_000, 500 * 2 ** Math.min(sidecarRestartAttempts - 1, 6)); + sidecarRestartTimer = setTimeout(async () => { + sidecarRestartTimer = null; + if (isQuitting || sidecarProcess || sidecarRestartInFlight) return; + sidecarRestartInFlight = true; + try { + const restarted = await startSidecar(activeAccountId); + if (!restarted) throw new Error("EdgeEver sidecar is unavailable"); + await restarted.waitUntilReady(); + void writeDiagnostic("sidecar.restarted", { attempt: sidecarRestartAttempts, delayMs }); + } catch (error) { + void writeDiagnostic("sidecar.restart-failed", { attempt: sidecarRestartAttempts, message: error.message }); + await stopSidecar(); + sidecarRestartInFlight = false; + scheduleSidecarRestart(); + } finally { + sidecarRestartInFlight = false; + } + }, delayMs); +}; + +const createWindow = async () => { + const state = await readWindowState(); + mainWindow = new BrowserWindow({ + width: state.width, + height: state.height, + x: state.x, + y: state.y, + minWidth: 960, + minHeight: 640, + webPreferences: { + preload: join(currentDirectory, "../preload/index.mjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + rendererReady = false; + if (state.isMaximized) mainWindow.maximize(); + mainWindow.on("resize", () => void saveWindowState()); + mainWindow.on("move", () => void saveWindowState()); + mainWindow.on("close", (event) => { + void saveWindowState(); + if (!isQuitting) { + event.preventDefault(); + mainWindow.hide(); + } + }); + + if (app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL) { + await mainWindow.loadFile(join(process.resourcesPath, "web/index.html")); + } else { + await mainWindow.loadURL(webUrl); + } + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return { action: "allow" }; + if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url); + return { action: "deny" }; + }); + mainWindow.webContents.on("render-process-gone", (_event, details) => { + void writeDiagnostic("renderer.gone", details); + }); + mainWindow.webContents.on("unresponsive", () => { void writeDiagnostic("renderer.unresponsive"); }); + mainWindow.webContents.on("responsive", () => { void writeDiagnostic("renderer.responsive"); }); + mainWindow.webContents.on("will-navigate", (event, url) => { + if (url.startsWith(webUrl) || url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return; + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url); + }); + buildApplicationMenu(); +}; + +app.whenReady().then(async () => { + if (!hasSingleInstanceLock) { + app.quit(); + return; + } + await loadConfiguredApiBaseUrl(); + app.setAsDefaultProtocolClient("edgeever"); + const previousSessionWasActive = existsSync(crashMarkerPath()); + void writeDiagnostic(previousSessionWasActive ? "session.recovered-after-abnormal-exit" : "session.started"); + await writeFile(crashMarkerPath(), new Date().toISOString()); + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + registerResourceProtocol(); + await startSidecar(); + createTray(); + + ipcMain.handle("desktop:sidecar-request", async (_event, method, params) => { + if (!sidecar) throw new Error("EdgeEver sidecar is unavailable"); + return sidecar.request(method, params); + }); + ipcMain.handle("desktop:sidecar-status", () => ({ available: Boolean(sidecar), path: sidecarPath, scope: sidecarScopeKey })); + ipcMain.handle("desktop:set-account-scope", async (_event, accountId) => { + const normalizedAccountId = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null; + const nextScopeKey = accountScopeKey(configuredApiBaseUrl, normalizedAccountId); + if (sidecar && sidecarScopeKey === nextScopeKey) { + await sidecar.waitUntilReady(); + return { ready: true, scope: nextScopeKey }; + } + await stopSidecar(); + const nextSidecar = await startSidecar(normalizedAccountId); + if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable"); + await nextSidecar.waitUntilReady(); + return { ready: true, scope: nextScopeKey }; + }); + ipcMain.on("desktop:renderer-ready", (event) => { + if (event.sender !== mainWindow?.webContents) return; + rendererReady = true; + flushPendingDesktopCommands(); + flushPendingMarkdownImport(); + }); + ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; }); + ipcMain.handle("desktop:set-api-base-url", async (_event, value) => { + const normalized = typeof value === "string" ? value.trim().replace(/\/$/, "") : ""; + if (normalized) { + let parsed; + try { parsed = new URL(normalized); } catch { throw new Error("Desktop API URL must be a valid HTTP(S) URL"); } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Desktop API URL must use HTTP(S)"); + } + if (normalized === configuredApiBaseUrl) return configuredApiBaseUrl; + configuredApiBaseUrl = normalized; + void writeFile(instanceUrlPath(), configuredApiBaseUrl); + if (sidecar) { + await stopSidecar(); + const nextSidecar = await startSidecar(activeAccountId); + if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable after changing instance"); + await nextSidecar.waitUntilReady(); + } + return configuredApiBaseUrl; + }); + ipcMain.handle("desktop:update-status", () => ({ state: updateState })); + ipcMain.handle("desktop:download-update", () => autoUpdater.downloadUpdate()); + ipcMain.handle("desktop:install-update", () => autoUpdater.quitAndInstall()); + ipcMain.handle("desktop:stage-resource", async (_event, input) => { + const { memoId, name, type, bytes } = normalizeStagedResourceInput(input); + const id = `stage_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const directory = stagedResourceDirectory(); + await mkdir(directory, { recursive: true }); + await restrictDirectory(directory); + const metadata = { id, memoId, name, type, size: bytes.byteLength }; + const metadataPath = join(directory, `${id}.json`); + const bytesPath = join(directory, `${id}.bin`); + await writeFile(metadataPath, JSON.stringify(metadata), { mode: 0o600 }); + await writeFile(bytesPath, Buffer.from(bytes), { mode: 0o600 }); + await restrictFile(metadataPath); + await restrictFile(bytesPath); + return { id }; + }); + ipcMain.handle("desktop:list-staged-resources", async () => { + const directory = stagedResourceDirectory(); + try { await mkdir(directory, { recursive: true }); await restrictDirectory(directory); } catch {} + const names = await readdir(directory); + const result = []; + for (const name of names.filter((value) => value.endsWith(".json"))) { + try { result.push(JSON.parse(await readFile(join(directory, name), "utf8"))); } catch {} + } + return result; + }); + ipcMain.handle("desktop:read-staged-resource", async (_event, id) => { + if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); + const directory = stagedResourceDirectory(); + const metadata = JSON.parse(await readFile(join(directory, `${id}.json`), "utf8")); + const bytes = await readFile(join(directory, `${id}.bin`)); + return { ...metadata, bytes: new Uint8Array(bytes) }; + }); + ipcMain.handle("desktop:remove-staged-resource", async (_event, id) => { + if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); + const directory = stagedResourceDirectory(); + await Promise.all([ + unlink(join(directory, `${id}.json`)).catch(() => {}), + unlink(join(directory, `${id}.bin`)).catch(() => {}), + ]); + }); + + await createWindow(); + configureAutoUpdater(); + handleOpenTarget(process.argv); + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) void createWindow(); + }); +}); + +app.on("open-file", (event, filePath) => { + event.preventDefault(); + void importMarkdownFile(filePath); +}); + +app.on("second-instance", (_event, commandLine) => { + if (mainWindow?.isMinimized()) mainWindow.restore(); + mainWindow?.show(); + mainWindow?.focus(); + handleOpenTarget(commandLine); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("before-quit", (event) => { + if (shutdownCleanupStarted) return; + event.preventDefault(); + shutdownCleanupStarted = true; + isQuitting = true; + if (sidecarRestartTimer) { + clearTimeout(sidecarRestartTimer); + sidecarRestartTimer = null; + } + tray?.destroy(); + void (async () => { + await stopSidecar(); + await unlink(crashMarkerPath()).catch(() => {}); + await writeDiagnostic("session.quit"); + app.quit(); + })(); +}); diff --git a/apps/desktop/src/main/resource-url.mjs b/apps/desktop/src/main/resource-url.mjs new file mode 100644 index 00000000..1949b059 --- /dev/null +++ b/apps/desktop/src/main/resource-url.mjs @@ -0,0 +1,13 @@ +export const isSafeResourceId = (value) => + typeof value === "string" && value.length > 0 && value.length <= 160 && /^[A-Za-z0-9._~-]+$/.test(value) && value !== "." && value !== ".."; + +export const resourceIdFromRequest = (requestUrl) => { + try { + const url = new URL(requestUrl); + const pathId = url.pathname.replace(/^\//, ""); + const id = decodeURIComponent(pathId || url.hostname); + return isSafeResourceId(id) ? id : null; + } catch { + return null; + } +}; diff --git a/apps/desktop/src/main/resource-url.test.mjs b/apps/desktop/src/main/resource-url.test.mjs new file mode 100644 index 00000000..df151a8b --- /dev/null +++ b/apps/desktop/src/main/resource-url.test.mjs @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { isSafeResourceId, resourceIdFromRequest } from "./resource-url.mjs"; + +test("desktop resource URL parsing accepts safe resource and staged IDs", () => { + expect(resourceIdFromRequest("edgeever-resource://resource/resource_123")).toBe("resource_123"); + expect(resourceIdFromRequest("edgeever-staged://stage_123")).toBe("stage_123"); + expect(resourceIdFromRequest("edgeever-staged://stage%5F123")).toBe("stage_123"); + expect(resourceIdFromRequest("edgeever-staged://bad/id")).toBe("id"); + expect(resourceIdFromRequest("edgeever-staged://../../etc/passwd")).toBeNull(); + expect(resourceIdFromRequest("edgeever-staged://stage_%ZZ")).toBeNull(); +}); + +test("desktop staged resource IPC IDs reject path traversal", () => { + expect(isSafeResourceId("stage_123")).toBe(true); + expect(isSafeResourceId("../edgeever.sqlite")).toBe(false); + expect(isSafeResourceId("stage/123")).toBe(false); + expect(isSafeResourceId("..")).toBe(false); +}); diff --git a/apps/desktop/src/main/rpc.mjs b/apps/desktop/src/main/rpc.mjs new file mode 100644 index 00000000..a6db4ae2 --- /dev/null +++ b/apps/desktop/src/main/rpc.mjs @@ -0,0 +1,78 @@ +import { createInterface } from "node:readline"; + +export class SidecarRpcClient { + #child; + #nextId = 1; + #pending = new Map(); + #ready = null; + #readyMessage = null; + + constructor(child) { + this.#child = child; + const input = createInterface({ input: child.stdout }); + input.on("line", (line) => this.#handleLine(line)); + child.on("exit", (code, signal) => { + const error = new Error(`EdgeEver sidecar exited (${code ?? signal ?? "unknown"})`); + for (const { reject } of this.#pending.values()) reject(error); + this.#pending.clear(); + this.#ready?.reject(error); + this.#ready = null; + }); + } + + #handleLine(line) { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + + if (message.event === "ready") { + this.#readyMessage = message; + this.#ready?.resolve(message); + this.#ready = null; + return; + } + + const pending = this.#pending.get(message.id); + if (!pending) return; + this.#pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message || "Sidecar request failed")); + else pending.resolve(message.result); + } + + waitUntilReady(timeoutMs = 5000) { + if (this.#readyMessage) return Promise.resolve(this.#readyMessage); + if (!this.#ready) { + this.#ready = {}; + this.#ready.promise = new Promise((resolve, reject) => { + this.#ready.resolve = resolve; + this.#ready.reject = reject; + }); + } + return Promise.race([ + this.#ready.promise, + new Promise((_, reject) => setTimeout(() => reject(new Error("Sidecar ready timeout")), timeoutMs)), + ]); + } + + request(method, params = {}, timeoutMs = 10000) { + const id = this.#nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`Sidecar request timed out: ${method}`)); + }, timeoutMs); + this.#pending.set(id, { + resolve: (value) => { clearTimeout(timer); resolve(value); }, + reject: (error) => { clearTimeout(timer); reject(error); }, + }); + this.#child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + + close() { + this.#child.stdin.end(); + } +} diff --git a/apps/desktop/src/main/staged-resource.mjs b/apps/desktop/src/main/staged-resource.mjs new file mode 100644 index 00000000..d2b6628b --- /dev/null +++ b/apps/desktop/src/main/staged-resource.mjs @@ -0,0 +1,21 @@ +export const MAX_STAGED_RESOURCE_BYTES = 256 * 1024 * 1024; +const MAX_STAGED_RESOURCE_NAME_LENGTH = 512; +const MAX_STAGED_RESOURCE_TYPE_LENGTH = 256; +const MAX_STAGED_RESOURCE_MEMO_ID_LENGTH = 160; + +export const normalizeStagedResourceInput = (input) => { + if (!input || typeof input !== "object") throw new Error("Invalid staged resource input"); + const memoId = typeof input.memoId === "string" ? input.memoId.trim() : ""; + const name = typeof input.name === "string" ? input.name.trim() : ""; + const type = typeof input.type === "string" && input.type.trim() ? input.type.trim() : "application/octet-stream"; + const bytes = input.bytes instanceof Uint8Array + ? input.bytes + : input.bytes instanceof ArrayBuffer + ? new Uint8Array(input.bytes) + : null; + if (!memoId || memoId.length > MAX_STAGED_RESOURCE_MEMO_ID_LENGTH) throw new Error("Invalid staged resource memo id"); + if (!name || name.length > MAX_STAGED_RESOURCE_NAME_LENGTH || /[\u0000-\u001f\u007f]/.test(name)) throw new Error("Invalid staged resource name"); + if (type.length > MAX_STAGED_RESOURCE_TYPE_LENGTH || /[\u0000-\u001f\u007f]/.test(type)) throw new Error("Invalid staged resource type"); + if (!bytes || bytes.byteLength > MAX_STAGED_RESOURCE_BYTES) throw new Error("Staged resource exceeds the 256 MiB limit"); + return { memoId, name, type, bytes }; +}; diff --git a/apps/desktop/src/main/staged-resource.test.mjs b/apps/desktop/src/main/staged-resource.test.mjs new file mode 100644 index 00000000..fccb3347 --- /dev/null +++ b/apps/desktop/src/main/staged-resource.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { MAX_STAGED_RESOURCE_BYTES, normalizeStagedResourceInput } from "./staged-resource.mjs"; + +describe("desktop staged resource input", () => { + test("normalizes valid structured-clone payloads", () => { + const input = normalizeStagedResourceInput({ memoId: "memo-1", name: " photo.png ", type: " image/png ", bytes: new ArrayBuffer(3) }); + expect(input).toMatchObject({ memoId: "memo-1", name: "photo.png", type: "image/png" }); + expect(input.bytes).toBeInstanceOf(Uint8Array); + }); + + test("rejects control characters and invalid or oversized payloads", () => { + expect(() => normalizeStagedResourceInput({ memoId: "memo-1", name: "bad\u0000name", bytes: new Uint8Array() })).toThrow("Invalid staged resource name"); + expect(() => normalizeStagedResourceInput({ memoId: "memo-1", name: "large.bin", bytes: { byteLength: MAX_STAGED_RESOURCE_BYTES + 1 } })).toThrow("Staged resource exceeds"); + }); +}); diff --git a/apps/desktop/src/preload/index.mjs b/apps/desktop/src/preload/index.mjs new file mode 100644 index 00000000..fb431269 --- /dev/null +++ b/apps/desktop/src/preload/index.mjs @@ -0,0 +1,28 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ + isAvailable: true, + sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"), + setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId), + apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"), + setApiBaseUrl: (value) => ipcRenderer.invoke("desktop:set-api-base-url", value), + updateStatus: () => ipcRenderer.invoke("desktop:update-status"), + downloadUpdate: () => ipcRenderer.invoke("desktop:download-update"), + installUpdate: () => ipcRenderer.invoke("desktop:install-update"), + sidecarRequest: (method, params = {}) => ipcRenderer.invoke("desktop:sidecar-request", method, params), + stageResource: (input) => ipcRenderer.invoke("desktop:stage-resource", input), + listStagedResources: () => ipcRenderer.invoke("desktop:list-staged-resources"), + readStagedResource: (id) => ipcRenderer.invoke("desktop:read-staged-resource", id), + removeStagedResource: (id) => ipcRenderer.invoke("desktop:remove-staged-resource", id), + onCommand: (callback) => { + const listener = (_event, command) => callback(command); + ipcRenderer.on("desktop:command", listener); + return () => ipcRenderer.removeListener("desktop:command", listener); + }, + onImportMarkdown: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on("desktop:import-markdown", listener); + ipcRenderer.send("desktop:renderer-ready"); + return () => ipcRenderer.removeListener("desktop:import-markdown", listener); + }, +})); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index d9fc7201..ab5cddc1 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect } from "react"; +import { lazy, Suspense, useEffect, useState, type FormEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Navigate, Route, Routes, useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; @@ -6,7 +6,18 @@ import { PwaUpdateNotice } from "@/components/PwaUpdateNotice"; import { ReleaseUpdateNotice } from "@/components/ReleaseUpdateNotice"; import { PwaInstallProvider } from "@/components/PwaInstallContext"; import { PwaIosPrompt } from "@/components/PwaIosPrompt"; -import { api, ApiRequestError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + api, + ApiRequestError, + cacheDesktopSession, + clearCachedDesktopSession, + getConfiguredDesktopApiBaseUrl, + getCachedDesktopSession, + isDesktopInstanceConfigurationRequired, + saveDesktopApiBaseUrl, +} from "@/lib/api"; import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes"; import type { AuthSession } from "@edgeever/shared"; @@ -22,6 +33,47 @@ const AuthLoadingScreen = () => ( ); +const DesktopInstanceSetup = () => { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + + const save = (event: FormEvent) => { + event.preventDefault(); + try { + saveDesktopApiBaseUrl(value); + window.location.reload(); + } catch { + setError(t("login.desktopInstanceUrlInvalid")); + } + }; + + return ( +
+
+

{t("login.desktopInstanceTitle")}

+

{t("login.desktopInstanceDescription")}

+
+ + {error &&

{error}

} + +
+
+
+ ); +}; + const EvernoteMigrationRoute = () => { const navigate = useNavigate(); @@ -44,16 +96,52 @@ const EvernoteMigrationRoute = () => { const AuthenticatedWorkspace = () => { const queryClient = useQueryClient(); const { t } = useTranslation(); + const desktopBridge = window.edgeeverDesktop; + const [desktopScopeReady, setDesktopScopeReady] = useState(() => !desktopBridge?.isAvailable); + const [desktopScopeError, setDesktopScopeError] = useState(null); + const [desktopScopeAttempt, setDesktopScopeAttempt] = useState(0); const sessionQuery = useQuery({ queryKey: ["auth", "session"], - queryFn: () => api.getSession(), + queryFn: async () => { + try { + const session = await api.getSession(); + cacheDesktopSession(session); + return session; + } catch (error) { + const cached = getCachedDesktopSession(); + if (cached?.authenticated && typeof navigator !== "undefined" && !navigator.onLine) return cached; + throw error; + } + }, retry: false, }); + const desktopAccountId = sessionQuery.data?.authenticated ? sessionQuery.data.user?.id ?? null : null; + + useEffect(() => { + if (!desktopBridge?.isAvailable || sessionQuery.isLoading) return; + let active = true; + setDesktopScopeReady(false); + setDesktopScopeError(null); + void desktopBridge.setAccountScope(desktopAccountId).then( + () => { + if (active) setDesktopScopeReady(true); + }, + (error) => { + console.error("Failed to switch desktop account scope", error); + if (active) setDesktopScopeError(error instanceof Error ? error : new Error(String(error))); + }, + ); + return () => { + active = false; + }; + }, [desktopAccountId, desktopBridge, desktopScopeAttempt, sessionQuery.isLoading]); + const loginMutation = useMutation({ mutationFn: api.login, onSuccess: (session) => { + cacheDesktopSession(session); queryClient.clear(); queryClient.setQueryData(["auth", "session"], session); }, @@ -62,6 +150,7 @@ const AuthenticatedWorkspace = () => { const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: () => { + clearCachedDesktopSession(); queryClient.clear(); queryClient.setQueryData(["auth", "session"], { authRequired: true, @@ -75,6 +164,7 @@ const AuthenticatedWorkspace = () => { useEffect(() => { const handleUnauthorized = () => { const current = queryClient.getQueryData(["auth", "session"]); + clearCachedDesktopSession(); queryClient.clear(); queryClient.setQueryData(["auth", "session"], { authRequired: current?.authRequired ?? true, @@ -110,6 +200,22 @@ const AuthenticatedWorkspace = () => { ? loginMutation.error.message : null; + if (desktopBridge?.isAvailable && !desktopScopeReady) { + if (desktopScopeError) { + return ( +
+
+

{t("login.desktopScopeUnavailable")}

+ +
+
+ ); + } + return ; + } + if (!session?.authenticated) { return ( }> @@ -139,16 +245,26 @@ const AuthenticatedWorkspace = () => { ); }; -export const App = () => ( - - - } /> - } /> - } /> - } /> - } /> - - - - -); +export const App = () => { + useEffect(() => { + const bridge = window.edgeeverDesktop; + const baseUrl = getConfiguredDesktopApiBaseUrl(); + if (bridge?.isAvailable && baseUrl) void bridge.setApiBaseUrl(baseUrl); + }, []); + + if (isDesktopInstanceConfigurationRequired()) return ; + + return ( + + + } /> + } /> + } /> + } /> + } /> + + + + + ); +}; diff --git a/apps/web/src/components/AssetsPane.tsx b/apps/web/src/components/AssetsPane.tsx index 9b4f7b45..188e0378 100644 --- a/apps/web/src/components/AssetsPane.tsx +++ b/apps/web/src/components/AssetsPane.tsx @@ -31,11 +31,12 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { api } from "@/lib/api"; import { formatDateTime } from "@/lib/utils"; import { compressImageForUpload } from "@/lib/image-compression"; +import { stageDesktopResource } from "@/lib/desktop-resources"; import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui"; import type { MemoDetail } from "@edgeever/shared"; +import type { EdgeEverRepository } from "@/lib/repository"; export const formatBytes = (bytes: number) => { if (!Number.isFinite(bytes) || bytes <= 0) { @@ -116,9 +117,10 @@ const getFileIcon = (mimeType: string | null, filename: string | null) => { interface AssetsPaneProps { onClose: () => void; activeMemo?: MemoDetail | null; + repository: EdgeEverRepository; } -export const AssetsPane = ({ onClose, activeMemo }: AssetsPaneProps) => { +export const AssetsPane = ({ onClose, activeMemo, repository }: AssetsPaneProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); @@ -136,7 +138,7 @@ export const AssetsPane = ({ onClose, activeMemo }: AssetsPaneProps) => { // Query resources const resourcesQuery = useQuery({ queryKey: ["resources"], - queryFn: () => api.listResources(), + queryFn: () => repository.listResources(), }); const resources = resourcesQuery.data?.resources ?? []; @@ -174,7 +176,17 @@ export const AssetsPane = ({ onClose, activeMemo }: AssetsPaneProps) => { setUploadState("uploading"); setUploadProgress(t("assets.uploadingFile", { filename: uploadFile.name })); - await api.uploadMemoResource(targetMemoId, uploadFile); + if (typeof navigator !== "undefined" && !navigator.onLine) { + await repository.uploadMemoResource(targetMemoId, uploadFile); + continue; + } + + try { + await repository.uploadMemoResource(targetMemoId, uploadFile); + } catch (error) { + const staged = await stageDesktopResource(targetMemoId, uploadFile); + if (!staged) throw error; + } } void queryClient.invalidateQueries({ queryKey: ["resources"] }); @@ -185,7 +197,7 @@ export const AssetsPane = ({ onClose, activeMemo }: AssetsPaneProps) => { setTimeout(() => setUploadState("idle"), 3000); } }, - [activeMemo, queryClient, t] + [activeMemo, queryClient, repository, t] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 69017d57..95749e83 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -68,6 +68,7 @@ import { ThemeToggle } from "./ThemeToggle"; import { useTheme } from "./ThemeProvider"; import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog"; import { api } from "@/lib/api"; +import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { consumeStandaloneMobileEditorReturn, openStandaloneMobileEditor } from "@/lib/mobile-editor"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui"; @@ -94,6 +95,8 @@ import { codeBlockLowlight, EdgeEverCodeBlock } from "@/lib/code-block"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; +import { isLocalMemoId } from "@/lib/local-mirror"; +import type { EdgeEverRepository } from "@/lib/repository"; import { getEditableMemoTitle, getNotebookMoveOptions, @@ -111,6 +114,19 @@ const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; const MOBILE_DRAFT_PERSIST_DELAY_MS = 800; const NOTE_SEARCH_HIGHLIGHT_PLUGIN_KEY = new PluginKey("edgeever-note-search-highlight"); +const createLocalEditSession = (memo: MemoDetail): MemoEditSession => ({ + id: `local-edit:${memo.id}`, + memoId: memo.id, + baseRevision: memo.revision, + baseContentHash: memo.contentHash, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), +}); + +const requiresLocalEditSession = (memo: MemoDetail) => + isDesktopResourceRuntime() || + isLocalMemoId(memo.id) || + (typeof navigator !== "undefined" && !navigator.onLine); + const IconTooltip = ({ label, children }: { label: string; children: ReactNode }) => ( @@ -497,6 +513,7 @@ const MobileNotebookSelectSheet = ({ type EditorPaneProps = { memo: MemoDetail | null; + repository: EdgeEverRepository; desktopFocusMode: boolean; onToggleDesktopFocusMode: () => void; mobileDefaultEditMemoId: string | null; @@ -531,6 +548,7 @@ type RichEditorPaneProps = EditorPaneProps & { const MobileNativeEditorPane = ({ memo, + repository, notebooks, isTrashView, autoSaveIntervalMs, @@ -636,28 +654,16 @@ const MobileNativeEditorPane = ({ }; try { - const data = await api.updateMemo(currentMemo.id, { - expectedRevision: payload.expectedRevision, - expectedContentHash: payload.expectedContentHash, - editSessionId: payload.editSessionId, - title: payload.title, - contentJson: payload.contentJson, - tags: payload.tags, - }); + const { memo: localMemo } = await repository.updateMemo(currentMemo, payload); - memoRef.current = data.memo; - editSessionRef.current = { - ...editSession, - baseRevision: data.memo.revision, - baseContentHash: data.memo.contentHash, - }; - await onSaved(data.memo); + memoRef.current = localMemo; + await onSaved(localMemo); if (currentSnapshot() === snapshot) { hasUnsavedChangesRef.current = false; setHasUnsavedChanges(false); - await localDb.drafts.delete(data.memo.id); - setSaveState("saved"); + await localDb.drafts.delete(localMemo.id); + setSaveState("queued"); window.setTimeout(() => setSaveState("idle"), 1200); } else { persistDraft(); @@ -695,7 +701,7 @@ const MobileNativeEditorPane = ({ } finally { savingRef.current = false; } - }, [clearTimers, currentSnapshot, getBodyValue, getTagsValue, getTitleValue, onSaved, persistDraft]); + }, [clearTimers, currentSnapshot, getBodyValue, getTagsValue, getTitleValue, onSaved, persistDraft, repository]); const markDirty = useCallback(() => { const currentMemo = memoRef.current; @@ -774,7 +780,7 @@ const MobileNativeEditorPane = ({ : await Promise.all([ localDb.drafts.get(memo.id), localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)), - api.createMemoEditSession(memo.id), + requiresLocalEditSession(memo) ? Promise.resolve(null) : api.createMemoEditSession(memo.id), ]); if (cancelled) { @@ -798,7 +804,7 @@ const MobileNativeEditorPane = ({ const nextContent = useDraft && draft ? draft.contentJson : resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown); - editSessionRef.current = editSessionResponse?.editSession ?? null; + editSessionRef.current = editSessionResponse?.editSession ?? (requiresLocalEditSession(memo) ? createLocalEditSession(memo) : null); hydratingRef.current = true; editingMemoIdRef.current = memo.id; @@ -1127,6 +1133,7 @@ export const EditorPane = (props: EditorPaneProps) => { const RichEditorPane = ({ memo, + repository, desktopFocusMode, onToggleDesktopFocusMode, mobileDefaultEditMemoId, @@ -1194,7 +1201,7 @@ const RichEditorPane = ({ const [wechatCopyState, setWechatCopyState] = useState<"idle" | "copying" | "copied" | "error">("idle"); const noteLinkResultsQuery = useQuery({ queryKey: ["memo-link-search", noteLinkQuery], - queryFn: () => api.listMemos({ q: noteLinkQuery, limit: 20 }), + queryFn: () => repository.listMemos({ q: noteLinkQuery, limit: 20 }), enabled: noteLinkPickerOpen, }); const [editorScrollContainer, setEditorScrollContainer] = useState(null); @@ -1331,7 +1338,20 @@ const RichEditorPane = ({ const uploadFile = shouldCompress ? (await compressImageForUpload(file)).file : file; setImageUploadState("uploading"); - const { resource } = await api.uploadMemoResource(targetMemoId, uploadFile); + let resource: { kind: "image" | "attachment"; filename: string | null; url: string }; + try { + const uploadedResource = (await repository.uploadMemoResource(targetMemoId, uploadFile)).resource; + resource = { ...uploadedResource, url: toDesktopResourceUrl(uploadedResource.url) }; + } catch (error) { + if (!isDesktopResourceRuntime()) throw error; + const staged = await stageDesktopResource(targetMemoId, uploadFile); + if (!staged) throw error; + resource = { + kind: isImage ? "image" : "attachment", + filename: uploadFile.name, + url: `edgeever-staged://${staged.id}`, + }; + } void queryClient.invalidateQueries({ queryKey: ["resources"] }); const activeEditor = editorRef.current; @@ -1376,7 +1396,7 @@ const RichEditorPane = ({ window.setTimeout(() => setImageUploadState("idle"), 2200); } })(); - }, [queryClient, t]); + }, [queryClient, repository, t]); const editor = useEditor({ extensions: [ @@ -1904,7 +1924,7 @@ const RichEditorPane = ({ : await Promise.all([ localDb.drafts.get(memo.id), localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)), - api.createMemoEditSession(memo.id), + requiresLocalEditSession(memo) ? Promise.resolve(null) : api.createMemoEditSession(memo.id), ]); if (cancelled) { @@ -1952,7 +1972,7 @@ const RichEditorPane = ({ } hydratedMemoIdRef.current = memo.id; - editSessionRef.current = editSessionResponse?.editSession ?? null; + editSessionRef.current = editSessionResponse?.editSession ?? (requiresLocalEditSession(memo) ? createLocalEditSession(memo) : null); window.setTimeout(() => { hydratingRef.current = false; @@ -2178,26 +2198,11 @@ const RichEditorPane = ({ contentMarkdown: useMarkdownSourceEditor ? markdownSource : undefined, tags: parseTagsText(tagsText), }; - let data; - - try { - data = await api.updateMemo(currentMemo.id, { - expectedRevision: payload.expectedRevision, - expectedContentHash: payload.expectedContentHash, - editSessionId: payload.editSessionId, - title: payload.title, - contentJson: payload.contentJson, - contentMarkdown: payload.contentMarkdown, - tags: payload.tags, - }); - } catch (error) { - throw new MemoSaveRequestError(error, payload, tagsText); - } - - return { memo: data.memo, snapshot }; + const { memo: localMemo } = await repository.updateMemo(currentMemo, payload); + return { memo: localMemo, snapshot, queued: true }; }, onMutate: () => setSaveState("saving"), - onSuccess: async ({ memo: savedMemo, snapshot }) => { + onSuccess: async ({ memo: savedMemo, snapshot, queued }) => { memoRef.current = savedMemo; const currentEditSession = editSessionRef.current; if (currentEditSession) { @@ -2228,7 +2233,7 @@ const RichEditorPane = ({ hasUnsavedChangesRef.current = false; setHasUnsavedChanges(false); await localDb.drafts.delete(savedMemo.id); - setSaveState("saved"); + setSaveState(queued ? "queued" : "saved"); window.setTimeout(() => setSaveState("idle"), 1400); return; } @@ -3352,6 +3357,7 @@ const RichEditorPane = ({ : memo.contentMarkdown } memo={memo} + repository={repository} onClose={() => setHistoryOpen(false)} onRestored={async (restoredMemo) => { await localDb.drafts.delete(restoredMemo.id); diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index 86edf56d..ae7db7f8 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -33,7 +33,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { NotebookTreeItem } from "./NotebookTreeItem"; -import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import type { Notebook, AuthUser } from "@edgeever/shared"; import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers"; @@ -47,6 +46,7 @@ import { writeNotebookSortPreference, } from "@/lib/app-helpers"; import { usePwaInstall } from "./PwaInstallContext"; +import type { EdgeEverRepository } from "@/lib/repository"; const NOTEBOOK_DRAG_SCROLL_EDGE_PX = 56; const NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX = 18; @@ -187,11 +187,13 @@ const SyncStatusBar = ({ isOnline, isSyncing, onSyncNow, + onDiscardConflicts, }: { summary: SyncQueueSummary; isOnline: boolean; isSyncing: boolean; onSyncNow: () => void; + onDiscardConflicts: () => void; }) => { const { t } = useTranslation(); const hasQueuedWork = summary.total > 0; @@ -216,6 +218,16 @@ const SyncStatusBar = ({ )} {label} + {summary.conflict > 0 && ( + + )} {hasQueuedWork && ( + + + + + + ); +}; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0ef4b472..dde8e1a9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -18,6 +18,8 @@ import type { ResourceStorageSummary, TagSummary, TiptapDoc, + SyncBootstrapResponse, + SyncChangesResponse, } from "@edgeever/shared"; import type { MemoFilterMode, MemoSortMode } from "./app-helpers"; @@ -44,6 +46,8 @@ type ListTagsResponse = { tags: TagSummary[]; }; +export type { SyncBootstrapResponse, SyncChangesResponse }; + type ListApiTokensResponse = { apiTokens: ApiToken[]; availableScopes: string[]; @@ -54,6 +58,68 @@ type UserResponse = { user: InstanceUser }; type ListLoginDeviceSessionsResponse = { sessions: LoginDeviceSession[] }; const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id"; +export const DESKTOP_API_BASE_URL_STORAGE_KEY = "edgeever.desktop.api-base-url"; +const DESKTOP_SESSION_STORAGE_KEY = "edgeever.desktop.session"; + +export const getCachedDesktopSession = (): AuthSession | null => { + if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return null; + try { + const value = window.localStorage.getItem(DESKTOP_SESSION_STORAGE_KEY); + if (!value) return null; + const parsed = JSON.parse(value) as AuthSession; + return parsed && typeof parsed === "object" && "authenticated" in parsed ? parsed : null; + } catch { + return null; + } +}; + +export const cacheDesktopSession = (session: AuthSession) => { + if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return; + try { + window.localStorage.setItem(DESKTOP_SESSION_STORAGE_KEY, JSON.stringify(session)); + } catch { + // A session cache is an offline convenience and must never block login. + } +}; + +export const clearCachedDesktopSession = () => { + if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return; + try { + window.localStorage.removeItem(DESKTOP_SESSION_STORAGE_KEY); + } catch { + // Ignore restricted storage contexts. + } +}; + +export const getConfiguredDesktopApiBaseUrl = () => { + if (typeof window === "undefined") return ""; + const bridgeUrl = (window.edgeeverDesktop?.apiBaseUrl ?? "").trim(); + if (bridgeUrl) return bridgeUrl.replace(/\/$/, ""); + + try { + return (window.localStorage.getItem(DESKTOP_API_BASE_URL_STORAGE_KEY) ?? "").trim().replace(/\/$/, ""); + } catch { + return ""; + } +}; + +export const isDesktopInstanceConfigurationRequired = () => + typeof window !== "undefined" && + Boolean(window.edgeeverDesktop?.isAvailable) && + window.location.protocol === "file:" && + !getConfiguredDesktopApiBaseUrl(); + +export const saveDesktopApiBaseUrl = (value: string) => { + const normalized = value.trim().replace(/\/$/, ""); + const parsed = new URL(normalized); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Desktop instance URL must use http or https"); + } + + window.localStorage.setItem(DESKTOP_API_BASE_URL_STORAGE_KEY, normalized); + void window.edgeeverDesktop?.setApiBaseUrl(normalized); + return normalized; +}; const createWebDeviceId = () => { const uuid = globalThis.crypto?.randomUUID?.(); @@ -121,7 +187,8 @@ const request = async (path: string, init?: RequestInit): Promise => { headers.set("Content-Type", "application/json"); } - const response = await fetch(path, { + const baseUrl = getConfiguredDesktopApiBaseUrl(); + const response = await fetch(`${baseUrl}${path}`, { credentials: "include", ...init, headers, @@ -197,6 +264,20 @@ export const api = { listNotebooks: () => request("/api/v1/notebooks"), + syncBootstrap: (params?: { afterId?: string | null; limit?: number }) => { + const search = new URLSearchParams(); + if (params?.afterId) search.set("afterId", params.afterId); + if (params?.limit) search.set("limit", String(params.limit)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request(`/api/v1/sync/bootstrap${suffix}`); + }, + + syncChanges: (params: { cursor: number; limit?: number }) => { + const search = new URLSearchParams({ cursor: String(params.cursor) }); + if (params.limit) search.set("limit", String(params.limit)); + return request(`/api/v1/sync/changes?${search.toString()}`); + }, + createNotebook: (payload: { name: string; parentId?: string | null }) => request("/api/v1/notebooks", { method: "POST", @@ -214,6 +295,11 @@ export const api = { method: "DELETE", }), + restoreNotebook: (notebookId: string) => + request(`/api/v1/notebooks/${notebookId}/restore`, { + method: "POST", + }), + listTags: () => request("/api/v1/tags"), renameTag: (tag: string, name: string) => @@ -390,7 +476,8 @@ export const api = { }, getResourceBlob: async (resourceUrl: string) => { - const response = await fetch(resourceUrl, { credentials: "include" }); + const baseUrl = getConfiguredDesktopApiBaseUrl(); + const response = await fetch(resourceUrl.startsWith("/") ? `${baseUrl}${resourceUrl}` : resourceUrl, { credentials: "include" }); if (!response.ok) { if (response.status === 401) { diff --git a/apps/web/src/lib/desktop-repository.ts b/apps/web/src/lib/desktop-repository.ts new file mode 100644 index 00000000..c30b0680 --- /dev/null +++ b/apps/web/src/lib/desktop-repository.ts @@ -0,0 +1,261 @@ +import type { + DesktopMemoCreateParams, + DesktopMemoListParams, + DesktopMemoUpdateParams, + DesktopRpcMethod, + DesktopRpcParams, + DesktopRpcResponses, +} from "@edgeever/shared"; +import { docToText, type MemoDetail } from "@edgeever/shared"; +import type { EdgeEverRepository } from "@/lib/repository"; +import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api"; +import { createStagedResourceListItem, mapMarkdownResourceUrls, mapTiptapResourceUrls, toApiResourceUrl, toDesktopResourceUrl } from "@/lib/desktop-resources"; +import type { ResourceListItem, ResourceStorageSummary } from "@edgeever/shared"; + +const resolveResourceUrls = }>(result: T): T => { + const baseUrl = getConfiguredDesktopApiBaseUrl(); + return { + ...result, + resources: result.resources.map((resource) => ({ ...resource, url: toDesktopResourceUrl(resource.url.startsWith("/") ? `${baseUrl}${resource.url}` : resource.url) })), + }; +}; + +const toDisplayMemo = (memo: MemoDetail): MemoDetail => ({ + ...memo, + contentJson: mapTiptapResourceUrls(memo.contentJson, toDesktopResourceUrl), + contentMarkdown: mapMarkdownResourceUrls(memo.contentMarkdown, toDesktopResourceUrl) ?? "", +}); + +const listStagedResources = async (): Promise => { + const bridge = window.edgeeverDesktop; + if (!bridge?.isAvailable) return []; + const staged = await bridge.listStagedResources(); + const now = new Date().toISOString(); + return staged.map((item) => createStagedResourceListItem(item, now)); +}; + +const mergeStagedResources = async (result: T): Promise => { + const staged = await listStagedResources(); + if (staged.length === 0) return result; + const stagedSummary: ResourceStorageSummary = { + totalCount: staged.length, + totalBytes: staged.reduce((total, resource) => total + resource.byteSize, 0), + imageCount: staged.filter((resource) => resource.kind === "image").length, + attachmentCount: staged.filter((resource) => resource.kind === "attachment").length, + }; + return { + ...result, + resources: [...staged, ...result.resources], + summary: { + totalCount: result.summary.totalCount + stagedSummary.totalCount, + totalBytes: result.summary.totalBytes + stagedSummary.totalBytes, + imageCount: result.summary.imageCount + stagedSummary.imageCount, + attachmentCount: result.summary.attachmentCount + stagedSummary.attachmentCount, + }, + }; +}; + +const request = async (method: M, params: DesktopRpcParams[M]) => { + const bridge = window.edgeeverDesktop; + if (!bridge?.isAvailable) throw new Error("EdgeEver desktop bridge is unavailable"); + return bridge.sidecarRequest(method, params); +}; + +export const createDesktopRepository = (): EdgeEverRepository => ({ + listNotebooks: () => request("notebook.list", {}), + + createNotebook: (input) => request("notebook.create", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + + updateNotebook: (notebookId, input) => request("notebook.update", { notebookId, ...input }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + + deleteNotebook: (notebookId) => request("notebook.delete", { notebookId }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + + restoreNotebook: (notebookId) => request("notebook.restore", { notebookId }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + + listTemplates: async () => { + const local = await request("template.list", {}); + const refresh = async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) return; + try { + const remote = await api.listTemplates(); + await Promise.all(remote.templates.map((template) => request("template.cache", { template }))); + } catch { + // The local snapshot remains the durable fallback for the next open. + } + }; + if (local.templates.length > 0) { + void refresh(); + return local; + } + if (typeof navigator !== "undefined" && !navigator.onLine) return local; + try { + const remote = await api.listTemplates(); + await Promise.all(remote.templates.map((template) => request("template.cache", { template }))); + return remote; + } catch (error) { + throw error; + } + }, + createTemplate: (input) => request("template.create", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + updateTemplate: (templateId, input) => request("template.update", { templateId, ...input }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + deleteTemplate: (templateId) => request("template.delete", { templateId }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + useTemplate: async (templateId, notebookId) => { + const templates = await createDesktopRepository().listTemplates(); + const template = templates.templates.find((candidate) => candidate.id === templateId); + if (!template) throw new Error(`Template not found: ${templateId}`); + return createDesktopRepository().createMemo({ notebookId, title: template.title ?? "", contentMarkdown: template.contentMarkdown, tags: template.tags }); + }, + uploadMemoResource: async (memoId, file) => { + const result = await api.uploadMemoResource(memoId, file); + await request("resource.cache", { resource: result.resource }); + return { resource: { ...result.resource, url: toDesktopResourceUrl(result.resource.url) } }; + }, + + listResources: async () => { + const local = await request("resource.list", { limit: 500 }); + const staged = await listStagedResources(); + const localResult = () => mergeStagedResources(resolveResourceUrls(local)); + const refresh = async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) return; + try { + const remote = await api.listResources(); + await Promise.all(remote.resources.map((resource) => request("resource.cache", { resource }))); + } catch { + // Keep the last local resource index available for offline and startup use. + } + }; + if (local.resources.length > 0 || staged.length > 0) { + void refresh(); + return localResult(); + } + if (typeof navigator !== "undefined" && !navigator.onLine) return localResult(); + try { + const remote = await api.listResources(); + await Promise.all(remote.resources.map((resource) => request("resource.cache", { resource }))); + return mergeStagedResources(resolveResourceUrls(remote)); + } catch (error) { + if (staged.length > 0) { + return mergeStagedResources({ resources: [], summary: { totalCount: 0, totalBytes: 0, imageCount: 0, attachmentCount: 0 } }); + } + throw error; + } + }, + listTags: () => request("tag.list", {}), + renameTag: (tag, name) => request("tag.rename", { tag, name }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + deleteTag: (tag) => request("tag.delete", { tag }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + moveMemos: (input) => request("memo.moveBatch", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + deleteMemos: (input) => request("memo.deleteBatch", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + emptyTrash: () => request("memo.emptyTrash", {}).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + pinMemos: (input) => request("memo.pinBatch", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + mergeMemos: (input) => request("memo.merge", input).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo: toDisplayMemo(result.memo) }; + }), + + listMemos: (params) => { + const rpcParams: DesktopMemoListParams = { + ...params, + notebookId: params.notebookId ?? params.notebookIds?.[0] ?? null, + }; + return request("memo.list", rpcParams); + }, + + getMemo: async (memoId, includeDeleted = false) => { + const result = await request("memo.get", { memoId, includeDeleted }); + return { memo: toDisplayMemo(result.memo) }; + }, + + createMemo: (input) => { + const rpcParams: DesktopMemoCreateParams = input; + return request("memo.create", rpcParams).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }); + }, + + updateMemo: async (memo: MemoDetail, input) => { + const rpcParams: DesktopMemoUpdateParams & { contentText: string } = { + memoId: memo.id, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + title: input.title, + contentJson: mapTiptapResourceUrls(input.contentJson, toApiResourceUrl), + contentMarkdown: mapMarkdownResourceUrls(input.contentMarkdown, toApiResourceUrl), + contentText: docToText(input.contentJson), + tags: input.tags, + }; + const result = await request("memo.update", rpcParams); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo: toDisplayMemo(result.memo), queued: true as const }; + }, + + deleteMemo: (memoId, permanent = false) => request("memo.delete", { memoId, permanent }).then((result) => { + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result; + }), + + restoreMemo: async (memoId) => { + const result = await request("memo.restore", { memoId }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ...result, queued: true as const }; + }, + + listMemoRevisions: async (memoId) => { + const local = await request("memo.revisions", { memoId, limit: 100 }); + if (typeof navigator !== "undefined" && !navigator.onLine) return local; + try { + const remote = await api.listMemoRevisions(memoId); + await Promise.all(remote.revisions.map((revision) => request("memo.revision.cache", { revision }))); + return remote; + } catch (error) { + if (local.revisions.length > 0) return local; + throw error; + } + }, + restoreMemoRevision: async (memoId, revisionId) => { + const result = await request("memo.restoreRevision", { memoId, revisionId }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo: toDisplayMemo(result.memo) }; + }, + + sync: async () => ({ bootstrapped: false, changed: 0 }), +}); diff --git a/apps/web/src/lib/desktop-resources.test.mjs b/apps/web/src/lib/desktop-resources.test.mjs new file mode 100644 index 00000000..9bbc9f10 --- /dev/null +++ b/apps/web/src/lib/desktop-resources.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; + +globalThis.window = { + edgeeverDesktop: { isAvailable: true, apiBaseUrl: "https://notes.example.com" }, +}; + +const { + mapTiptapResourceUrls, + createStagedResourceListItem, + toApiResourceUrl, + toDesktopResourceUrl, +} = await import("./desktop-resources.ts"); + +describe("desktop resource URLs", () => { + test("maps remote resource URLs to the native cache protocol", () => { + expect(toDesktopResourceUrl("/api/v1/resources/resource-1/blob")).toBe("edgeever-resource://resource/resource-1"); + expect(toDesktopResourceUrl("https://cdn.example.com/image.png")).toBe("https://cdn.example.com/image.png"); + }); + + test("restores portable API URLs before a memo is saved", () => { + expect(toApiResourceUrl("edgeever-resource://resource/resource-1")).toBe("/api/v1/resources/resource-1/blob"); + const mapped = mapTiptapResourceUrls({ + type: "doc", + content: [{ type: "image", attrs: { src: "edgeever-resource://resource/resource-1" } }], + }, toApiResourceUrl); + expect(mapped.content?.[0]?.attrs?.src).toBe("/api/v1/resources/resource-1/blob"); + }); + + test("exposes staged offline attachments as local resources", () => { + expect(createStagedResourceListItem({ id: "stage-1", memoId: "memo-1", name: "photo.png", type: "image/png", size: 42 }, "2026-01-01T00:00:00.000Z")).toMatchObject({ + id: "staged_stage-1", + memoId: "memo-1", + kind: "image", + byteSize: 42, + url: "edgeever-staged://stage-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + }); +}); diff --git a/apps/web/src/lib/desktop-resources.ts b/apps/web/src/lib/desktop-resources.ts new file mode 100644 index 00000000..fd123625 --- /dev/null +++ b/apps/web/src/lib/desktop-resources.ts @@ -0,0 +1,79 @@ +import type { ResourceListItem, TiptapDoc } from "@edgeever/shared"; +import { getConfiguredDesktopApiBaseUrl } from "@/lib/api"; + +export const isDesktopResourceRuntime = () => Boolean(typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable); + +const RESOURCE_PATH_PATTERN = /\/api\/v1\/resources\/([^/]+)\/blob(?:$|[?#])/; + +const getResourceId = (url: string) => { + if (url.startsWith("edgeever-resource://")) { + const parsed = new URL(url); + return decodeURIComponent(parsed.pathname.replace(/^\//, "")); + } + + try { + const base = getConfiguredDesktopApiBaseUrl() || "http://edgeever.local"; + const parsed = new URL(url, base); + const match = parsed.pathname.match(RESOURCE_PATH_PATTERN); + return match ? decodeURIComponent(match[1]) : null; + } catch { + return null; + } +}; + +export const toDesktopResourceUrl = (url: string) => { + if (!isDesktopResourceRuntime() || url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return url; + const resourceId = getResourceId(url); + return resourceId ? `edgeever-resource://resource/${encodeURIComponent(resourceId)}` : url; +}; + +export const toApiResourceUrl = (url: string) => { + if (!url.startsWith("edgeever-resource://")) return url; + const resourceId = getResourceId(url); + return resourceId ? `/api/v1/resources/${encodeURIComponent(resourceId)}/blob` : url; +}; + +export const mapTiptapResourceUrls = (doc: TiptapDoc, mapper: (url: string) => string): TiptapDoc => { + const visit = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(visit); + if (!value || typeof value !== "object") return value; + + const result: Record = {}; + for (const [key, child] of Object.entries(value)) { + result[key] = (key === "src" || key === "href") && typeof child === "string" ? mapper(child) : visit(child); + } + return result; + }; + + return visit(doc) as TiptapDoc; +}; + +export const mapMarkdownResourceUrls = (markdown: string | undefined, mapper: (url: string) => string) => + markdown?.replace(/(?:edgeever-resource:\/\/resource\/[^\s)"']+|\/api\/v1\/resources\/[^\s)"']+\/blob)/g, mapper) ?? markdown; + +export const stageDesktopResource = async (memoId: string, file: File) => { + if (!isDesktopResourceRuntime()) return null; + return window.edgeeverDesktop!.stageResource({ memoId, name: file.name, type: file.type, bytes: await file.arrayBuffer() }); +}; + +export const createStagedResourceListItem = ( + item: { id: string; memoId: string; name: string; type: string; size: number }, + now = new Date().toISOString(), +): ResourceListItem => ({ + id: `staged_${item.id}`, + memoId: item.memoId, + originalMemoId: null, + kind: item.type.startsWith("image/") ? "image" : "attachment", + mimeType: item.type || null, + filename: item.name, + byteSize: item.size, + sha256: null, + width: null, + height: null, + createdAt: now, + updatedAt: now, + url: `edgeever-staged://${item.id}`, + memoTitle: null, + memoExcerpt: null, + memoDeleted: false, +}); diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs new file mode 100644 index 00000000..5c1ec49a --- /dev/null +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; + +const { rewriteStagedResource } = await import("./desktop-sync.ts"); + +describe("desktop staged resource sync", () => { + test("rewrites placeholders in memo JSON and markdown", () => { + const rewrites = [{ memoId: "memo-1", placeholder: "edgeever-staged://stage-1", url: "/api/v1/resources/resource-1/blob" }]; + const value = { + contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "edgeever-staged://stage-1" } }] }, + contentMarkdown: "![photo](edgeever-staged://stage-1)", + }; + + expect(rewriteStagedResource(value, rewrites)).toEqual({ + contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "/api/v1/resources/resource-1/blob" } }] }, + contentMarkdown: "![photo](/api/v1/resources/resource-1/blob)", + }); + }); +}); diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts new file mode 100644 index 00000000..e4f993a5 --- /dev/null +++ b/apps/web/src/lib/desktop-sync.ts @@ -0,0 +1,401 @@ +import type { DesktopOutboxItem, DesktopRpcParams, DesktopRpcResponses, SyncBootstrapResponse, SyncChangesResponse, TiptapDoc } from "@edgeever/shared"; +import { api, ApiRequestError } from "@/lib/api"; +import { isDesktopResourceRuntime } from "@/lib/desktop-resources"; + +type StagedResourceRewrite = { memoId: string; placeholder: string; url: string }; + +const syncStagedResources = async (memoIdMappings: Map) => { + if (!isDesktopResourceRuntime() || (typeof navigator !== "undefined" && !navigator.onLine)) return { attempted: 0, synced: 0, failed: 0, rewrites: [] as StagedResourceRewrite[], stagedIds: [] as string[] }; + const staged = await window.edgeeverDesktop!.listStagedResources(); + let synced = 0; + let failed = 0; + const rewrites: StagedResourceRewrite[] = []; + const stagedIds: string[] = []; + for (const item of staged) { + try { + const stored = await window.edgeeverDesktop!.readStagedResource(item.id); + const memoId = memoIdMappings.get(item.memoId) ?? item.memoId; + const uploaded = await api.uploadMemoResource(memoId, new File([stored.bytes as unknown as ArrayBuffer], stored.name, { type: stored.type })); + await window.edgeeverDesktop!.sidecarRequest("resource.cache", { resource: uploaded.resource }); + rewrites.push({ memoId, placeholder: `edgeever-staged://${item.id}`, url: uploaded.resource.url }); + stagedIds.push(item.id); + synced += 1; + } catch { + failed += 1; + } + } + try { + await patchCreatedMemoResources(rewrites); + } catch { + // Keep the staged files and rewritten outbox payloads recoverable. A later + // sync can retry the remote patch without losing the local attachment. + failed += rewrites.length > 0 ? 1 : 0; + } + return { attempted: staged.length, synced, failed, rewrites, stagedIds }; +}; + +const removeSyncedStagedResources = async (stagedIds: string[]) => { + for (const id of stagedIds) { + try { + await window.edgeeverDesktop!.removeStagedResource(id); + } catch { + // The uploaded resource and the local memo are already durable; a later + // pass can remove this harmless orphan from the staging directory. + } + } +}; + +export const rewriteStagedResource = (value: unknown, rewrites: StagedResourceRewrite[]): unknown => { + if (typeof value === "string") { + return rewrites.reduce((current, rewrite) => current.split(rewrite.placeholder).join(rewrite.url), value); + } + if (Array.isArray(value)) return value.map((item) => rewriteStagedResource(item, rewrites)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteStagedResource(item, rewrites)])); + } + return value; +}; + +const patchCreatedMemoResources = async (rewrites: StagedResourceRewrite[]) => { + const memoIds = [...new Set(rewrites.map((rewrite) => rewrite.memoId))]; + if (memoIds.length === 0) return; + + const pending = await request("sync.outbox.list", { limit: 200 }); + const pendingUpdates = new Set( + pending.items + .filter((item) => item.kind === "memo.update") + .map((item) => String(item.payload.memoId ?? item.entityId)), + ); + + for (const memoId of memoIds) { + // A queued edit will carry the rewritten URLs through syncOutbox. Patching + // here as well would advance the revision and turn that edit into a false + // conflict, so only patch creates that have no later local edit. + if (pendingUpdates.has(memoId)) continue; + + const remote = await api.getMemo(memoId, { includeDeleted: true }); + const memoRewrites = rewrites.filter((rewrite) => rewrite.memoId === memoId); + const contentJson = rewriteStagedResource(remote.memo.contentJson, memoRewrites) as TiptapDoc; + const contentMarkdown = rewriteStagedResource(remote.memo.contentMarkdown, memoRewrites); + if (JSON.stringify(contentJson) === JSON.stringify(remote.memo.contentJson) && contentMarkdown === remote.memo.contentMarkdown) continue; + + const editSession = (await api.createMemoEditSession(memoId)).editSession; + const updated = await api.updateMemo(memoId, { + expectedRevision: editSession.baseRevision, + expectedContentHash: editSession.baseContentHash, + editSessionId: editSession.id, + contentJson, + contentMarkdown: typeof contentMarkdown === "string" ? contentMarkdown : undefined, + title: remote.memo.title ?? "", + tags: remote.memo.tags, + }); + await request("sync.apply", { + changes: [{ entityType: "memo", operation: "upsert", entityId: updated.memo.id, memo: updated.memo, notebook: null }], + }); + } +}; + +const request = async (method: M, params: DesktopRpcParams[M]) => { + const bridge = window.edgeeverDesktop; + if (!bridge?.isAvailable) throw new Error("EdgeEver desktop bridge is unavailable"); + return bridge.sidecarRequest(method, params); +}; + +const applyRemoteMemo = async (memo: NonNullable["memo"]) => { + await request("sync.apply", { + changes: [{ entityType: "memo", operation: "upsert", entityId: memo.id, memo, notebook: null }], + }); +}; + +const applyRemoteNotebook = async (notebook: DesktopRpcResponses["notebook.list"]["notebooks"][number]) => { + await request("sync.apply", { + changes: [{ entityType: "notebook", operation: "upsert", entityId: notebook.id, notebook, memo: null }], + }); +}; + +const acknowledge = async ( + item: DesktopOutboxItem, + remoteMemo?: DesktopRpcResponses["memo.get"]["memo"], + remoteNotebook?: DesktopRpcResponses["notebook.list"]["notebooks"][number], + remoteTemplate?: DesktopRpcResponses["template.list"]["templates"][number], +) => { + if (remoteMemo) await applyRemoteMemo(remoteMemo); + if (remoteNotebook) await applyRemoteNotebook(remoteNotebook); + await request("sync.outbox.ack", { id: item.id, remoteMemo, remoteNotebook, remoteTemplate }); +}; + +const syncOutboxItem = async (item: DesktopOutboxItem, stagedRewrites: StagedResourceRewrite[]) => { + const payload = stagedRewrites.length > 0 + ? rewriteStagedResource(item.payload, stagedRewrites.filter((rewrite) => rewrite.memoId === String(item.payload.memoId ?? item.entityId))) as Record + : item.payload; + if (item.kind === "memo.create") { + const data = await api.createMemo({ + notebookId: String(payload.notebookId), + title: String(payload.title ?? ""), + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : undefined, + tags: Array.isArray(payload.tags) ? payload.tags.filter((tag): tag is string => typeof tag === "string") : [], + createdAt: typeof payload.createdAt === "string" ? payload.createdAt : undefined, + updatedAt: typeof payload.updatedAt === "string" ? payload.updatedAt : undefined, + }); + await acknowledge(item, data.memo); + return data.memo; + } + + if (item.kind === "memo.update") { + const memoId = String(payload.memoId ?? item.entityId); + const editSessionResponse = await api.createMemoEditSession(memoId); + const editSession = editSessionResponse.editSession; + if (editSession.baseRevision !== Number(payload.expectedRevision) || editSession.baseContentHash !== String(payload.expectedContentHash ?? "")) { + throw new ApiRequestError("Note changed before the offline draft could sync.", 409, "revision_conflict"); + } + const data = await api.updateMemo(memoId, { + expectedRevision: Number(payload.expectedRevision), + expectedContentHash: String(payload.expectedContentHash ?? ""), + editSessionId: editSession.id, + title: String(payload.title ?? ""), + contentJson: payload.contentJson as TiptapDoc, + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : undefined, + tags: Array.isArray(payload.tags) ? payload.tags.filter((tag): tag is string => typeof tag === "string") : [], + }); + await acknowledge(item, data.memo); + return data.memo; + } + + if (item.kind === "memo.delete") { + await api.deleteMemo(String(payload.memoId ?? item.entityId), { permanent: Boolean(payload.permanent) }); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.deleteBatch") { + await api.deleteMemos({ + memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((memoId): memoId is string => typeof memoId === "string") : [], + permanent: Boolean(payload.permanent), + }); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.emptyTrash") { + await api.emptyTrash(); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.pinBatch") { + const memoIds = Array.isArray(payload.memoIds) ? payload.memoIds.filter((memoId): memoId is string => typeof memoId === "string") : []; + await Promise.all(memoIds.map((memoId) => api.updateMemo(memoId, { isPinned: Boolean(payload.isPinned) }))); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.merge") { + const data = await api.mergeMemos({ + memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((memoId): memoId is string => typeof memoId === "string") : [], + notebookId: typeof payload.notebookId === "string" ? payload.notebookId : undefined, + title: typeof payload.title === "string" ? payload.title : undefined, + }); + await acknowledge(item, data.memo); + return data.memo; + } + + if (item.kind === "notebook.create") { + const data = await api.createNotebook({ name: String(payload.name ?? ""), parentId: typeof payload.parentId === "string" ? payload.parentId : null }); + await acknowledge(item, undefined, data.notebook); + return null; + } + + if (item.kind === "notebook.update") { + const data = await api.updateNotebook(String(payload.notebookId ?? item.entityId), { + name: typeof payload.name === "string" ? payload.name : undefined, + parentId: Object.prototype.hasOwnProperty.call(payload, "parentId") ? (typeof payload.parentId === "string" ? payload.parentId : null) : undefined, + sortOrder: typeof payload.sortOrder === "number" ? payload.sortOrder : undefined, + }); + await acknowledge(item, undefined, data.notebook); + return null; + } + + if (item.kind === "notebook.delete") { + await api.deleteNotebook(String(payload.notebookId ?? item.entityId)); + await acknowledge(item); + return null; + } + + if (item.kind === "notebook.restore") { + const data = await api.restoreNotebook(String(payload.notebookId ?? item.entityId)); + await acknowledge(item, undefined, data.notebook); + return null; + } + + if (item.kind === "template.create") { + const data = await api.createTemplate({ + name: String(payload.name ?? ""), + description: typeof payload.description === "string" ? payload.description : null, + title: typeof payload.title === "string" ? payload.title : null, + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : "", + tags: Array.isArray(payload.tags) ? payload.tags.filter((tag): tag is string => typeof tag === "string") : [], + }); + await acknowledge(item, undefined, undefined, data.template); + return null; + } + + if (item.kind === "template.update") { + const data = await api.updateTemplate(String(payload.templateId ?? item.entityId), { + name: typeof payload.name === "string" ? payload.name : undefined, + description: typeof payload.description === "string" ? payload.description : null, + title: typeof payload.title === "string" ? payload.title : null, + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : undefined, + tags: Array.isArray(payload.tags) ? payload.tags.filter((tag): tag is string => typeof tag === "string") : [], + }); + await acknowledge(item, undefined, undefined, data.template); + return null; + } + + if (item.kind === "template.delete") { + await api.deleteTemplate(String(payload.templateId ?? item.entityId)); + await acknowledge(item); + return null; + } + + if (item.kind === "tag.rename") { + await api.renameTag(String(payload.tag ?? item.entityId), String(payload.name ?? "")); + await acknowledge(item); + return null; + } + + if (item.kind === "tag.delete") { + await api.deleteTag(String(payload.tag ?? item.entityId)); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.move") { + await api.moveMemos({ + memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((memoId): memoId is string => typeof memoId === "string") : [], + notebookId: String(payload.notebookId ?? ""), + }); + await acknowledge(item); + return null; + } + + if (item.kind === "memo.restore") { + await api.restoreMemo(String(payload.memoId ?? item.entityId)); + await acknowledge(item); + return null; + } + + throw new Error(`Unsupported desktop sync operation: ${item.kind}`); +}; + +const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: Set) => { + let synced = 0; + let failed = 0; + let conflicted = 0; + const response = await request("sync.outbox.list", { limit: 100 }); + const memoIdMappings = new Map(); + for (const item of response.items) { + if (onlyKinds && !onlyKinds.has(item.kind)) continue; + try { + const result = await syncOutboxItem(item, stagedRewrites); + if (item.kind === "memo.create" && result && typeof result === "object" && "id" in result && typeof result.id === "string") { + memoIdMappings.set(item.entityId, result.id); + } + synced += 1; + } catch (error) { + const conflict = error instanceof ApiRequestError && error.code === "revision_conflict"; + await request("sync.outbox.fail", { id: item.id, error: error instanceof Error ? error.message : String(error), conflict }); + if (conflict) conflicted += 1; + else failed += 1; + } + } + return { attempted: onlyKinds ? synced + failed + conflicted : response.items.length, synced, failed, conflicted, memoIdMappings }; +}; + +const applyBootstrap = async (page: SyncBootstrapResponse) => { + const changes = [ + ...page.notebooks.map((notebook) => ({ entityType: "notebook" as const, operation: "upsert" as const, entityId: notebook.id, notebook, memo: null })), + ...page.memos.map((memo) => ({ entityType: "memo" as const, operation: "upsert" as const, entityId: memo.id, memo, notebook: null })), + ]; + if (changes.length > 0) await request("sync.apply", { changes }); +}; + +const pullRemoteChanges = async () => { + const status = await request("sync.status", {}); + if (!status.syncIdentity) { + let afterId: string | null = null; + let snapshotCursor = 0; + let syncIdentity = ""; + do { + const page = await api.syncBootstrap({ afterId, limit: 200 }); + if (!snapshotCursor) snapshotCursor = page.snapshotCursor; + syncIdentity = page.syncIdentity ?? syncIdentity; + await applyBootstrap(page); + afterId = page.nextAfterId; + } while (afterId); + await request("sync.cursor.set", { cursor: snapshotCursor, syncIdentity }); + return; + } + + let cursor = status.cursor; + let response: SyncChangesResponse = await api.syncChanges({ cursor, limit: 200 }); + while (response.changes.length > 0) { + await request("sync.apply", { changes: response.changes }); + cursor = response.cursor; + if (!response.hasMore) break; + response = await api.syncChanges({ cursor, limit: 200 }); + } + await request("sync.cursor.set", { cursor: response.cursor ?? cursor, syncIdentity: response.syncIdentity ?? status.syncIdentity }); +}; + +let activeSync: Promise<{ attempted: number; synced: number; failed: number; conflicted: number }> | null = null; + +export const syncDesktopData = () => { + if (activeSync) return activeSync; + activeSync = (async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) { + return { attempted: 0, synced: 0, failed: 0, conflicted: 0 }; + } + try { + const creates = await syncOutbox([], new Set(["memo.create"])); + const stagedResources = await syncStagedResources(creates.memoIdMappings); + const outbox = await syncOutbox(stagedResources.rewrites); + if (stagedResources.failed === 0 && outbox.failed === 0 && outbox.conflicted === 0 && creates.conflicted === 0) { + await removeSyncedStagedResources(stagedResources.stagedIds); + } + if (outbox.conflicted === 0 && creates.conflicted === 0 && (typeof navigator === "undefined" || navigator.onLine)) await pullRemoteChanges(); + return { ...outbox, attempted: creates.attempted + outbox.attempted + stagedResources.attempted, synced: creates.synced + outbox.synced + stagedResources.synced, failed: creates.failed + outbox.failed + stagedResources.failed, conflicted: creates.conflicted + outbox.conflicted }; + } catch { + return { attempted: 0, synced: 0, failed: 1, conflicted: 0 }; + } + })().finally(() => { activeSync = null; }); + return activeSync; +}; + +export const getDesktopSyncSummary = async () => { + const status = await request("sync.status", {}); + return { total: status.pending + status.syncing + status.conflict + status.error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error: status.error }; +}; + +export const discardDesktopConflicts = async () => { + const response = await request("sync.outbox.list", { limit: 200 }); + const conflicts = response.items.filter((item) => item.status === "conflict"); + let discarded = 0; + + for (const item of conflicts) { + if (item.kind === "memo.update") { + // Replace the local conflicted draft with the authoritative remote memo + // before removing the queue item. If the remote read fails, keep the + // conflict so the user's local changes remain recoverable. + const remote = await api.getMemo(item.entityId, { includeDeleted: true }); + await request("sync.apply", { + changes: [{ entityType: "memo", operation: "upsert", entityId: remote.memo.id, memo: remote.memo, notebook: null }], + }); + await request("sync.outbox.ack", { id: item.id, remoteMemo: remote.memo }); + } else { + await request("sync.outbox.discard", { id: item.id }); + } + discarded += 1; + } + + return discarded; +}; diff --git a/apps/web/src/lib/local-db.ts b/apps/web/src/lib/local-db.ts index e02c1c62..82c5c7a9 100644 --- a/apps/web/src/lib/local-db.ts +++ b/apps/web/src/lib/local-db.ts @@ -1,4 +1,4 @@ -import type { TiptapDoc } from "@edgeever/shared"; +import type { MemoDetail, MemoRevision, MemoTemplate, Notebook, ResourceListItem, TiptapDoc } from "@edgeever/shared"; import Dexie, { type Table } from "dexie"; export type LocalDraft = { @@ -21,12 +21,37 @@ export type MemoUpdateSyncPayload = { tags: string[]; }; +export type MemoCreateSyncPayload = { + temporaryId: string; + notebookId: string; + title: string; + contentMarkdown?: string; + tags: string[]; + createdAt: string; + updatedAt: string; +}; + +export type MemoDeleteSyncPayload = { + memoId: string; + permanent: boolean; +}; + +export type MemoRestoreSyncPayload = { + memoId: string; +}; + +export type LocalActionKind = "tag.rename" | "tag.delete" | "memo.moveBatch" | "memo.deleteBatch" | "memo.emptyTrash" | "memo.pinBatch" | "memo.merge" | "notebook.create" | "notebook.update" | "notebook.delete" | "template.create" | "template.update" | "template.delete" | "resource.create"; +export type LocalActionPayload = Record; + +export type SyncQueueStatus = "pending" | "syncing" | "conflict" | "error"; + export type SyncQueueItem = { id: string; - kind: "memo.update"; + kind: "memo.update" | "memo.create" | "memo.delete" | "memo.restore" | LocalActionKind; + scope?: string; memoId: string; - status: "pending" | "syncing" | "conflict" | "error"; - payload: MemoUpdateSyncPayload; + status: SyncQueueStatus; + payload: MemoUpdateSyncPayload | MemoCreateSyncPayload | MemoDeleteSyncPayload | MemoRestoreSyncPayload | LocalActionPayload; attemptCount: number; lastError: string | null; nextAttemptAt: string | null; @@ -35,9 +60,34 @@ export type SyncQueueItem = { updatedAt: string; }; +export type LocalNotebook = Notebook & { scope: string }; +export type LocalMemo = MemoDetail & { scope: string }; +export type LocalTemplate = MemoTemplate & { scope: string }; +export type LocalSyncMeta = { + key: string; + scope: string; + value: string; + updatedAt: string; +}; +export type LocalIdMapping = { + temporaryId: string; + scope: string; + remoteId: string; + createdAt: string; +}; +export type LocalRevision = MemoRevision & { scope: string }; +export type LocalResource = ResourceListItem & { scope: string }; + class EdgeEverLocalDb extends Dexie { drafts!: Table; syncQueue!: Table; + notebooks!: Table; + memos!: Table; + templates!: Table; + syncMeta!: Table; + idMappings!: Table; + revisions!: Table; + resources!: Table; constructor() { super("edgeever-local"); @@ -48,6 +98,44 @@ class EdgeEverLocalDb extends Dexie { drafts: "memoId, updatedAt", syncQueue: "id, memoId, status, updatedAt, nextAttemptAt", }); + this.version(3).stores({ + drafts: "memoId, updatedAt", + syncQueue: "id, memoId, status, updatedAt, nextAttemptAt", + notebooks: "[scope+id], scope, parentId, sortOrder, updatedAt", + memos: "[scope+id], scope, notebookId, isDeleted, updatedAt, createdAt", + templates: "[scope+id], scope, updatedAt", + syncMeta: "[scope+key], scope, updatedAt", + }); + this.version(4).stores({ + drafts: "memoId, updatedAt", + syncQueue: "id, memoId, status, updatedAt, nextAttemptAt", + notebooks: "[scope+id], scope, parentId, sortOrder, updatedAt", + memos: "[scope+id], scope, notebookId, isDeleted, updatedAt, createdAt", + templates: "[scope+id], scope, updatedAt", + syncMeta: "[scope+key], scope, updatedAt", + idMappings: "[scope+temporaryId], scope, remoteId, createdAt", + }); + this.version(5).stores({ + drafts: "memoId, updatedAt", + syncQueue: "id, memoId, status, updatedAt, nextAttemptAt", + notebooks: "[scope+id], scope, parentId, sortOrder, updatedAt", + memos: "[scope+id], scope, notebookId, isDeleted, updatedAt, createdAt", + templates: "[scope+id], scope, updatedAt", + syncMeta: "[scope+key], scope, updatedAt", + idMappings: "[scope+temporaryId], scope, remoteId, createdAt", + revisions: "[scope+id], scope, memoId, revision, createdAt", + }); + this.version(6).stores({ + drafts: "memoId, updatedAt", + syncQueue: "id, memoId, status, updatedAt, nextAttemptAt", + notebooks: "[scope+id], scope, parentId, sortOrder, updatedAt", + memos: "[scope+id], scope, notebookId, isDeleted, updatedAt, createdAt", + templates: "[scope+id], scope, updatedAt", + syncMeta: "[scope+key], scope, updatedAt", + idMappings: "[scope+temporaryId], scope, remoteId, createdAt", + revisions: "[scope+id], scope, memoId, revision, createdAt", + resources: "[scope+id], scope, memoId, updatedAt, createdAt", + }); } } diff --git a/apps/web/src/lib/local-mirror.test.mjs b/apps/web/src/lib/local-mirror.test.mjs new file mode 100644 index 00000000..ea32465e --- /dev/null +++ b/apps/web/src/lib/local-mirror.test.mjs @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { IDBKeyRange, indexedDB } from "fake-indexeddb"; + +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +const { localDb } = await import("./local-db.ts"); +const { + createLocalDataScope, + createLocalMemo, + getLocalMemo, + listLocalTemplates, + listLocalTags, + listLocalMemos, + putLocalTemplate, + putLocalMemoUpdate, + applyLocalTagRename, + applyLocalMemoMove, + applyLocalMemoPin, + applyLocalMemoDeleteBatch, + applyLocalEmptyTrash, + updateLocalNotebook, + getLocalNotebook, + deleteLocalNotebook, + putLocalNotebook, + mergeLocalMemos, + putLocalMemoRevisions, + listLocalMemoRevisions, + getLocalMemoRevision, + putLocalResource, + createLocalResource, + listLocalResources, + replaceLocalResources, +} = await import("./local-mirror.ts"); +const { getCachedLocalResourceBytes } = await import("./local-resource-cache.ts"); + +afterEach(async () => { + await localDb.transaction( + "rw", + [localDb.drafts, localDb.syncQueue, localDb.notebooks, localDb.memos, localDb.templates, localDb.revisions, localDb.resources, localDb.syncMeta, localDb.idMappings], + async () => { + await Promise.all([ + localDb.drafts.clear(), + localDb.syncQueue.clear(), + localDb.notebooks.clear(), + localDb.memos.clear(), + localDb.templates.clear(), + localDb.revisions.clear(), + localDb.resources.clear(), + localDb.syncMeta.clear(), + localDb.idMappings.clear(), + ]); + }, + ); +}); + +describe("local mirror", () => { + test("creates and lists a memo without a network request", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { + notebookId: "nb-inbox", + title: "Local note", + contentMarkdown: "Hello local first", + tags: ["draft"], + }); + + const result = await listLocalMemos(scope, { notebookId: "nb-inbox" }); + expect(result.totalCount).toBe(1); + expect(result.memos[0]?.id).toBe(memo.id); + expect(result.memos[0]?.excerpt).toContain("Hello local first"); + }); + + test("updates local content and preserves the memo identity", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { notebookId: "nb-inbox" }); + const updated = await putLocalMemoUpdate(scope, memo, { + title: "Updated locally", + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Changed" }] }] }, + tags: ["updated"], + }); + + expect(updated.id).toBe(memo.id); + expect((await getLocalMemo(scope, memo.id))?.title).toBe("Updated locally"); + expect((await getLocalMemo(scope, memo.id))?.tags).toEqual(["updated"]); + }); + + test("caches templates inside the same local data scope", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + await putLocalTemplate(scope, { + id: "template-1", + name: "Daily note", + description: null, + title: "Daily", + contentJson: { type: "doc", content: [] }, + contentMarkdown: "# Daily", + tags: ["journal"], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + expect((await listLocalTemplates(scope)).templates[0]?.id).toBe("template-1"); + }); + + test("derives tags from the local memo mirror", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + await createLocalMemo(scope, { notebookId: "nb-inbox", tags: ["offline", "offline"] }); + expect((await listLocalTags(scope)).tags).toEqual([{ name: "offline", memoCount: 1, updatedAt: expect.any(String) }]); + }); + + test("applies batch mutations to the local mirror immediately", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const first = await createLocalMemo(scope, { notebookId: "inbox", tags: ["old"] }); + const second = await createLocalMemo(scope, { notebookId: "inbox" }); + expect(await applyLocalTagRename(scope, "old", "new")).toBe(1); + expect(await applyLocalMemoMove(scope, [first.id, second.id], "archive")).toBe(2); + expect(await applyLocalMemoPin(scope, [first.id], true)).toBe(1); + expect((await getLocalMemo(scope, first.id))?.tags).toEqual(["new"]); + expect((await getLocalMemo(scope, first.id))?.notebookId).toBe("archive"); + expect((await getLocalMemo(scope, first.id))?.isPinned).toBe(true); + }); + + test("deletes and empties trash locally without waiting for the API", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { notebookId: "inbox" }); + expect(await applyLocalMemoDeleteBatch(scope, [memo.id])).toBe(1); + expect((await getLocalMemo(scope, memo.id))?.isDeleted).toBe(true); + expect(await applyLocalEmptyTrash(scope)).toBe(1); + expect(await getLocalMemo(scope, memo.id)).toBeNull(); + }); + + test("updates and removes notebooks in the local mirror immediately", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + await putLocalNotebook(scope, { + id: "notebook-1", + parentId: null, + name: "Inbox", + slug: "inbox", + icon: null, + color: null, + sortOrder: 0, + memoCount: 0, + lastMemoUpdatedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect((await updateLocalNotebook(scope, "notebook-1", { name: "Archive" }))?.name).toBe("Archive"); + expect((await getLocalNotebook(scope, "notebook-1"))?.name).toBe("Archive"); + expect(await deleteLocalNotebook(scope, "notebook-1")).toBe(true); + expect(await getLocalNotebook(scope, "notebook-1")).toBeNull(); + }); + + test("merges memos locally and marks source notes as deleted", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const first = await createLocalMemo(scope, { notebookId: "inbox", title: "First", contentMarkdown: "one", tags: ["a"] }); + const second = await createLocalMemo(scope, { notebookId: "inbox", title: "Second", contentMarkdown: "two", tags: ["b"] }); + const merged = await mergeLocalMemos(scope, { memoIds: [first.id, second.id], title: "Merged" }); + expect(merged?.title).toBe("Merged"); + expect(merged?.contentMarkdown).toContain("one\n\n---\n\ntwo"); + expect(merged?.sourceMemoIds).toEqual([first.id, second.id]); + expect((await getLocalMemo(scope, first.id))?.isDeleted).toBe(true); + expect((await getLocalMemo(scope, second.id))?.mergedIntoMemoId).toBe(merged?.id); + }); + + test("caches memo revisions by account scope", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { notebookId: "inbox" }); + const revision = { + id: "revision-local-1", + memoId: memo.id, + revision: 1, + title: "Old title", + tags: ["old"], + contentMarkdown: "old body", + contentText: "old body", + contentHash: "hash", + createdBy: "user-1", + createdAt: "2026-01-01T00:00:00.000Z", + }; + await putLocalMemoRevisions(scope, [revision]); + expect((await listLocalMemoRevisions(scope, memo.id)).revisions[0]?.id).toBe(revision.id); + expect((await getLocalMemoRevision(scope, memo.id, revision.id))?.contentMarkdown).toBe("old body"); + }); + + test("caches resource metadata without storing resource bytes", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + await putLocalResource(scope, { + id: "resource-1", + memoId: "memo-1", + originalMemoId: null, + kind: "attachment", + mimeType: "text/plain", + filename: "readme.txt", + byteSize: 12, + sha256: "hash", + width: null, + height: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + url: "/api/v1/resources/resource-1/blob", + memoTitle: "Memo", + memoExcerpt: "Excerpt", + memoDeleted: false, + }); + const result = await listLocalResources(scope); + expect(result.resources[0]?.filename).toBe("readme.txt"); + expect(result.summary).toEqual({ totalCount: 1, totalBytes: 12, imageCount: 0, attachmentCount: 1 }); + }); + + test("stages resource bytes for offline upload", async () => { + const previousCaches = globalThis.caches; + const entries = new Map(); + globalThis.caches = { + open: async () => ({ + put: async (key, response) => entries.set(key, response), + match: async (key) => entries.get(key) ?? undefined, + delete: async (key) => entries.delete(key), + }), + }; + try { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const resource = await createLocalResource(scope, "memo-1", new File(["offline bytes"], "offline.txt", { type: "text/plain" })); + const cached = await getCachedLocalResourceBytes(resource.url); + expect(resource.id).toStartWith("local_resource_"); + expect(await cached?.text()).toBe("offline bytes"); + expect((await listLocalResources(scope)).resources[0]?.url).toBe(resource.url); + await replaceLocalResources(scope, []); + expect((await listLocalResources(scope)).resources[0]?.id).toBe(resource.id); + } finally { + globalThis.caches = previousCaches; + } + }); +}); diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts new file mode 100644 index 00000000..fe552359 --- /dev/null +++ b/apps/web/src/lib/local-mirror.ts @@ -0,0 +1,632 @@ +import { createExcerpt, docToText, markdownToDoc, type MemoDetail, type MemoRevision, type MemoSummary, type MemoTemplate, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import type { MemoFilterMode, MemoSortMode } from "@/lib/app-helpers"; +import { api, type SyncChangesResponse } from "@/lib/api"; +import { localDb, type LocalMemo, type LocalNotebook, type LocalResource, type LocalRevision } from "@/lib/local-db"; +import { cacheLocalResourceBytes, localResourceUrl, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache"; + +export type LocalMemoListParams = { + notebookId?: string | null; + notebookIds?: string[]; + q?: string; + trash?: boolean; + sort?: MemoSortMode; + filter?: MemoFilterMode; + limit?: number; + offset?: number; +}; + +export type LocalMemoListResponse = { + memos: MemoSummary[]; + totalCount: number; + nextCursor: string | null; +}; + +const BOOTSTRAP_PAGE_SIZE = 200; +const CHANGE_PAGE_SIZE = 200; +const SYNC_CURSOR_KEY = "cursor"; +const SYNC_IDENTITY_KEY = "identity"; +const activeSyncs = new Map>(); + +export const createLocalDataScope = (baseUrl: string, userId?: string | null) => + `${baseUrl.trim().toLowerCase()}|${userId ?? "anonymous"}`; + +const getMeta = async (scope: string, key: string) => + (await localDb.syncMeta.get([scope, key]))?.value ?? null; + +export const isLocalMirrorInitialized = async (scope: string) => Boolean(await getMeta(scope, SYNC_IDENTITY_KEY)); + +export const hasLocalMirrorData = async (scope: string) => + (await localDb.memos.where("scope").equals(scope).count()) > 0 || + (await localDb.notebooks.where("scope").equals(scope).count()) > 0; + +const setMeta = async (scope: string, key: string, value: string) => { + await localDb.syncMeta.put({ scope, key, value, updatedAt: new Date().toISOString() }); +}; + +const toSummary = (memo: MemoDetail): MemoSummary => ({ + id: memo.id, + notebookId: memo.notebookId, + title: memo.title, + excerpt: memo.excerpt, + tags: memo.tags, + isPinned: memo.isPinned, + isArchived: memo.isArchived, + isDeleted: memo.isDeleted, + revision: memo.revision, + createdAt: memo.createdAt, + updatedAt: memo.updatedAt, + deletedAt: memo.deletedAt, +}); + +const replaceScope = async (scope: string, notebooks: Notebook[], memos: MemoDetail[]) => { + await localDb.transaction("rw", [localDb.notebooks, localDb.memos, localDb.revisions, localDb.resources], async () => { + const pendingLocalResources = (await localDb.resources.where("scope").equals(scope).toArray()) + .filter((resource) => resource.id.startsWith("local_resource_")); + await localDb.notebooks.where("scope").equals(scope).delete(); + await localDb.memos.where("scope").equals(scope).delete(); + await localDb.revisions.where("scope").equals(scope).delete(); + await localDb.resources.where("scope").equals(scope).delete(); + await localDb.notebooks.bulkPut(notebooks.map((notebook) => ({ ...notebook, scope }))); + await localDb.memos.bulkPut(memos.map((memo) => ({ ...memo, scope }))); + await localDb.resources.bulkPut(pendingLocalResources); + }); +}; + +const applyChanges = async (scope: string, changes: SyncChangesResponse["changes"]) => { + await localDb.transaction("rw", [localDb.notebooks, localDb.memos], async () => { + for (const change of changes) { + if (change.entityType === "memo") { + if (change.operation === "delete" || !change.memo) { + await localDb.memos.delete([scope, change.entityId]); + } else { + await localDb.memos.put({ ...change.memo, scope }); + } + continue; + } + + if (change.operation === "delete" || !change.notebook) { + await localDb.notebooks.delete([scope, change.entityId]); + } else { + await localDb.notebooks.put({ ...change.notebook, scope }); + } + } + }); +}; + +const bootstrapScope = async (scope: string) => { + let afterId: string | null = null; + let firstPage = true; + let snapshotCursor = 0; + let syncIdentity: string | undefined; + const notebooks: Notebook[] = []; + const memos: MemoDetail[] = []; + + do { + const page = await api.syncBootstrap({ afterId, limit: BOOTSTRAP_PAGE_SIZE }); + if (firstPage) { + snapshotCursor = page.snapshotCursor; + syncIdentity = page.syncIdentity; + } + notebooks.push(...page.notebooks); + memos.push(...page.memos); + afterId = page.nextAfterId; + firstPage = false; + } while (afterId); + + await replaceScope(scope, notebooks, memos); + await setMeta(scope, SYNC_CURSOR_KEY, String(snapshotCursor)); + if (syncIdentity) { + await setMeta(scope, SYNC_IDENTITY_KEY, syncIdentity); + } + return memos.length; +}; + +const performSyncLocalMirror = async (scope: string) => { + if (typeof navigator !== "undefined" && !navigator.onLine) { + return { bootstrapped: false, changed: 0 }; + } + + const storedCursor = Number(await getMeta(scope, SYNC_CURSOR_KEY)); + const cursor = Number.isFinite(storedCursor) ? storedCursor : null; + const storedIdentity = await getMeta(scope, SYNC_IDENTITY_KEY); + let changed = 0; + + if (cursor === null || !storedIdentity) { + changed = await bootstrapScope(scope); + return { bootstrapped: true, changed }; + } + + let currentCursor = cursor; + let response = await api.syncChanges({ cursor: currentCursor, limit: CHANGE_PAGE_SIZE }); + + if (response.syncIdentity && response.syncIdentity !== storedIdentity) { + changed = await bootstrapScope(scope); + return { bootstrapped: true, changed }; + } + + while (response.changes.length > 0) { + await applyChanges(scope, response.changes); + changed += response.changes.length; + currentCursor = response.cursor; + await setMeta(scope, SYNC_CURSOR_KEY, String(currentCursor)); + if (!response.hasMore) break; + response = await api.syncChanges({ cursor: currentCursor, limit: CHANGE_PAGE_SIZE }); + } + + if (response.syncIdentity) { + await setMeta(scope, SYNC_IDENTITY_KEY, response.syncIdentity); + } + if (response.serverCursor > currentCursor && response.changes.length === 0) { + await setMeta(scope, SYNC_CURSOR_KEY, String(response.cursor)); + } + return { bootstrapped: false, changed }; +}; + +export const syncLocalMirror = (scope: string) => { + const active = activeSyncs.get(scope); + if (active) return active; + + const operation = performSyncLocalMirror(scope).finally(() => { + activeSyncs.delete(scope); + }); + activeSyncs.set(scope, operation); + return operation; +}; + +export const listLocalNotebooks = async (scope: string): Promise<{ notebooks: Notebook[] }> => { + const notebooks = await localDb.notebooks.where("scope").equals(scope).toArray(); + const memos = await localDb.memos.where("scope").equals(scope).toArray(); + const activeMemos = memos.filter((memo) => !memo.isDeleted); + const counts = new Map(); + + for (const memo of activeMemos) { + const current = counts.get(memo.notebookId) ?? { count: 0, last: null }; + current.count += 1; + if (!current.last || memo.updatedAt > current.last) current.last = memo.updatedAt; + counts.set(memo.notebookId, current); + } + + return { + notebooks: notebooks + .sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name)) + .map(({ scope: _scope, ...notebook }) => ({ + ...notebook, + memoCount: counts.get(notebook.id)?.count ?? 0, + lastMemoUpdatedAt: counts.get(notebook.id)?.last ?? null, + })), + }; +}; + +export const listLocalMemos = async (scope: string, params: LocalMemoListParams): Promise => { + let memos = await localDb.memos.where("scope").equals(scope).toArray(); + const notebookIds = params.notebookIds ?? (params.notebookId ? [params.notebookId] : null); + const q = params.q?.trim().toLocaleLowerCase(); + + memos = memos.filter((memo) => { + if (memo.isDeleted !== Boolean(params.trash)) return false; + if (notebookIds?.length && !notebookIds.includes(memo.notebookId)) return false; + if (params.filter === "tagged" && memo.tags.length === 0) return false; + if (params.filter === "untagged" && memo.tags.length > 0) return false; + if (params.filter === "pinned" && !memo.isPinned) return false; + if (q && ![memo.title, memo.excerpt, memo.contentText, memo.tags.join(" ")].some((value) => value?.toLocaleLowerCase().includes(q))) return false; + return true; + }); + + memos.sort((left, right) => { + if (params.trash) return (right.deletedAt ?? "").localeCompare(left.deletedAt ?? "") || right.id.localeCompare(left.id); + if (params.sort === "created-desc") return right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id); + if (params.sort === "title-asc") return (left.title || left.excerpt).localeCompare(right.title || right.excerpt, "zh-CN") || right.updatedAt.localeCompare(left.updatedAt); + return Number(right.isPinned) - Number(left.isPinned) || right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id); + }); + + const totalCount = memos.length; + const offset = Math.max(0, params.offset ?? 0); + const page = memos.slice(offset, offset + (params.limit ?? 50)); + return { + memos: page.map(toSummary), + totalCount, + nextCursor: offset + page.length < totalCount ? String(offset + page.length) : null, + }; +}; + +export const getLocalMemo = async (scope: string, memoId: string) => { + const memo = await localDb.memos.get([scope, memoId]); + if (!memo) return null; + const { scope: _scope, ...detail } = memo; + return detail; +}; + +export const putLocalMemo = async (scope: string, memo: MemoDetail) => { + await localDb.memos.put({ ...memo, scope }); +}; + +export const isLocalMemoId = (memoId: string) => memoId.startsWith("local_"); + +export const createLocalMemo = async ( + scope: string, + input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[]; createdAt?: string; updatedAt?: string }, +) => { + const now = new Date().toISOString(); + const contentMarkdown = input.contentMarkdown ?? ""; + const contentJson = markdownToDoc(contentMarkdown); + const contentText = docToText(contentJson); + const memo: MemoDetail = { + id: `local_${crypto.randomUUID()}`, + notebookId: input.notebookId, + title: input.title?.trim() || null, + excerpt: createExcerpt(contentText), + tags: input.tags ?? [], + isPinned: false, + isArchived: false, + isDeleted: false, + revision: 0, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + deletedAt: null, + contentJson, + contentMarkdown, + contentText, + contentHash: "0".repeat(64), + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: null, + }; + await putLocalMemo(scope, memo); + return memo; +}; + +export const replaceLocalMemoId = async (scope: string, temporaryId: string, memo: MemoDetail) => { + await localDb.transaction("rw", [localDb.memos, localDb.idMappings], async () => { + await localDb.memos.put({ ...memo, scope }); + await localDb.memos.delete([scope, temporaryId]); + await localDb.idMappings.put({ + scope, + temporaryId, + remoteId: memo.id, + createdAt: new Date().toISOString(), + }); + }); +}; + +export const putLocalMemoUpdate = async ( + scope: string, + memo: MemoDetail, + input: { title: string; contentJson: TiptapDoc; contentMarkdown?: string; tags: string[] }, +) => { + const contentText = docToText(input.contentJson); + const updatedMemo: MemoDetail = { + ...memo, + title: input.title.trim() || null, + excerpt: createExcerpt(contentText), + tags: input.tags, + contentJson: input.contentJson, + contentMarkdown: input.contentMarkdown ?? memo.contentMarkdown, + contentText, + updatedAt: new Date().toISOString(), + }; + await putLocalMemo(scope, updatedMemo); + return updatedMemo; +}; + +export const putLocalNotebook = async (scope: string, notebook: Notebook) => { + await localDb.notebooks.put({ ...notebook, scope }); +}; + +export const listLocalMemoRevisions = async (scope: string, memoId: string): Promise<{ revisions: MemoRevision[] }> => { + const revisions = await localDb.revisions.where("scope").equals(scope).toArray(); + return { + revisions: revisions + .filter((revision) => revision.memoId === memoId) + .sort((left, right) => right.revision - left.revision || right.createdAt.localeCompare(left.createdAt)) + .map(({ scope: _scope, ...revision }) => revision), + }; +}; + +export const getLocalMemoRevision = async (scope: string, memoId: string, revisionId: string) => { + const revision = await localDb.revisions.get([scope, revisionId]); + if (!revision || revision.memoId !== memoId) return null; + const { scope: _scope, ...result } = revision; + return result; +}; + +export const putLocalMemoRevisions = async (scope: string, revisions: MemoRevision[]) => { + await localDb.revisions.bulkPut(revisions.map((revision): LocalRevision => ({ ...revision, scope }))); +}; + +const getResourceSummary = (resources: ResourceListItem[]) => ({ + totalCount: resources.length, + totalBytes: resources.reduce((total, resource) => total + resource.byteSize, 0), + imageCount: resources.filter((resource) => resource.kind === "image").length, + attachmentCount: resources.filter((resource) => resource.kind === "attachment").length, +}); + +export const listLocalResources = async (scope: string): Promise<{ resources: ResourceListItem[]; summary: ReturnType }> => { + const stored = await localDb.resources.where("scope").equals(scope).sortBy("updatedAt"); + const resources = stored.reverse().map(({ scope: _scope, ...resource }) => resource); + return { resources, summary: getResourceSummary(resources) }; +}; + +export const replaceLocalResources = async (scope: string, resources: ResourceListItem[]) => { + await localDb.transaction("rw", localDb.resources, async () => { + const pendingLocalResources = (await localDb.resources.where("scope").equals(scope).toArray()) + .filter((resource) => resource.id.startsWith("local_resource_")); + await localDb.resources.where("scope").equals(scope).delete(); + await localDb.resources.bulkPut([ + ...resources.map((resource): LocalResource => ({ ...resource, scope })), + ...pendingLocalResources, + ]); + }); +}; + +export const putLocalResource = async (scope: string, resource: ResourceListItem) => { + await localDb.resources.put({ ...resource, scope }); +}; + +export const createLocalResource = async (scope: string, memoId: string, file: File) => { + const now = new Date().toISOString(); + const id = `local_resource_${crypto.randomUUID()}`; + const url = localResourceUrl(id); + await cacheLocalResourceBytes(url, file); + const resource: ResourceListItem = { + id, + memoId, + originalMemoId: null, + kind: file.type.startsWith("image/") ? "image" : "attachment", + mimeType: file.type || null, + filename: file.name, + byteSize: file.size, + sha256: null, + width: null, + height: null, + createdAt: now, + updatedAt: now, + url, + memoTitle: null, + memoExcerpt: null, + memoDeleted: false, + }; + await putLocalResource(scope, resource); + return resource; +}; + +export const replaceLocalResource = async (scope: string, localResourceId: string, resource: ResourceListItem) => { + const current = await localDb.resources.get([scope, localResourceId]); + if (current) await localDb.resources.delete([scope, localResourceId]); + await putLocalResource(scope, resource); + if (current) await removeCachedLocalResourceBytes(current.url); +}; + +export const getLocalNotebook = async (scope: string, notebookId: string) => { + const notebook = await localDb.notebooks.get([scope, notebookId]); + if (!notebook) return null; + const { scope: _scope, ...result } = notebook; + return result; +}; + +export const updateLocalNotebook = async (scope: string, notebookId: string, input: Partial>) => { + const notebook = await getLocalNotebook(scope, notebookId); + if (!notebook) return null; + const updated = { ...notebook, ...input, updatedAt: new Date().toISOString() }; + await putLocalNotebook(scope, updated); + return updated; +}; + +export const deleteLocalNotebook = async (scope: string, notebookId: string) => { + const notebook = await getLocalNotebook(scope, notebookId); + if (!notebook) return false; + await localDb.notebooks.delete([scope, notebookId]); + return true; +}; + +export const createLocalNotebook = async (scope: string, input: { name: string; parentId?: string | null }) => { + const now = new Date().toISOString(); + const notebook: Notebook = { + id: `local_${crypto.randomUUID()}`, + parentId: input.parentId ?? null, + name: input.name.trim(), + slug: null, + icon: null, + color: null, + sortOrder: 0, + memoCount: 0, + lastMemoUpdatedAt: null, + createdAt: now, + updatedAt: now, + }; + await putLocalNotebook(scope, notebook); + return notebook; +}; + +export const createLocalTemplate = async (scope: string, input: { name: string; description?: string | null; title?: string | null; contentMarkdown?: string; tags?: string[] }) => { + const now = new Date().toISOString(); + const contentMarkdown = input.contentMarkdown ?? ""; + const template: MemoTemplate = { + id: `local_${crypto.randomUUID()}`, + name: input.name.trim(), + description: input.description ?? null, + title: input.title ?? null, + contentJson: markdownToDoc(contentMarkdown), + contentMarkdown, + tags: input.tags ?? [], + createdAt: now, + updatedAt: now, + }; + await putLocalTemplate(scope, template); + return template; +}; + +export const updateLocalTemplate = async (scope: string, templateId: string, input: Partial> & { contentMarkdown?: string }) => { + const current = (await localDb.templates.get([scope, templateId])); + if (!current) return null; + const contentMarkdown = input.contentMarkdown ?? current.contentMarkdown; + const { scope: _scope, ...template } = current; + const updated: MemoTemplate = { + ...template, + ...input, + contentMarkdown, + contentJson: input.contentMarkdown === undefined ? current.contentJson : markdownToDoc(contentMarkdown), + updatedAt: new Date().toISOString(), + }; + await putLocalTemplate(scope, updated); + return updated; +}; + +export const listLocalTemplates = async (scope: string): Promise<{ templates: MemoTemplate[] }> => { + const templates = await localDb.templates.where("scope").equals(scope).sortBy("updatedAt"); + return { templates: templates.reverse().map(({ scope: _scope, ...template }) => template) }; +}; + +export const putLocalTemplate = async (scope: string, template: MemoTemplate) => { + await localDb.templates.put({ ...template, scope }); +}; + +export const deleteLocalTemplate = async (scope: string, templateId: string) => { + await localDb.templates.delete([scope, templateId]); +}; + +export const listLocalTags = async (scope: string): Promise<{ tags: TagSummary[] }> => { + const memos = await localDb.memos.where("scope").equals(scope).toArray(); + const tags = new Map(); + for (const memo of memos) { + if (memo.isDeleted) continue; + for (const name of new Set(memo.tags.map((tag) => tag.trim()))) { + if (!name) continue; + const current = tags.get(name) ?? { memoCount: 0, updatedAt: null }; + current.memoCount += 1; + if (!current.updatedAt || memo.updatedAt > current.updatedAt) current.updatedAt = memo.updatedAt; + tags.set(name, current); + } + } + return { + tags: [...tags.entries()] + .sort(([left], [right]) => left.localeCompare(right, undefined, { sensitivity: "base" })) + .map(([name, value]) => ({ name, memoCount: value.memoCount, updatedAt: value.updatedAt })), + }; +}; + +const updateLocalMemos = async (scope: string, memoIds: string[], update: (memo: MemoDetail) => MemoDetail | null) => { + const wanted = new Set(memoIds); + let updated = 0; + await localDb.transaction("rw", localDb.memos, async () => { + const memos = await localDb.memos.where("scope").equals(scope).toArray(); + for (const stored of memos) { + if (wanted.size > 0 && !wanted.has(stored.id)) continue; + const { scope: _scope, ...memo } = stored; + const next = update(memo); + if (!next) { + await localDb.memos.delete([scope, memo.id]); + } else { + await localDb.memos.put({ ...next, scope }); + } + updated += 1; + } + }); + return updated; +}; + +export const applyLocalTagRename = async (scope: string, tag: string, name: string) => { + const normalizedTag = tag.trim(); + const normalizedName = name.trim(); + const memoIds = (await localDb.memos.where("scope").equals(scope).toArray()) + .filter((memo) => memo.tags.includes(normalizedTag)) + .map((memo) => memo.id); + return updateLocalMemos(scope, memoIds, (memo) => ({ + ...memo, + tags: memo.tags.map((current) => current === normalizedTag ? normalizedName : current).filter((current, index, all) => current && all.indexOf(current) === index), + updatedAt: new Date().toISOString(), + })); +}; + +export const applyLocalTagDelete = async (scope: string, tag: string) => + applyLocalTagRename(scope, tag, ""); + +export const applyLocalMemoMove = (scope: string, memoIds: string[], notebookId: string) => + updateLocalMemos(scope, memoIds, (memo) => ({ ...memo, notebookId, updatedAt: new Date().toISOString() })); + +export const applyLocalMemoPin = (scope: string, memoIds: string[], isPinned: boolean) => + updateLocalMemos(scope, memoIds, (memo) => ({ ...memo, isPinned, updatedAt: new Date().toISOString() })); + +export const applyLocalMemoDeleteBatch = async (scope: string, memoIds: string[], permanent = false) => { + if (permanent) return updateLocalMemos(scope, memoIds, () => null); + return updateLocalMemos(scope, memoIds, (memo) => ({ ...memo, isDeleted: true, deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString() })); +}; + +export const applyLocalEmptyTrash = async (scope: string) => { + const deletedIds = (await localDb.memos.where("scope").equals(scope).toArray()).filter((memo) => memo.isDeleted).map((memo) => memo.id); + return updateLocalMemos(scope, deletedIds, () => null); +}; + +export const mergeLocalMemos = async (scope: string, input: { memoIds: string[]; notebookId?: string; title?: string }) => { + const sources = (await Promise.all(input.memoIds.map((memoId) => getLocalMemo(scope, memoId)))).filter((memo): memo is MemoDetail => Boolean(memo)); + if (sources.length < 2) return null; + const contentMarkdown = sources.map((memo) => memo.contentMarkdown).filter(Boolean).join("\n\n---\n\n"); + const memo = await createLocalMemo(scope, { + notebookId: input.notebookId ?? sources[0]!.notebookId, + title: input.title ?? sources[0]!.title ?? "", + contentMarkdown, + tags: [...new Set(sources.flatMap((source) => source.tags))], + }); + const merged = { + ...memo, + sourceMemoIds: sources.map((source) => source.id), + mergeSourceCount: sources.length, + }; + await putLocalMemo(scope, merged); + await updateLocalMemos(scope, sources.map((source) => source.id), (source) => ({ + ...source, + isDeleted: true, + deletedAt: new Date().toISOString(), + mergedIntoMemoId: memo.id, + updatedAt: new Date().toISOString(), + })); + return merged; +}; + +export const deleteLocalMemo = async (scope: string, memoId: string, permanent = false) => { + if (permanent) { + await localDb.memos.delete([scope, memoId]); + return; + } + + const memo = await getLocalMemo(scope, memoId); + if (!memo) return; + await putLocalMemo(scope, { + ...memo, + isDeleted: true, + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +}; + +export const restoreLocalMemo = async (scope: string, memoId: string) => { + const memo = await getLocalMemo(scope, memoId); + if (!memo) return; + await putLocalMemo(scope, { + ...memo, + isDeleted: false, + deletedAt: null, + updatedAt: new Date().toISOString(), + }); +}; + +export const clearLocalScope = async (scope: string) => { + const scopedResources = await localDb.resources.where("scope").equals(scope).toArray(); + await localDb.transaction("rw", [localDb.notebooks, localDb.memos, localDb.templates, localDb.revisions, localDb.resources, localDb.syncQueue, localDb.syncMeta, localDb.idMappings], async () => { + const scopedMemos = await localDb.memos.where("scope").equals(scope).toArray(); + const scopedMemoIds = new Set(scopedMemos.map((memo) => memo.id)); + const queuedItems = await localDb.syncQueue.toArray(); + await localDb.notebooks.where("scope").equals(scope).delete(); + await localDb.memos.where("scope").equals(scope).delete(); + await localDb.templates.where("scope").equals(scope).delete(); + await localDb.revisions.where("scope").equals(scope).delete(); + await localDb.resources.where("scope").equals(scope).delete(); + await localDb.syncQueue.bulkDelete(queuedItems.filter((item) => item.scope === scope || scopedMemoIds.has(item.memoId)).map((item) => item.id)); + await localDb.syncMeta.where("scope").equals(scope).delete(); + await localDb.idMappings.where("scope").equals(scope).delete(); + }); + await Promise.all(scopedResources + .filter((resource) => resource.id.startsWith("local_resource_")) + .map((resource) => removeCachedLocalResourceBytes(resource.url))); +}; + +export type { LocalMemo, LocalNotebook }; diff --git a/apps/web/src/lib/local-resource-cache.ts b/apps/web/src/lib/local-resource-cache.ts new file mode 100644 index 00000000..5097fd60 --- /dev/null +++ b/apps/web/src/lib/local-resource-cache.ts @@ -0,0 +1,34 @@ +export const LOCAL_RESOURCE_CACHE_NAME = "edgeever-resource-blobs"; + +const absoluteResourceUrl = (url: string) => { + if (typeof window === "undefined" || !window.location?.origin) return url; + return new URL(url, window.location.origin).toString(); +}; + +export const localResourceUrl = (resourceId: string) => `/api/v1/resources/${encodeURIComponent(resourceId)}/blob`; + +const getResourceCache = async () => { + if (typeof caches === "undefined") return null; + return caches.open(LOCAL_RESOURCE_CACHE_NAME); +}; + +export const cacheLocalResourceBytes = async (url: string, file: Blob) => { + const cache = await getResourceCache(); + if (!cache) throw new Error("Offline resource storage is unavailable in this browser"); + await cache.put(absoluteResourceUrl(url), new Response(file, { + headers: { "Content-Type": file.type || "application/octet-stream" }, + })); +}; + +export const getCachedLocalResourceBytes = async (url: string) => { + const cache = await getResourceCache(); + if (!cache) return null; + const response = await cache.match(absoluteResourceUrl(url)); + return response ? response.blob() : null; +}; + +export const removeCachedLocalResourceBytes = async (url: string) => { + const cache = await getResourceCache(); + if (!cache) return; + await cache.delete(absoluteResourceUrl(url)); +}; diff --git a/apps/web/src/lib/repository.test.mjs b/apps/web/src/lib/repository.test.mjs new file mode 100644 index 00000000..b74f2d4a --- /dev/null +++ b/apps/web/src/lib/repository.test.mjs @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { IDBKeyRange, indexedDB } from "fake-indexeddb"; + +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +const { localDb } = await import("./local-db.ts"); +const { api } = await import("./api.ts"); +const { createWebRepository } = await import("./repository.ts"); + +afterEach(async () => { + await localDb.transaction("rw", [localDb.templates, localDb.notebooks, localDb.memos, localDb.resources, localDb.revisions, localDb.syncMeta], async () => { + await Promise.all([ + localDb.templates.clear(), + localDb.notebooks.clear(), + localDb.memos.clear(), + localDb.resources.clear(), + localDb.revisions.clear(), + localDb.syncMeta.clear(), + ]); + }); +}); + +describe("web repository offline boundaries", () => { + test("returns empty initialized collections without cloud fallbacks", async () => { + const previousOnline = globalThis.navigator?.onLine; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: false }); + const scope = "https://demo.edgeever.org|user-1"; + await localDb.syncMeta.put({ scope, key: "identity", value: "sync-1", updatedAt: new Date().toISOString() }); + const original = { + listTags: api.listTags, + listTemplates: api.listTemplates, + listResources: api.listResources, + listNotebooks: api.listNotebooks, + }; + api.listTags = async () => { throw new Error("cloud fallback"); }; + api.listTemplates = async () => { throw new Error("cloud fallback"); }; + api.listResources = async () => { throw new Error("cloud fallback"); }; + api.listNotebooks = async () => { throw new Error("cloud fallback"); }; + + try { + const repository = createWebRepository(scope); + expect(await repository.listTags()).toEqual({ tags: [] }); + expect(await repository.listTemplates()).toEqual({ templates: [] }); + expect(await repository.listResources()).toEqual({ resources: [], summary: { totalCount: 0, totalBytes: 0, imageCount: 0, attachmentCount: 0 } }); + expect((await repository.listNotebooks()).notebooks).toEqual([]); + } finally { + api.listTags = original.listTags; + api.listTemplates = original.listTemplates; + api.listResources = original.listResources; + api.listNotebooks = original.listNotebooks; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); + } + }); +}); diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts new file mode 100644 index 00000000..9300e4bb --- /dev/null +++ b/apps/web/src/lib/repository.ts @@ -0,0 +1,381 @@ +import { markdownToDoc, type MemoDetail, type MemoRevision, type MemoSummary, type MemoTemplate, type Notebook, type Resource, type ResourceListItem, type ResourceStorageSummary, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import { api } from "@/lib/api"; +import { + getLocalMemo, + hasLocalMirrorData, + isLocalMirrorInitialized, + listLocalMemos, + listLocalNotebooks, + listLocalTemplates, + putLocalTemplate, + deleteLocalTemplate, + listLocalTags, + applyLocalTagRename, + applyLocalTagDelete, + applyLocalMemoMove, + applyLocalMemoDeleteBatch, + applyLocalEmptyTrash, + applyLocalMemoPin, + updateLocalNotebook, + deleteLocalNotebook, + createLocalNotebook, + createLocalTemplate, + updateLocalTemplate, + mergeLocalMemos, + listLocalMemoRevisions, + getLocalMemoRevision, + putLocalMemoRevisions, + listLocalResources, + replaceLocalResources, + putLocalResource, + createLocalResource, + putLocalMemo, + putLocalNotebook, + putLocalMemoUpdate, + restoreLocalMemo, + syncLocalMirror, + type LocalMemoListParams, + type LocalMemoListResponse, +} from "@/lib/local-mirror"; +import { queueLocalAction, queueMemoCreate, queueMemoDelete, queueMemoRestore, queueMemoUpdate } from "@/lib/sync-queue"; +import type { MemoUpdateSyncPayload } from "@/lib/local-db"; +import { createDesktopRepository } from "@/lib/desktop-repository"; + +export type EdgeEverRepository = { + listNotebooks(): Promise<{ notebooks: Notebook[] }>; + createNotebook(input: { name: string; parentId?: string | null }): Promise<{ notebook: Notebook }>; + updateNotebook(notebookId: string, input: { name?: string; parentId?: string | null; sortOrder?: number }): Promise<{ notebook: Notebook }>; + deleteNotebook(notebookId: string): Promise<{ ok: true }>; + restoreNotebook(notebookId: string): Promise<{ notebook: Notebook }>; + listTemplates(): Promise<{ templates: MemoTemplate[] }>; + createTemplate(input: { name: string; description?: string | null; memoId?: string; title?: string | null; contentMarkdown?: string; tags?: string[] }): Promise<{ template: MemoTemplate }>; + updateTemplate(templateId: string, input: { name?: string; description?: string | null; title?: string | null; contentMarkdown?: string; tags?: string[] }): Promise<{ template: MemoTemplate }>; + deleteTemplate(templateId: string): Promise<{ ok: true }>; + useTemplate(templateId: string, notebookId: string): Promise<{ memo: MemoDetail }>; + uploadMemoResource(memoId: string, file: File): Promise<{ resource: Resource }>; + listResources(): Promise<{ resources: ResourceListItem[]; summary: ResourceStorageSummary }>; + listTags(): Promise<{ tags: TagSummary[] }>; + renameTag(tag: string, name: string): Promise<{ ok: true; updated: number }>; + deleteTag(tag: string): Promise<{ ok: true; updated: number }>; + moveMemos(input: { memoIds: string[]; notebookId: string }): Promise<{ ok: true; moved: number }>; + deleteMemos(input: { memoIds: string[]; permanent?: boolean }): Promise<{ ok: true; deleted: number }>; + emptyTrash(): Promise<{ ok: true; deleted: number }>; + pinMemos(input: { memoIds: string[]; isPinned: boolean }): Promise<{ ok: true; updated: number }>; + mergeMemos(input: { memoIds: string[]; notebookId?: string; title?: string }): Promise<{ memo: MemoDetail }>; + listMemos(params: LocalMemoListParams): Promise; + getMemo(memoId: string, includeDeleted?: boolean): Promise<{ memo: MemoDetail }>; + createMemo(input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[] }): Promise<{ memo: MemoDetail }>; + updateMemo(memo: MemoDetail, input: Omit): Promise<{ memo: MemoDetail; queued: true }>; + deleteMemo(memoId: string, permanent?: boolean): Promise<{ ok: true }>; + restoreMemo(memoId: string): Promise<{ memo: MemoDetail; queued: true }>; + listMemoRevisions(memoId: string): Promise<{ revisions: MemoRevision[] }>; + restoreMemoRevision(memoId: string, revisionId: string): Promise<{ memo: MemoDetail }>; + sync(): Promise<{ bootstrapped: boolean; changed: number }>; +}; + +export const createWebRepository = (scope: string): EdgeEverRepository => { + const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine; + + return ({ + async listNotebooks() { + const local = await listLocalNotebooks(scope); + if (local.notebooks.length > 0 || isOffline()) { + if (!isOffline()) void api.listNotebooks().then((remote) => Promise.all(remote.notebooks.map((notebook) => putLocalNotebook(scope, notebook)))).catch(() => {}); + return local; + } + + const remote = await api.listNotebooks(); + await Promise.all(remote.notebooks.map((notebook) => putLocalNotebook(scope, notebook))); + return remote; + }, + + async createNotebook(input) { + const notebook = await createLocalNotebook(scope, input); + await queueLocalAction(scope, "notebook.create", notebook.id, { ...input, temporaryId: notebook.id }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { notebook }; + }, + + async updateNotebook(notebookId, input) { + const local = await updateLocalNotebook(scope, notebookId, input); + if (!local) { + if (isOffline()) throw new Error("Notebook is not available in the local mirror"); + const result = await api.updateNotebook(notebookId, input); + await putLocalNotebook(scope, result.notebook); + return result; + } + await queueLocalAction(scope, "notebook.update", notebookId, { notebookId, ...input }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { notebook: local }; + }, + + async deleteNotebook(notebookId) { + await deleteLocalNotebook(scope, notebookId); + await queueLocalAction(scope, "notebook.delete", notebookId, { notebookId }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const }; + }, + + restoreNotebook: async (notebookId) => { + if (isOffline()) throw new Error("Notebook restore requires a connection"); + const result = await api.restoreNotebook(notebookId); + await putLocalNotebook(scope, result.notebook); + return result; + }, + + async listTemplates() { + const local = await listLocalTemplates(scope); + if (local.templates.length > 0 || isOffline()) { + if (!isOffline()) void api.listTemplates().then((remote) => Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template)))).catch(() => {}); + return local; + } + const remote = await api.listTemplates(); + await Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template))); + return remote; + }, + createTemplate: async (input) => { + const template = await createLocalTemplate(scope, input); + await queueLocalAction(scope, "template.create", template.id, { ...input, temporaryId: template.id }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { template }; + }, + updateTemplate: async (templateId, input) => { + const template = await updateLocalTemplate(scope, templateId, input); + if (!template) { + if (isOffline()) throw new Error("Template is not available in the local mirror"); + const result = await api.updateTemplate(templateId, input); + await putLocalTemplate(scope, result.template); + return result; + } + await queueLocalAction(scope, "template.update", templateId, { templateId, ...input }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { template }; + }, + deleteTemplate: async (templateId) => { + await deleteLocalTemplate(scope, templateId); + await queueLocalAction(scope, "template.delete", templateId, { templateId }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const }; + }, + useTemplate: async (templateId, notebookId) => { + const template = (await listLocalTemplates(scope)).templates.find((candidate) => candidate.id === templateId); + if (!template) { + if (isOffline()) throw new Error("Template is not available offline"); + const result = await api.useTemplate(templateId, notebookId); + await putLocalMemo(scope, result.memo); + return result; + } + const { createLocalMemo } = await import("@/lib/local-mirror"); + const memo = await createLocalMemo(scope, { + notebookId, + title: template.title ?? "", + contentMarkdown: template.contentMarkdown, + tags: template.tags, + }); + await queueMemoCreate(scope, { + temporaryId: memo.id, + notebookId, + title: template.title ?? "", + contentMarkdown: template.contentMarkdown, + tags: template.tags, + createdAt: memo.createdAt, + updatedAt: memo.updatedAt, + }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo }; + }, + uploadMemoResource: async (memoId, file) => { + if (typeof navigator !== "undefined" && !navigator.onLine) { + const resource = await createLocalResource(scope, memoId, file); + await queueLocalAction(scope, "resource.create", resource.id, { + resourceId: resource.id, + memoId, + filename: resource.filename, + mimeType: resource.mimeType, + url: resource.url, + }, memoId); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { resource }; + } + const result = await api.uploadMemoResource(memoId, file); + await putLocalResource(scope, { ...result.resource, memoTitle: null, memoExcerpt: null, memoDeleted: false }); + return result; + }, + async listResources() { + const local = await listLocalResources(scope); + if (local.resources.length > 0 || isOffline()) { + if (!isOffline()) void api.listResources().then((remote) => replaceLocalResources(scope, remote.resources)).catch(() => {}); + return local; + } + const remote = await api.listResources(); + await replaceLocalResources(scope, remote.resources); + return remote; + }, + async listTags() { + const local = await listLocalTags(scope); + if (local.tags.length > 0 || isOffline()) return local; + return api.listTags(); + }, + renameTag: async (tag, name) => { + const updated = await applyLocalTagRename(scope, tag, name); + await queueLocalAction(scope, "tag.rename", tag, { tag, name }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, updated }; + }, + deleteTag: async (tag) => { + const updated = await applyLocalTagDelete(scope, tag); + await queueLocalAction(scope, "tag.delete", tag, { tag }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, updated }; + }, + moveMemos: async (input) => { + const moved = await applyLocalMemoMove(scope, input.memoIds, input.notebookId); + await queueLocalAction(scope, "memo.moveBatch", input.memoIds.join(","), input); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, moved }; + }, + deleteMemos: async (input) => { + const deleted = await applyLocalMemoDeleteBatch(scope, input.memoIds, input.permanent); + await queueLocalAction(scope, "memo.deleteBatch", input.memoIds.join(","), input); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, deleted }; + }, + emptyTrash: async () => { + const deleted = await applyLocalEmptyTrash(scope); + await queueLocalAction(scope, "memo.emptyTrash", "trash", {}); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, deleted }; + }, + pinMemos: async ({ memoIds, isPinned }) => { + const updated = await applyLocalMemoPin(scope, memoIds, isPinned); + await queueLocalAction(scope, "memo.pinBatch", memoIds.join(","), { memoIds, isPinned }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const, updated }; + }, + mergeMemos: async (input) => { + const memo = await mergeLocalMemos(scope, input); + if (!memo) { + if (isOffline()) throw new Error("Memos are not available in the local mirror"); + return api.mergeMemos(input); + } + await queueLocalAction(scope, "memo.merge", memo.id, { ...input, temporaryId: memo.id }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo }; + }, + + async listMemos(params) { + if (await isLocalMirrorInitialized(scope) || await hasLocalMirrorData(scope)) { + return listLocalMemos(scope, params); + } + + const remote = await api.listMemos({ + notebookId: params.notebookId, + includeDescendants: Boolean(params.notebookIds?.length), + q: params.q, + trash: params.trash, + filter: params.filter, + sort: params.sort, + cursor: params.offset ? String(params.offset) : null, + limit: params.limit, + }); + return remote; + }, + + async getMemo(memoId, includeDeleted = false) { + const local = await getLocalMemo(scope, memoId); + if (local && (includeDeleted || !local.isDeleted)) return { memo: local }; + + if (isOffline()) throw new Error("Memo is not available in the local mirror"); + const remote = await api.getMemo(memoId, { includeDeleted }); + await putLocalMemo(scope, remote.memo); + return remote; + }, + + async createMemo(input) { + const { createLocalMemo } = await import("@/lib/local-mirror"); + const memo = await createLocalMemo(scope, input); + await queueMemoCreate(scope, { + temporaryId: memo.id, + notebookId: input.notebookId, + title: input.title ?? "", + contentMarkdown: input.contentMarkdown, + tags: input.tags ?? [], + createdAt: memo.createdAt, + updatedAt: memo.updatedAt, + }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo }; + }, + + async updateMemo(memo, input) { + const payload: MemoUpdateSyncPayload = { ...input, memoId: memo.id }; + const updated = await putLocalMemoUpdate(scope, memo, payload); + await queueMemoUpdate(payload, scope); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo: updated, queued: true }; + }, + + async deleteMemo(memoId, permanent = false) { + const { deleteLocalMemo } = await import("@/lib/local-mirror"); + await deleteLocalMemo(scope, memoId, permanent); + await queueMemoDelete(scope, { memoId, permanent }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { ok: true as const }; + }, + + async restoreMemo(memoId) { + await restoreLocalMemo(scope, memoId); + await queueMemoRestore(scope, { memoId }); + const memo = await getLocalMemo(scope, memoId); + if (!memo) throw new Error("Memo is not available in the local mirror"); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo, queued: true }; + }, + + async listMemoRevisions(memoId) { + const local = await listLocalMemoRevisions(scope, memoId); + if (local.revisions.length > 0 || isOffline()) { + if (!isOffline()) void api.listMemoRevisions(memoId).then((remote) => putLocalMemoRevisions(scope, remote.revisions)).catch(() => {}); + return local; + } + const remote = await api.listMemoRevisions(memoId); + await putLocalMemoRevisions(scope, remote.revisions); + return remote; + }, + async restoreMemoRevision(memoId, revisionId) { + const [memo, revision] = await Promise.all([getLocalMemo(scope, memoId), getLocalMemoRevision(scope, memoId, revisionId)]); + if (!memo || !revision) { + if (isOffline()) throw new Error("Revision is not available offline"); + return api.restoreMemoRevision(memoId, revisionId); + } + const contentJson = markdownToDoc(revision.contentMarkdown); + const updated = await putLocalMemoUpdate(scope, memo, { + title: revision.title ?? "", + contentJson, + contentMarkdown: revision.contentMarkdown, + tags: revision.tags, + }); + await queueMemoUpdate({ + memoId, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + editSessionId: `offline-revision:${revisionId}`, + title: revision.title ?? "", + contentJson, + contentMarkdown: revision.contentMarkdown, + tags: revision.tags, + }, scope); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return { memo: updated }; + }, + + sync: () => syncLocalMirror(scope), + }); +}; + +export const createRepository = (scope: string): EdgeEverRepository => { + if (typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable) { + return createDesktopRepository(); + } + return createWebRepository(scope); +}; diff --git a/apps/web/src/lib/sync-queue.test.mjs b/apps/web/src/lib/sync-queue.test.mjs new file mode 100644 index 00000000..ff180004 --- /dev/null +++ b/apps/web/src/lib/sync-queue.test.mjs @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { IDBKeyRange, indexedDB } from "fake-indexeddb"; + +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +const { localDb } = await import("./local-db.ts"); +const { api } = await import("./api.ts"); +const { createLocalDataScope, createLocalMemo, getLocalMemo, putLocalMemo, createLocalResource, listLocalResources } = await import("./local-mirror.ts"); +const { discardWebConflicts, queueLocalAction, syncQueuedChanges } = await import("./sync-queue.ts"); + +afterEach(async () => { + await localDb.transaction( + "rw", + [localDb.drafts, localDb.syncQueue, localDb.notebooks, localDb.memos, localDb.templates, localDb.revisions, localDb.resources, localDb.syncMeta, localDb.idMappings], + async () => { + await Promise.all([ + localDb.drafts.clear(), + localDb.syncQueue.clear(), + localDb.notebooks.clear(), + localDb.memos.clear(), + localDb.templates.clear(), + localDb.revisions.clear(), + localDb.resources.clear(), + localDb.syncMeta.clear(), + localDb.idMappings.clear(), + ]); + }, + ); +}); + +describe("web sync conflict recovery", () => { + test("restores the authoritative remote memo and removes the conflict", async () => { + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "Local draft" }); + const remote = { ...memo, title: "Remote version", revision: 4 }; + const originalGetMemo = api.getMemo; + api.getMemo = async () => ({ memo: remote }); + await localDb.syncQueue.put({ + id: "memo.update:conflict", + kind: "memo.update", + scope, + memoId: memo.id, + status: "conflict", + payload: { memoId: memo.id, expectedRevision: 0, expectedContentHash: memo.contentHash, editSessionId: "session", title: "Local draft", contentJson: memo.contentJson, tags: [] }, + attemptCount: 1, + lastError: "conflict", + nextAttemptAt: null, + claimId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + expect(await discardWebConflicts(scope)).toBe(1); + expect((await getLocalMemo(scope, memo.id))?.title).toBe("Remote version"); + expect(await localDb.syncQueue.get("memo.update:conflict")).toBeUndefined(); + api.getMemo = originalGetMemo; + }); + + test("uploads an offline resource and rewrites its memo reference", async () => { + const previousCaches = globalThis.caches; + const previousOnline = globalThis.navigator?.onLine; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); + const entries = new Map(); + globalThis.caches = { + open: async () => ({ + put: async (key, response) => entries.set(key, response), + match: async (key) => entries.get(key) ?? undefined, + delete: async (key) => entries.delete(key), + }), + }; + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "With file" }); + const staged = await createLocalResource(scope, memo.id, new File(["offline"], "offline.txt", { type: "text/plain" })); + const localMemo = await getLocalMemo(scope, memo.id); + const placeholderMemo = { + ...localMemo, + contentMarkdown: `[offline](${staged.url})`, + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: staged.url }] }] }, + }; + await putLocalMemo(scope, placeholderMemo); + await queueLocalAction(scope, "resource.create", staged.id, { + resourceId: staged.id, + memoId: memo.id, + filename: staged.filename, + mimeType: staged.mimeType, + url: staged.url, + }, memo.id); + + const original = { + uploadMemoResource: api.uploadMemoResource, + getMemo: api.getMemo, + createMemoEditSession: api.createMemoEditSession, + updateMemo: api.updateMemo, + }; + const remoteResource = { ...staged, id: "resource-remote", url: "/api/v1/resources/resource-remote/blob" }; + const remoteMemo = { ...placeholderMemo, contentMarkdown: `[offline](${staged.url})` }; + api.uploadMemoResource = async () => ({ resource: remoteResource }); + api.getMemo = async () => ({ memo: remoteMemo }); + api.createMemoEditSession = async () => ({ editSession: { id: "edit-1", baseRevision: 1, baseContentHash: remoteMemo.contentHash } }); + api.updateMemo = async (_memoId, payload) => ({ memo: { ...remoteMemo, ...payload, contentHash: "patched", revision: 2 } }); + + try { + const result = await syncQueuedChanges({ scope }); + expect(result.synced).toBe(1); + expect((await getLocalMemo(scope, memo.id))?.contentMarkdown).toContain(remoteResource.url); + expect((await listLocalResources(scope)).resources[0]?.id).toBe(remoteResource.id); + expect(await localDb.syncQueue.count()).toBe(0); + } finally { + api.uploadMemoResource = original.uploadMemoResource; + api.getMemo = original.getMemo; + api.createMemoEditSession = original.createMemoEditSession; + api.updateMemo = original.updateMemo; + globalThis.caches = previousCaches; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); + } + }); +}); diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index b9c7cf8d..03ee971a 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -1,7 +1,17 @@ -import type { MemoDetail, TiptapDoc } from "@edgeever/shared"; +import type { MemoDetail, MemoTemplate, Notebook, Resource, TiptapDoc } from "@edgeever/shared"; import { liveQuery } from "dexie"; import { ApiRequestError, api } from "@/lib/api"; -import { localDb, type MemoUpdateSyncPayload, type SyncQueueItem } from "@/lib/local-db"; +import { + localDb, + type MemoCreateSyncPayload, + type MemoDeleteSyncPayload, + type MemoRestoreSyncPayload, + type LocalActionKind, + type LocalActionPayload, + type MemoUpdateSyncPayload, + type SyncQueueItem, +} from "@/lib/local-db"; +import { getCachedLocalResourceBytes, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache"; export type SyncQueueSummary = { total: number; @@ -17,6 +27,7 @@ export type SyncRunResult = { failed: number; conflicted: number; }; +export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null; export const emptySyncQueueSummary = (): SyncQueueSummary => ({ total: 0, @@ -27,8 +38,32 @@ export const emptySyncQueueSummary = (): SyncQueueSummary => ({ }); export const getMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`; +export const getMemoCreateQueueId = (temporaryId: string) => `memo.create:${temporaryId}`; +export const getMemoDeleteQueueId = (memoId: string) => `memo.delete:${memoId}`; +export const getMemoRestoreQueueId = (memoId: string) => `memo.restore:${memoId}`; -export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => { +export const getLocalActionQueueId = (scope: string, kind: LocalActionKind, entityId: string) => `action:${scope}:${kind}:${entityId}`; + +export const queueLocalAction = async (scope: string, kind: LocalActionKind, entityId: string, payload: LocalActionPayload, memoId = entityId) => { + const id = getLocalActionQueueId(scope, kind, entityId); + const now = new Date().toISOString(); + await localDb.syncQueue.put({ + id, + kind, + scope, + memoId, + status: "pending", + payload, + attemptCount: 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: now, + updatedAt: now, + }); +}; + +export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload, scope?: string) => { const id = getMemoUpdateQueueId(payload.memoId); const now = new Date().toISOString(); await localDb.transaction("rw", localDb.syncQueue, async () => { @@ -37,6 +72,7 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => { await localDb.syncQueue.put({ id, kind: "memo.update", + scope: scope ?? existing?.scope, memoId: payload.memoId, status: "pending", payload, @@ -50,6 +86,89 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => { }); }; +export const queueMemoCreate = async (scope: string, payload: MemoCreateSyncPayload) => { + const id = getMemoCreateQueueId(payload.temporaryId); + const now = new Date().toISOString(); + await localDb.syncQueue.put({ + id, + kind: "memo.create", + scope, + memoId: payload.temporaryId, + status: "pending", + payload, + attemptCount: 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: now, + updatedAt: now, + }); +}; + +export const queueMemoDelete = async (scope: string, payload: MemoDeleteSyncPayload) => { + const id = getMemoDeleteQueueId(payload.memoId); + const now = new Date().toISOString(); + await localDb.syncQueue.put({ + id, + kind: "memo.delete", + scope, + memoId: payload.memoId, + status: "pending", + payload, + attemptCount: 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: now, + updatedAt: now, + }); +}; + +export const queueMemoRestore = async (scope: string, payload: MemoRestoreSyncPayload) => { + const id = getMemoRestoreQueueId(payload.memoId); + const now = new Date().toISOString(); + await localDb.syncQueue.put({ + id, + kind: "memo.restore", + scope, + memoId: payload.memoId, + status: "pending", + payload, + attemptCount: 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: now, + updatedAt: now, + }); +}; + +const remapQueuedMemoId = async (scope: string, temporaryId: string, remoteId: string) => { + const items = (await localDb.syncQueue.toArray()).filter( + (item) => item.scope === scope && item.memoId === temporaryId && item.kind !== "memo.create" + ); + await localDb.transaction("rw", localDb.syncQueue, async () => { + for (const item of items) { + const payload = { ...item.payload } as Record; + if ("memoId" in payload) payload.memoId = remoteId; + const nextId = item.kind === "memo.update" + ? getMemoUpdateQueueId(remoteId) + : item.kind === "memo.delete" + ? getMemoDeleteQueueId(remoteId) + : item.kind === "memo.restore" + ? getMemoRestoreQueueId(remoteId) + : item.id; + if (nextId !== item.id) await localDb.syncQueue.delete(item.id); + await localDb.syncQueue.put({ + ...item, + id: nextId, + memoId: remoteId, + payload: payload as SyncQueueItem["payload"], + }); + } + }); +}; + export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void) => { const subscription = liveQuery(async () => summarizeSyncQueue(await localDb.syncQueue.toArray())).subscribe({ next: onChange, @@ -59,21 +178,57 @@ export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void) return () => subscription.unsubscribe(); }; +export const discardWebConflicts = async (scope: string) => { + const conflicts: SyncQueueItem[] = []; + for (const item of await localDb.syncQueue.where("status").equals("conflict").toArray()) { + if (item.scope === scope) { + conflicts.push(item); + continue; + } + if (!item.scope && item.kind === "memo.update" && await localDb.memos.get([scope, item.memoId])) { + conflicts.push(item); + } + } + let discarded = 0; + const { putLocalMemo } = await import("@/lib/local-mirror"); + for (const item of conflicts) { + try { + if (item.kind === "memo.update") { + const remote = await api.getMemo(item.memoId, { includeDeleted: true }); + await putLocalMemo(scope, remote.memo); + await localDb.drafts.delete(item.memoId); + } + await localDb.syncQueue.delete(item.id); + discarded += 1; + } catch { + // Keep the conflict durable when the authoritative remote snapshot is + // unavailable; the user can retry after the connection recovers. + } + } + return discarded; +}; + export const isMemoUpdateAlreadyApplied = (memo: MemoDetail, item: SyncQueueItem) => { - if (memo.id !== item.memoId || memo.title !== item.payload.title) { + if (item.kind !== "memo.update") { + return false; + } + const payload = item.payload as MemoUpdateSyncPayload; + if (memo.id !== item.memoId || memo.title !== payload.title) { return false; } const remoteTags = [...memo.tags].sort((left, right) => left.localeCompare(right)); - const queuedTags = [...item.payload.tags].sort((left, right) => left.localeCompare(right)); + const queuedTags = [...payload.tags].sort((left, right) => left.localeCompare(right)); return JSON.stringify(remoteTags) === JSON.stringify(queuedTags) && - JSON.stringify(memo.contentJson) === JSON.stringify(item.payload.contentJson); + JSON.stringify(memo.contentJson) === JSON.stringify(payload.contentJson); }; let activeSyncPromise: Promise | null = null; export const syncQueuedChanges = (options: { - onSynced?: (memo: MemoDetail) => void | Promise; + scope?: string; + onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise; + onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise; } = {}): Promise => { if (activeSyncPromise) { return activeSyncPromise; @@ -87,7 +242,9 @@ export const syncQueuedChanges = (options: { }; const runQueuedChanges = async (options: { - onSynced?: (memo: MemoDetail) => void | Promise; + scope?: string; + onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise; + onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise; }): Promise => { const result: SyncRunResult = { attempted: 0, @@ -101,9 +258,27 @@ const runQueuedChanges = async (options: { } const now = new Date(); - const items = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray()) + const candidates = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray()) .filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + const items: SyncQueueItem[] = []; + for (const candidate of candidates) { + if (!options.scope || candidate.scope === options.scope) { + items.push(candidate); + continue; + } + if (candidate.scope || candidate.kind !== "memo.update") { + continue; + } + // Older releases did not persist a scope for editor retry items. Adopt one + // only when the current account already owns the memo in its local mirror; + // otherwise leave the item untouched instead of risking cross-account sync. + const localMemo = await localDb.memos.get([options.scope, candidate.memoId]); + if (!localMemo) continue; + const scopedCandidate = { ...candidate, scope: options.scope }; + await localDb.syncQueue.update(candidate.id, { scope: options.scope, updatedAt: new Date().toISOString() }); + items.push(scopedCandidate); + } for (const candidate of items) { const item = await claimQueueItem(candidate.id); @@ -115,10 +290,18 @@ const runQueuedChanges = async (options: { try { const memo = await syncQueueItem(item); + if (item.kind === "memo.create" && memo && item.scope) { + await remapQueuedMemoId(item.scope, item.memoId, memo.id); + } const removed = await removeClaimedQueueItem(item); if (removed) { await localDb.drafts.delete(item.memoId); - await options.onSynced?.(memo); + if (memo && (item.kind === "memo.create" || item.kind === "memo.update" || item.kind === "memo.restore" || item.kind === "memo.delete")) { + await options.onSynced?.(memo as MemoDetail, item); + } + if (item.kind !== "memo.create" && item.kind !== "memo.update" && item.kind !== "memo.restore" && item.kind !== "memo.delete") { + await options.onActionSynced?.(memo, item); + } result.synced += 1; } } catch (error) { @@ -199,27 +382,213 @@ export const shouldQueueMemoSaveError = (error: unknown) => { return error instanceof TypeError; }; -const syncQueueItem = async (item: SyncQueueItem): Promise => { +const rewriteResourceValue = (value: unknown, placeholder: string, url: string): unknown => { + if (typeof value === "string") return value.split(placeholder).join(url); + if (Array.isArray(value)) return value.map((item) => rewriteResourceValue(item, placeholder, url)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, rewriteResourceValue(child, placeholder, url)])); + } + return value; +}; + +const syncLocalResource = async (item: SyncQueueItem) => { + const payload = item.payload as LocalActionPayload; + const memoId = String(payload.memoId ?? item.memoId); + const placeholder = String(payload.url ?? ""); + const blob = placeholder ? await getCachedLocalResourceBytes(placeholder) : null; + if (!blob) throw new Error("Offline resource bytes are unavailable"); + + const file = new File([blob], String(payload.filename ?? "attachment"), { type: String(payload.mimeType ?? blob.type ?? "application/octet-stream") }); + const result = await api.uploadMemoResource(memoId, file); + const remoteUrl = result.resource.url; + const { getLocalMemo, putLocalMemo, replaceLocalResource } = await import("@/lib/local-mirror"); + const localResource = String(payload.resourceId ?? ""); + + const localMemo = await getLocalMemo(item.scope ?? "", memoId); + if (localMemo) { + const contentJson = rewriteResourceValue(localMemo.contentJson, placeholder, remoteUrl); + const contentMarkdown = rewriteResourceValue(localMemo.contentMarkdown, placeholder, remoteUrl); + if (JSON.stringify(contentJson) !== JSON.stringify(localMemo.contentJson) || contentMarkdown !== localMemo.contentMarkdown) { + await putLocalMemo(item.scope ?? "", { + ...localMemo, + contentJson: contentJson as MemoDetail["contentJson"], + contentMarkdown: typeof contentMarkdown === "string" ? contentMarkdown : localMemo.contentMarkdown, + updatedAt: new Date().toISOString(), + }); + } + } + + const queuedItems = (await localDb.syncQueue.toArray()).filter((candidate) => candidate.scope === item.scope); + for (const queued of queuedItems) { + const payloadValue = rewriteResourceValue(queued.payload, placeholder, remoteUrl); + if (JSON.stringify(payloadValue) !== JSON.stringify(queued.payload)) { + await localDb.syncQueue.update(queued.id, { payload: payloadValue as SyncQueueItem["payload"], updatedAt: new Date().toISOString() }); + } + } + + const remote = await api.getMemo(memoId, { includeDeleted: true }); + const contentJson = rewriteResourceValue(remote.memo.contentJson, placeholder, remoteUrl) as MemoDetail["contentJson"]; + const contentMarkdown = rewriteResourceValue(remote.memo.contentMarkdown, placeholder, remoteUrl); + if (JSON.stringify(contentJson) !== JSON.stringify(remote.memo.contentJson) || contentMarkdown !== remote.memo.contentMarkdown) { + const session = (await api.createMemoEditSession(memoId)).editSession; + const patched = await api.updateMemo(memoId, { + expectedRevision: session.baseRevision, + expectedContentHash: session.baseContentHash, + editSessionId: session.id, + title: remote.memo.title ?? "", + contentJson, + contentMarkdown: typeof contentMarkdown === "string" ? contentMarkdown : undefined, + tags: remote.memo.tags, + }); + await putLocalMemo(item.scope ?? "", patched.memo); + } + await replaceLocalResource(item.scope ?? "", localResource, { + ...result.resource, + memoTitle: null, + memoExcerpt: null, + memoDeleted: false, + }); + await removeCachedLocalResourceBytes(placeholder); + return result.resource; +}; + +const syncQueueItem = async (item: SyncQueueItem): Promise => { + if (item.kind === "memo.create") { + const payload = item.payload as MemoCreateSyncPayload; + const data = await api.createMemo({ + notebookId: payload.notebookId, + title: payload.title, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + }); + return data.memo; + } + + if (item.kind === "memo.delete") { + const payload = item.payload as MemoDeleteSyncPayload; + await api.deleteMemo(payload.memoId, { permanent: payload.permanent }); + return null; + } + + if (item.kind === "memo.restore") { + const payload = item.payload as MemoRestoreSyncPayload; + const data = await api.restoreMemo(payload.memoId); + return data.memo; + } + + if (item.kind === "tag.rename") { + const payload = item.payload as LocalActionPayload; + await api.renameTag(String(payload.tag ?? item.memoId), String(payload.name ?? "")); + return null; + } + if (item.kind === "tag.delete") { + const payload = item.payload as LocalActionPayload; + await api.deleteTag(String(payload.tag ?? item.memoId)); + return null; + } + if (item.kind === "memo.moveBatch") { + const payload = item.payload as LocalActionPayload; + await api.moveMemos({ memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((value): value is string => typeof value === "string") : [], notebookId: String(payload.notebookId ?? "") }); + return null; + } + if (item.kind === "memo.deleteBatch") { + const payload = item.payload as LocalActionPayload; + await api.deleteMemos({ memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((value): value is string => typeof value === "string") : [], permanent: Boolean(payload.permanent) }); + return null; + } + if (item.kind === "memo.emptyTrash") { + await api.emptyTrash(); + return null; + } + if (item.kind === "memo.pinBatch") { + const payload = item.payload as LocalActionPayload; + const memoIds = Array.isArray(payload.memoIds) ? payload.memoIds.filter((value): value is string => typeof value === "string") : []; + await Promise.all(memoIds.map((memoId) => api.updateMemo(memoId, { isPinned: Boolean(payload.isPinned) }))); + return null; + } + if (item.kind === "memo.merge") { + const payload = item.payload as LocalActionPayload; + const result = await api.mergeMemos({ + memoIds: Array.isArray(payload.memoIds) ? payload.memoIds.filter((value): value is string => typeof value === "string") : [], + notebookId: typeof payload.notebookId === "string" ? payload.notebookId : undefined, + title: typeof payload.title === "string" ? payload.title : undefined, + }); + return result.memo; + } + if (item.kind === "notebook.update") { + const payload = item.payload as LocalActionPayload; + const result = await api.updateNotebook(String(payload.notebookId ?? item.memoId), { + name: typeof payload.name === "string" ? payload.name : undefined, + parentId: Object.prototype.hasOwnProperty.call(payload, "parentId") ? (typeof payload.parentId === "string" ? payload.parentId : null) : undefined, + sortOrder: typeof payload.sortOrder === "number" ? payload.sortOrder : undefined, + }); + return result.notebook; + } + if (item.kind === "notebook.create") { + const payload = item.payload as LocalActionPayload; + const result = await api.createNotebook({ name: String(payload.name ?? ""), parentId: typeof payload.parentId === "string" ? payload.parentId : null }); + return result.notebook; + } + if (item.kind === "notebook.delete") { + const payload = item.payload as LocalActionPayload; + await api.deleteNotebook(String(payload.notebookId ?? item.memoId)); + return null; + } + if (item.kind === "template.create") { + const payload = item.payload as LocalActionPayload; + const result = await api.createTemplate({ + name: String(payload.name ?? ""), + description: typeof payload.description === "string" ? payload.description : null, + title: typeof payload.title === "string" ? payload.title : null, + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : "", + tags: Array.isArray(payload.tags) ? payload.tags.filter((value): value is string => typeof value === "string") : [], + }); + return result.template; + } + if (item.kind === "template.update") { + const payload = item.payload as LocalActionPayload; + const result = await api.updateTemplate(String(payload.templateId ?? item.memoId), { + name: typeof payload.name === "string" ? payload.name : undefined, + description: typeof payload.description === "string" ? payload.description : null, + title: typeof payload.title === "string" ? payload.title : null, + contentMarkdown: typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : undefined, + tags: Array.isArray(payload.tags) ? payload.tags.filter((value): value is string => typeof value === "string") : [], + }); + return result.template; + } + if (item.kind === "template.delete") { + const payload = item.payload as LocalActionPayload; + await api.deleteTemplate(String(payload.templateId ?? item.memoId)); + return null; + } + + if (item.kind === "resource.create") { + return syncLocalResource(item); + } + if (item.kind !== "memo.update") { throw new Error(`Unsupported sync item kind: ${item.kind}`); } + const payload = item.payload as MemoUpdateSyncPayload; const { editSession } = await api.createMemoEditSession(item.memoId); if ( - editSession.baseRevision !== item.payload.expectedRevision || - editSession.baseContentHash !== item.payload.expectedContentHash + editSession.baseRevision !== payload.expectedRevision || + editSession.baseContentHash !== payload.expectedContentHash ) { throw new ApiRequestError("Note changed before the offline draft could sync.", 409, "revision_conflict"); } const data = await api.updateMemo(item.memoId, { - expectedRevision: item.payload.expectedRevision, - expectedContentHash: item.payload.expectedContentHash, + expectedRevision: payload.expectedRevision, + expectedContentHash: payload.expectedContentHash, editSessionId: editSession.id, - title: item.payload.title, - contentJson: item.payload.contentJson as TiptapDoc, - contentMarkdown: item.payload.contentMarkdown, - tags: item.payload.tags, + title: payload.title, + contentJson: payload.contentJson as TiptapDoc, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags, }); return data.memo; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 55789b5a..ddf88e27 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,3 +7,25 @@ declare const __EDGEEVER_APP_VERSION__: string; declare const __EDGEEVER_RELEASED_AT__: string; declare const __EDGEEVER_DEPLOYMENT_TRIGGER__: string; declare const __EDGEEVER_DEPLOYMENT_METHOD__: string; + +interface EdgeEverDesktopBridge { + isAvailable: boolean; + apiBaseUrl: string; + setApiBaseUrl(value: string): Promise; + sidecarStatus(): Promise<{ available: boolean; path: string; scope: string }>; + setAccountScope(accountId: string | null): Promise<{ ready: true; scope: string }>; + updateStatus(): Promise<{ state: "idle" | "available" | "downloaded" }>; + downloadUpdate(): Promise; + installUpdate(): Promise; + sidecarRequest(method: string, params?: Record): Promise; + stageResource(input: { memoId: string; name: string; type: string; bytes: ArrayBuffer }): Promise<{ id: string }>; + listStagedResources(): Promise>; + readStagedResource(id: string): Promise<{ name: string; type: string; bytes: Uint8Array }>; + removeStagedResource(id: string): Promise; + onCommand(callback: (command: string) => void): () => void; + onImportMarkdown(callback: (payload: { name: string; content: string }) => void): () => void; +} + +interface Window { + edgeeverDesktop?: EdgeEverDesktopBridge; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a07f6ff7..79b9cd4b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -118,9 +118,40 @@ export default defineConfig({ }, workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,webp,woff2}"], + globIgnores: [ + "**/vendor-beautiful-mermaid-*.js", + "**/vendor-mermaid-*.js", + "**/mermaid.core-*.js", + "**/*Diagram-*.js", + ], navigateFallback: "/index.html", navigateFallbackDenylist: [/^\/api\//, /^\/mcp\//, /^\/mobile-edit\.html$/, /^\/note-print\.html/, /^\/tiptap-ime-test\.html$/], runtimeCaching: [ + { + urlPattern: ({ url }) => /^\/api\/v1\/resources\/[^/]+\/blob$/.test(url.pathname), + handler: "CacheFirst", + options: { + cacheName: "edgeever-resource-blobs", + cacheableResponse: { + statuses: [0, 200], + }, + expiration: { + maxEntries: 500, + maxAgeSeconds: 60 * 60 * 24 * 90, + }, + }, + }, + { + urlPattern: ({ url }) => /\/assets\/(?:vendor-(?:beautiful-mermaid|mermaid)|mermaid\.core|.*Diagram-)/.test(url.pathname), + handler: "CacheFirst", + options: { + cacheName: "edgeever-optional-diagrams", + expiration: { + maxEntries: 120, + maxAgeSeconds: 60 * 60 * 24 * 30, + }, + }, + }, { urlPattern: ({ url }) => url.pathname.startsWith("/api/") || url.pathname.startsWith("/mcp/"), handler: "NetworkOnly", @@ -149,6 +180,11 @@ export default defineConfig({ build: { outDir: "dist", emptyOutDir: true, + modulePreload: { + resolveDependencies: (_filename, dependencies) => dependencies.filter((dependency) => + !/(?:vendor-code-highlight|vendor-(?:mermaid|D3|tiptap|prosemirror|floating)|ui-primitives)/.test(dependency), + ), + }, rolldownOptions: { input: { app: fileURLToPath(new URL("./index.html", import.meta.url)), @@ -159,6 +195,11 @@ export default defineConfig({ output: { codeSplitting: { groups: [ + { + name: "vendor-code-highlight", + test: /node_modules[\\/](?:lowlight|highlight\.js|@tiptap[\\/]extension-code-block-lowlight)[\\/]/, + priority: 50, + }, { name: "vendor-react", test: /node_modules[\\/](react|react-dom|scheduler|react-router)[\\/]/, diff --git a/bun.lock b/bun.lock index 45dccf67..d34741c4 100644 --- a/bun.lock +++ b/bun.lock @@ -73,9 +73,20 @@ "name": "@edgeever/api", "version": "0.1.3", }, + "apps/desktop": { + "name": "@edgeever/desktop", + "version": "0.1.0", + "dependencies": { + "electron-updater": "^6.6.2", + }, + "devDependencies": { + "electron": "^38.0.0", + "electron-builder": "^26.0.12", + }, + }, "apps/extension": { "name": "@edgeever/extension", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@mozilla/readability": "^0.6.0", "turndown": "^7.2.4", @@ -519,6 +530,8 @@ "@edgeever/client": ["@edgeever/client@workspace:packages/client"], + "@edgeever/desktop": ["@edgeever/desktop@workspace:apps/desktop"], + "@edgeever/extension": ["@edgeever/extension@workspace:apps/extension"], "@edgeever/mobile": ["@edgeever/mobile@workspace:apps/mobile"], @@ -531,6 +544,22 @@ "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + + "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], + + "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="], + + "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], @@ -777,6 +806,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], @@ -787,6 +820,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -799,6 +834,14 @@ "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -1027,7 +1070,7 @@ "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@smithy/core": ["@smithy/core@3.30.0", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw=="], @@ -1043,6 +1086,8 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], @@ -1171,6 +1216,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -1239,18 +1286,24 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/marked": ["@types/marked@6.0.0", "", { "dependencies": { "marked": "*" } }, "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -1271,6 +1324,8 @@ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], @@ -1313,6 +1368,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -1339,7 +1396,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], @@ -1347,9 +1404,11 @@ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -1363,6 +1422,8 @@ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astro": ["astro@5.18.2", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ=="], @@ -1375,8 +1436,12 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], @@ -1387,6 +1452,8 @@ "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], @@ -1423,8 +1490,12 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], @@ -1445,10 +1516,20 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], + + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], + "byte-length": ["byte-length@1.0.2", "", {}, "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -1489,6 +1570,8 @@ "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -1507,6 +1590,8 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1521,6 +1606,8 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], @@ -1529,10 +1616,14 @@ "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], + "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -1549,8 +1640,12 @@ "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1671,10 +1766,14 @@ "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -1683,6 +1782,8 @@ "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -1693,6 +1794,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], @@ -1707,8 +1810,12 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -1723,16 +1830,34 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + "electron": ["electron@38.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-lyBhcVi9QYAZL6FO6r5twAWAjWnYomo3iVDvrb5SJZlq928BGemHOKG0tPIq41NOLaCu9f3XdEEjMkjQPjprRg=="], + + "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="], + + "electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="], + "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], + + "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], @@ -1749,6 +1874,10 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], @@ -1773,6 +1902,8 @@ "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], "es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], @@ -1945,6 +2076,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], @@ -1953,7 +2086,9 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1991,12 +2126,16 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -2053,7 +2192,7 @@ "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], - "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "hot-patcher": ["hot-patcher@2.0.1", "", {}, "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="], @@ -2069,6 +2208,10 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], @@ -2087,6 +2230,8 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -2187,9 +2332,11 @@ "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], @@ -2215,8 +2362,12 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], @@ -2227,6 +2378,8 @@ "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -2239,6 +2392,8 @@ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], @@ -2277,10 +2432,16 @@ "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], @@ -2291,6 +2452,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], @@ -2315,6 +2478,8 @@ "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], @@ -2467,7 +2632,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -2475,12 +2640,16 @@ "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "miniflare": ["miniflare@4.20260714.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260714.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw=="], "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], @@ -2515,6 +2684,10 @@ "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + "node-abi": ["node-abi@4.33.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA=="], + + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -2523,14 +2696,20 @@ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -2573,6 +2752,8 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], @@ -2605,6 +2786,8 @@ "path-expression-matcher": ["path-expression-matcher@1.6.1", "", {}, "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -2617,6 +2800,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], @@ -2631,7 +2816,9 @@ "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], - "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], @@ -2655,6 +2842,8 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "prettier": ["prettier@3.9.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg=="], "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], @@ -2665,14 +2854,20 @@ "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], @@ -2705,6 +2900,10 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], @@ -2715,6 +2914,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2771,8 +2972,12 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], @@ -2837,12 +3042,18 @@ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], @@ -2853,8 +3064,14 @@ "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], @@ -2881,15 +3098,19 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], - "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], "serialize-javascript": ["serialize-javascript@7.0.6", "", {}, "sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg=="], @@ -2929,12 +3150,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="], @@ -2963,6 +3186,8 @@ "standard-navigation": ["standard-navigation@0.0.5", "", {}, "sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -2983,6 +3208,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], @@ -3007,6 +3234,8 @@ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], @@ -3025,8 +3254,12 @@ "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="], + "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="], "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], @@ -3039,12 +3272,20 @@ "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + + "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3061,6 +3302,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -3147,6 +3390,8 @@ "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], + "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -3163,6 +3408,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], @@ -3237,6 +3484,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webdav": ["webdav@5.10.0", "", { "dependencies": { "@buttercup/fetch": "^0.2.1", "base-64": "^1.0.0", "byte-length": "^1.0.2", "entities": "^6.0.1", "fast-xml-parser": "^5.7.2", "hot-patcher": "^2.0.1", "layerr": "^3.0.0", "md5": "^2.3.0", "minimatch": "^9.0.9", "nested-property": "^4.0.0", "node-fetch": "^3.3.2", "path-posix": "^1.0.0", "url-join": "^5.0.0", "url-parse": "^1.5.10" } }, "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A=="], "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -3251,7 +3500,7 @@ "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -3389,6 +3638,32 @@ "@edgeever/site/tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@electron/fuses/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/osx-sign/plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "@electron/universal/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + + "@electron/universal/plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "@expo/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -3433,8 +3708,12 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], "@radix-ui/react-tabs/@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="], @@ -3493,6 +3772,16 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "app-builder-lib/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "astro/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -3513,6 +3802,8 @@ "boxen/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -3521,6 +3812,8 @@ "cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3531,6 +3824,8 @@ "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -3543,8 +3838,24 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "electron/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-builder/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "expo/babel-preset-expo": ["babel-preset-expo@57.0.3", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.0", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.5", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-JuTLwC4dt30GF3L8sY7EBwh5iD7L3dSWumfg+i99bFK2SIXWyfn01UHxu+azfKXFU/ufim9oUt9KUoJ9AvyOPA=="], "expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -3577,13 +3888,15 @@ "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -3599,14 +3912,14 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "markdown-it/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "markdown-it/entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -3621,6 +3934,8 @@ "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "metro/serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], + "metro/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "metro/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], @@ -3639,6 +3954,14 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "node-gyp/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "npm-package-arg/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -3649,10 +3972,14 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -3665,18 +3992,34 @@ "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "simple-plist/plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + "sitemap/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -3687,30 +4030,38 @@ "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "temp/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "unified/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "workbox-build/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "workbox-build/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3779,54 +4130,48 @@ "@edgeever/site/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "@expo/cli/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "@electron/fuses/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "@electron/osx-sign/plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "@electron/osx-sign/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "@electron/universal/plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "@electron/universal/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "@expo/cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/config-plugins/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/config-plugins/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/devtools/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/devtools/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/env/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/env/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/fingerprint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/fingerprint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "@expo/image-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/image-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/local-build-cache-provider/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/local-build-cache-provider/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/metro-config/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/metro-config/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/package-manager/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/package-manager/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/xcpretty/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@expo/xcpretty/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@iconify/tools/svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "@iconify/tools/svgo/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], - "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@radix-ui/react-tabs/@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="], @@ -3835,12 +4180,20 @@ "@react-native/codegen/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@testing-library/dom/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -3945,6 +4298,8 @@ "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "chromium-edge-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -3957,32 +4312,46 @@ "cross-fetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], + + "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "electron-builder/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "expo-linking/expo-constants/@expo/env": ["@expo/env@2.4.1", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ=="], - "expo-modules-autolinking/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "expo-modules-autolinking/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "expo-updates/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "expo-updates/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-validate/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -4055,6 +4424,10 @@ "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -4067,8 +4440,14 @@ "react-native/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "simple-plist/plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "simple-plist/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], @@ -4101,6 +4480,12 @@ "@react-native/codegen/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -4157,6 +4542,12 @@ "cross-fetch/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "electron-builder/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "electron-builder/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "expo-linking/expo-constants/@expo/env/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -4181,9 +4572,9 @@ "react-native/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], - "expo-linking/expo-constants/@expo/env/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "expo-linking/expo-constants/@expo/env/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/crates/desktop-sidecar/Cargo.lock b/crates/desktop-sidecar/Cargo.lock new file mode 100644 index 00000000..5e1feb37 --- /dev/null +++ b/crates/desktop-sidecar/Cargo.lock @@ -0,0 +1,342 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "edgeever-sidecar" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/desktop-sidecar/Cargo.toml b/crates/desktop-sidecar/Cargo.toml new file mode 100644 index 00000000..26aac30e --- /dev/null +++ b/crates/desktop-sidecar/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "edgeever-sidecar" +version = "0.1.0" +edition = "2021" + +[dependencies] +rusqlite = { version = "0.32", features = ["backup", "bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/crates/desktop-sidecar/README.md b/crates/desktop-sidecar/README.md new file mode 100644 index 00000000..bc9ad67d --- /dev/null +++ b/crates/desktop-sidecar/README.md @@ -0,0 +1,15 @@ +# EdgeEver desktop sidecar + +The sidecar is a private local process owned by the Electron main process. It +communicates over newline-delimited JSON-RPC on stdin/stdout and owns the +desktop SQLite data directory. + +Build locally with: + +```sh +cargo build --manifest-path crates/desktop-sidecar/Cargo.toml +``` + +The first milestone exposes `system.info`, `storage.health`, and +`app.shutdown`. Domain storage and sync methods will be added behind the same +protocol without exposing Node.js APIs to the renderer. diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs new file mode 100644 index 00000000..ca10e9ea --- /dev/null +++ b/crates/desktop-sidecar/src/main.rs @@ -0,0 +1,1784 @@ +use rusqlite::{backup::Backup, Connection, DatabaseName, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[derive(Debug, Deserialize)] +struct RpcRequest { + id: Value, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +struct RpcError<'a> { + code: &'a str, + message: String, +} + +fn data_dir() -> PathBuf { + let args: Vec = env::args().collect(); + args.windows(2) + .find(|pair| pair[0] == "--data-dir") + .map(|pair| PathBuf::from(&pair[1])) + .unwrap_or_else(|| PathBuf::from(".edgeever-desktop")) +} + +fn migrations_dir() -> PathBuf { + let args: Vec = env::args().collect(); + args.windows(2) + .find(|pair| pair[0] == "--migrations-dir") + .map(|pair| PathBuf::from(&pair[1])) + .unwrap_or_else(|| PathBuf::from("migrations")) +} + +fn restrict_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions)?; + } + Ok(()) +} + +fn restrict_file(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + if path.exists() { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o600); + fs::set_permissions(path, permissions)?; + } + } + Ok(()) +} + +fn apply_migrations(connection: &Connection, migrations: &Path) -> rusqlite::Result<()> { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS _edgeever_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + );", + )?; + + let mut files: Vec = fs::read_dir(migrations) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + .filter_map(|entry| entry.ok().map(|item| item.path())) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("sql")) + .collect(); + files.sort(); + + for path in files { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let already_applied: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM _edgeever_migrations WHERE name = ?1)", + [name], + |row| row.get(0), + )?; + if already_applied { + continue; + } + + let sql = fs::read_to_string(&path) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let transaction = connection.unchecked_transaction()?; + transaction.execute_batch(&sql)?; + transaction.execute( + "INSERT INTO _edgeever_migrations (name, applied_at) VALUES (?1, datetime('now'))", + [name], + )?; + transaction.commit()?; + } + Ok(()) +} + +fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> { + if !source.exists() { + return Ok(()); + } + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_directory(&source_path, &destination_path)?; + } else { + fs::copy(source_path, destination_path)?; + } + } + Ok(()) +} + +fn backup_database(connection: &Connection, root: &Path) -> rusqlite::Result { + let backup_dir = root.join("backups"); + fs::create_dir_all(&backup_dir) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + restrict_directory(&backup_dir) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let backup_path = backup_dir.join(format!( + "edgeever-{}.sqlite", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + let backup_path_text = backup_path.to_string_lossy().to_string(); + connection.execute("VACUUM INTO ?1", [&backup_path_text])?; + restrict_file(&backup_path) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let resource_backup = backup_path.with_extension("resources"); + fs::remove_dir_all(&resource_backup).ok(); + copy_directory(&root.join("resource-outbox"), &resource_backup) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let mut backups: Vec = fs::read_dir(&backup_dir) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + .filter_map(|entry| entry.ok().map(|item| item.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.starts_with("edgeever-") && name.ends_with(".sqlite")) + .unwrap_or(false) + }) + .collect(); + backups.sort(); + while backups.len() > 5 { + if let Some(oldest) = backups.first() { + fs::remove_file(oldest) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + fs::remove_dir_all(oldest.with_extension("resources")).ok(); + } + backups.remove(0); + } + Ok(backup_path) +} + +fn list_backups(root: &Path) -> rusqlite::Result { + let backup_dir = root.join("backups"); + fs::create_dir_all(&backup_dir) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let mut backups = Vec::new(); + for entry in fs::read_dir(&backup_dir) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + { + let path = entry + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))? + .path(); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_owned(); + if !name.starts_with("edgeever-") || !name.ends_with(".sqlite") { + continue; + } + let metadata = fs::metadata(&path) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let modified_at = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_secs().to_string()) + .unwrap_or_default(); + backups.push(json!({ "path": path.to_string_lossy(), "name": name, "size": metadata.len(), "modifiedAt": modified_at })); + } + backups.sort_by(|left, right| { + right + .get("name") + .and_then(Value::as_str) + .cmp(&left.get("name").and_then(Value::as_str)) + }); + Ok(json!({ "backups": backups })) +} + +fn restore_database( + database: &mut Connection, + root: &Path, + migrations: &Path, + params: &Value, +) -> Result { + let requested = string_param(params, "path")?; + let backup_dir = root + .join("backups") + .canonicalize() + .map_err(|error| error.to_string())?; + let backup_path = PathBuf::from(requested) + .canonicalize() + .map_err(|error| error.to_string())?; + if backup_path.parent() != Some(backup_dir.as_path()) + || backup_path.extension().and_then(|value| value.to_str()) != Some("sqlite") + { + return Err("Backup path must point to a managed EdgeEver backup".to_owned()); + } + + let source = Connection::open(&backup_path).map_err(|error| error.to_string())?; + // A protective backup rotates the five-file retention window. Preserve the + // selected snapshot's resource companion before that rotation can remove + // it when the user restores the oldest retained backup. + let resource_backup = backup_path.with_extension("resources"); + let resource_restore_source = root.join("resource-outbox.restore-source"); + fs::remove_dir_all(&resource_restore_source).ok(); + if resource_backup.is_dir() { + copy_directory(&resource_backup, &resource_restore_source) + .map_err(|error| error.to_string())?; + } + // Open the selected snapshot before rotation: if it is the oldest of the + // five retained files, the protective backup may remove its directory + // entry while this read-only connection keeps the snapshot available. + let protective_backup = backup_database(&*database, root).map_err(|error| error.to_string())?; + let backup = Backup::new_with_names(&source, DatabaseName::Main, database, DatabaseName::Main) + .map_err(|error| error.to_string())?; + backup + .run_to_completion(100, Duration::from_millis(5), None) + .map_err(|error| error.to_string())?; + drop(backup); + apply_migrations(database, migrations).map_err(|error| error.to_string())?; + let resource_source = if resource_restore_source.is_dir() { + resource_restore_source.as_path() + } else { + resource_backup.as_path() + }; + if resource_source.is_dir() { + let restored_resources = root.join("resource-outbox.restore"); + fs::remove_dir_all(&restored_resources).ok(); + copy_directory(resource_source, &restored_resources).map_err(|error| error.to_string())?; + fs::remove_dir_all(root.join("resource-outbox")).ok(); + fs::rename(restored_resources, root.join("resource-outbox")) + .map_err(|error| error.to_string())?; + } + fs::remove_dir_all(&resource_restore_source).ok(); + Ok(json!({ "ok": true, "path": protective_backup })) +} + +fn open_database(root: &Path, migrations: &Path) -> rusqlite::Result { + fs::create_dir_all(root) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + restrict_directory(root) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let existed = root.join("edgeever.sqlite").exists(); + let connection = Connection::open(root.join("edgeever.sqlite"))?; + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS _edgeever_sidecar_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS _edgeever_sidecar_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + CREATE INDEX IF NOT EXISTS idx_sidecar_outbox_status ON _edgeever_sidecar_outbox(status, id);", + )?; + if existed { + backup_database(&connection, root)?; + } + apply_migrations(&connection, migrations)?; + for path in [ + root.join("edgeever.sqlite"), + root.join("edgeever.sqlite-wal"), + root.join("edgeever.sqlite-shm"), + ] { + restrict_file(&path) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + } + Ok(connection) +} + +fn enqueue_change( + database: &Connection, + kind: &str, + entity_id: &str, + payload: &Value, +) -> Result<(), String> { + if kind == "memo.update" { + let existing: Option<(i64, String)> = database + .query_row( + "SELECT id, payload_json FROM _edgeever_sidecar_outbox WHERE kind = 'memo.update' AND entity_id = ?1 AND status IN ('pending', 'error') ORDER BY id LIMIT 1", + [entity_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some((outbox_id, existing_payload)) = existing { + let previous: Value = + serde_json::from_str(&existing_payload).unwrap_or_else(|_| json!({})); + let mut merged = payload.clone(); + if let (Some(merged_object), Some(previous_object)) = + (merged.as_object_mut(), previous.as_object()) + { + // Keep the original remote base while replacing the document + // with the newest local snapshot. This avoids conflicts caused + // solely by multiple offline autosaves. + for key in ["expectedRevision", "expectedContentHash", "editSessionId"] { + if let Some(value) = previous_object.get(key) { + merged_object.insert(key.to_owned(), value.clone()); + } + } + } + database.execute( + "UPDATE _edgeever_sidecar_outbox SET payload_json = ?1, status = 'pending', last_error = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2", + rusqlite::params![merged.to_string(), outbox_id], + ).map(|_| ()).map_err(|e| e.to_string())?; + return Ok(()); + } + } + database.execute( + "INSERT INTO _edgeever_sidecar_outbox (kind, entity_id, payload_json) VALUES (?1, ?2, ?3)", + rusqlite::params![kind, entity_id, payload.to_string()], + ).map(|_| ()).map_err(|e| e.to_string()) +} + +fn meta_value(database: &Connection, key: &str) -> Option { + database + .query_row( + "SELECT value FROM _edgeever_sidecar_meta WHERE key = ?1", + [key], + |row| row.get(0), + ) + .ok() +} + +fn set_meta(database: &Connection, key: &str, value: &str) -> Result<(), String> { + database.execute( + "INSERT INTO _edgeever_sidecar_meta (key, value, updated_at) VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + rusqlite::params![key, value], + ).map(|_| ()).map_err(|e| e.to_string()) +} + +fn string_param(params: &Value, key: &str) -> Result { + params + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("Missing string parameter: {key}")) +} + +fn bool_param(params: &Value, key: &str, default: bool) -> bool { + params.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn now_id(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{prefix}_{nanos}") +} + +fn content_hash(markdown: &str, content_json: &str) -> String { + let mut digest = Sha256::new(); + digest.update(markdown.as_bytes()); + digest.update(content_json.as_bytes()); + format!("{:x}", digest.finalize()) +} + +fn markdown_doc(markdown: &str) -> Value { + let content: Vec = markdown + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| json!({ "type": "paragraph", "content": [{ "type": "text", "text": line }] })) + .collect(); + json!({ "type": "doc", "content": if content.is_empty() { vec![json!({ "type": "paragraph" })] } else { content } }) +} + +fn tags_from_json(value: &str) -> Vec { + serde_json::from_str(value).unwrap_or_default() +} + +fn summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let tags: String = row.get("tags_json")?; + Ok(json!({ + "id": row.get::<_, String>("id")?, + "notebookId": row.get::<_, String>("notebook_id")?, + "title": row.get::<_, Option>("title")?, + "excerpt": row.get::<_, String>("excerpt")?, + "tags": tags_from_json(&tags), + "isPinned": row.get::<_, i64>("is_pinned")? != 0, + "isArchived": row.get::<_, i64>("is_archived")? != 0, + "isDeleted": row.get::<_, i64>("is_deleted")? != 0, + "revision": row.get::<_, i64>("revision")?, + "createdAt": row.get::<_, String>("created_at")?, + "updatedAt": row.get::<_, String>("updated_at")?, + "deletedAt": row.get::<_, Option>("deleted_at")?, + })) +} + +fn memo_value( + database: &Connection, + memo_id: &str, + include_deleted: bool, +) -> Result { + let mut statement = database.prepare( + "SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted, + m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id, m.created_at, m.updated_at, m.deleted_at, + c.content_json, c.content_markdown, c.content_text, c.content_hash, c.revision + FROM memos m JOIN memo_contents c ON c.memo_id = m.id + WHERE m.id = ?1 AND (?2 OR m.is_deleted = 0)", + ).map_err(|e| e.to_string())?; + statement.query_row(rusqlite::params![memo_id, include_deleted as i64], |row| { + let tags: String = row.get("tags_json")?; + let source_ids: String = row.get("source_memo_ids")?; + let content_json: String = row.get("content_json")?; + Ok(json!({ + "id": row.get::<_, String>("id")?, "notebookId": row.get::<_, String>("notebook_id")?, + "title": row.get::<_, Option>("title")?, "excerpt": row.get::<_, String>("excerpt")?, + "tags": tags_from_json(&tags), "isPinned": row.get::<_, i64>("is_pinned")? != 0, + "isArchived": row.get::<_, i64>("is_archived")? != 0, "isDeleted": row.get::<_, i64>("is_deleted")? != 0, + "revision": row.get::<_, i64>("revision")?, "createdAt": row.get::<_, String>("created_at")?, + "updatedAt": row.get::<_, String>("updated_at")?, "deletedAt": row.get::<_, Option>("deleted_at")?, + "contentJson": serde_json::from_str::(&content_json).unwrap_or_else(|_| json!({"type":"doc","content":[]})), + "contentMarkdown": row.get::<_, String>("content_markdown")?, "contentText": row.get::<_, String>("content_text")?, + "contentHash": row.get::<_, String>("content_hash")?, "sourceMemoIds": tags_from_json(&source_ids), + "mergeSourceCount": row.get::<_, i64>("merge_source_count")?, "mergedIntoMemoId": row.get::<_, Option>("merged_into_memo_id")?, + })) + }).map_err(|e| e.to_string()) +} + +fn insert_revision( + transaction: &rusqlite::Transaction<'_>, + memo: &Value, + created_by: &str, +) -> Result<(), String> { + let memo_id = string_param(memo, "id")?; + let content_json = memo + .get("contentJson") + .cloned() + .unwrap_or_else(|| json!({"type":"doc","content":[]})); + let tags = memo.get("tags").cloned().unwrap_or_else(|| json!([])); + transaction.execute( + "INSERT INTO memo_revisions (id, memo_id, revision, title, content_json, content_markdown, content_hash, created_by, created_at, tags_json, content_text) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?9, ?10)", + rusqlite::params![ + now_id("revision_local"), memo_id, + memo.get("revision").and_then(Value::as_i64).unwrap_or(0), + memo.get("title").and_then(Value::as_str), content_json.to_string(), + memo.get("contentMarkdown").and_then(Value::as_str).unwrap_or(""), + memo.get("contentHash").and_then(Value::as_str).unwrap_or(""), created_by, + tags.to_string(), memo.get("contentText").and_then(Value::as_str).unwrap_or(""), + ], + ).map(|_| ()).map_err(|e| e.to_string()) +} + +fn revision_value( + database: &Connection, + memo_id: &str, + revision_id: &str, +) -> Result { + database.query_row( + "SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown, content_text, content_hash, created_by, created_at FROM memo_revisions WHERE id = ?1 AND memo_id = ?2", + rusqlite::params![revision_id, memo_id], + |row| { + let tags: String = row.get(4)?; + let content_json: String = row.get(5)?; + Ok(json!({ + "id": row.get::<_, String>(0)?, "memoId": row.get::<_, String>(1)?, "revision": row.get::<_, i64>(2)?, + "title": row.get::<_, Option>(3)?, "tags": tags_from_json(&tags), + "contentJson": serde_json::from_str::(&content_json).unwrap_or_else(|_| json!({"type":"doc","content":[]})), + "contentMarkdown": row.get::<_, String>(6)?, "contentText": row.get::<_, String>(7)?, + "contentHash": row.get::<_, String>(8)?, "createdBy": row.get::<_, String>(9)?, "createdAt": row.get::<_, String>(10)?, + })) + }, + ).map_err(|e| e.to_string()) +} + +fn list_memo_revisions(database: &Connection, params: &Value) -> Result { + let memo_id = string_param(params, "memoId")?; + let limit = params + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(100) + .clamp(1, 200); + let mut statement = database.prepare("SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown, content_text, content_hash, created_by, created_at FROM memo_revisions WHERE memo_id = ?1 ORDER BY revision DESC, created_at DESC LIMIT ?2").map_err(|e| e.to_string())?; + let rows = statement.query_map(rusqlite::params![memo_id, limit], |row| { + let tags: String = row.get(4)?; + let content_json: String = row.get(5)?; + Ok(json!({ + "id": row.get::<_, String>(0)?, "memoId": row.get::<_, String>(1)?, "revision": row.get::<_, i64>(2)?, + "title": row.get::<_, Option>(3)?, "tags": tags_from_json(&tags), + "contentJson": serde_json::from_str::(&content_json).unwrap_or_else(|_| json!({"type":"doc","content":[]})), + "contentMarkdown": row.get::<_, String>(6)?, "contentText": row.get::<_, String>(7)?, + "contentHash": row.get::<_, String>(8)?, "createdBy": row.get::<_, String>(9)?, "createdAt": row.get::<_, String>(10)?, + })) + }).map_err(|e| e.to_string())?; + let revisions: Result, _> = rows.collect(); + Ok(json!({ "revisions": revisions.map_err(|e| e.to_string())? })) +} + +fn cache_memo_revision(database: &Connection, params: &Value) -> Result { + let revision = params + .get("revision") + .ok_or_else(|| "Missing revision".to_owned())?; + let memo_id = string_param(revision, "memoId")?; + let revision_id = string_param(revision, "id")?; + let content_json = revision + .get("contentJson") + .cloned() + .unwrap_or_else(|| json!({"type":"doc","content":[]})); + let tags = revision.get("tags").cloned().unwrap_or_else(|| json!([])); + database.execute( + "INSERT INTO memo_revisions (id, memo_id, revision, title, content_json, content_markdown, content_hash, created_by, created_at, tags_json, content_text) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(id) DO UPDATE SET memo_id=excluded.memo_id, revision=excluded.revision, title=excluded.title, content_json=excluded.content_json, content_markdown=excluded.content_markdown, content_hash=excluded.content_hash, created_by=excluded.created_by, created_at=excluded.created_at, tags_json=excluded.tags_json, content_text=excluded.content_text", + rusqlite::params![ + revision_id, memo_id, revision.get("revision").and_then(Value::as_i64).unwrap_or(0), + revision.get("title").and_then(Value::as_str), content_json.to_string(), + revision.get("contentMarkdown").and_then(Value::as_str).unwrap_or(""), + revision.get("contentHash").and_then(Value::as_str).unwrap_or(""), + revision.get("createdBy").and_then(Value::as_str).unwrap_or("remote"), + revision.get("createdAt").and_then(Value::as_str).unwrap_or(""), tags.to_string(), + revision.get("contentText").and_then(Value::as_str).unwrap_or(""), + ], + ).map_err(|e| e.to_string())?; + Ok(json!({ "ok": true })) +} + +fn list_notebooks(database: &Connection) -> Result { + let mut statement = database.prepare( + "SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at, + COUNT(CASE WHEN m.is_deleted = 0 THEN 1 END) AS memo_count, + MAX(CASE WHEN m.is_deleted = 0 THEN m.updated_at END) AS last_memo_updated_at + FROM notebooks n LEFT JOIN memos m ON m.notebook_id = n.id + WHERE n.is_deleted = 0 GROUP BY n.id ORDER BY n.sort_order, n.name", + ).map_err(|e| e.to_string())?; + let rows = statement.query_map([], |row| Ok(json!({ + "id": row.get::<_, String>("id")?, "parentId": row.get::<_, Option>("parent_id")?, + "name": row.get::<_, String>("name")?, "slug": row.get::<_, Option>("slug")?, + "icon": row.get::<_, Option>("icon")?, "color": row.get::<_, Option>("color")?, + "sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?, + "lastMemoUpdatedAt": row.get::<_, Option>("last_memo_updated_at")?, + "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?, + }))).map_err(|e| e.to_string())?; + let notebooks: Result, _> = rows.collect(); + Ok(json!({ "notebooks": notebooks.map_err(|e| e.to_string())? })) +} + +fn notebook_value( + database: &Connection, + notebook_id: &str, + include_deleted: bool, +) -> Result { + let mut statement = database.prepare( + "SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at, + COUNT(CASE WHEN m.is_deleted = 0 THEN 1 END) AS memo_count, + MAX(CASE WHEN m.is_deleted = 0 THEN m.updated_at END) AS last_memo_updated_at + FROM notebooks n LEFT JOIN memos m ON m.notebook_id = n.id + WHERE n.id = ?1 AND (?2 OR n.is_deleted = 0) GROUP BY n.id", + ).map_err(|e| e.to_string())?; + statement.query_row(rusqlite::params![notebook_id, include_deleted as i64], |row| Ok(json!({ + "id": row.get::<_, String>("id")?, "parentId": row.get::<_, Option>("parent_id")?, + "name": row.get::<_, String>("name")?, "slug": row.get::<_, Option>("slug")?, + "icon": row.get::<_, Option>("icon")?, "color": row.get::<_, Option>("color")?, + "sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?, + "lastMemoUpdatedAt": row.get::<_, Option>("last_memo_updated_at")?, + "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?, + }))).map_err(|e| e.to_string()) +} + +fn create_notebook(database: &Connection, params: &Value) -> Result { + let name = string_param(params, "name")?; + let parent_id = params.get("parentId").and_then(Value::as_str); + let id = now_id("nb_local"); + database.execute("INSERT INTO notebooks (id, parent_id, name, slug, sort_order) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![id, parent_id, name, name.to_lowercase().replace(' ', "-"), SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64]).map_err(|e| e.to_string())?; + let notebook = notebook_value(database, &id, true)?; + enqueue_change( + database, + "notebook.create", + &id, + &json!({ "temporaryId": id, "name": name, "parentId": parent_id }), + )?; + Ok(notebook) +} + +fn update_notebook(database: &Connection, params: &Value) -> Result { + let notebook_id = string_param(params, "notebookId")?; + if let Some(name) = params.get("name").and_then(Value::as_str) { + database.execute("UPDATE notebooks SET name = ?1, slug = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3 AND is_deleted = 0", rusqlite::params![name, name.to_lowercase().replace(' ', "-"), notebook_id]).map_err(|e| e.to_string())?; + } + if params.get("parentId").is_some() { + database.execute("UPDATE notebooks SET parent_id = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![params.get("parentId").and_then(Value::as_str), notebook_id]).map_err(|e| e.to_string())?; + } + if let Some(sort_order) = params.get("sortOrder").and_then(Value::as_i64) { + database.execute("UPDATE notebooks SET sort_order = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![sort_order, notebook_id]).map_err(|e| e.to_string())?; + } + let notebook = notebook_value(database, ¬ebook_id, true)?; + enqueue_change(database, "notebook.update", ¬ebook_id, params)?; + Ok(notebook) +} + +fn delete_notebook(database: &Connection, params: &Value) -> Result { + let notebook_id = string_param(params, "notebookId")?; + let child_count: i64 = database + .query_row( + "SELECT COUNT(*) FROM notebooks WHERE parent_id = ?1 AND is_deleted = 0", + [¬ebook_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + let memo_count: i64 = database + .query_row( + "SELECT COUNT(*) FROM memos WHERE notebook_id = ?1 AND is_deleted = 0", + [¬ebook_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if child_count > 0 || memo_count > 0 { + return Err("notebook_not_empty".to_owned()); + } + database.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND slug <> 'inbox'", [¬ebook_id]).map_err(|e| e.to_string())?; + enqueue_change( + database, + "notebook.delete", + ¬ebook_id, + &json!({ "notebookId": notebook_id }), + )?; + Ok(json!({ "ok": true })) +} + +fn restore_notebook(database: &Connection, params: &Value) -> Result { + let notebook_id = string_param(params, "notebookId")?; + let changed = database.execute("UPDATE notebooks SET is_deleted = 0, deleted_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND slug <> 'inbox'", [¬ebook_id]).map_err(|e| e.to_string())?; + if changed == 0 { + return Err(format!("Notebook not found: {notebook_id}")); + } + let notebook = notebook_value(database, ¬ebook_id, true)?; + enqueue_change( + database, + "notebook.restore", + ¬ebook_id, + &json!({ "notebookId": notebook_id }), + )?; + Ok(notebook) +} + +fn template_value(database: &Connection, template_id: &str) -> Result { + let mut statement = database.prepare("SELECT id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at FROM memo_templates WHERE id = ?1").map_err(|e| e.to_string())?; + statement.query_row([template_id], |row| { + let content_json: String = row.get("content_json")?; + let tags: String = row.get("tags_json")?; + Ok(json!({ "id": row.get::<_, String>("id")?, "name": row.get::<_, String>("name")?, "description": row.get::<_, Option>("description")?, "title": row.get::<_, Option>("title")?, "contentJson": serde_json::from_str::(&content_json).unwrap_or_else(|_| json!({"type":"doc","content":[]})), "contentMarkdown": row.get::<_, String>("content_markdown")?, "tags": tags_from_json(&tags), "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")? })) + }).map_err(|e| e.to_string()) +} + +fn list_templates(database: &Connection) -> Result { + let mut statement = database.prepare("SELECT id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at FROM memo_templates ORDER BY updated_at DESC, name").map_err(|e| e.to_string())?; + let rows = statement.query_map([], |row| { + let content_json: String = row.get("content_json")?; + let tags: String = row.get("tags_json")?; + Ok(json!({ "id": row.get::<_, String>("id")?, "name": row.get::<_, String>("name")?, "description": row.get::<_, Option>("description")?, "title": row.get::<_, Option>("title")?, "contentJson": serde_json::from_str::(&content_json).unwrap_or_else(|_| json!({"type":"doc","content":[]})), "contentMarkdown": row.get::<_, String>("content_markdown")?, "tags": tags_from_json(&tags), "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")? })) + }).map_err(|e| e.to_string())?; + let templates: Result, _> = rows.collect(); + Ok(json!({ "templates": templates.map_err(|e| e.to_string())? })) +} + +fn cache_template(database: &Connection, params: &Value) -> Result { + let template = params + .get("template") + .ok_or_else(|| "Missing template payload".to_owned())?; + let id = string_param(template, "id")?; + let name = string_param(template, "name")?; + let markdown = template + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or(""); + let content_json = template + .get("contentJson") + .cloned() + .unwrap_or_else(|| markdown_doc(markdown)); + let tags = template.get("tags").cloned().unwrap_or_else(|| json!([])); + database.execute("INSERT INTO memo_templates (id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at) VALUES (?1, 'ws_default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, title=excluded.title, content_json=excluded.content_json, content_markdown=excluded.content_markdown, tags_json=excluded.tags_json, updated_at=excluded.updated_at", rusqlite::params![id, name, template.get("description").and_then(Value::as_str), template.get("title").and_then(Value::as_str), content_json.to_string(), markdown, tags.to_string(), string_param(template, "createdAt")?, string_param(template, "updatedAt")?]).map_err(|e| e.to_string())?; + template_value(database, &id).map(|template| json!({ "template": template })) +} + +fn create_template(database: &Connection, params: &Value) -> Result { + let name = string_param(params, "name")?; + let id = now_id("tpl_local"); + let (title, markdown, tags, content_json) = + if let Some(memo_id) = params.get("memoId").and_then(Value::as_str) { + let memo = memo_value(database, memo_id, true)?; + ( + memo.get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + memo.get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + memo.get("tags").cloned().unwrap_or_else(|| json!([])), + memo.get("contentJson") + .cloned() + .unwrap_or_else(|| markdown_doc("")), + ) + } else { + let markdown = params + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + ( + params + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + markdown.clone(), + params.get("tags").cloned().unwrap_or_else(|| json!([])), + markdown_doc(&markdown), + ) + }; + let description = params.get("description").and_then(Value::as_str); + database.execute("INSERT INTO memo_templates (id, workspace_id, name, description, title, content_json, content_markdown, tags_json) VALUES (?1, 'ws_default', ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![id, name, description, if title.is_empty() { None } else { Some(title.clone()) }, content_json.to_string(), markdown, tags.to_string()]).map_err(|e| e.to_string())?; + let template = template_value(database, &id)?; + enqueue_change( + database, + "template.create", + &id, + &json!({ + "temporaryId": id, "name": name, "description": description, + "title": if title.is_empty() { Value::Null } else { json!(title) }, + "contentMarkdown": markdown, "tags": tags, + }), + )?; + Ok(template) +} + +fn update_template(database: &Connection, params: &Value) -> Result { + let template_id = string_param(params, "templateId")?; + let current = template_value(database, &template_id)?; + let name = params + .get("name") + .and_then(Value::as_str) + .unwrap_or_else(|| current.get("name").and_then(Value::as_str).unwrap_or("")); + let description = if params.get("description").is_some() { + params.get("description").and_then(Value::as_str) + } else { + current.get("description").and_then(Value::as_str) + }; + let title = if params.get("title").is_some() { + params.get("title").and_then(Value::as_str) + } else { + current.get("title").and_then(Value::as_str) + }; + let markdown = params + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or_else(|| { + current + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + }); + let tags = params + .get("tags") + .cloned() + .unwrap_or_else(|| current.get("tags").cloned().unwrap_or_else(|| json!([]))); + database.execute("UPDATE memo_templates SET name = ?1, description = ?2, title = ?3, content_json = ?4, content_markdown = ?5, tags_json = ?6, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?7", rusqlite::params![name, description, title, markdown_doc(markdown).to_string(), markdown, tags.to_string(), template_id]).map_err(|e| e.to_string())?; + let template = template_value(database, &template_id)?; + enqueue_change(database, "template.update", &template_id, params)?; + Ok(template) +} + +fn delete_template(database: &Connection, params: &Value) -> Result { + let template_id = string_param(params, "templateId")?; + database + .execute("DELETE FROM memo_templates WHERE id = ?1", [&template_id]) + .map_err(|e| e.to_string())?; + enqueue_change(database, "template.delete", &template_id, params)?; + Ok(json!({ "ok": true })) +} + +fn list_resources(database: &Connection, params: &Value) -> Result { + let limit = params + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(500) + .clamp(1, 500); + let mut statement = database.prepare( + "SELECT r.id, r.memo_id, r.original_memo_id, r.kind, r.mime_type, r.filename, r.byte_size, r.sha256, r.width, r.height, r.created_at, r.updated_at, m.title, m.excerpt, m.is_deleted + FROM resources r INNER JOIN memos m ON m.id = r.memo_id + WHERE r.is_deleted = 0 ORDER BY r.created_at DESC LIMIT ?1", + ).map_err(|e| e.to_string())?; + let rows = statement.query_map([limit], |row| Ok(json!({ + "id": row.get::<_, String>("id")?, "memoId": row.get::<_, String>("memo_id")?, "originalMemoId": row.get::<_, Option>("original_memo_id")?, + "kind": row.get::<_, String>("kind")?, "mimeType": row.get::<_, Option>("mime_type")?, "filename": row.get::<_, Option>("filename")?, + "byteSize": row.get::<_, i64>("byte_size")?, "sha256": row.get::<_, Option>("sha256")?, "width": row.get::<_, Option>("width")?, "height": row.get::<_, Option>("height")?, + "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?, "url": format!("/api/v1/resources/{}/blob", row.get::<_, String>("id")?), + "memoTitle": row.get::<_, Option>("title")?, "memoExcerpt": row.get::<_, Option>("excerpt")?, "memoDeleted": row.get::<_, i64>("is_deleted")? != 0 + }))).map_err(|e| e.to_string())?; + let resources: Result, _> = rows.collect(); + let stats = database.query_row("SELECT COUNT(*), COALESCE(SUM(byte_size), 0), COALESCE(SUM(CASE WHEN kind = 'image' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN kind = 'attachment' THEN 1 ELSE 0 END), 0) FROM resources WHERE is_deleted = 0", [], |row| Ok(json!({ "totalCount": row.get::<_, i64>(0)?, "totalBytes": row.get::<_, i64>(1)?, "imageCount": row.get::<_, i64>(2)?, "attachmentCount": row.get::<_, i64>(3)? }))).map_err(|e| e.to_string())?; + Ok(json!({ "resources": resources.map_err(|e| e.to_string())?, "summary": stats })) +} + +fn cache_resource(database: &Connection, params: &Value) -> Result { + let resource = params + .get("resource") + .ok_or_else(|| "Missing resource payload".to_owned())?; + let id = string_param(resource, "id")?; + let memo_id = string_param(resource, "memoId")?; + database.execute("INSERT INTO resources (id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type, filename, byte_size, sha256, width, height, metadata_json) VALUES (?1, ?2, ?3, 'edgeever-resources', ?1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '{}') ON CONFLICT(id) DO UPDATE SET memo_id=excluded.memo_id, kind=excluded.kind, mime_type=excluded.mime_type, filename=excluded.filename, byte_size=excluded.byte_size, sha256=excluded.sha256, width=excluded.width, height=excluded.height, is_deleted=0, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", rusqlite::params![id, memo_id, resource.get("originalMemoId").and_then(Value::as_str), string_param(resource, "kind")?, resource.get("mimeType").and_then(Value::as_str), resource.get("filename").and_then(Value::as_str), resource.get("byteSize").and_then(Value::as_i64).unwrap_or(0), resource.get("sha256").and_then(Value::as_str), resource.get("width").and_then(Value::as_i64), resource.get("height").and_then(Value::as_i64)]).map_err(|e| e.to_string())?; + Ok(json!({ "ok": true })) +} + +fn list_tags(database: &Connection) -> Result { + let mut statement = database.prepare( + "SELECT trim(j.value) AS name, COUNT(DISTINCT m.id) AS memo_count, MAX(m.updated_at) AS updated_at + FROM memos m, json_each(m.tags_json) j + WHERE m.is_deleted = 0 AND trim(j.value) <> '' + GROUP BY trim(j.value) ORDER BY name COLLATE NOCASE", + ).map_err(|e| e.to_string())?; + let rows = statement.query_map([], |row| Ok(json!({ "name": row.get::<_, String>("name")?, "memoCount": row.get::<_, i64>("memo_count")?, "updatedAt": row.get::<_, Option>("updated_at")? }))).map_err(|e| e.to_string())?; + let tags: Result, _> = rows.collect(); + Ok(json!({ "tags": tags.map_err(|e| e.to_string())? })) +} + +fn rewrite_tag(database: &Connection, params: &Value, delete: bool) -> Result { + let tag = string_param(params, "tag")?; + let replacement = params + .get("name") + .and_then(Value::as_str) + .map(str::to_owned); + let mut statement = database + .prepare("SELECT id, tags_json FROM memos WHERE is_deleted = 0") + .map_err(|e| e.to_string())?; + let rows: Vec<(String, String)> = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| e.to_string())? + .collect::>() + .map_err(|e| e.to_string())?; + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + let mut updated = 0; + for (memo_id, tags_json) in rows { + let mut tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); + let mut changed = false; + for value in &mut tags { + if value == &tag { + changed = true; + if delete { + value.clear(); + } else if let Some(name) = replacement.as_ref() { + *value = name.clone(); + } + } + } + tags.retain(|value| !value.is_empty()); + tags.sort(); + tags.dedup(); + if changed { + tx.execute("UPDATE memos SET tags_json = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_by = 'desktop' WHERE id = ?2", rusqlite::params![serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_owned()), memo_id]).map_err(|e| e.to_string())?; + updated += 1; + } + } + tx.commit().map_err(|e| e.to_string())?; + enqueue_change( + database, + if delete { "tag.delete" } else { "tag.rename" }, + &tag, + params, + )?; + Ok(json!({ "ok": true, "updated": updated })) +} + +fn move_memos(database: &Connection, params: &Value) -> Result { + let notebook_id = string_param(params, "notebookId")?; + let memo_ids = params + .get("memoIds") + .and_then(Value::as_array) + .ok_or_else(|| "Missing memoIds array".to_owned())?; + if memo_ids.is_empty() { + return Ok(json!({ "ok": true, "moved": 0 })); + } + let exists: bool = database + .query_row( + "SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1 AND is_deleted = 0)", + [¬ebook_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if !exists { + return Err(format!("Notebook not found: {notebook_id}")); + } + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + let mut moved = 0; + for memo_id in memo_ids { + if let Some(id) = memo_id.as_str() { + moved += tx.execute("UPDATE memos SET notebook_id = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_by = 'desktop' WHERE id = ?2 AND is_deleted = 0", rusqlite::params![notebook_id, id]).map_err(|e| e.to_string())?; + } + } + tx.commit().map_err(|e| e.to_string())?; + enqueue_change(database, "memo.move", ¬ebook_id, params)?; + Ok(json!({ "ok": true, "moved": moved })) +} + +fn delete_memos(database: &Connection, params: &Value) -> Result { + let memo_ids = params + .get("memoIds") + .and_then(Value::as_array) + .ok_or_else(|| "Missing memoIds array".to_owned())?; + let permanent = bool_param(params, "permanent", false); + if memo_ids.is_empty() { + return Ok(json!({ "ok": true, "deleted": 0 })); + } + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + let mut deleted = 0; + for memo_id in memo_ids { + let Some(id) = memo_id.as_str() else { continue }; + if permanent { + tx.execute("DELETE FROM resources WHERE memo_id = ?1", [id]) + .map_err(|e| e.to_string())?; + deleted += tx + .execute("DELETE FROM memos WHERE id = ?1 AND is_deleted = 1", [id]) + .map_err(|e| e.to_string())?; + } else { + deleted += tx.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND is_deleted = 0", [id]).map_err(|e| e.to_string())?; + } + } + tx.commit().map_err(|e| e.to_string())?; + enqueue_change(database, "memo.deleteBatch", "batch", params)?; + Ok(json!({ "ok": true, "deleted": deleted })) +} + +fn empty_trash(database: &Connection) -> Result { + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)", + [], + ) + .map_err(|e| e.to_string())?; + let deleted = tx + .execute("DELETE FROM memos WHERE is_deleted = 1", []) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + enqueue_change(database, "memo.emptyTrash", "trash", &json!({}))?; + Ok(json!({ "ok": true, "deleted": deleted })) +} + +fn pin_memos(database: &Connection, params: &Value) -> Result { + let memo_ids = params + .get("memoIds") + .and_then(Value::as_array) + .ok_or_else(|| "Missing memoIds array".to_owned())?; + let is_pinned = bool_param(params, "isPinned", false); + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + let mut updated = 0; + for memo_id in memo_ids { + if let Some(id) = memo_id.as_str() { + updated += tx.execute("UPDATE memos SET is_pinned = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_by = 'desktop' WHERE id = ?2 AND is_deleted = 0", rusqlite::params![is_pinned as i64, id]).map_err(|e| e.to_string())?; + } + } + tx.commit().map_err(|e| e.to_string())?; + enqueue_change(database, "memo.pinBatch", "batch", params)?; + Ok(json!({ "ok": true, "updated": updated })) +} + +fn merge_memos(database: &Connection, params: &Value) -> Result { + let memo_ids = params + .get("memoIds") + .and_then(Value::as_array) + .ok_or_else(|| "Missing memoIds array".to_owned())?; + let ids: Vec = memo_ids + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + if ids.len() < 2 { + return Err("At least two memos are required to merge".to_owned()); + } + let target_notebook = params + .get("notebookId") + .and_then(Value::as_str) + .map(str::to_owned); + let mut first_notebook_id: Option = None; + let mut titles = Vec::new(); + let mut markdowns = Vec::new(); + let mut tags = Vec::::new(); + for id in &ids { + let row = database.query_row("SELECT m.title, m.notebook_id, c.content_markdown, m.tags_json FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE m.id = ?1 AND m.is_deleted = 0", [id], |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?))).map_err(|e| e.to_string())?; + if first_notebook_id.is_none() { + first_notebook_id = Some(row.1); + } + if let Some(title) = row.0 { + if !title.trim().is_empty() { + titles.push(title); + } + } + markdowns.push(row.2); + let memo_tags: Vec = serde_json::from_str(&row.3).unwrap_or_default(); + tags.extend(memo_tags); + } + let notebook_id = target_notebook.or(first_notebook_id).unwrap_or_default(); + if notebook_id.is_empty() { + return Err("Target notebook not found".to_owned()); + } + let title = params + .get("title") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .or_else(|| titles.into_iter().find(|value| !value.starts_with("nb_"))) + .unwrap_or_default(); + tags.sort(); + tags.dedup(); + let markdown = markdowns.join("\n\n---\n\n"); + let content_json = markdown_doc(&markdown); + let content_json_text = content_json.to_string(); + let content_text = markdown.lines().collect::>().join(" "); + let id = now_id("memo_local"); + let source_ids = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_owned()); + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, source_memo_ids, merge_source_count, created_by, updated_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'desktop', 'desktop')", rusqlite::params![id, notebook_id, if title.is_empty() { None } else { Some(title.clone()) }, content_text.chars().take(180).collect::(), serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_owned()), source_ids, ids.len() as i64]).map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![id, content_json_text, markdown, content_text, content_hash(&markdown, &content_json_text)]).map_err(|e| e.to_string())?; + for source_id in &ids { + tx.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), merged_into_memo_id = ?1, merged_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2", rusqlite::params![id, source_id]).map_err(|e| e.to_string())?; + tx.execute("UPDATE resources SET original_memo_id = COALESCE(original_memo_id, memo_id), memo_id = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE memo_id = ?2", rusqlite::params![id, source_id]).map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string())?; + enqueue_change( + database, + "memo.merge", + &id, + &json!({ "memoIds": ids, "notebookId": notebook_id, "title": title, "temporaryId": id }), + )?; + memo_value(database, &id, true).map(|memo| json!({ "memo": memo })) +} + +fn list_memos(database: &Connection, params: &Value) -> Result { + let trash = bool_param(params, "trash", false); + let q = params + .get("q") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_owned(); + let notebook_id = params.get("notebookId").and_then(Value::as_str); + let notebook_ids = params + .get("notebookIds") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let notebook_ids_json = Value::Array(notebook_ids).to_string(); + let filter = match params.get("filter").and_then(Value::as_str) { + Some("pinned") => " AND m.is_pinned = 1", + Some("tagged") => " AND json_array_length(m.tags_json) > 0", + Some("untagged") => " AND json_array_length(m.tags_json) = 0", + _ => "", + }; + let order = match params.get("sort").and_then(Value::as_str) { + Some("created-desc") => "m.created_at DESC, m.id DESC", + Some("title-asc") => { + "COALESCE(m.title, m.excerpt) COLLATE NOCASE ASC, m.updated_at DESC, m.id DESC" + } + _ if trash => "m.deleted_at DESC, m.id DESC", + _ => "m.is_pinned DESC, m.updated_at DESC, m.id DESC", + }; + let limit = params + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(50) + .clamp(1, 200); + let offset = params + .get("offset") + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0); + let query = format!("SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted, + c.revision, m.created_at, m.updated_at, m.deleted_at + FROM memos m JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = ?1 AND (?2 = '' OR lower(COALESCE(m.title, '') || ' ' || m.excerpt || ' ' || c.content_text || ' ' || m.tags_json) LIKE '%' || lower(?2) || '%') + AND (?3 IS NULL OR m.notebook_id = ?3) + AND (?4 = '[]' OR m.notebook_id IN (SELECT value FROM json_each(?4))){} + ORDER BY {} LIMIT ?5 OFFSET ?6", filter, order); + let mut statement = database.prepare(&query).map_err(|e| e.to_string())?; + let rows = statement + .query_map( + rusqlite::params![ + trash as i64, + q, + notebook_id, + notebook_ids_json, + limit, + offset + ], + summary_from_row, + ) + .map_err(|e| e.to_string())?; + let memos: Result, _> = rows.collect(); + let count_query = format!("SELECT COUNT(*) FROM memos m JOIN memo_contents c ON c.memo_id = m.id WHERE m.is_deleted = ?1 AND (?2 = '' OR lower(COALESCE(m.title, '') || ' ' || m.excerpt || ' ' || c.content_text || ' ' || m.tags_json) LIKE '%' || lower(?2) || '%') AND (?3 IS NULL OR m.notebook_id = ?3) AND (?4 = '[]' OR m.notebook_id IN (SELECT value FROM json_each(?4))){}", filter); + let total: i64 = database + .query_row( + &count_query, + rusqlite::params![trash as i64, q, notebook_id, notebook_ids_json], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + let memos = memos.map_err(|e| e.to_string())?; + let next_cursor = if offset + (memos.len() as i64) < total { + Some((offset + memos.len() as i64).to_string()) + } else { + None + }; + Ok(json!({ "memos": memos, "totalCount": total, "nextCursor": next_cursor })) +} + +fn create_memo(database: &Connection, params: &Value) -> Result { + let notebook_id = string_param(params, "notebookId")?; + let title = params + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_owned(); + let markdown = params + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let tags = params.get("tags").cloned().unwrap_or_else(|| json!([])); + let content_json = markdown_doc(&markdown); + let content_json_text = content_json.to_string(); + let text = markdown.lines().collect::>().join(" "); + let id = now_id("memo_local"); + let hash = content_hash(&markdown, &content_json_text); + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, created_by, updated_by) VALUES (?1, ?2, ?3, ?4, ?5, 'desktop', 'desktop')", rusqlite::params![id, notebook_id, if title.is_empty() { None } else { Some(title.clone()) }, text.chars().take(180).collect::(), tags.to_string()]).map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![id, content_json_text, markdown, text, hash]).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + let memo = memo_value(database, &id, true)?; + enqueue_change( + database, + "memo.create", + &id, + &json!({ + "temporaryId": id, "notebookId": notebook_id, "title": title, "contentMarkdown": markdown, + "tags": tags, "createdAt": memo.get("createdAt").and_then(Value::as_str).unwrap_or(""), + "updatedAt": memo.get("updatedAt").and_then(Value::as_str).unwrap_or("") + }), + )?; + Ok(memo) +} + +fn update_memo(database: &Connection, params: &Value) -> Result { + let memo_id = string_param(params, "memoId")?; + let previous = memo_value(database, &memo_id, true)?; + let title = params + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_owned(); + let content_json = params.get("contentJson").cloned().unwrap_or_else(|| { + markdown_doc( + params + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or(""), + ) + }); + let content_json_text = content_json.to_string(); + let markdown = params + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let text = params + .get("contentText") + .and_then(Value::as_str) + .unwrap_or(&markdown) + .to_owned(); + let tags = params.get("tags").cloned().unwrap_or_else(|| json!([])); + let hash = content_hash(&markdown, &content_json_text); + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + insert_revision(&tx, &previous, "desktop")?; + let changed = tx.execute("UPDATE memos SET title = ?2, excerpt = ?3, tags_json = ?4, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_by = 'desktop' WHERE id = ?1 AND is_deleted = 0", rusqlite::params![memo_id, if title.is_empty() { None } else { Some(title) }, text.chars().take(180).collect::(), tags.to_string()]).map_err(|e| e.to_string())?; + if changed == 0 { + return Err(format!("Memo not found or deleted: {memo_id}")); + } + tx.execute("UPDATE memo_contents SET content_json = ?2, content_markdown = ?3, content_text = ?4, content_hash = ?5, revision = revision + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE memo_id = ?1", rusqlite::params![memo_id, content_json_text, markdown, text, hash]).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + enqueue_change( + database, + "memo.update", + &memo_id, + &json!({ + "memoId": memo_id, "expectedRevision": params.get("expectedRevision").and_then(Value::as_i64).unwrap_or(0), + "expectedContentHash": params.get("expectedContentHash").and_then(Value::as_str).unwrap_or(""), + "title": params.get("title").and_then(Value::as_str).unwrap_or(""), "contentJson": content_json, + "contentMarkdown": markdown, "tags": tags + }), + )?; + memo_value(database, &memo_id, true) +} + +fn delete_memo(database: &Connection, params: &Value) -> Result { + let memo_id = string_param(params, "memoId")?; + if bool_param(params, "permanent", false) { + database + .execute("DELETE FROM memos WHERE id = ?1", [&memo_id]) + .map_err(|e| e.to_string())?; + } else { + database.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&memo_id]).map_err(|e| e.to_string())?; + } + enqueue_change( + database, + "memo.delete", + &memo_id, + &json!({ "memoId": memo_id, "permanent": bool_param(params, "permanent", false) }), + )?; + Ok(json!({ "ok": true })) +} + +fn restore_memo(database: &Connection, params: &Value) -> Result { + let memo_id = string_param(params, "memoId")?; + let changed = database.execute("UPDATE memos SET is_deleted = 0, deleted_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&memo_id]).map_err(|e| e.to_string())?; + if changed == 0 { + return Err(format!("Memo not found: {memo_id}")); + } + enqueue_change( + database, + "memo.restore", + &memo_id, + &json!({ "memoId": memo_id }), + )?; + memo_value(database, &memo_id, true) +} + +fn restore_memo_revision(database: &Connection, params: &Value) -> Result { + let memo_id = string_param(params, "memoId")?; + let revision_id = string_param(params, "revisionId")?; + let current = memo_value(database, &memo_id, true)?; + let revision = revision_value(database, &memo_id, &revision_id)?; + let title = revision + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_owned(); + let content_json = revision + .get("contentJson") + .cloned() + .unwrap_or_else(|| json!({"type":"doc","content":[]})); + let content_json_text = content_json.to_string(); + let markdown = revision + .get("contentMarkdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let text = revision + .get("contentText") + .and_then(Value::as_str) + .unwrap_or(&markdown) + .to_owned(); + let tags = revision.get("tags").cloned().unwrap_or_else(|| json!([])); + let hash = content_hash(&markdown, &content_json_text); + let next_revision = current.get("revision").and_then(Value::as_i64).unwrap_or(0) + 1; + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + insert_revision(&tx, ¤t, "desktop")?; + tx.execute("UPDATE memos SET title = ?2, excerpt = ?3, tags_json = ?4, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_by = 'desktop' WHERE id = ?1", rusqlite::params![memo_id, if title.is_empty() { None } else { Some(title.clone()) }, text.chars().take(180).collect::(), tags.to_string()]).map_err(|e| e.to_string())?; + tx.execute("UPDATE memo_contents SET content_json = ?2, content_markdown = ?3, content_text = ?4, content_hash = ?5, revision = ?6, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE memo_id = ?1", rusqlite::params![memo_id, content_json_text, markdown, text, hash, next_revision]).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + enqueue_change( + database, + "memo.update", + &memo_id, + &json!({ + "memoId": memo_id, "expectedRevision": current.get("revision").and_then(Value::as_i64).unwrap_or(0), + "expectedContentHash": current.get("contentHash").and_then(Value::as_str).unwrap_or(""), + "title": title, "contentJson": content_json, "contentMarkdown": markdown, "tags": tags, + }), + )?; + memo_value(database, &memo_id, true).map(|memo| json!({ "memo": memo })) +} + +fn sync_status(database: &Connection) -> Result { + let count = |status: &str| -> Result { + database + .query_row( + "SELECT COUNT(*) FROM _edgeever_sidecar_outbox WHERE status = ?1", + [status], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) + }; + Ok(json!({ + "pending": count("pending")?, "syncing": count("syncing")?, "conflict": count("conflict")?, "error": count("error")?, + "cursor": meta_value(database, "sync.cursor").and_then(|v| v.parse::().ok()).unwrap_or(0), + "syncIdentity": meta_value(database, "sync.identity"), "lastSyncedAt": meta_value(database, "sync.last_synced_at") + })) +} + +fn sync_outbox_list(database: &Connection, params: &Value) -> Result { + let limit = params + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(50) + .clamp(1, 200); + let mut statement = database.prepare( + "SELECT id, kind, entity_id, payload_json, attempt_count, status, last_error FROM _edgeever_sidecar_outbox WHERE status IN ('pending', 'error', 'conflict') ORDER BY id LIMIT ?1", + ).map_err(|e| e.to_string())?; + let rows = statement.query_map([limit], |row| { + let payload: String = row.get(3)?; + Ok(json!({ "id": row.get::<_, i64>(0)?, "kind": row.get::<_, String>(1)?, "entityId": row.get::<_, String>(2)?, "payload": serde_json::from_str::(&payload).unwrap_or_else(|_| json!({})), "attemptCount": row.get::<_, i64>(4)?, "status": row.get::<_, String>(5)?, "lastError": row.get::<_, Option>(6)? })) + }).map_err(|e| e.to_string())?; + let items: Result, _> = rows.collect(); + Ok(json!({ "items": items.map_err(|e| e.to_string())? })) +} + +fn sync_outbox_ack(database: &Connection, params: &Value) -> Result { + let id = params + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "Missing outbox id".to_owned())?; + let (kind, entity_id): (String, String) = database + .query_row( + "SELECT kind, entity_id FROM _edgeever_sidecar_outbox WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| e.to_string())?; + let remote_memo = params.get("remoteMemo").cloned(); + let remote_notebook = params.get("remoteNotebook").cloned(); + let remote_template = params.get("remoteTemplate").cloned(); + if kind == "memo.create" || kind == "memo.merge" { + if let Some(remote) = remote_memo.as_ref() { + let remote_id = string_param(remote, "id")?; + let remote_exists: bool = database + .query_row( + "SELECT EXISTS(SELECT 1 FROM memos WHERE id = ?1)", + [&remote_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if remote_exists { + database + .execute("DELETE FROM memos WHERE id = ?1", [&entity_id]) + .map_err(|e| e.to_string())?; + } else { + database + .execute( + "UPDATE memos SET id = ?1 WHERE id = ?2", + rusqlite::params![remote_id, entity_id], + ) + .map_err(|e| e.to_string())?; + } + let mut pending = database.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?; + let rows: Vec<(i64, String, String)> = pending + .query_map([id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .map_err(|e| e.to_string())? + .collect::>() + .map_err(|e| e.to_string())?; + for (outbox_id, outbox_entity, payload_text) in rows { + if outbox_entity != entity_id && !payload_text.contains(&entity_id) { + continue; + } + let mut payload: Value = + serde_json::from_str(&payload_text).unwrap_or_else(|_| json!({})); + if payload.get("memoId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["memoId"] = json!(remote_id); + } + if payload.get("temporaryId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["temporaryId"] = json!(remote_id); + } + if payload.get("memoId").and_then(Value::as_str) == Some(remote_id.as_str()) { + if let Some(revision) = remote.get("revision") { + payload["expectedRevision"] = revision.clone(); + } + if let Some(hash) = remote.get("contentHash") { + payload["expectedContentHash"] = hash.clone(); + } + } + let next_entity = if outbox_entity == entity_id { + remote_id.clone() + } else { + outbox_entity + }; + database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?; + } + } + } + if kind == "notebook.create" { + if let Some(remote) = remote_notebook.as_ref() { + let remote_id = string_param(remote, "id")?; + let remote_exists: bool = database + .query_row( + "SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1)", + [&remote_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if remote_exists { + database + .execute("DELETE FROM notebooks WHERE id = ?1", [&entity_id]) + .map_err(|e| e.to_string())?; + } else { + database + .execute( + "UPDATE notebooks SET id = ?1 WHERE id = ?2", + rusqlite::params![remote_id, entity_id], + ) + .map_err(|e| e.to_string())?; + } + let mut pending = database.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?; + let rows: Vec<(i64, String, String)> = pending + .query_map([id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .map_err(|e| e.to_string())? + .collect::>() + .map_err(|e| e.to_string())?; + for (outbox_id, outbox_entity, payload_text) in rows { + if outbox_entity != entity_id && !payload_text.contains(&entity_id) { + continue; + } + let mut payload: Value = + serde_json::from_str(&payload_text).unwrap_or_else(|_| json!({})); + if payload.get("notebookId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["notebookId"] = json!(remote_id); + } + if payload.get("temporaryId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["temporaryId"] = json!(remote_id); + } + let next_entity = if outbox_entity == entity_id { + remote_id.clone() + } else { + outbox_entity + }; + database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?; + } + } + } + if kind == "template.create" { + if let Some(remote) = remote_template.as_ref() { + let remote_id = string_param(remote, "id")?; + let remote_exists: bool = database + .query_row( + "SELECT EXISTS(SELECT 1 FROM memo_templates WHERE id = ?1)", + [&remote_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if remote_exists { + database + .execute("DELETE FROM memo_templates WHERE id = ?1", [&entity_id]) + .map_err(|e| e.to_string())?; + } else { + database + .execute( + "UPDATE memo_templates SET id = ?1 WHERE id = ?2", + rusqlite::params![remote_id, entity_id], + ) + .map_err(|e| e.to_string())?; + } + let mut pending = database.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?; + let rows: Vec<(i64, String, String)> = pending + .query_map([id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .map_err(|e| e.to_string())? + .collect::>() + .map_err(|e| e.to_string())?; + for (outbox_id, outbox_entity, payload_text) in rows { + if outbox_entity != entity_id && !payload_text.contains(&entity_id) { + continue; + } + let mut payload: Value = + serde_json::from_str(&payload_text).unwrap_or_else(|_| json!({})); + if payload.get("templateId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["templateId"] = json!(remote_id); + } + if payload.get("temporaryId").and_then(Value::as_str) == Some(entity_id.as_str()) { + payload["temporaryId"] = json!(remote_id); + } + let next_entity = if outbox_entity == entity_id { + remote_id.clone() + } else { + outbox_entity + }; + database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?; + } + } + } + database + .execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id]) + .map_err(|e| e.to_string())?; + Ok( + json!({ "ok": true, "memo": remote_memo, "notebook": remote_notebook, "template": remote_template }), + ) +} + +fn sync_outbox_fail(database: &Connection, params: &Value) -> Result { + let id = params + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "Missing outbox id".to_owned())?; + let error = params + .get("error") + .and_then(Value::as_str) + .unwrap_or("sync failed"); + let status = if bool_param(params, "conflict", false) { + "conflict" + } else { + "error" + }; + database.execute("UPDATE _edgeever_sidecar_outbox SET status = ?1, attempt_count = attempt_count + 1, last_error = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3", rusqlite::params![status, error, id]).map_err(|e| e.to_string())?; + Ok(json!({ "ok": true })) +} + +fn sync_outbox_discard(database: &Connection, params: &Value) -> Result { + let id = params + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "Missing outbox id".to_owned())?; + database + .execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id]) + .map_err(|e| e.to_string())?; + Ok(json!({ "ok": true })) +} + +fn apply_sync_changes(database: &Connection, params: &Value) -> Result { + let changes = params + .get("changes") + .and_then(Value::as_array) + .ok_or_else(|| "Missing changes array".to_owned())?; + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + for change in changes { + let entity_type = string_param(change, "entityType")?; + let operation = string_param(change, "operation")?; + let entity_id = string_param(change, "entityId")?; + if entity_type == "notebook" { + if operation == "delete" { + tx.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&entity_id]).map_err(|e| e.to_string())?; + continue; + } + let notebook = change + .get("notebook") + .ok_or_else(|| "Missing notebook change payload".to_owned())?; + tx.execute("INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id, name=excluded.name, slug=excluded.slug, icon=excluded.icon, color=excluded.color, sort_order=excluded.sort_order, updated_at=excluded.updated_at, is_deleted=0", rusqlite::params![entity_id, notebook.get("parentId").and_then(Value::as_str), string_param(notebook, "name")?, notebook.get("slug").and_then(Value::as_str), notebook.get("icon").and_then(Value::as_str), notebook.get("color").and_then(Value::as_str), notebook.get("sortOrder").and_then(Value::as_i64).unwrap_or(0), string_param(notebook, "createdAt")?, string_param(notebook, "updatedAt")?]).map_err(|e| e.to_string())?; + continue; + } + if operation == "delete" { + tx.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&entity_id]).map_err(|e| e.to_string())?; + continue; + } + let memo = change + .get("memo") + .ok_or_else(|| "Missing memo change payload".to_owned())?; + let tags = memo + .get("tags") + .cloned() + .unwrap_or_else(|| json!([])) + .to_string(); + let source_ids = memo + .get("sourceMemoIds") + .cloned() + .unwrap_or_else(|| json!([])) + .to_string(); + let content_json = memo + .get("contentJson") + .cloned() + .unwrap_or_else(|| json!({"type":"doc","content":[]})); + tx.execute("INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted, source_memo_ids, merge_source_count, merged_into_memo_id, created_at, updated_at, deleted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(id) DO UPDATE SET notebook_id=excluded.notebook_id, title=excluded.title, excerpt=excluded.excerpt, tags_json=excluded.tags_json, is_pinned=excluded.is_pinned, is_archived=excluded.is_archived, is_deleted=excluded.is_deleted, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at", rusqlite::params![entity_id, string_param(memo, "notebookId")?, memo.get("title").and_then(Value::as_str), string_param(memo, "excerpt")?, tags, memo.get("isPinned").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isArchived").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isDeleted").and_then(Value::as_bool).unwrap_or(false) as i64, source_ids, memo.get("mergeSourceCount").and_then(Value::as_i64).unwrap_or(0), memo.get("mergedIntoMemoId").and_then(Value::as_str), string_param(memo, "createdAt")?, string_param(memo, "updatedAt")?, memo.get("deletedAt").and_then(Value::as_str)]).map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash, revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(memo_id) DO UPDATE SET content_json=excluded.content_json, content_markdown=excluded.content_markdown, content_text=excluded.content_text, content_hash=excluded.content_hash, revision=excluded.revision, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", rusqlite::params![entity_id, content_json.to_string(), string_param(memo, "contentMarkdown")?, string_param(memo, "contentText")?, string_param(memo, "contentHash")?, memo.get("revision").and_then(Value::as_i64).unwrap_or(0)]).map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string())?; + Ok(json!({ "applied": changes.len() })) +} + +fn handle( + request: RpcRequest, + database: &mut Connection, + root: &Path, + migrations: &Path, +) -> Result { + match request.method.as_str() { + "system.info" => Ok(json!({ + "platform": env::consts::OS, + "architecture": env::consts::ARCH, + "dataDir": root, + "protocolVersion": 1, + })), + "storage.health" => database + .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) + .map(|_| json!({ "ok": true })) + .map_err(|error| error.to_string()), + "storage.backup" => backup_database(database, root) + .map(|path| json!({ "ok": true, "path": path })) + .map_err(|error| error.to_string()), + "storage.backups" => list_backups(root).map_err(|error| error.to_string()), + "storage.restore" => restore_database(database, root, migrations, &request.params), + "sync.status" => sync_status(database), + "sync.outbox.list" => sync_outbox_list(database, &request.params), + "sync.outbox.ack" => sync_outbox_ack(database, &request.params), + "sync.outbox.fail" => sync_outbox_fail(database, &request.params), + "sync.outbox.discard" => sync_outbox_discard(database, &request.params), + "sync.apply" => apply_sync_changes(database, &request.params), + "sync.cursor.set" => { + set_meta( + database, + "sync.cursor", + &request + .params + .get("cursor") + .and_then(Value::as_i64) + .unwrap_or(0) + .to_string(), + )?; + set_meta( + database, + "sync.identity", + request + .params + .get("syncIdentity") + .and_then(Value::as_str) + .unwrap_or(""), + )?; + set_meta(database, "sync.last_synced_at", &chrono_like_now())?; + Ok(json!({ "ok": true })) + } + "notebook.list" => list_notebooks(database), + "notebook.create" => create_notebook(database, &request.params) + .map(|notebook| json!({ "notebook": notebook })), + "notebook.update" => update_notebook(database, &request.params) + .map(|notebook| json!({ "notebook": notebook })), + "notebook.delete" => delete_notebook(database, &request.params), + "notebook.restore" => restore_notebook(database, &request.params) + .map(|notebook| json!({ "notebook": notebook })), + "template.list" => list_templates(database), + "template.cache" => cache_template(database, &request.params), + "template.create" => create_template(database, &request.params) + .map(|template| json!({ "template": template })), + "template.update" => update_template(database, &request.params) + .map(|template| json!({ "template": template })), + "template.delete" => delete_template(database, &request.params), + "resource.list" => list_resources(database, &request.params), + "resource.cache" => cache_resource(database, &request.params), + "tag.list" => list_tags(database), + "tag.rename" => rewrite_tag(database, &request.params, false), + "tag.delete" => rewrite_tag(database, &request.params, true), + "memo.moveBatch" => move_memos(database, &request.params), + "memo.deleteBatch" => delete_memos(database, &request.params), + "memo.emptyTrash" => empty_trash(database), + "memo.pinBatch" => pin_memos(database, &request.params), + "memo.merge" => merge_memos(database, &request.params), + "memo.list" => list_memos(database, &request.params), + "memo.get" => { + let memo_id = string_param(&request.params, "memoId")?; + memo_value( + database, + &memo_id, + bool_param(&request.params, "includeDeleted", false), + ) + .map(|memo| json!({ "memo": memo })) + } + "memo.create" => create_memo(database, &request.params).map(|memo| json!({ "memo": memo })), + "memo.update" => update_memo(database, &request.params).map(|memo| json!({ "memo": memo })), + "memo.delete" => delete_memo(database, &request.params), + "memo.restore" => { + restore_memo(database, &request.params).map(|memo| json!({ "memo": memo })) + } + "memo.revisions" => list_memo_revisions(database, &request.params), + "memo.restoreRevision" => restore_memo_revision(database, &request.params), + "memo.revision.cache" => cache_memo_revision(database, &request.params), + "app.shutdown" => Ok(json!({ "ok": true })), + _ => Err(format!("Unknown sidecar method: {}", request.method)), + } +} + +fn chrono_like_now() -> String { + // SQLite owns persisted timestamps; this value is only a human-readable sync marker. + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +fn main() -> Result<(), Box> { + let root = data_dir(); + let mut database = open_database(&root, &migrations_dir())?; + println!("{}", json!({ "event": "ready", "protocolVersion": 1 })); + io::stdout().flush()?; + + for line in io::stdin().lock().lines() { + let line = line?; + let request: RpcRequest = serde_json::from_str(&line)?; + let request_id = request.id.clone(); + let result = match handle(request, &mut database, &root, &migrations_dir()) { + Ok(value) => json!({ "id": request_id, "result": value }), + Err(message) => json!({ + "id": request_id, + "error": RpcError { code: "sidecar_error", message }, + }), + }; + println!("{result}"); + io::stdout().flush()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn string_param_rejects_missing_or_non_string_values() { + assert_eq!( + string_param(&json!({ "name": "EdgeEver" }), "name").unwrap(), + "EdgeEver" + ); + assert!(string_param(&json!({}), "name").is_err()); + assert!(string_param(&json!({ "name": 42 }), "name").is_err()); + } + + #[test] + fn markdown_doc_produces_a_tiptap_document() { + let document = markdown_doc("# Heading\n\nBody"); + assert_eq!(document.get("type").and_then(Value::as_str), Some("doc")); + assert!(document + .get("content") + .and_then(Value::as_array) + .map(|nodes| !nodes.is_empty()) + .unwrap_or(false)); + } + + #[test] + fn content_hash_is_stable_and_content_sensitive() { + let json = markdown_doc("same").to_string(); + let first = content_hash("same", &json); + assert_eq!(first, content_hash("same", &json)); + assert_ne!(first, content_hash("different", &json)); + } +} diff --git a/docs/desktop-performance-baseline.md b/docs/desktop-performance-baseline.md new file mode 100644 index 00000000..65ea9fd9 --- /dev/null +++ b/docs/desktop-performance-baseline.md @@ -0,0 +1,62 @@ +# Desktop performance baseline + +The desktop app must remain a local-first application. Remote network latency +must not be part of the critical path for opening or editing a note. + +## Acceptance targets + +| Operation | Target | +| --- | ---: | +| Sidecar ready after process spawn | `< 500 ms` | +| Local SQLite health request | `< 20 ms` | +| Existing local memo list query | `< 50 ms` | +| Existing local memo detail query | `< 20 ms` | +| Create/edit local memo acknowledgement | `< 50 ms` | +| Cloud sync | Background only | + +The Web/PWA build keeps Mermaid, diagram renderer, code-highlighting, and +editor-only Tiptap/ProseMirror chunks out of both the service-worker precache +and the initial HTML `modulepreload` list. They load and cache on first use +instead, so ordinary note-list startup does not pay for optional editor/diagram +support. The initial modulepreload list is also capped at 700 KiB uncompressed; +the build verifier reports the measured bytes and fails if this critical-path +budget regresses. + +Remote resource blobs use a separate 90-day, 500-entry cache-first runtime +cache. When the browser is offline, newly attached files are written to the +same Cache Storage namespace under a local resource URL, added to the local +resource mirror, and queued for upload. The sync worker uploads the bytes, +rewrites memo JSON/Markdown references, and only then removes the local blob. + +## Repeatable checks + +Build and benchmark the sidecar: + +```sh +cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release +EDGE_EVER_SIDECAR_PATH="$PWD/crates/desktop-sidecar/target/release/edgeever-sidecar" \ + bun run benchmark:desktop:sidecar +``` + +Build an unsigned local desktop bundle and inspect that the bundle contains +the Web renderer, migrations, and sidecar: + +```sh +bun run build:web +cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release +CSC_IDENTITY_AUTO_DISCOVERY=false \ + bun --cwd apps/desktop run dist -- --dir +``` + +The benchmark exercises the same local domain RPCs used by the desktop +renderer: notebook discovery, memo creation, memo list, and memo detail. Its +`thresholds` object is suitable for CI gating; remote sync is deliberately not +included in the interactive critical path. + +Verify that optional editor and diagram chunks stay out of the initial PWA +precache and HTML modulepreload list: + +```sh +bun run build:web +bun run verify:web-performance +``` diff --git a/package.json b/package.json index d5878d66..b88d79d6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:remote": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:remote\" \"bun run dev:web\"", "dev:api:remote": "bun scripts/run-wrangler.mjs dev --remote --port 8787", "dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173", + "dev:desktop": "concurrently -k -n web,desktop -c magenta,cyan \"bun run dev:web\" \"bun run --cwd apps/desktop dev\"", "dev:mobile": "bun --cwd apps/mobile expo start", "prepare:mobile:icons": "bun scripts/prepare-mobile-icons.mjs", "build:android:fast": "bash scripts/build-android-local.sh fast", @@ -29,6 +30,13 @@ "build:extension": "vite build --config apps/extension/vite.config.ts", "dev:extension": "vite --config apps/extension/vite.config.ts", "build:web": "vite build --config apps/web/vite.config.ts", + "build:desktop:sidecar": "cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release", + "build:desktop": "bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", + "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", + "verify:web-performance": "bun scripts/verify-web-performance.mjs", + "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", + "verify:desktop-package": "bun scripts/verify-desktop-package.mjs", "build:cloudflare": "bun run typecheck && bun run build", "build:site": "bun --cwd apps/site build", "preview:web": "vite preview --config apps/web/vite.config.ts --host 127.0.0.1 --port 4173", diff --git a/packages/shared/src/desktop-rpc.ts b/packages/shared/src/desktop-rpc.ts new file mode 100644 index 00000000..93544911 --- /dev/null +++ b/packages/shared/src/desktop-rpc.ts @@ -0,0 +1,171 @@ +import type { MemoDetail, MemoSummary, MemoTemplate, Notebook, ResourceListItem, ResourceStorageSummary } from "./types"; +import type { TiptapDoc } from "./content"; + +export type DesktopRpcMethod = + | "system.info" + | "storage.health" + | "storage.backup" + | "storage.backups" + | "storage.restore" + | "sync.status" + | "notebook.list" + | "notebook.create" + | "notebook.update" + | "notebook.delete" + | "notebook.restore" + | "template.list" + | "template.cache" + | "template.create" + | "template.update" + | "template.delete" + | "resource.list" + | "resource.cache" + | "tag.list" + | "tag.rename" + | "tag.delete" + | "memo.moveBatch" + | "memo.deleteBatch" + | "memo.emptyTrash" + | "memo.pinBatch" + | "memo.merge" + | "memo.list" + | "memo.get" + | "memo.create" + | "memo.update" + | "memo.delete" + | "memo.restore" + | "memo.revisions" + | "memo.restoreRevision" + | "memo.revision.cache" + | "sync.outbox.list" + | "sync.outbox.ack" + | "sync.outbox.fail" + | "sync.outbox.discard" + | "sync.apply" + | "sync.cursor.set"; + +export type DesktopMemoListParams = { + notebookId?: string | null; + notebookIds?: string[]; + q?: string; + trash?: boolean; + sort?: "updated-desc" | "created-desc" | "title-asc"; + filter?: "all" | "pinned" | "tagged" | "untagged"; + limit?: number; + offset?: number; +}; + +export type DesktopMemoCreateParams = { + notebookId: string; + title?: string; + contentMarkdown?: string; + tags?: string[]; +}; + +export type DesktopMemoUpdateParams = { + memoId: string; + expectedRevision?: number; + expectedContentHash?: string; + title: string; + contentJson: TiptapDoc; + contentMarkdown?: string; + tags: string[]; +}; + +export type DesktopRpcResponses = { + "notebook.list": { notebooks: Notebook[] }; + "notebook.create": { notebook: Notebook }; + "notebook.update": { notebook: Notebook }; + "notebook.delete": { ok: true }; + "notebook.restore": { notebook: Notebook }; + "template.list": { templates: MemoTemplate[] }; + "template.cache": { template: MemoTemplate }; + "template.create": { template: MemoTemplate }; + "template.update": { template: MemoTemplate }; + "template.delete": { ok: true }; + "resource.list": { resources: ResourceListItem[]; summary: ResourceStorageSummary }; + "resource.cache": { ok: true }; + "tag.list": { tags: import("./types").TagSummary[] }; + "tag.rename": { ok: true; updated: number }; + "tag.delete": { ok: true; updated: number }; + "memo.moveBatch": { ok: true; moved: number }; + "memo.deleteBatch": { ok: true; deleted: number }; + "memo.emptyTrash": { ok: true; deleted: number }; + "memo.pinBatch": { ok: true; updated: number }; + "memo.merge": { memo: MemoDetail }; + "memo.list": { memos: MemoSummary[]; totalCount: number; nextCursor: string | null }; + "memo.get": { memo: MemoDetail }; + "memo.create": { memo: MemoDetail }; + "memo.update": { memo: MemoDetail }; + "memo.delete": { ok: true }; + "memo.restore": { memo: MemoDetail }; + "memo.revisions": { revisions: import("./types").MemoRevision[] }; + "memo.restoreRevision": { memo: MemoDetail }; + "memo.revision.cache": { ok: true }; + "sync.status": { pending: number; syncing: number; conflict: number; error: number; cursor: number; syncIdentity: string | null; lastSyncedAt: string | null }; + "sync.outbox.list": { items: DesktopOutboxItem[] }; + "sync.outbox.ack": { ok: true; memo: MemoDetail | null; notebook: Notebook | null; template: MemoTemplate | null }; + "sync.outbox.fail": { ok: true }; + "sync.outbox.discard": { ok: true }; + "sync.apply": { applied: number }; + "sync.cursor.set": { ok: true }; + "system.info": { platform: string; architecture: string; dataDir: string; protocolVersion: number }; + "storage.health": { ok: true }; + "storage.backup": { ok: true; path: string }; + "storage.backups": { backups: Array<{ path: string; name: string; size: number; modifiedAt: string }> }; + "storage.restore": { ok: true; path: string }; +}; + +export type DesktopRpcParams = { + "notebook.list": Record; + "notebook.create": { name: string; parentId?: string | null }; + "notebook.update": { notebookId: string; name?: string; parentId?: string | null; sortOrder?: number }; + "notebook.delete": { notebookId: string }; + "notebook.restore": { notebookId: string }; + "template.list": Record; + "template.cache": { template: MemoTemplate }; + "template.create": { name: string; description?: string | null; memoId?: string; title?: string | null; contentMarkdown?: string; tags?: string[] }; + "template.update": { templateId: string; name?: string; description?: string | null; title?: string | null; contentMarkdown?: string; tags?: string[] }; + "template.delete": { templateId: string }; + "resource.list": { limit?: number }; + "resource.cache": { resource: import("./types").Resource }; + "tag.list": Record; + "tag.rename": { tag: string; name: string }; + "tag.delete": { tag: string }; + "memo.moveBatch": { memoIds: string[]; notebookId: string }; + "memo.deleteBatch": { memoIds: string[]; permanent?: boolean }; + "memo.emptyTrash": Record; + "memo.pinBatch": { memoIds: string[]; isPinned: boolean }; + "memo.merge": { memoIds: string[]; notebookId?: string; title?: string }; + "memo.list": DesktopMemoListParams; + "memo.get": { memoId: string; includeDeleted?: boolean }; + "memo.create": DesktopMemoCreateParams; + "memo.update": DesktopMemoUpdateParams; + "memo.delete": { memoId: string; permanent?: boolean }; + "memo.restore": { memoId: string }; + "memo.revisions": { memoId: string; limit?: number }; + "memo.restoreRevision": { memoId: string; revisionId: string }; + "memo.revision.cache": { revision: import("./types").MemoRevision }; + "sync.status": Record; + "sync.outbox.list": { limit?: number }; + "sync.outbox.ack": { id: number; remoteMemo?: MemoDetail; remoteNotebook?: Notebook; remoteTemplate?: MemoTemplate }; + "sync.outbox.fail": { id: number; error: string; conflict?: boolean }; + "sync.outbox.discard": { id: number }; + "sync.apply": { changes: Array<{ entityType: "memo" | "notebook"; operation: "upsert" | "delete"; memo?: MemoDetail | null; notebook?: Notebook | null; entityId: string }> }; + "sync.cursor.set": { cursor: number; syncIdentity: string }; + "system.info": Record; + "storage.health": Record; + "storage.backup": Record; + "storage.backups": Record; + "storage.restore": { path: string }; +}; + +export type DesktopOutboxItem = { + id: number; + kind: "memo.create" | "memo.update" | "memo.delete" | "memo.restore" | "memo.move" | "memo.deleteBatch" | "memo.emptyTrash" | "memo.pinBatch" | "memo.merge" | "notebook.create" | "notebook.update" | "notebook.delete" | "notebook.restore" | "template.create" | "template.update" | "template.delete" | "tag.rename" | "tag.delete"; + entityId: string; + payload: Record; + attemptCount: number; + status?: "pending" | "syncing" | "conflict" | "error"; + lastError?: string | null; +}; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index b78863ea..28ee659c 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -13,6 +13,7 @@ export const enUS = { processing: "Processing", saving: "Saving", copied: "Copied", + refresh: "Refresh", githubRepository: "Open source", }, dialogs: { @@ -46,7 +47,14 @@ export const enUS = { authNotConfigured: "This instance has not completed its security setup. Set EDGE_EVER_AUTH_PASSWORD as a Worker Secret and redeploy. Unauthenticated access stays blocked until it is fixed.", databaseNotReady: "The database is not ready. Verify that the D1 binding is named DB, apply the remote migrations, and redeploy.", instanceUnavailable: "This instance is temporarily unavailable. Check the Worker logs and run bun run deploy:doctor to validate the deployment.", + desktopScopeUnavailable: "The desktop local data scope could not be switched. The workspace is paused to protect account data.", + desktopScopeRetry: "Retry", passwordHashInvalid: "This account's password data is invalid; plaintext passwords are not supported. Follow the deployment guide and run auth:reset-password to reset it safely.", + desktopInstanceTitle: "Connect an EdgeEver instance", + desktopInstanceDescription: "The desktop app needs your self-hosted instance URL. It is stored only in this computer's local storage.", + desktopInstanceUrl: "Instance URL", + desktopInstanceContinue: "Continue", + desktopInstanceUrlInvalid: "Enter a valid instance URL beginning with http:// or https://.", }, nav: { mobileMain: "Mobile primary navigation", @@ -116,6 +124,8 @@ export const enUS = { offline: "Offline", syncing: "Syncing", conflicts: "{{count}} sync conflicts", + discardConflicts: "Use cloud versions", + discardingConflicts: "Using cloud versions", retry: "{{count}} waiting to retry", pending: "{{count}} pending", synced: "Synced", @@ -266,6 +276,16 @@ export const enUS = { progress: "{{completed}} / {{total}}", complete: "Export complete. Your ZIP download has started.", error: "Export failed. Check your connection and try again.", + desktopLocalTitle: "Desktop local backups", + desktopLocalDescription: "The sidecar keeps the five most recent backups in the local SQLite data directory. A protective backup is created before every restore.", + desktopLocalBackup: "Back up now", + desktopLocalRestore: "Restore selected backup", + desktopLocalRefreshing: "Reading backup list…", + desktopLocalEmpty: "No local backups yet", + desktopLocalSuccess: "Local backup operation completed.", + desktopLocalRestoreConfirm: "Restoring will replace the current desktop data and reload the workspace. Continue?", + desktopLocalError: "The local backup operation failed. Check the desktop diagnostic log.", + desktopLocalProtectedBackup: "Protective backup before restore: {{name}}", webdavTitle: "WebDAV backup", webdavDescription: "Upload a complete ZIP backup containing notes, attachments, and revision history to your WebDAV storage.", webdavManualDescription: "Manually upload a complete ZIP backup containing notes, attachments, and revision history to your WebDAV storage.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 76dee84f..a3f9812f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -13,6 +13,7 @@ export const zhCN = { processing: "处理中", saving: "保存中", copied: "已复制", + refresh: "刷新", githubRepository: "开源", }, dialogs: { @@ -46,7 +47,14 @@ export const zhCN = { authNotConfigured: "实例尚未完成安全配置。请在 Worker 中设置 EDGE_EVER_AUTH_PASSWORD Secret,然后重新部署;在修复前不会开放免登录访问。", databaseNotReady: "数据库尚未就绪。请确认 D1 绑定名称为 DB,并执行远程数据库迁移后重新部署。", instanceUnavailable: "实例暂时不可用。请查看 Worker 日志并运行 bun run deploy:doctor 检查部署配置。", + desktopScopeUnavailable: "桌面本地数据空间暂时无法切换。为保护账户数据,工作区已暂停加载。", + desktopScopeRetry: "重试", passwordHashInvalid: "该账号的密码数据格式无效,不能使用明文密码。请按照部署指南运行 auth:reset-password 安全重置。", + desktopInstanceTitle: "连接 EdgeEver 实例", + desktopInstanceDescription: "桌面端需要知道你的自托管实例地址。地址只保存在本机浏览器存储中。", + desktopInstanceUrl: "实例地址", + desktopInstanceContinue: "继续", + desktopInstanceUrlInvalid: "请输入以 http:// 或 https:// 开头的有效实例地址。", }, nav: { mobileMain: "移动端主导航", @@ -116,6 +124,8 @@ export const zhCN = { offline: "离线", syncing: "同步中", conflicts: "{{count}} 项同步冲突", + discardConflicts: "采用云端版本", + discardingConflicts: "正在采用云端版本", retry: "{{count}} 项等待重试", pending: "{{count}} 项待同步", synced: "已同步", @@ -266,6 +276,16 @@ export const zhCN = { progress: "{{completed}} / {{total}}", complete: "导出完成,ZIP 文件已开始下载。", error: "导出失败,请检查网络连接后重试。", + desktopLocalTitle: "桌面端本地备份", + desktopLocalDescription: "sidecar 会在本机 SQLite 数据目录中保留最近 5 份备份。恢复前会自动创建保护性备份。", + desktopLocalBackup: "立即备份", + desktopLocalRestore: "恢复所选备份", + desktopLocalRefreshing: "正在读取备份列表…", + desktopLocalEmpty: "暂无本地备份", + desktopLocalSuccess: "本地备份操作完成。", + desktopLocalRestoreConfirm: "恢复后会覆盖当前桌面端本地数据,并重载工作区。确定继续吗?", + desktopLocalError: "本地备份操作失败,请查看桌面诊断日志。", + desktopLocalProtectedBackup: "恢复前保护性备份:{{name}}", webdavTitle: "WebDAV 备份", webdavDescription: "将包含笔记、附件和历史版本的完整 ZIP 备份上传到你的 WebDAV 存储。", webdavManualDescription: "手动将包含笔记、附件和历史版本的完整 ZIP 备份上传到你的 WebDAV 存储。", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0cffa0b..bd7e6407 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,4 +7,6 @@ export * from "./note-links"; export * from "./notebooks"; export * from "./revision-diff"; export * from "./schemas"; +export * from "./sync"; +export * from "./desktop-rpc"; export * from "./types"; diff --git a/packages/shared/src/sync.ts b/packages/shared/src/sync.ts new file mode 100644 index 00000000..16424dc6 --- /dev/null +++ b/packages/shared/src/sync.ts @@ -0,0 +1,44 @@ +import type { MemoDetail, Notebook } from "./types"; + +export type SyncEntityType = "memo" | "notebook"; +export type SyncOperation = "upsert" | "delete"; + +export type SyncChange = { + cursor: number; + entityType: SyncEntityType; + entityId: string; + operation: SyncOperation; + notebook: Notebook | null; + memo: MemoDetail | null; +}; + +export type SyncBootstrapResponse = { + notebooks: Notebook[]; + memos: MemoDetail[]; + snapshotCursor: number; + syncIdentity?: string; + totalCount: number; + nextAfterId: string | null; +}; + +export type SyncChangesResponse = { + changes: SyncChange[]; + cursor: number; + hasMore: boolean; + serverCursor: number; + syncIdentity?: string; +}; + +export type SyncCursorState = { + cursor: number; + syncIdentity: string; +}; + +export type SyncOutboxOperation = + | "memo.create" + | "memo.update" + | "memo.delete" + | "memo.restore" + | "notebook.create" + | "notebook.update" + | "notebook.delete"; diff --git a/scripts/benchmark-desktop-sidecar.mjs b/scripts/benchmark-desktop-sidecar.mjs new file mode 100644 index 00000000..64c6d80e --- /dev/null +++ b/scripts/benchmark-desktop-sidecar.mjs @@ -0,0 +1,80 @@ +import { performance } from "node:perf_hooks"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { existsSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const sidecarPath = process.env.EDGE_EVER_SIDECAR_PATH + ?? join(process.cwd(), "crates/desktop-sidecar/target/debug/edgeever-sidecar"); +const migrationsPath = process.env.EDGE_EVER_MIGRATIONS_PATH ?? join(process.cwd(), "migrations"); +const dataPath = join(tmpdir(), `edgeever-sidecar-benchmark-${randomUUID()}`); + +if (!existsSync(sidecarPath)) { + throw new Error(`Sidecar not found: ${sidecarPath}`); +} +mkdirSync(dataPath, { recursive: true }); + +const startedAt = performance.now(); +const child = spawn(sidecarPath, ["--data-dir", dataPath, "--migrations-dir", migrationsPath], { + stdio: ["pipe", "pipe", "pipe"], +}); +const lines = createInterface({ input: child.stdout }); +const messages = []; +lines.on("line", (line) => { + try { + messages.push(JSON.parse(line)); + } catch { + // Ignore diagnostic output that is not JSON-RPC. + } +}); + +const waitFor = async (predicate, timeoutMs = 10000) => { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + const match = messages.find(predicate); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for sidecar response"); +}; + +let nextRequestId = 1; +const request = async (method, params = {}) => { + const id = nextRequestId++; + const startedAt = performance.now(); + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + const response = await waitFor((message) => message.id === id); + if (response.error) throw new Error(response.error.message || `${method} failed`); + return { result: response.result, elapsedMs: performance.now() - startedAt }; +}; + +await waitFor((message) => message.event === "ready"); +const readyMs = performance.now() - startedAt; +const health = await request("storage.health"); +const notebooks = await request("notebook.list"); +const notebookId = notebooks.result.notebooks.find((notebook) => notebook.slug === "inbox")?.id ?? notebooks.result.notebooks[0]?.id; +if (!notebookId) throw new Error("No notebook available for benchmark"); +const created = await request("memo.create", { notebookId, title: "Benchmark memo", contentMarkdown: "Local benchmark content", tags: [] }); +const listed = await request("memo.list", { notebookId, limit: 50 }); +const detailed = await request("memo.get", { memoId: created.result.memo.id }); +child.stdin.end(); + +console.log(JSON.stringify({ + sidecarPath, + readyMs: Number(readyMs.toFixed(2)), + storageHealthMs: Number(health.elapsedMs.toFixed(2)), + memoListMs: Number(listed.elapsedMs.toFixed(2)), + memoDetailMs: Number(detailed.elapsedMs.toFixed(2)), + memoCreateMs: Number(created.elapsedMs.toFixed(2)), + storageHealthy: health.result?.ok === true, + memoListHealthy: listed.result?.memos?.some((memo) => memo.id === created.result.memo.id) === true, + thresholds: { + ready: readyMs < 500, + storageHealth: health.elapsedMs < 20, + memoList: listed.elapsedMs < 50, + memoDetail: detailed.elapsedMs < 20, + memoCreate: created.elapsedMs < 50, + }, +}, null, 2)); diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs new file mode 100644 index 00000000..e614a84a --- /dev/null +++ b/scripts/test-desktop-sidecar.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const sidecarPath = process.env.EDGE_EVER_SIDECAR_PATH ?? join(process.cwd(), "crates/desktop-sidecar/target/debug/edgeever-sidecar"); +const migrationsPath = process.env.EDGE_EVER_MIGRATIONS_PATH ?? join(process.cwd(), "migrations"); +const dataDir = mkdtempSync(join(tmpdir(), "edgeever-sidecar-test-")); +const child = spawn(sidecarPath, ["--data-dir", dataDir, "--migrations-dir", migrationsPath], { stdio: ["pipe", "pipe", "inherit"] }); +const lines = createInterface({ input: child.stdout }); +const messages = []; +lines.on("line", (line) => { + try { messages.push(JSON.parse(line)); } catch { /* Ignore diagnostics. */ } +}); + +const waitFor = async (predicate, timeoutMs = 10000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const index = messages.findIndex(predicate); + if (index >= 0) return messages.splice(index, 1)[0]; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("Timed out waiting for sidecar response"); +}; + +let nextId = 1; +const request = async (method, params = {}) => { + const id = nextId++; + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + const response = await waitFor((message) => message.id === id); + if (response.error) throw new Error(response.error.message); + return response.result; +}; + +await waitFor((message) => message.event === "ready"); +const notebooks = await request("notebook.list"); +const inbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox"); +assert.ok(inbox, "seed inbox notebook should exist"); +if (process.platform !== "win32") { + assert.equal(statSync(dataDir).mode & 0o777, 0o700, "sidecar data directory should be private"); + assert.equal(statSync(join(dataDir, "edgeever.sqlite")).mode & 0o777, 0o600, "sidecar database should be private"); +} + +const first = await request("memo.create", { notebookId: inbox.id, title: "Local first", contentMarkdown: "searchable body", tags: ["local"] }); +const second = await request("memo.create", { notebookId: inbox.id, title: "Second memo", contentMarkdown: "another body", tags: [] }); +const search = await request("memo.list", { q: "searchable", limit: 20 }); +assert.deepEqual(search.memos.map((memo) => memo.id), [first.memo.id]); +await request("memo.update", { + memoId: first.memo.id, + title: "Local first updated", + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "updated body" }] }] }, + contentMarkdown: "updated body", + contentText: "updated body", + tags: ["updated"], +}); +const revisions = await request("memo.revisions", { memoId: first.memo.id }); +assert.ok(revisions.revisions.length >= 1, "memo update should create a revision"); +await request("memo.restoreRevision", { memoId: first.memo.id, revisionId: revisions.revisions[0].id }); +const restoredRevisionMemo = await request("memo.get", { memoId: first.memo.id }); +assert.equal(restoredRevisionMemo.memo.contentMarkdown, "searchable body"); +await request("memo.revision.cache", { + revision: { + id: "remote_revision_test", + memoId: first.memo.id, + revision: 99, + title: "Remote cached revision", + tags: ["remote"], + contentJson: { type: "doc", content: [] }, + contentMarkdown: "cached remote revision", + contentText: "cached remote revision", + contentHash: "remote-hash", + createdBy: "remote", + createdAt: "2026-01-01T00:00:00.000Z", + }, +}); +const cachedRevisions = await request("memo.revisions", { memoId: first.memo.id }); +assert.ok(cachedRevisions.revisions.some((revision) => revision.id === "remote_revision_test")); + +const coalesced = await request("memo.create", { notebookId: inbox.id, title: "Autosave coalesce", contentMarkdown: "initial", tags: [] }); +const updatePayload = (contentMarkdown) => ({ + memoId: coalesced.memo.id, + title: "Autosave coalesce", + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: contentMarkdown }] }] }, + contentMarkdown, + contentText: contentMarkdown, + tags: [], +}); +await request("memo.update", updatePayload("first autosave")); +await request("memo.update", updatePayload("latest autosave")); +const coalescedUpdates = (await request("sync.outbox.list", { limit: 200 })).items.filter((item) => item.kind === "memo.update" && item.entityId === coalesced.memo.id); +assert.equal(coalescedUpdates.length, 1, "offline autosaves should coalesce into one sidecar outbox item"); +assert.equal(coalescedUpdates[0].payload.contentMarkdown, "latest autosave"); + +const batchNotebook = (await request("notebook.create", { name: "Batch destination" })).notebook; +const batchFirst = (await request("memo.create", { notebookId: inbox.id, title: "Batch one", contentMarkdown: "batch one", tags: ["batch-tag"] })).memo; +const batchSecond = (await request("memo.create", { notebookId: inbox.id, title: "Batch two", contentMarkdown: "batch two", tags: ["batch-tag"] })).memo; +const renamedTag = await request("tag.rename", { tag: "batch-tag", name: "renamed-batch-tag" }); +assert.equal(renamedTag.updated, 2); +assert.ok((await request("tag.list")).tags.some((tag) => tag.name === "renamed-batch-tag")); +assert.equal((await request("memo.moveBatch", { memoIds: [batchFirst.id, batchSecond.id], notebookId: batchNotebook.id })).moved, 2); +assert.equal((await request("memo.pinBatch", { memoIds: [batchFirst.id, batchSecond.id], isPinned: true })).updated, 2); +assert.equal((await request("memo.list", { notebookId: batchNotebook.id, filter: "pinned", limit: 20 })).totalCount, 2); +assert.equal((await request("memo.deleteBatch", { memoIds: [batchFirst.id, batchSecond.id] })).deleted, 2); +assert.equal((await request("memo.list", { trash: true, notebookId: batchNotebook.id, limit: 20 })).totalCount, 2); +await request("memo.restore", { memoId: batchFirst.id }); +assert.equal((await request("memo.list", { notebookId: batchNotebook.id, limit: 20 })).totalCount, 1); +assert.equal((await request("memo.emptyTrash")).deleted, 1); +assert.equal((await request("memo.list", { trash: true, notebookId: batchNotebook.id, limit: 20 })).totalCount, 0); + +const merged = await request("memo.merge", { memoIds: [first.memo.id, second.memo.id] }); +assert.equal(merged.memo.sourceMemoIds.length, 2); +const trash = await request("memo.list", { trash: true, limit: 20 }); +assert.ok(trash.memos.some((memo) => memo.id === first.memo.id)); + +await request("template.cache", { + template: { + id: "tpl_remote_test", + name: "Remote test template", + description: null, + title: "Template title", + contentJson: { type: "doc", content: [] }, + contentMarkdown: "Template body", + tags: ["test"], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, +}); +const templates = await request("template.list"); +assert.ok(templates.templates.some((template) => template.id === "tpl_remote_test")); +const localTemplate = await request("template.create", { name: "Local template", memoId: first.memo.id }); +const templateOutbox = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === localTemplate.template.id); +assert.ok(templateOutbox, "template create should be queued"); +assert.equal(templateOutbox.payload.contentMarkdown, "searchable body"); +assert.deepEqual(templateOutbox.payload.tags, ["local"]); +await request("template.update", { templateId: localTemplate.template.id, name: "Local template updated", contentMarkdown: "updated template" }); +const templateUpdateBeforeAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "template.update" && item.entityId === localTemplate.template.id); +assert.ok(templateUpdateBeforeAck, "template update should be queued behind create"); +await request("sync.outbox.ack", { + id: templateOutbox.id, + remoteTemplate: { + id: "tpl_remote_mapped", + name: "Local template updated", + description: null, + title: "", + contentJson: { type: "doc", content: [] }, + contentMarkdown: "updated template", + tags: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, +}); +const templateUpdateAfterAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "template.update"); +assert.equal(templateUpdateAfterAck.entityId, "tpl_remote_mapped"); +assert.equal(templateUpdateAfterAck.payload.templateId, "tpl_remote_mapped"); +await request("template.delete", { templateId: "tpl_remote_test" }); +assert.equal((await request("template.list")).templates.some((template) => template.id === "tpl_remote_test"), false); + +const backup = await request("storage.backup"); +assert.equal(backup.ok, true); +const outsideBackup = join(dataDir, "outside.sqlite"); +writeFileSync(outsideBackup, "not a managed backup"); +await assert.rejects(() => request("storage.restore", { path: outsideBackup }), /managed EdgeEver backup/); +const stagedResourceDirectory = join(dataDir, "resource-outbox"); +mkdirSync(stagedResourceDirectory, { recursive: true }); +writeFileSync(join(stagedResourceDirectory, "stage-test.bin"), "offline attachment snapshot"); +const backupWithResource = await request("storage.backup"); +if (process.platform !== "win32") assert.equal(statSync(backupWithResource.path).mode & 0o777, 0o600, "database backups should be private"); +const resourceBackupDirectory = backupWithResource.path.replace(/\.sqlite$/, ".resources"); +assert.ok(existsSync(join(resourceBackupDirectory, "stage-test.bin")), "backup should include staged resources"); +writeFileSync(join(stagedResourceDirectory, "stage-test.bin"), "changed after backup"); +for (let index = 0; index < 4; index += 1) await request("storage.backup"); +const backups = await request("storage.backups"); +assert.ok(backups.backups.some((item) => item.path === backupWithResource.path)); +const afterBackup = await request("memo.create", { notebookId: inbox.id, title: "After backup", contentMarkdown: "should disappear after restore", tags: [] }); +await request("storage.restore", { path: backupWithResource.path }); +await assert.rejects(() => request("memo.get", { memoId: afterBackup.memo.id }), /Query returned no rows/); +assert.equal(readFileSync(join(stagedResourceDirectory, "stage-test.bin"), "utf8"), "offline attachment snapshot", "restore should recover staged resources"); +const outbox = await request("sync.outbox.list", { limit: 100 }); +assert.ok(outbox.items.some((item) => item.kind === "memo.merge")); +const conflictMemo = await request("memo.create", { notebookId: inbox.id, title: "Conflict test", contentMarkdown: "local conflict", tags: [] }); +const conflictCandidate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === conflictMemo.memo.id); +assert.ok(conflictCandidate, "conflict test should create an outbox item"); +await request("sync.outbox.fail", { id: conflictCandidate.id, error: "test conflict", conflict: true }); +assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === conflictCandidate.id && item.status === "conflict")); +await request("sync.outbox.discard", { id: conflictCandidate.id }); +assert.equal((await request("sync.status")).conflict, 0); + +child.stdin.end(); +await new Promise((resolve) => child.once("close", resolve)); +console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.outbox", "sync.outbox.discard"] })); diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs new file mode 100644 index 00000000..1b735233 --- /dev/null +++ b/scripts/verify-desktop-package.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +const outputDirectory = join(process.cwd(), "release", "desktop"); +const version = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")).version; + +const walk = (directory) => { + if (!existsSync(directory)) return []; + const result = []; + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + if (statSync(path).isDirectory()) result.push(...walk(path)); + else result.push(path); + } + return result; +}; + +const files = walk(outputDirectory); +const matchingPrefix = (prefix) => files.filter((path) => basename(path).startsWith(prefix)); +const requestedPlatform = process.env.EDGE_EVER_VERIFY_TARGET ?? process.platform; + +for (const sidecarPath of files.filter((path) => /[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i.test(path))) { + const bundleRoot = sidecarPath.replace(/[\\/]resources[\\/]sidecar[\\/]edgeever-sidecar(?:\.exe)?$/i, ""); + assert.ok(existsSync(join(bundleRoot, "resources", "web", "index.html")), `Desktop bundle is missing the Web renderer: ${bundleRoot}`); + assert.ok(existsSync(join(bundleRoot, "resources", "migrations")), `Desktop bundle is missing migrations: ${bundleRoot}`); +} + +if (requestedPlatform === "darwin") { + assert.ok(existsSync(join(outputDirectory, `EdgeEver-${version}-mac-arm64.dmg`)), "macOS package must contain the current arm64 DMG"); + const sidecar = join(outputDirectory, "mac-arm64", "EdgeEver.app", "Contents", "Resources", "sidecar", "edgeever-sidecar"); + assert.ok(existsSync(sidecar), `macOS app bundle is missing the sidecar: ${sidecar}`); +} else if (requestedPlatform === "win32") { + const installer = matchingPrefix(`EdgeEver-${version}-windows-`).some((path) => path.endsWith(".exe")); + const unpacked = existsSync(join(outputDirectory, "win-arm64-unpacked", "EdgeEver.exe")); + assert.ok(installer || unpacked, "Windows package must contain the current NSIS installer or an unpacked executable"); +} else if (requestedPlatform === "linux") { + assert.ok(matchingPrefix(`EdgeEver-${version}-linux-`).some((path) => path.endsWith(".AppImage")), "Linux package must contain the current AppImage"); + const unpacked = files.find((path) => path.endsWith("/resources/sidecar/edgeever-sidecar") && path.includes("linux-") && path.includes("-unpacked")); + if (unpacked) { + const root = unpacked.slice(0, unpacked.indexOf("/resources/sidecar/edgeever-sidecar")); + assert.ok(existsSync(join(root, "resources", "web", "index.html")), "Linux app bundle is missing the Web renderer"); + assert.ok(existsSync(join(root, "resources", "migrations")), "Linux app bundle is missing migrations"); + } +} else { + throw new Error(`Unsupported packaging platform: ${requestedPlatform}`); +} + +console.log(JSON.stringify({ ok: true, platform: requestedPlatform, artifacts: files.filter((path) => /\.(dmg|exe|AppImage)$/.test(path)) })); diff --git a/scripts/verify-web-performance.mjs b/scripts/verify-web-performance.mjs new file mode 100644 index 00000000..0c18dc25 --- /dev/null +++ b/scripts/verify-web-performance.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const distDirectory = join(process.cwd(), "apps", "web", "dist"); +const indexHtml = readFileSync(join(distDirectory, "index.html"), "utf8"); +const serviceWorker = readFileSync(join(distDirectory, "sw.js"), "utf8"); +assert.match(serviceWorker, /edgeever-resource-blobs/, "PWA must provide a runtime cache for resource bytes"); +assert.match(serviceWorker, /CacheFirst/, "PWA resource bytes must use a cache-first runtime strategy"); +const precacheStart = serviceWorker.indexOf("precacheAndRoute("); +const precacheEnd = serviceWorker.indexOf(");", precacheStart); +assert.ok(precacheStart >= 0 && precacheEnd > precacheStart, "Web service worker must contain a precache manifest"); + +const precacheManifest = serviceWorker.slice(precacheStart, precacheEnd); +const optionalDiagramPattern = /(?:vendor-(?:beautiful-mermaid|mermaid)|mermaid\.core|[^"']*Diagram-)[^"']*\.js/; +assert.doesNotMatch(precacheManifest, optionalDiagramPattern, "Optional diagram chunks must remain out of the initial PWA precache"); + +const entryCount = (precacheManifest.match(/\{url:/g) ?? []).length; +assert.ok(entryCount > 0, "Web service worker precache manifest must not be empty"); +const modulePreloads = indexHtml.match(/]+>/g)?.join("\n") ?? ""; +const initialOptionalPattern = /vendor-code-highlight|vendor-D3|vendor-(?:beautiful-mermaid|mermaid|tiptap|prosemirror|floating)|ui-primitives|mermaid\.core|[^"']*Diagram-/; +assert.doesNotMatch(modulePreloads, initialOptionalPattern, "Optional editor and diagram chunks must remain out of the initial HTML modulepreload list"); +const initialModulePreloadBytes = [...indexHtml.matchAll(/]+href="([^"]+)"[^>]*>/g)] + .map((match) => statSync(join(distDirectory, match[1].replace(/^\//, ""))).size) + .reduce((total, size) => total + size, 0); +const INITIAL_MODULE_PRELOAD_BUDGET = 700 * 1024; +assert.ok(initialModulePreloadBytes <= INITIAL_MODULE_PRELOAD_BUDGET, `Initial modulepreload budget exceeded: ${initialModulePreloadBytes} > ${INITIAL_MODULE_PRELOAD_BUDGET}`); +console.log(JSON.stringify({ ok: true, precacheEntries: entryCount, initialModulePreloadBytes, initialModulePreloadBudget: INITIAL_MODULE_PRELOAD_BUDGET, resourceBytesCache: "cache-first", optionalDiagramChunksDeferred: true, optionalInitialChunksDeferred: true }));