feat: add native desktop local-first foundation

This commit is contained in:
tianma-if committed 2026-07-28 12:49:44 +08:00
1 parent d2ea527fa4
commit ec1009d4dc
63 files changed
+7865 -188

No files matched your search

+89
View File
@@ -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
+4
View File
@@ -9,8 +9,12 @@ node_modules/
.idea/
dist/
build/
release/desktop/
coverage/
# Rust sidecar build artifacts.
target/
.wrangler/
.wrangler.generated-migrations/
.wrangler.generated*.toml
+2 -2
View File
@@ -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
+26 -2
View File
@@ -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");
+69
View File
@@ -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.
+57
View File
@@ -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}
+22
View File
@@ -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"
}
}
+10
View File
@@ -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));
@@ -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);
});
});
+15
View File
@@ -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;
};
@@ -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);
});
@@ -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() : "";
@@ -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");
});
});
@@ -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. */ }
};
@@ -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");
});
});
+567
View File
@@ -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();
})();
});
+13
View File
@@ -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;
}
};
@@ -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);
});
+78
View File
@@ -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();
}
}
+21
View File
@@ -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 };
};
@@ -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");
});
});
+28
View File
@@ -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);
},
}));
+132 -16
View File
@@ -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 = () => (
</div>
);
const DesktopInstanceSetup = () => {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
saveDesktopApiBaseUrl(value);
window.location.reload();
} catch {
setError(t("login.desktopInstanceUrlInvalid"));
}
};
return (
<main className="flex h-[100dvh] items-center justify-center bg-gradient-to-tr from-emerald-50/70 via-emerald-50 to-emerald-100 px-4 py-8 text-slate-950">
<section className="w-full max-w-[440px] rounded-2xl border border-emerald-500/15 bg-white/95 p-8 shadow-[0_20px_50px_rgb(var(--brand-green-rgb)/0.08)]">
<h1 className="text-xl font-bold tracking-tight text-slate-900">{t("login.desktopInstanceTitle")}</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">{t("login.desktopInstanceDescription")}</p>
<form className="mt-6 space-y-4" onSubmit={save}>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-slate-700">{t("login.desktopInstanceUrl")}</span>
<Input
autoFocus
className="h-11 rounded-lg bg-slate-50/50 px-3.5 focus-visible:bg-white focus-visible:ring-4 focus-visible:ring-emerald-500/10"
placeholder="https://notes.example.com"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</label>
{error && <p className="text-sm text-rose-700">{error}</p>}
<Button className="h-11 w-full justify-center rounded-lg bg-emerald-500 font-semibold text-white transition hover:bg-emerald-600" type="submit" variant="solid">
{t("login.desktopInstanceContinue")}
</Button>
</form>
</section>
</main>
);
};
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<Error | null>(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<AuthSession>(["auth", "session"], {
authRequired: true,
@@ -75,6 +164,7 @@ const AuthenticatedWorkspace = () => {
useEffect(() => {
const handleUnauthorized = () => {
const current = queryClient.getQueryData<AuthSession>(["auth", "session"]);
clearCachedDesktopSession();
queryClient.clear();
queryClient.setQueryData<AuthSession>(["auth", "session"], {
authRequired: current?.authRequired ?? true,
@@ -110,6 +200,22 @@ const AuthenticatedWorkspace = () => {
? loginMutation.error.message
: null;
if (desktopBridge?.isAvailable && !desktopScopeReady) {
if (desktopScopeError) {
return (
<main className="flex h-[100dvh] items-center justify-center bg-slate-50 px-4 text-slate-900">
<section className="w-full max-w-md rounded-xl border border-rose-200 bg-white p-6 shadow-sm">
<p className="text-sm leading-6 text-rose-800">{t("login.desktopScopeUnavailable")}</p>
<Button className="mt-4" variant="outline" onClick={() => setDesktopScopeAttempt((value) => value + 1)}>
{t("login.desktopScopeRetry")}
</Button>
</section>
</main>
);
}
return <AuthLoadingScreen />;
}
if (!session?.authenticated) {
return (
<Suspense fallback={<AuthLoadingScreen />}>
@@ -139,16 +245,26 @@ const AuthenticatedWorkspace = () => {
);
};
export const App = () => (
<PwaInstallProvider>
<Routes>
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<PwaUpdateNotice />
<PwaIosPrompt />
</PwaInstallProvider>
);
export const App = () => {
useEffect(() => {
const bridge = window.edgeeverDesktop;
const baseUrl = getConfiguredDesktopApiBaseUrl();
if (bridge?.isAvailable && baseUrl) void bridge.setApiBaseUrl(baseUrl);
}, []);
if (isDesktopInstanceConfigurationRequired()) return <DesktopInstanceSetup />;
return (
<PwaInstallProvider>
<Routes>
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<PwaUpdateNotice />
<PwaIosPrompt />
</PwaInstallProvider>
);
};
+17 -5
View File
@@ -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<HTMLInputElement>(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({
+50 -44
View File
@@ -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 }) => (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
@@ -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<HTMLDivElement | null>(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);
+30 -2
View File
@@ -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 = ({
<CheckCircle2 className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
{summary.conflict > 0 && (
<button
className="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-amber-800 transition-colors hover:bg-white/70 disabled:opacity-50"
type="button"
disabled={!isOnline || isSyncing}
onClick={onDiscardConflicts}
>
{t("notebookPane.sync.discardConflicts")}
</button>
)}
{hasQueuedWork && (
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50 transition-colors"
@@ -233,6 +245,7 @@ const SyncStatusBar = ({
};
export const NotebookPane = ({
repository,
user,
view,
selectedNotebookId,
@@ -256,6 +269,7 @@ export const NotebookPane = ({
isOnline,
isSyncingQueuedChanges,
onSyncQueuedChanges,
onDiscardConflicts,
imageCompressionEnabled,
onImageCompressionChange,
authRequired,
@@ -265,6 +279,7 @@ export const NotebookPane = ({
onResetDemo,
isResettingDemo = false,
}: {
repository: EdgeEverRepository;
user: AuthUser | null;
view: string;
selectedNotebookId: string | null;
@@ -288,6 +303,7 @@ export const NotebookPane = ({
isOnline: boolean;
isSyncingQueuedChanges: boolean;
onSyncQueuedChanges: () => void;
onDiscardConflicts: () => void;
imageCompressionEnabled: boolean;
onImageCompressionChange: (enabled: boolean) => void;
authRequired: boolean;
@@ -363,7 +379,7 @@ export const NotebookPane = ({
const notebooksQuery = useQuery({
queryKey: ["notebooks"],
queryFn: () => api.listNotebooks(),
queryFn: () => repository.listNotebooks(),
});
const notebooks = notebooksQuery.data?.notebooks ?? [];
@@ -416,6 +432,18 @@ export const NotebookPane = ({
</nav>
</TooltipProvider>
{window.edgeeverDesktop?.isAvailable && (
<div className="px-3 pt-2">
<SyncStatusBar
summary={syncSummary}
isOnline={isOnline}
isSyncing={isSyncingQueuedChanges}
onSyncNow={onSyncQueuedChanges}
onDiscardConflicts={onDiscardConflicts}
/>
</div>
)}
<div
ref={notebookScrollRef}
className="flex-1 overflow-y-auto px-3 py-4"
+2 -1
View File
@@ -27,6 +27,7 @@ import { PasswordCard } from "./settings/PasswordCard";
import { SessionCard } from "./settings/SessionCard";
import { UserManagementCard } from "./settings/UserManagementCard";
import { WebDavBackupCard } from "./settings/WebDavBackupCard";
import { DesktopLocalBackupCard } from "./settings/DesktopLocalBackupCard";
import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
import { isNativeDesktopRuntime } from "@/lib/runtime";
@@ -198,7 +199,7 @@ export const SettingsPane = ({
return (
<SettingsGroup>
<DataExportCard />
{isNativeDesktopRuntime() ? <WebDavBackupCard /> : null}
{isNativeDesktopRuntime() ? <><DesktopLocalBackupCard /><WebDavBackupCard /></> : null}
<EvernoteImportGuideCard />
</SettingsGroup>
);
+5 -5
View File
@@ -4,19 +4,19 @@ import { ChevronLeft, Pencil, Tags, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import { cn, formatDateTime } from "@/lib/utils";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import type { TagSummary } from "@edgeever/shared";
import { AppConfirmDialog } from "./dialogs/ConfirmDialogs";
import type { EdgeEverRepository } from "@/lib/repository";
export const TagsPane = ({ onClose }: { onClose: () => void }) => {
export const TagsPane = ({ onClose, repository }: { onClose: () => void; repository: EdgeEverRepository }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [editingTagName, setEditingTagName] = useState<string | null>(null);
const [editingTagValue, setEditingTagValue] = useState("");
const [tagDeleteConfirmation, setTagDeleteConfirmation] = useState<TagSummary | null>(null);
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: () => api.listTags() });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: () => repository.listTags() });
const tags = tagsQuery.data?.tags ?? [];
const invalidateTagData = async () => {
await Promise.all([
@@ -26,14 +26,14 @@ export const TagsPane = ({ onClose }: { onClose: () => void }) => {
]);
};
const renameMutation = useMutation({
mutationFn: ({ tag, name }: { tag: string; name: string }) => api.renameTag(tag, name),
mutationFn: ({ tag, name }: { tag: string; name: string }) => repository.renameTag(tag, name),
onSuccess: async () => {
setEditingTagName(null);
setEditingTagValue("");
await invalidateTagData();
},
});
const deleteMutation = useMutation({ mutationFn: api.deleteTag, onSuccess: invalidateTagData });
const deleteMutation = useMutation({ mutationFn: repository.deleteTag, onSuccess: invalidateTagData });
const cancelRename = () => {
setEditingTagName(null);
setEditingTagValue("");
+264 -33
View File
@@ -37,7 +37,7 @@ import {
type MobileEditorReturnPreview,
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { createExcerpt, docToText, getNotebookDescendantIds, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createExcerpt, docToText, getNotebookDescendantIds, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type Resource, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -82,6 +82,13 @@ import {
import { useBrowserBackLayer } from "@/lib/app-hooks";
import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache";
import type { SyncQueueSummary } from "@/lib/sync-queue";
import {
createLocalDataScope,
putLocalMemo,
putLocalNotebook,
replaceLocalMemoId,
} from "@/lib/local-mirror";
import { createRepository } from "@/lib/repository";
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const PULL_TO_REFRESH_TRIGGER_PX = 72;
@@ -647,6 +654,11 @@ export const WorkspaceApp = ({
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const localDataScope = useMemo(
() => createLocalDataScope(window.location.origin, user?.id),
[user?.id]
);
const repository = useMemo(() => createRepository(localDataScope), [localDataScope]);
const isInitialSettingsRoute = location.pathname === SETTINGS_PATH;
const isInitialTemplatesRoute = location.pathname === TEMPLATES_PATH;
const isInitialMobileEditorReturn = Boolean(getMobileEditorReturnMemoId(location.search));
@@ -765,20 +777,100 @@ export const WorkspaceApp = ({
setIsSyncingQueuedChanges(true);
try {
if (window.edgeeverDesktop?.isAvailable) {
const { getDesktopSyncSummary, syncDesktopData } = await import("@/lib/desktop-sync");
await syncDesktopData();
setSyncSummary(await getDesktopSyncSummary());
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
]);
return;
}
const { syncQueuedChanges } = await import("@/lib/sync-queue");
await syncQueuedChanges({
onSynced: async (memo) => {
scope: localDataScope,
onSynced: async (memo, item) => {
if (item.kind === "memo.create") {
await replaceLocalMemoId(localDataScope, item.memoId, memo);
if (selectedMemoId === item.memoId) {
setSelectedMemoId(memo.id);
setCreatedMemoEditId(null);
}
} else {
await putLocalMemo(localDataScope, memo);
}
cacheMemoDetail(queryClient, memo);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }),
]);
},
onActionSynced: async (result, item) => {
if (!result || (item.kind !== "memo.merge" && item.kind !== "notebook.create" && item.kind !== "notebook.update" && item.kind !== "template.create" && item.kind !== "template.update" && item.kind !== "resource.create")) return;
const { deleteLocalMemo, deleteLocalNotebook, deleteLocalTemplate, getLocalMemo, putLocalMemo, putLocalNotebook, putLocalTemplate, putLocalResource } = await import("@/lib/local-mirror");
const actionPayload = item.payload as Record<string, unknown>;
const temporaryId = typeof actionPayload.temporaryId === "string" ? actionPayload.temporaryId : null;
if (item.kind === "resource.create" && "url" in result) {
await putLocalResource(localDataScope, {
...(result as Resource),
memoTitle: null,
memoExcerpt: null,
memoDeleted: false,
});
} else if (item.kind === "memo.merge" && "contentJson" in result && temporaryId) {
await deleteLocalMemo(localDataScope, temporaryId, true);
await putLocalMemo(localDataScope, result as MemoDetail);
const sourceIds = Array.isArray(actionPayload.memoIds) ? actionPayload.memoIds.filter((value): value is string => typeof value === "string") : [];
await Promise.all(sourceIds.map(async (sourceId) => {
const source = await getLocalMemo(localDataScope, sourceId);
if (source) await putLocalMemo(localDataScope, { ...source, mergedIntoMemoId: (result as MemoDetail).id });
}));
} else if (item.kind === "notebook.create" && "name" in result && temporaryId) {
await deleteLocalNotebook(localDataScope, temporaryId);
await putLocalNotebook(localDataScope, result as Notebook);
} else if (item.kind === "notebook.update" && "name" in result) {
await putLocalNotebook(localDataScope, result as Notebook);
} else if (item.kind === "template.create" && "contentMarkdown" in result && temporaryId) {
await deleteLocalTemplate(localDataScope, temporaryId);
await putLocalTemplate(localDataScope, result as SavedMemoTemplate);
} else if (item.kind === "template.update" && "contentMarkdown" in result) {
await putLocalTemplate(localDataScope, result as SavedMemoTemplate);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
queryClient.invalidateQueries({ queryKey: ["templates"] }),
queryClient.invalidateQueries({ queryKey: ["resources"] }),
]);
},
});
} finally {
setIsSyncingQueuedChanges(false);
}
}, [queryClient]);
}, [localDataScope, queryClient, selectedMemoId]);
const discardConflictsNow = useCallback(async () => {
if (!isOnline) return;
setIsSyncingQueuedChanges(true);
try {
if (window.edgeeverDesktop?.isAvailable) {
const { discardDesktopConflicts, getDesktopSyncSummary } = await import("@/lib/desktop-sync");
await discardDesktopConflicts();
setSyncSummary(await getDesktopSyncSummary());
} else {
const { discardWebConflicts } = await import("@/lib/sync-queue");
await discardWebConflicts(localDataScope);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
]);
} finally {
setIsSyncingQueuedChanges(false);
}
}, [isOnline, queryClient]);
const refreshLatestMemos = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
@@ -799,7 +891,7 @@ export const WorkspaceApp = ({
setIsPullRefreshing(false);
setPullToRefreshDistance(0);
}
}, [queryClient]);
}, [isOnline, localDataScope, queryClient]);
const syncMemosManually = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
@@ -824,14 +916,73 @@ export const WorkspaceApp = ({
const notebooksQuery = useQuery({
queryKey: ["notebooks"],
queryFn: () => api.listNotebooks(),
queryFn: () => repository.listNotebooks(),
});
const templatesQuery = useQuery({
queryKey: ["templates"],
queryFn: () => api.listTemplates(),
queryFn: () => repository.listTemplates(),
enabled: rightView === "templates",
});
useEffect(() => {
let cancelled = false;
let timeoutId: number | null = null;
let idleId: number | null = null;
const refreshLocalMirror = async () => {
try {
const result = await repository.sync();
if (!cancelled && result.changed > 0) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo"] }),
]);
}
} catch {
// The existing remote queries remain the safe fallback when local
// mirror sync is unavailable (for example before login or offline).
}
};
const scheduleRefresh = (delay: number) => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
timeoutId = null;
const idleWindow = window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
if (idleWindow.requestIdleCallback) {
idleId = idleWindow.requestIdleCallback(() => void refreshLocalMirror(), { timeout: 2500 });
} else {
void refreshLocalMirror();
}
}, delay);
};
// Do not compete with the first remote queries for bandwidth. The initial
// screen can render from the remote fallback; the full local snapshot is
// hydrated once the browser is idle.
scheduleRefresh(1200);
const handleOnline = () => scheduleRefresh(300);
window.addEventListener("online", handleOnline);
return () => {
cancelled = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
const idleWindow = window as Window & { cancelIdleCallback?: (id: number) => void };
if (idleId !== null) {
idleWindow.cancelIdleCallback?.(idleId);
}
window.removeEventListener("online", handleOnline);
};
}, [queryClient, repository]);
const savedTemplates = templatesQuery.data?.templates ?? [];
const notebooks = notebooksQuery.data?.notebooks ?? [];
@@ -1025,7 +1176,24 @@ export const WorkspaceApp = ({
}, [shortcutSettings]);
useEffect(() => {
void import("./EditorPane");
let timeoutId: number | null = null;
let idleId: number | null = null;
const prefetchEditor = () => {
void import("./EditorPane");
};
const idleWindow = window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
if (idleWindow.requestIdleCallback) {
idleId = idleWindow.requestIdleCallback(prefetchEditor, { timeout: 2500 });
} else {
timeoutId = window.setTimeout(prefetchEditor, 1200);
}
return () => {
if (idleId !== null) idleWindow.cancelIdleCallback?.(idleId);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, []);
useEffect(() => {
@@ -1056,6 +1224,15 @@ export const WorkspaceApp = ({
}, [isTrashRoute, location.pathname]);
useEffect(() => {
if (window.edgeeverDesktop?.isAvailable) {
let active = true;
const update = async () => {
const { getDesktopSyncSummary } = await import("@/lib/desktop-sync");
if (active) setSyncSummary(await getDesktopSyncSummary());
};
void update();
return () => { active = false; };
}
let unsubscribe: (() => void) | undefined;
let active = true;
@@ -1203,6 +1380,14 @@ export const WorkspaceApp = ({
};
}, [runQueuedSync]);
useEffect(() => {
const handleQueueChanged = () => {
void runQueuedSync();
};
window.addEventListener("edgeever:sync-queue-changed", handleQueueChanged);
return () => window.removeEventListener("edgeever:sync-queue-changed", handleQueueChanged);
}, [runQueuedSync]);
useEffect(() => {
if (!isStandaloneRuntime) {
return;
@@ -1245,15 +1430,14 @@ export const WorkspaceApp = ({
);
const memosQuery = useInfiniteQuery({
queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds],
queryFn: ({ pageParam }) =>
api.listMemos({
queryFn: ({ pageParam }) => repository.listMemos({
notebookId: memoView === "notebook" ? selectedNotebookId : null,
includeDescendants: memoView === "notebook" && Boolean(selectedNotebookId),
notebookIds: memoView === "notebook" ? selectedNotebookDescendantIds : undefined,
q: search,
trash: memoView === "trash",
filter: memoFilterMode,
sort: memoSortMode,
cursor: pageParam,
offset: pageParam ? Number(pageParam) : 0,
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
@@ -1319,13 +1503,14 @@ export const WorkspaceApp = ({
const memoQuery = useQuery({
queryKey: selectedMemoId ? memoDetailQueryKey(selectedMemoId, memoView) : ["memo", selectedMemoId, memoView],
queryFn: () => api.getMemo(selectedMemoId as string, { includeDeleted: memoView === "trash" }),
queryFn: () => repository.getMemo(selectedMemoId as string, memoView === "trash"),
enabled: Boolean(selectedMemoId),
});
const createNotebookMutation = useMutation({
mutationFn: api.createNotebook,
mutationFn: repository.createNotebook,
onSuccess: async (data) => {
await putLocalNotebook(localDataScope, data.notebook);
await queryClient.invalidateQueries({ queryKey: ["notebooks"] });
setSelectedNotebookId(data.notebook.id);
setActivePane("memos");
@@ -1339,14 +1524,17 @@ export const WorkspaceApp = ({
}: {
notebookId: string;
payload: { name?: string; parentId?: string | null; sortOrder?: number };
}) => api.updateNotebook(notebookId, payload),
onSuccess: async () => {
}) => repository.updateNotebook(notebookId, payload),
onSuccess: async (data) => {
if (data?.notebook) {
await putLocalNotebook(localDataScope, data.notebook);
}
await queryClient.invalidateQueries({ queryKey: ["notebooks"] });
},
});
const deleteNotebookMutation = useMutation({
mutationFn: api.deleteNotebook,
mutationFn: repository.deleteNotebook,
onSuccess: async (_data, notebookId) => {
if (selectedNotebookId === notebookId) {
setSelectedNotebookId(null);
@@ -1358,8 +1546,9 @@ export const WorkspaceApp = ({
});
const createMemoMutation = useMutation({
mutationFn: api.createMemo,
mutationFn: repository.createMemo,
onSuccess: (data) => {
void putLocalMemo(localDataScope, data.memo);
const targetNotebookId = data.memo.notebookId;
setMemoView("notebook");
@@ -1391,7 +1580,7 @@ export const WorkspaceApp = ({
});
const saveTemplateMutation = useMutation({
mutationFn: (input: { name: string; memoId: string }) => api.createTemplate(input),
mutationFn: (input: { name: string; memoId: string }) => repository.createTemplate(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["templates"] });
setAppNoticeDialog({ title: t("templates.templateSaved"), description: t("templates.templateSaved") });
@@ -1400,7 +1589,7 @@ export const WorkspaceApp = ({
const createTemplateMutation = useMutation({
mutationFn: (input: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) =>
api.createTemplate(input),
repository.createTemplate(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["templates"] });
setAppNoticeDialog({ title: t("templates.templateCreated"), description: t("templates.templateCreated") });
@@ -1408,7 +1597,7 @@ export const WorkspaceApp = ({
});
const useTemplateMutation = useMutation({
mutationFn: (input: { templateId: string; notebookId: string }) => api.useTemplate(input.templateId, input.notebookId),
mutationFn: (input: { templateId: string; notebookId: string }) => repository.useTemplate(input.templateId, input.notebookId),
onSuccess: (data) => {
const targetNotebookId = data.memo.notebookId;
setTemplatesOpen(false);
@@ -1431,7 +1620,7 @@ export const WorkspaceApp = ({
});
const deleteTemplateMutation = useMutation({
mutationFn: (templateId: string) => api.deleteTemplate(templateId),
mutationFn: (templateId: string) => repository.deleteTemplate(templateId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["templates"] });
},
@@ -1441,14 +1630,14 @@ export const WorkspaceApp = ({
mutationFn: (input: {
templateId: string;
payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] };
}) => api.updateTemplate(input.templateId, input.payload),
}) => repository.updateTemplate(input.templateId, input.payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["templates"] });
},
});
const mergeMutation = useMutation({
mutationFn: api.mergeMemos,
mutationFn: repository.mergeMemos,
onSuccess: async (data) => {
clearMemoSelection();
cacheMemoDetail(queryClient, data.memo, "notebook");
@@ -1464,7 +1653,7 @@ export const WorkspaceApp = ({
});
const moveMemosMutation = useMutation({
mutationFn: api.moveMemos,
mutationFn: repository.moveMemos,
onSuccess: async () => {
clearMemoSelection();
await Promise.all([
@@ -1476,8 +1665,7 @@ export const WorkspaceApp = ({
});
const pinMemosMutation = useMutation({
mutationFn: async ({ memoIds, isPinned }: { memoIds: string[]; isPinned: boolean }) =>
Promise.all(memoIds.map((memoId) => api.updateMemo(memoId, { isPinned }))),
mutationFn: repository.pinMemos,
onMutate: async ({ memoIds, isPinned }) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: ["memos"] }),
@@ -1512,7 +1700,7 @@ export const WorkspaceApp = ({
});
const deleteMemosMutation = useMutation({
mutationFn: api.deleteMemos,
mutationFn: repository.deleteMemos,
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
const deletedMemoIds = new Set(variables.memoIds);
await Promise.all([
@@ -1569,8 +1757,9 @@ export const WorkspaceApp = ({
});
const deleteMemoMutation = useMutation({
mutationFn: ({ memoId, permanent }: { memoId: string; permanent?: boolean }) =>
api.deleteMemo(memoId, { permanent }),
mutationFn: async ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => {
return repository.deleteMemo(memoId, Boolean(permanent));
},
onMutate: async (variables): Promise<MemoDeleteOptimisticContext> => {
const deletedMemoIds = new Set([variables.memoId]);
await Promise.all([
@@ -1620,7 +1809,7 @@ export const WorkspaceApp = ({
});
const emptyTrashMutation = useMutation({
mutationFn: api.emptyTrash,
mutationFn: repository.emptyTrash,
onMutate: async (): Promise<EmptyTrashOptimisticContext> => {
const previousActivePane = activePane;
const previousSelectedMemoId = selectedMemoId;
@@ -1688,7 +1877,7 @@ export const WorkspaceApp = ({
});
const restoreMemoMutation = useMutation({
mutationFn: api.restoreMemo,
mutationFn: repository.restoreMemo,
onSuccess: (data) => {
setMemoView("notebook");
cacheMemoDetail(queryClient, data.memo, "notebook");
@@ -2166,6 +2355,44 @@ export const WorkspaceApp = ({
updateDesktopFocusMode(!desktopFocusModeActive);
}, [desktopFocusModeActive, updateDesktopFocusMode]);
useEffect(() => {
const bridge = window.edgeeverDesktop;
if (!bridge?.isAvailable) return;
const removeCommandListener = bridge.onCommand((command) => {
if (command === "new-memo") handleCreateMemo();
if (command === "new-notebook") handleCreateNotebook();
if (command === "focus-search") handleMobileSearch();
if (command === "toggle-focus-mode") toggleDesktopFocusMode();
if (command === "sync-now") {
void runQueuedSync();
}
if (command === "backup-now") {
void bridge.sidecarRequest("storage.backup", {});
}
if (command.startsWith("open-memo:")) {
const memoId = command.slice("open-memo:".length);
if (memoId) {
navigateWorkspaceHome();
setMemoView("notebook");
setSelectedMemoId(memoId);
setActivePane("editor");
}
}
});
const removeMarkdownListener = bridge.onImportMarkdown((payload) => {
const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId)
? selectedNotebookId
: defaultMemoNotebookId;
if (!notebookId) return;
const title = payload.name.replace(/\.(?:md|markdown)$/i, "").trim();
createMemoMutation.mutate({ notebookId, title, contentMarkdown: payload.content, tags: [] });
});
return () => {
removeCommandListener();
removeMarkdownListener();
};
}, [createMemoMutation, defaultMemoNotebookId, handleCreateMemo, handleCreateNotebook, handleMobileSearch, notebooks, selectedNotebookId, toggleDesktopFocusMode]);
const handleWorkspaceBackRequest = useCallback(() => {
if (appNoticeDialog) {
setAppNoticeDialog(null);
@@ -2547,6 +2774,7 @@ export const WorkspaceApp = ({
{(isDesktop || visibleActivePane === "notebooks") && (
<Suspense fallback={<PaneLoadingFallback label={t("workspace.loading.notebooks")} />}>
<NotebookPane
repository={repository}
authRequired={authRequired}
user={user}
selectedNotebookId={selectedNotebookId}
@@ -2576,6 +2804,7 @@ export const WorkspaceApp = ({
isOnline={isOnline}
isSyncingQueuedChanges={isSyncingQueuedChanges}
onSyncQueuedChanges={() => void runQueuedSync()}
onDiscardConflicts={() => void discardConflictsNow()}
onOpenAssets={handleOpenAssets}
onOpenTags={handleOpenTags}
onOpenTemplates={handleOpenTemplates}
@@ -2743,9 +2972,9 @@ export const WorkspaceApp = ({
user={user}
/>
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} activeMemo={selectedMemo} />
<AssetsPane onClose={handleCloseAssets} activeMemo={selectedMemo} repository={repository} />
) : rightView === "tags" ? (
<TagsPane onClose={handleCloseAssets} />
<TagsPane onClose={handleCloseAssets} repository={repository} />
) : rightView === "templates" ? (
<TemplatesPane
canCreateMemo={canCreateMemo}
@@ -2767,6 +2996,7 @@ export const WorkspaceApp = ({
) : (
<EditorPane
memo={selectedMemo}
repository={repository}
desktopFocusMode={desktopFocusModeActive}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
mobileDefaultEditMemoId={createdMemoEditId}
@@ -2807,6 +3037,7 @@ export const WorkspaceApp = ({
}
}}
onSaved={async (memo) => {
await putLocalMemo(localDataScope, memo);
cacheMemoDetail(queryClient, memo, memoView);
updateMemoSummaryInLists(queryClient, memoToSummary(memo));
await Promise.all([
@@ -4,11 +4,11 @@ import { Clock3, History, RotateCcw, UserRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { cn, formatDateTime } from "@/lib/utils";
import { getMemoTitle } from "@/lib/app-helpers";
import { AppConfirmDialog } from "./ConfirmDialogs";
import { buildRevisionDiffRows, type MemoDetail } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
const formatRevisionActor = (actor: string) => {
if (actor.startsWith("user:")) {
@@ -24,11 +24,13 @@ const formatRevisionActor = (actor: string) => {
export const RevisionHistoryDialog = ({
memo,
repository,
currentMarkdown,
onClose,
onRestored,
}: {
memo: MemoDetail;
repository: EdgeEverRepository;
currentMarkdown: string;
onClose: () => void;
onRestored: (memo: MemoDetail) => Promise<void>;
@@ -39,7 +41,7 @@ export const RevisionHistoryDialog = ({
const revisionsQuery = useQuery({
queryKey: ["memo-revisions", memo.id],
queryFn: () => api.listMemoRevisions(memo.id),
queryFn: () => repository.listMemoRevisions(memo.id),
});
const revisions = revisionsQuery.data?.revisions ?? [];
@@ -65,7 +67,7 @@ export const RevisionHistoryDialog = ({
}, [diffRows]);
const restoreMutation = useMutation({
mutationFn: (revisionId: string) => api.restoreMemoRevision(memo.id, revisionId),
mutationFn: (revisionId: string) => repository.restoreMemoRevision(memo.id, revisionId),
onSuccess: async (data) => {
setRestoreRevisionConfirmationId(null);
await onRestored(data.memo);
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Database, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type LocalBackup = { path: string; name: string; size: number; modifiedAt: string };
type OperationState = "idle" | "working" | "complete" | "error";
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export const DesktopLocalBackupCard = () => {
const { t } = useTranslation();
const [backups, setBackups] = useState<LocalBackup[]>([]);
const [selectedPath, setSelectedPath] = useState("");
const [state, setState] = useState<OperationState>("idle");
const [message, setMessage] = useState<string | null>(null);
const bridge = window.edgeeverDesktop;
const refresh = useCallback(async () => {
if (!bridge?.isAvailable) return;
try {
const result = await bridge.sidecarRequest<{ backups: LocalBackup[] }>("storage.backups", {});
setBackups(result.backups);
setSelectedPath((current) => result.backups.some((backup) => backup.path === current) ? current : (result.backups[0]?.path ?? ""));
} catch {
setState("error");
setMessage(t("dataExport.desktopLocalError"));
}
}, [bridge, t]);
useEffect(() => { void refresh(); }, [refresh]);
const run = async (method: "storage.backup" | "storage.restore") => {
if (!bridge?.isAvailable) return;
if (method === "storage.restore" && (!selectedPath || !window.confirm(t("dataExport.desktopLocalRestoreConfirm")))) return;
setState("working");
setMessage(null);
try {
const result = await bridge.sidecarRequest<{ ok: true; path: string }>(method, method === "storage.restore" ? { path: selectedPath } : {});
await refresh();
setState("complete");
setMessage(method === "storage.restore" ? t("dataExport.desktopLocalProtectedBackup", { name: result.path.split(/[\\/]/).pop() }) : t("dataExport.desktopLocalSuccess"));
if (method === "storage.restore") window.setTimeout(() => window.location.reload(), 350);
} catch {
setState("error");
setMessage(t("dataExport.desktopLocalError"));
}
};
return (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4 pb-3">
<CardTitle className="flex items-center gap-2 text-sm"><Database className="h-4 w-4 text-emerald-700" />{t("dataExport.desktopLocalTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0">
<CardDescription className="text-xs leading-5">{t("dataExport.desktopLocalDescription")}</CardDescription>
{backups.length > 0 ? (
<select className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20" value={selectedPath} disabled={state === "working"} onChange={(event) => setSelectedPath(event.target.value)}>
{backups.map((backup) => <option key={backup.path} value={backup.path}>{backup.name} · {formatSize(backup.size)}</option>)}
</select>
) : <p className="text-xs text-slate-500">{state === "working" ? t("dataExport.desktopLocalRefreshing") : t("dataExport.desktopLocalEmpty")}</p>}
{message && <p className={`flex items-center gap-1.5 text-xs ${state === "error" ? "text-red-600" : "text-emerald-700"}`} role={state === "error" ? "alert" : undefined}>{state === "error" ? <AlertCircle className="h-3.5 w-3.5" /> : <CheckCircle2 className="h-3.5 w-3.5" />}{message}</p>}
<div className="flex flex-col gap-2 sm:flex-row">
<Button size="sm" variant="outline" type="button" disabled={state === "working"} onClick={() => void refresh()}><RefreshCw className="h-4 w-4" />{t("common.refresh")}</Button>
<Button size="sm" type="button" disabled={state === "working"} onClick={() => void run("storage.backup")}><Database className="h-4 w-4" />{t("dataExport.desktopLocalBackup")}</Button>
<Button size="sm" variant="outline" type="button" disabled={state === "working" || !selectedPath} onClick={() => void run("storage.restore")}>{t("dataExport.desktopLocalRestore")}</Button>
</div>
</CardContent>
</Card>
);
};
+89 -2
View File
@@ -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 <T>(path: string, init?: RequestInit): Promise<T> => {
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<ListNotebooksResponse>("/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<SyncBootstrapResponse>(`/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<SyncChangesResponse>(`/api/v1/sync/changes?${search.toString()}`);
},
createNotebook: (payload: { name: string; parentId?: string | null }) =>
request<NotebookResponse>("/api/v1/notebooks", {
method: "POST",
@@ -214,6 +295,11 @@ export const api = {
method: "DELETE",
}),
restoreNotebook: (notebookId: string) =>
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}/restore`, {
method: "POST",
}),
listTags: () => request<ListTagsResponse>("/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) {
+261
View File
@@ -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 = <T extends { resources: Array<{ url: string }> }>(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<ResourceListItem[]> => {
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 <T extends { resources: ResourceListItem[]; summary: ResourceStorageSummary }>(result: T): Promise<T> => {
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 <M extends DesktopRpcMethod>(method: M, params: DesktopRpcParams[M]) => {
const bridge = window.edgeeverDesktop;
if (!bridge?.isAvailable) throw new Error("EdgeEver desktop bridge is unavailable");
return bridge.sidecarRequest<DesktopRpcResponses[M]>(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 }),
});
@@ -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",
});
});
});
+79
View File
@@ -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<string, unknown> = {};
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,
});
+18
View File
@@ -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)",
});
});
});
+401
View File
@@ -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<string, string>) => {
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 <M extends keyof DesktopRpcResponses>(method: M, params: DesktopRpcParams[M]) => {
const bridge = window.edgeeverDesktop;
if (!bridge?.isAvailable) throw new Error("EdgeEver desktop bridge is unavailable");
return bridge.sidecarRequest<DesktopRpcResponses[M]>(method, params);
};
const applyRemoteMemo = async (memo: NonNullable<DesktopRpcResponses["memo.get"]>["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<string, unknown>
: 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<string>) => {
let synced = 0;
let failed = 0;
let conflicted = 0;
const response = await request("sync.outbox.list", { limit: 100 });
const memoIdMappings = new Map<string, string>();
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;
};
+92 -4
View File
@@ -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<string, unknown>;
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<LocalDraft, string>;
syncQueue!: Table<SyncQueueItem, string>;
notebooks!: Table<LocalNotebook, [string, string]>;
memos!: Table<LocalMemo, [string, string]>;
templates!: Table<LocalTemplate, [string, string]>;
syncMeta!: Table<LocalSyncMeta, [string, string]>;
idMappings!: Table<LocalIdMapping, [string, string]>;
revisions!: Table<LocalRevision, [string, string]>;
resources!: Table<LocalResource, [string, string]>;
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",
});
}
}
+232
View File
@@ -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;
}
});
});
+632
View File
@@ -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<string, Promise<{ bootstrapped: boolean; changed: number }>>();
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<string, { count: number; last: string | null }>();
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<LocalMemoListResponse> => {
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<typeof getResourceSummary> }> => {
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<Pick<Notebook, "name" | "parentId" | "sortOrder">>) => {
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<Omit<MemoTemplate, "id" | "createdAt" | "updatedAt" | "contentJson">> & { 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<string, { memoCount: number; updatedAt: string | null }>();
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 };
+34
View File
@@ -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));
};
+55
View File
@@ -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 });
}
});
});
+381
View File
@@ -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<LocalMemoListResponse>;
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<MemoUpdateSyncPayload, "memoId">): 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);
};
+118
View File
@@ -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 });
}
});
});
+388 -19
View File
@@ -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<string, unknown>;
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<SyncRunResult> | null = null;
export const syncQueuedChanges = (options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
scope?: string;
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
} = {}): Promise<SyncRunResult> => {
if (activeSyncPromise) {
return activeSyncPromise;
@@ -87,7 +242,9 @@ export const syncQueuedChanges = (options: {
};
const runQueuedChanges = async (options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
scope?: string;
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
}): Promise<SyncRunResult> => {
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<MemoDetail> => {
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<SyncQueueResult> => {
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;
+22
View File
@@ -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<string>;
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<unknown>;
installUpdate(): Promise<unknown>;
sidecarRequest<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
stageResource(input: { memoId: string; name: string; type: string; bytes: ArrayBuffer }): Promise<{ id: string }>;
listStagedResources(): Promise<Array<{ id: string; memoId: string; name: string; type: string; size: number }>>;
readStagedResource(id: string): Promise<{ name: string; type: string; bytes: Uint8Array }>;
removeStagedResource(id: string): Promise<void>;
onCommand(callback: (command: string) => void): () => void;
onImportMarkdown(callback: (payload: { name: string; content: string }) => void): () => void;
}
interface Window {
edgeeverDesktop?: EdgeEverDesktopBridge;
}
+41
View File
@@ -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)[\\/]/,
+441 -50
View File
File diff suppressed because it is too large. Load diff
+342
View File
@@ -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"
+10
View File
@@ -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"
+15
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+62
View File
@@ -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
```
+8
View File
@@ -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",
+171
View File
@@ -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<string, never>;
"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<string, never>;
"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<string, never>;
"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<string, never>;
"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<string, never>;
"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<string, never>;
"storage.health": Record<string, never>;
"storage.backup": Record<string, never>;
"storage.backups": Record<string, never>;
"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<string, unknown>;
attemptCount: number;
status?: "pending" | "syncing" | "conflict" | "error";
lastError?: string | null;
};
+20
View File
@@ -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.",
+20
View File
@@ -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 存储。",
+2
View File
@@ -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";
+44
View File
@@ -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";
+80
View File
@@ -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));
+192
View File
@@ -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"] }));
+49
View File
@@ -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)) }));
+28
View File
@@ -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(/<link rel="modulepreload"[^>]+>/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(/<link rel="modulepreload"[^>]+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 }));