feat: add native desktop local-first foundation
This commit is contained in:
1 parent
d2ea527fa4
commit
ec1009d4dc
63 files changed
+7865
-188
No files matched your search
@@ -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
|
||||
@@ -9,8 +9,12 @@ node_modules/
|
||||
.idea/
|
||||
dist/
|
||||
build/
|
||||
release/desktop/
|
||||
coverage/
|
||||
|
||||
# Rust sidecar build artifacts.
|
||||
target/
|
||||
|
||||
.wrangler/
|
||||
.wrangler.generated-migrations/
|
||||
.wrangler.generated*.toml
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
})();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
expect(rewriteStagedResource(value, rewrites)).toEqual({
|
||||
contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "/api/v1/resources/resource-1/blob" } }] },
|
||||
contentMarkdown: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+22
@@ -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;
|
||||
}
|
||||
@@ -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)[\\/]/,
|
||||
|
||||
Generated
+342
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 存储。",
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
@@ -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"] }));
|
||||
@@ -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)) }));
|
||||
@@ -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 }));
|
||||
Reference in new issue
Block a user