chore: update EdgeEver from v2.7.0

EdgeEver-Upstream-Commit: 30fcb3fc65c419000a3e6f8cfb70eca47ae53914
This commit is contained in:
edgeever-updater[bot] committed 2026-10-26 07:48:43 +00:00
1 parent 9756979c24
commit 05e53dcba3
77 files changed
+2485 -146

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ credentials.json
# Local HyperFrames video workspace.
/videos/edgeever-promo/
/videos/edgeever-launch/
# Local App Review crash dumps / image self-test screenshots.
.tmp-app-review-crashes/
+1
View File
@@ -40,6 +40,7 @@ EdgeEver は、オープンソースのノートと知識ベースの作業領
* **Evernote**:最もストレスがなく、滑らかなマルチデバイス即時クラウド同期を備えています。しかし多機能な商業向けスイートへと進化するにつれ、広告や追加機能が増えてシステム負荷が高まりました。さらに無料枠の制限が厳格化され、高度な AI サブスクリプションも高額で、現代のセルフホストやプライベートな AI ワークフローとの柔軟な連携も難しくなっています。
* **Obsidian**:ファイルは開いていますが、コアはクローズドです。公式同期は有料、第三者同期は手間がかかります。フラットなローカルファイル走査に依存するため、ノートが数千・数万件に増えたりプラグインを重ねると起動や検索がもたつきます。画像と添付をノートと一緒に置くと保管庫が膨らみ、モバイル同期が遅く、削除後に添付が残りやすいです。気軽な取り込みには重いことがあります。
* **Joplin**:定番のオープンソース三ペイン型ノートですが、UI の見た目や編集の操作感は十数年前のソフトウェアのまま止まっているような印象があり、現代的な洗練さや滑らかさに欠けます。公式の高速同期(Joplin Cloud)は有料サブスクリプションが必要で、WebDAV やクラウドストレージを使った無料同期はファイル単位のポーリングに依存するため、ノートや添付が増えると(特にモバイルで)同期が遅くなります。また Joplin Server のセルフホストには自前サーバーの保守が必要で、費用ゼロの Serverless 構成がありません。さらにブラウザですぐ使える完全な Web ワークスペースがなく、ネイティブな AI Agent 連携(MCP/ACP)やマルチモーダルな知識表現も備えていません。
* **Memos などのタイムライン型**:簡潔ですが、三ペインの整理作業とはレイアウトが違います。
* **思源ノート(SiYuan)などのブロック型知識ベース**:高機能でオープンソースのセルフホストに対応していますが、徹底した「ブロック(Block)」構造により日常的な気軽なメモや流れるような文章作成には心理的負荷がやや高くなります。また、サーバー費用ゼロの Serverless 運用形態がなく、マルチデバイス同期は公式の有料サブスクリプション、または S3/WebDAV 同期機能を有料でアンロックして自前ストレージを用意する必要があります。
+1
View File
@@ -40,6 +40,7 @@ Many users of **note-taking apps and personal knowledge bases** simply want a **
* **Evernote**: Offers the smoothest, most seamless multi-device cloud sync experience. Yet as it evolved into a full-featured commercial suite, ads and add-ons multiplied while system overhead climbed. Meanwhile, free-tier quotas tightened, premium AI subscriptions grew expensive, and it remains difficult to integrate into modern self-hosted and private AI workflows.
* **Obsidian**: Open files, closed-source core. Official Sync is paid and third-party sync is tedious; relying entirely on flat local file scanning causes noticeable cold-start and search lag once notes reach thousands or heavy plugins are loaded; storing images and attachments alongside notes quickly bloats vaults, making mobile sync sluggish and leaving orphaned files behind; and it is overly heavy for lightweight, capture-anywhere use.
* **Joplin**: A veteran open-source three-pane note app, yet its UI design and editing experience feel stuck more than a decade in the past, lacking modern polish and fluidity. Fast official sync (Joplin Cloud) requires a paid subscription; free sync via WebDAV or cloud drives relies on file-by-file polling that becomes sluggish on mobile as notes and attachments grow, while self-hosting Joplin Server requires maintaining your own server with no zero-cost serverless option. It also lacks a full browser-based web workspace and native AI Agent capabilities (MCP/ACP).
* **Memos & Stream Notes**: Clean and simple, but their social-timeline layouts differ fundamentally from the structured productivity of a classic three-pane workflow.
* **SiYuan & Block-based PKMs**: Powerful with self-hosting support, but their granular "block-level" architecture imposes noticeable cognitive overhead for quick daily capture and continuous prose writing. Furthermore, they lack a true zero-cost serverless deployment tier, and multi-device sync relies on paid official subscriptions or paying extra to unlock S3/WebDAV sync features with your own storage.
+1
View File
@@ -40,6 +40,7 @@ Wielu użytkowników **aplikacji do notatek i osobistych baz wiedzy** szuka po p
* **Evernote**: Oferuje najpłynniejszą i najbardziej bezproblemową synchronizację w chmurze między wieloma urządzeniami. Z czasem stało się jednak rozbudowanym komercyjnym pakietem: przybyło reklam i dodatków, wzrosło zużycie zasobów, ograniczono darmowy plan, a zaawansowane funkcje AI są kosztowne. Trudno też połączyć je z samodzielnie hostowanymi usługami i prywatnymi procesami AI.
* **Obsidian**: Pliki są otwarte, ale sam program ma zamknięty kod. Oficjalna synchronizacja jest płatna, a konfiguracja innych metod wymaga pracy. Przy tysiącach notatek lub wielu wtyczkach skanowanie lokalnych plików spowalnia uruchamianie i wyszukiwanie. Obrazy i załączniki powiększają magazyn, utrudniają synchronizację mobilną i mogą pozostawać po usunięciu notatek.
* **Joplin**: Zasłużona, otwartoźródłowa aplikacja z układem trzech paneli, jednak jej interfejs i wygoda edycji sprawiają wrażenie zatrzymanych kilkanaście lat temu, bez nowoczesnej lekkości i dopracowania. Szybka oficjalna synchronizacja (Joplin Cloud) wymaga płatnej subskrypcji; darmowa synchronizacja przez WebDAV lub dyski chmurowe odpytuje pliki pojedynczo i zwalnia na urządzeniach mobilnych wraz ze wzrostem liczby notatek i załączników, natomiast własny Joplin Server wymaga utrzymywania serwera bez darmowej opcji serverless. Brakuje też pełnej przestrzeni roboczej w przeglądarce oraz natywnej obsługi agentów AI (MCP/ACP).
* **Memos i notatki strumieniowe**: Są proste, ale interfejs przypominający oś czasu różni się od uporządkowanej pracy w trzech panelach.
* **SiYuan i systemy oparte na blokach**: Oferują wiele funkcji i własny hosting, lecz szczegółowa struktura bloków utrudnia szybkie zapisywanie myśli i pisanie dłuższego tekstu. Brakuje im również bezpłatnego wdrożenia bezserwerowego, a synchronizacja między urządzeniami wymaga płatnej subskrypcji lub dodatkowo płatnego dostępu do S3/WebDAV.
+1
View File
@@ -40,6 +40,7 @@ EdgeEver 是一款现代化的开源笔记与个人知识库工作区。它保
* **印象笔记**:拥有最无感、最丝滑的多端云同步体验。然而随着它演进为全功能商业套件,广告与附加功能渐多,系统开销也随之走高;加之免费版配额收紧、高级 AI 订阅昂贵,且难以灵活接入现代自托管与私有 AI 工作流。
* **Obsidian**:Markdown 开放,核心闭源;官方同步收费,第三方同步繁琐;纯本地文件依赖遍历扫描,当笔记积累到数千上万条或加载复杂插件后,冷启动与全库检索明显卡顿迟缓;图片与附件与文本混存,仓库体积极易膨胀导致移动端同步缓慢,且删笔记后残留附件难清理;对于“随时随地随手记”的轻量场景来说偏重。
* **Joplin**:老牌开源三栏笔记,但界面观感与编辑交互仿佛还停留在十几年前,缺乏现代软件的精致与流畅感;官方高速同步(Joplin Cloud)需付费订阅,免费同步若走 WebDAV 或网盘,受限于逐文件轮询,笔记与附件增多后(尤其在移动端)同步缓慢,而自建 Joplin Server 又必须自备服务器维护,缺少零成本 Serverless 方案;此外它没有可在浏览器即开即用的完整 Web 工作区,也缺乏原生 AI Agent(MCP/ACP)与多模态知识形态。
* **Memos / Flomo 等轻量笔记**:虽然简单好用,但流式卡片布局与习惯了经典“三栏工作流”的用户有着天然的交互习惯差异。
* **思源笔记等块级知识库**:功能深厚且支持开源自托管,但全面的“块级(Block)”架构使得日常随手记录与连续排版书写的心智负担偏重;且缺少零服务器成本的 Serverless 部署形态,多端同步主要依赖官方付费订阅,或需额外付费解锁 S3/WebDAV 同步特性并自备存储。
+1
View File
@@ -40,6 +40,7 @@ EdgeEver 是一款現代化的開源筆記與個人知識庫工作區。它延
* **印象筆記 / Evernote**:擁有最無感、最絲滑的多端雲端同步體驗。然而隨著它演進為全功能商業套件,廣告與附加功能漸多,系統開銷也隨之走高;加之免費版配額收緊、高級 AI 訂閱昂貴,且難以靈活接入現代自託管與私有 AI 工作流。
* **Obsidian**:Markdown 開放,核心閉源;官方同步收費,第三方同步繁瑣;純本地檔案依賴遍歷掃描,當筆記累積到數千上萬條或載入複雜外掛後,冷啟動與全庫檢索明顯卡頓遲緩;圖片與附件與文字混存,儲存庫體積極易膨脹導致行動端同步緩慢,且刪除筆記後殘留附件難清理;對於「隨時隨地隨手記」的輕量場景來說偏重。
* **Joplin**:老牌開源三欄筆記,但介面觀感與編輯互動彷彿還停留在十幾年前,缺乏現代軟體的精緻與流暢感;官方高速同步(Joplin Cloud)需付費訂閱,免費同步若走 WebDAV 或雲端硬碟,受限於逐檔輪詢,筆記與附件增多後(尤其在行動端)同步緩慢,而自建 Joplin Server 又必須自備伺服器維護,缺少零成本 Serverless 方案;此外它沒有可在瀏覽器即開即用的完整 Web 工作區,也缺乏原生 AI Agent(MCP/ACP)與多模態知識型態。
* **Memos / Flomo 等輕量筆記**:雖然簡單好用,但時間軸卡片版面與習慣了經典「三欄工作流程」的使用者有著天然的互動習慣差異。
* **思源筆記等區塊級知識庫**:功能深厚且支援開源自託管,但全盤的「區塊級(Block)」架構使得日常隨手記錄與連續排版書寫的心智負擔偏重;且缺少零伺服器成本的 Serverless 部署型態,多端同步主要依賴官方付費訂閱,或需額外付費解鎖 S3/WebDAV 同步特性並自備儲存。
+4 -4
View File
@@ -79,10 +79,10 @@ const installation = () => {
};
const notebookBackups = (sqlite) => sqlite.query(
`SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at
`SELECT id, parent_id, name, slug, icon, color, text_color, color_target, is_bold, icon_inverted, row_inverted, sort_order, created_at, updated_at
FROM notebooks WHERE workspace_id = ? AND is_deleted = 0 ORDER BY parent_id IS NOT NULL, sort_order, id`,
).all(WORKSPACE_ID).map((row) => ({
id: row.id, parentId: row.parent_id, name: row.name, slug: row.slug, icon: row.icon, color: row.color,
id: row.id, parentId: row.parent_id, name: row.name, slug: row.slug, icon: row.icon, color: row.color, textColor: row.text_color, colorTarget: row.color_target, isBold: row.is_bold === 1, iconInverted: row.icon_inverted === 1, rowInverted: row.row_inverted === 1,
sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at,
}));
@@ -142,8 +142,8 @@ const populate = async (source) => {
const { sqlite, storage, context } = source;
const inbox = sqlite.query("SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox'").get(WORKSPACE_ID).id;
sqlite.query(
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at)
VALUES ('nb_trips', ?, ?, 'Trips', 'trips', 'notebook', '#0f766e', 7, '2026-01-02T10:00:00.000Z', '2026-01-03T10:00:00.000Z')`,
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, text_color, color_target, is_bold, icon_inverted, row_inverted, sort_order, created_at, updated_at)
VALUES ('nb_trips', ?, ?, 'Trips', 'trips', 'plane', '#2563eb', '#dc2626', 'both', 1, 1, 1, 7, '2026-01-02T10:00:00.000Z', '2026-01-03T10:00:00.000Z')`,
).run(WORKSPACE_ID, inbox);
const plan = await createMemoRecord(storage.db, WORKSPACE_ID, {
notebookId: "nb_trips", title: "Trip plan", contentMarkdown: "Draft", tags: ["travel", "2026"],
+12 -2
View File
@@ -76,14 +76,19 @@ export const restoreJsonNotebooks = async (
await db.batch(notebooks.map((notebook) => db.prepare(
`INSERT INTO notebooks (
id, workspace_id, parent_id, name, slug, icon, color, sort_order, is_deleted, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
id, workspace_id, parent_id, name, slug, icon, color, text_color, color_target, is_bold, icon_inverted, row_inverted, sort_order, is_deleted, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL)
ON CONFLICT(id) DO UPDATE SET
parent_id = excluded.parent_id,
name = excluded.name,
slug = excluded.slug,
icon = excluded.icon,
color = excluded.color,
text_color = excluded.text_color,
color_target = excluded.color_target,
is_bold = excluded.is_bold,
icon_inverted = excluded.icon_inverted,
row_inverted = excluded.row_inverted,
sort_order = excluded.sort_order,
is_deleted = 0,
updated_at = excluded.updated_at,
@@ -96,6 +101,11 @@ export const restoreJsonNotebooks = async (
notebook.slug,
notebook.icon,
notebook.color,
notebook.textColor ?? null,
notebook.colorTarget ?? "icon",
Number(notebook.isBold ?? false),
Number(notebook.iconInverted ?? false),
Number(notebook.rowInverted ?? false),
notebook.sortOrder,
notebook.createdAt,
notebook.updatedAt,
+5 -2
View File
@@ -163,8 +163,11 @@ for (const legacyColumn of [false, true]) test(`upgrade preserves old SQLite dat
VALUES ('legacy-turn','paw','user','legacy-turn',0,'old','completed','old-model',0,1,'en-US','2026-09-01','2027-01-01');
INSERT INTO companion_discoveries(id,workspace_id,owner_id,turn_id,settings_version,kind,title,body,sources_json,fingerprint,created_at)
VALUES ('legacy-discovery','paw','user','legacy-turn',7,'insight','Old','Keep me','[]','old','2026-09-01');`);
const oldStorage = createSelfHostedStorageAdapter(sqlite, `${dir}/resources`);
const note = await createMemoRecord(oldStorage.db, scope.workspaceId, { notebookId: "inbox", title: "Original", contentMarkdown: "Preserve **exactly**", tags: ["original"] }, actor, "user");
// Seed the historical schema directly: current services require current migrations.
const note = { id: "legacy-note" };
sqlite.exec(`INSERT INTO memos(id,workspace_id,notebook_id,title,tags_json) VALUES ('legacy-note','paw','inbox','Original','["original"]');
INSERT INTO memo_contents(memo_id,content_json,content_markdown,content_text,content_hash)
VALUES ('legacy-note','{}','Preserve **exactly**','Preserve exactly','historical-hash');`);
if (legacyColumn) sqlite.exec("ALTER TABLE companion_discovery_settings ADD COLUMN use_memory INTEGER NOT NULL DEFAULT 0");
sqlite.close(); sqlite = new Database(`${dir}/old.sqlite`);
sqlite.exec("PRAGMA foreign_keys = ON");
+46 -1
View File
@@ -12,7 +12,7 @@ const createFixture = () => {
sqlite.exec(`
CREATE TABLE notebooks (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL,
slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
slug TEXT, icon TEXT, color TEXT, text_color TEXT, color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
deleted_at TEXT
);
@@ -281,3 +281,48 @@ describe("notebook identity", () => {
}
});
});
describe("notebook appearance", () => {
test("persists appearance, preserves it during rename, and explicitly resets it", async () => {
const { sqlite, db } = createFixture();
try {
const appearance = { icon: "network", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true, rowInverted: true };
expect(await updateNotebookRecord(db, "ws_1", "nb_notes", appearance, actor)).toMatchObject(appearance);
expect(await updateNotebookRecord(db, "ws_1", "nb_notes", { name: "Heimnetz" }, actor)).toMatchObject({ ...appearance, name: "Heimnetz" });
const reset = { icon: null, color: null, textColor: null, isBold: false, iconInverted: false, rowInverted: false };
expect(await updateNotebookRecord(db, "ws_1", "nb_notes", reset, actor)).toMatchObject(reset);
expect(sqlite.query("SELECT icon, color, color_target, is_bold FROM notebooks WHERE id = 'nb_notes'").get()).toEqual({ icon: null, color: null, color_target: "icon", is_bold: 0 });
await expect(updateNotebookRecord(db, "other_workspace", "nb_notes", appearance, actor)).rejects.toThrow("Notebook not found");
} finally { sqlite.close(); }
});
test("upgrades the previous complete schema without losing existing notebook data", () => {
const sqlite = new Database(":memory:");
try {
sqlite.exec("PRAGMA foreign_keys = ON");
for (const file of globSync("migrations/*.sql").sort().filter(file => file.split("/").at(-1) < "0073")) {
sqlite.exec(readFileSync(file, "utf8"));
}
sqlite.exec("UPDATE notebooks SET icon = 'network', color = '#2563eb' WHERE id = 'nb_inbox'");
// query() caches the statement, so SELECT * prepared before ALTER TABLE
// keeps the old columns. prepare() reads the schema after each migration.
const notebookRows = () => sqlite.prepare("SELECT * FROM notebooks ORDER BY id").all();
const before = notebookRows();
const memoCount = sqlite.query("SELECT COUNT(*) AS count FROM memos").get();
sqlite.exec(readFileSync("migrations/0073_notebook_appearance.sql", "utf8"));
const after = notebookRows();
expect(after).toEqual(before.map(row => ({ ...row, color_target: "icon" })));
sqlite.exec(readFileSync("migrations/0074_notebook_bold.sql", "utf8"));
expect(notebookRows()).toEqual(after.map(row => ({ ...row, is_bold: 0 })));
sqlite.exec(readFileSync("migrations/0075_notebook_text_color.sql", "utf8"));
const beforeInversion = notebookRows();
sqlite.exec(readFileSync("migrations/0076_notebook_icon_inverted.sql", "utf8"));
expect(notebookRows()).toEqual(beforeInversion.map(row => ({ ...row, icon_inverted: 0 })));
const beforeRowInversion = notebookRows();
sqlite.exec(readFileSync("migrations/0077_notebook_row_inverted.sql", "utf8"));
expect(notebookRows()).toEqual(beforeRowInversion.map(row => ({ ...row, row_inverted: 0 })));
expect(sqlite.query("SELECT COUNT(*) AS count FROM memos").get()).toEqual(memoCount);
expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]);
} finally { sqlite.close(); }
});
});
+25 -6
View File
@@ -1,4 +1,4 @@
import type { Notebook, NotebookCreateInput } from "@edgeever/shared";
import type { Notebook, NotebookCreateInput, NotebookUpdateInput } from "@edgeever/shared";
import type { AuditActor } from "./api-context";
import { AppError } from "./app-error";
import { auditStatement } from "./audit";
@@ -19,6 +19,11 @@ export type NotebookRow = {
slug: string | null;
icon: string | null;
color: string | null;
text_color?: string | null;
color_target?: Notebook["colorTarget"];
is_bold?: number;
icon_inverted?: number;
row_inverted?: number;
sort_order: number;
memo_count: number | null;
last_memo_updated_at: string | null;
@@ -33,6 +38,11 @@ export const mapNotebook = (row: NotebookRow): Notebook => ({
slug: row.slug,
icon: row.icon,
color: row.color,
textColor: row.text_color ?? null,
colorTarget: row.color_target ?? "icon",
isBold: row.is_bold === 1,
iconInverted: row.icon_inverted === 1,
rowInverted: row.row_inverted === 1,
sortOrder: row.sort_order,
memoCount: row.memo_count ?? 0,
lastMemoUpdatedAt: row.last_memo_updated_at,
@@ -47,6 +57,11 @@ export const notebookSelectSql = (tail: string) => `
n.slug,
n.icon,
n.color,
n.text_color,
n.color_target,
n.is_bold,
n.icon_inverted,
n.row_inverted,
n.sort_order,
COUNT(m.id) AS memo_count,
MAX(m.updated_at) AS last_memo_updated_at,
@@ -62,7 +77,7 @@ export const listNotebooks = async (db: DatabaseAdapter, workspaceId: string): P
.prepare(
notebookSelectSql(
`WHERE n.workspace_id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order, n.created_at, n.updated_at
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
)
)
@@ -138,7 +153,7 @@ export const getNotebook = async (db: DatabaseAdapter, workspaceId: string, id:
.prepare(
notebookSelectSql(
`WHERE n.id = ? AND n.workspace_id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at`
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order, n.created_at, n.updated_at`
)
)
.bind(id, workspaceId)
@@ -190,7 +205,7 @@ export const updateNotebookRecord = async (
db: DatabaseAdapter,
workspaceId: string,
id: string,
input: { name?: string; parentId?: string | null; sortOrder?: number },
input: NotebookUpdateInput,
actor: AuditActor
) => {
const current = await getNotebook(db, workspaceId, id);
@@ -225,10 +240,14 @@ export const updateNotebookRecord = async (
db
.prepare(
`UPDATE notebooks
SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ?
SET name = ?, slug = ?, parent_id = ?, sort_order = ?, icon = ?, color = ?, text_color = ?, color_target = ?, is_bold = ?, icon_inverted = ?, row_inverted = ?, updated_at = ?
WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
)
.bind(nextName, nextSlug, nextParentId ?? null, nextSortOrder, now, id, workspaceId),
.bind(nextName, nextSlug, nextParentId ?? null, nextSortOrder,
input.icon === undefined ? current.icon : input.icon,
input.color === undefined ? current.color : input.color,
input.textColor === undefined ? current.textColor : input.textColor,
input.colorTarget ?? current.colorTarget ?? "icon", Number(input.isBold ?? current.isBold ?? false), Number(input.iconInverted ?? current.iconInverted ?? false), Number(input.rowInverted ?? current.rowInverted ?? false), now, id, workspaceId),
auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input),
]);
+2 -2
View File
@@ -34,7 +34,7 @@ const createSyncIntegrationFixture = () => {
CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at TEXT NOT NULL);
CREATE TABLE notebooks (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL,
slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
slug TEXT, icon TEXT, color TEXT, text_color TEXT, color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE memos (
@@ -118,7 +118,7 @@ describe("sync route contracts", () => {
CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at TEXT NOT NULL);
CREATE TABLE notebooks (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL,
slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
slug TEXT, icon TEXT, color TEXT, text_color TEXT, color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE memos (
+4 -4
View File
@@ -105,12 +105,12 @@ export const registerSyncRoutes = (
const afterId = context.req.query("afterId")?.trim() ?? "";
const [notebookRows, memoRows, totalRow, cursorRow] = await Promise.all([
context.env.storage.db.prepare(
`SELECT n.id, ${syncNotebookParentIdSql} AS parent_id, n.name, n.slug, n.icon, n.color, n.sort_order,
`SELECT n.id, ${syncNotebookParentIdSql} AS parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order,
n.created_at, n.updated_at, COUNT(m.id) AS memo_count, MAX(m.updated_at) AS last_memo_updated_at
FROM notebooks n
LEFT JOIN memos m ON m.notebook_id = n.id AND m.workspace_id = n.workspace_id AND m.is_deleted = 0
WHERE n.workspace_id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order, n.created_at, n.updated_at
ORDER BY n.sort_order ASC, n.name ASC`,
).bind(workspaceId).all<NotebookRow>(),
context.env.storage.db.prepare(
@@ -218,12 +218,12 @@ export const registerSyncRoutes = (
Promise.all(splitSyncDetailIds(notebookIds).map((batch) => {
const placeholders = batch.map(() => "?").join(", ");
return context.env.storage.db.prepare(
`SELECT n.id, ${syncNotebookParentIdSql} AS parent_id, n.name, n.slug, n.icon, n.color, n.sort_order,
`SELECT n.id, ${syncNotebookParentIdSql} AS parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order,
n.created_at, n.updated_at, COUNT(m.id) AS memo_count, MAX(m.updated_at) AS last_memo_updated_at
FROM notebooks n
LEFT JOIN memos m ON m.notebook_id = n.id AND m.workspace_id = n.workspace_id AND m.is_deleted = 0
WHERE n.workspace_id = ? AND n.is_deleted = 0 AND n.id IN (${placeholders})
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at`,
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.text_color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.sort_order, n.created_at, n.updated_at`,
).bind(workspaceId, ...batch).all<NotebookRow>();
})),
]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/desktop",
"version": "2.5.0",
"version": "2.7.0",
"desktopName": "EdgeEver",
"private": true,
"description": "Native EdgeEver desktop client",
+1 -1
View File
@@ -8,5 +8,5 @@
// CI_BUILD_NUMBER into the archive when present.
// Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0).
// Build 49 is reserved for the 1.74.0 App Store submission.
MARKETING_VERSION = 2.5.0
MARKETING_VERSION = 2.7.0
CURRENT_PROJECT_VERSION = 49
+2 -2
View File
@@ -812,7 +812,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 2.5.0;
MARKETING_VERSION = 2.7.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -902,7 +902,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 2.5.0;
MARKETING_VERSION = 2.7.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "2.5.0",
"version": "2.7.0",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -32,7 +32,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 242,
"versionCode": 244,
"adaptiveIcon": {
"backgroundColor": "#16A06E",
"backgroundImage": "./assets/icon.png",
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import {
getMobileEditorSaveChrome,
shouldScheduleMobileEditorAutosave,
} from "./mobile-editor-save-chrome";
const idle = {
dirty: false,
draftRestored: false,
error: false,
ready: true,
saving: false,
uploading: false,
};
describe("getMobileEditorSaveChrome", () => {
test("keeps a saved note on the quiet chip", () => {
expect(getMobileEditorSaveChrome(idle)).toEqual({ emphasized: false, label: "已保存" });
});
test("shows unsaved edits without a saving flash or green pill", () => {
expect(getMobileEditorSaveChrome({ ...idle, dirty: true })).toEqual({
emphasized: false,
label: "未保存",
});
expect(getMobileEditorSaveChrome({ ...idle, dirty: true, saving: true })).toEqual({
emphasized: false,
label: "未保存",
});
});
test("keeps a restored draft visible while its first save is in flight", () => {
expect(getMobileEditorSaveChrome({ ...idle, dirty: true, draftRestored: true, saving: true })).toEqual({
emphasized: false,
label: "本地草稿",
});
});
test("keeps upload and failure visible", () => {
expect(getMobileEditorSaveChrome({ ...idle, uploading: true, dirty: true })).toEqual({
emphasized: true,
label: "上传中",
});
expect(getMobileEditorSaveChrome({ ...idle, error: true, saving: true })).toEqual({
emphasized: false,
label: "保存失败",
});
});
test("shows a loading label before the editor is ready", () => {
expect(getMobileEditorSaveChrome({ ...idle, ready: false }).label).toBe("加载中");
});
});
describe("shouldScheduleMobileEditorAutosave", () => {
const ready = {
active: true,
closing: false,
dirty: true,
ready: true,
saving: false,
uploading: false,
};
test("schedules after an edit settles", () => {
expect(shouldScheduleMobileEditorAutosave(ready)).toBe(true);
});
test("waits out an in-flight save, upload, or close", () => {
expect(shouldScheduleMobileEditorAutosave({ ...ready, saving: true })).toBe(false);
expect(shouldScheduleMobileEditorAutosave({ ...ready, uploading: true })).toBe(false);
expect(shouldScheduleMobileEditorAutosave({ ...ready, closing: true })).toBe(false);
expect(shouldScheduleMobileEditorAutosave({ ...ready, dirty: false })).toBe(false);
expect(shouldScheduleMobileEditorAutosave({ ...ready, active: false })).toBe(false);
});
});
@@ -0,0 +1,52 @@
export const MOBILE_EDITOR_AUTOSAVE_DELAY_MS = 1200;
export type MobileEditorSaveChromeInput = {
dirty: boolean;
draftRestored: boolean;
error: boolean;
ready: boolean;
saving: boolean;
uploading: boolean;
};
export type MobileEditorSaveChrome = {
/** Green pill. Routine autosave stays on the quiet gray chip. */
emphasized: boolean;
label: "保存失败" | "上传中" | "本地草稿" | "未保存" | "已保存" | "加载中";
};
/**
* Status chip for an existing note.
* A background save keeps the unsaved label, so the header does not cycle
* through “保存中” and “已保存” on every pause. The Done button stays a label.
*/
export const getMobileEditorSaveChrome = ({
dirty,
draftRestored,
error,
ready,
saving,
uploading,
}: MobileEditorSaveChromeInput): MobileEditorSaveChrome => {
if (error) return { emphasized: false, label: "保存失败" };
if (uploading) return { emphasized: true, label: "上传中" };
if (dirty || saving) return { emphasized: false, label: draftRestored ? "本地草稿" : "未保存" };
if (ready) return { emphasized: false, label: "已保存" };
return { emphasized: false, label: "加载中" };
};
export const shouldScheduleMobileEditorAutosave = ({
active,
closing,
dirty,
ready,
saving,
uploading,
}: {
active: boolean;
closing: boolean;
dirty: boolean;
ready: boolean;
saving: boolean;
uploading: boolean;
}) => active && dirty && ready && !saving && !uploading && !closing;
+35 -15
View File
@@ -28,6 +28,11 @@ import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard";
import { parseTags } from "../screens/workspace-utils";
import type { MobileMemoUpdatePayload } from "../screens/workspace-memo-cache";
import { resolveEditorCloseAction } from "./mobile-editor-close";
import {
getMobileEditorSaveChrome,
MOBILE_EDITOR_AUTOSAVE_DELAY_MS,
shouldScheduleMobileEditorAutosave,
} from "./mobile-editor-save-chrome";
import { useMobileEditorResourceActions } from "./useMobileEditorResourceActions";
import { useMobileEditorUploadAsset } from "./useMobileEditorUploadAsset";
import { useMobileSelectionAi } from "./useMobileSelectionAi";
@@ -140,23 +145,24 @@ export const useMobileRichEditor = ({
const [draftRestored, setDraftRestored] = useState(Boolean(restoredDraft));
const [ready, setReady] = useState(alreadyReady);
const [dirty, setDirty] = useState(Boolean(restoredDraft));
const [autosaveGeneration, setAutosaveGeneration] = useState(0);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const { pickUploadAssets, uploadSourcePicker } = useMobileEditorUploadAsset();
const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? localizeMissingNotebookName(resolvedLocale);
const saveLabel = error
? "保存失败"
: saving
? "保存中"
: uploading
? "上传中"
: dirty
? (draftRestored ? "本地草稿" : "未保存")
: ready
? "已保存"
: "加载中";
const saveChrome = getMobileEditorSaveChrome({
dirty,
draftRestored,
error: Boolean(error),
ready,
saving,
uploading,
});
const scheduleAutosave = () => {
setAutosaveGeneration((current) => current + 1);
};
const titleRef = useRef(title);
const tagsTextRef = useRef(tagsText);
const notebookIdRef = useRef(notebookId);
@@ -243,6 +249,7 @@ export const useMobileRichEditor = ({
contentMarkdownRef.current = docToMarkdown(contentJson);
dirtyRef.current = true;
setDirty(true);
scheduleAutosave();
setError(null);
flushResolverRef.current?.();
flushResolverRef.current = null;
@@ -287,8 +294,12 @@ export const useMobileRichEditor = ({
dirtyRef.current = false;
setDirty(false);
} else {
// Edits landed while this save was in flight. Bump the generation so
// the next autosave waits out another quiet period instead of firing
// on the stale timer that already elapsed.
dirtyRef.current = true;
setDirty(true);
scheduleAutosave();
}
setDraftRestored(false);
return savedMemo;
@@ -452,18 +463,26 @@ export const useMobileRichEditor = ({
}, [active, dirty, memo, notebookId, tagsText, title]);
useEffect(() => {
if (!active || !memoBaseRef.current || !dirty || !ready || savingRef.current || uploadingRef.current || closingRef.current) {
if (!memoBaseRef.current || !shouldScheduleMobileEditorAutosave({
active,
closing: closingRef.current,
dirty,
ready,
saving: savingRef.current,
uploading: uploadingRef.current,
})) {
return;
}
const timeout = setTimeout(() => {
void flushEditor().then(save);
}, 1200);
}, MOBILE_EDITOR_AUTOSAVE_DELAY_MS);
return () => clearTimeout(timeout);
}, [active, dirty, memo, notebookId, ready, tagsText, title]);
}, [active, autosaveGeneration, dirty, memo, notebookId, ready, tagsText, title]);
const markDirty = () => {
dirtyRef.current = true;
setDirty(true);
scheduleAutosave();
};
return {
@@ -487,7 +506,8 @@ export const useMobileRichEditor = ({
requestClose,
requestSelectionAi,
resourceTarget,
saveLabel,
saveEmphasized: saveChrome.emphasized,
saveLabel: saveChrome.label,
saveResourceAs,
saving,
selectResource,
@@ -73,12 +73,13 @@ describe("Android share receiver manifest", () => {
test("share receiver copies a cold-start payload before opening the host", () => {
const source = readFileSync(
new URL("../../node_modules/expo-sharing/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt", import.meta.url),
new URL("../../node_modules/expo-sharing/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt", import.meta.url),
"utf8",
);
const copy = source.indexOf("materializeShareIntent");
const publish = source.indexOf("publishSharedIntent");
const open = source.indexOf("openShareHost");
const body = source.slice(source.indexOf("class ShareReceiverActivity"));
const copy = body.indexOf("materializeShareIntent");
const publish = body.indexOf("publishSharedIntent");
const open = body.indexOf("openShareHost");
expect(copy).toBeGreaterThan(-1);
expect(publish).toBeGreaterThan(copy);
expect(open).toBeGreaterThan(publish);
+57 -25
View File
@@ -33,6 +33,11 @@ import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/Loca
import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom";
import { safeDomCall } from "../lib/safe-dom-call";
import { applyMobileEditorUpload, cancelMobileEditorUpload, flushMobileEditor } from "../lib/mobile-editor-controller";
import {
getMobileEditorSaveChrome,
MOBILE_EDITOR_AUTOSAVE_DELAY_MS,
shouldScheduleMobileEditorAutosave,
} from "../hooks/mobile-editor-save-chrome";
import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard";
import { MobileResourceActions } from "../components/MobileResourceActions";
import { MobileTemplatePickerModal } from "../components/MobileTemplatePicker";
@@ -882,6 +887,7 @@ export const RichEditorModal = ({
const [draftRestored, setDraftRestored] = useState(Boolean(restoredDraft));
const [ready, setReady] = useState(false);
const [dirty, setDirty] = useState(Boolean(restoredDraft));
const [autosaveGeneration, setAutosaveGeneration] = useState(0);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -890,7 +896,21 @@ export const RichEditorModal = ({
const editorStartup = useMobileEditorStartupGuard({ active: Boolean(memo && baseUrl), ready });
const { pickUploadAssets, uploadSourcePicker } = useMobileEditorUploadAsset();
const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? localizeMissingNotebookName(resolvedLocale);
const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中";
const saveChrome = getMobileEditorSaveChrome({
dirty,
draftRestored,
error: Boolean(error),
ready,
saving,
uploading,
});
const closingRef = useRef(false);
const requestCloseRef = useRef<() => Promise<void>>(async () => undefined);
const markAutosaveDirty = () => {
dirtyRef.current = true;
setDirty(true);
setAutosaveGeneration((current) => current + 1);
};
const titleRef = useRef(title);
const tagsTextRef = useRef(tagsText);
const notebookIdRef = useRef(notebookId);
@@ -930,8 +950,7 @@ export const RichEditorModal = ({
contentSnapshotRef.current = contentSnapshot;
contentJsonRef.current = contentJson;
contentMarkdownRef.current = docToMarkdown(contentJson);
dirtyRef.current = true;
setDirty(true);
markAutosaveDirty();
setError(null);
flushResolverRef.current?.();
flushResolverRef.current = null;
@@ -978,9 +997,9 @@ export const RichEditorModal = ({
dirtyRef.current = false;
setDirty(false);
} else {
// Newer local edits arrived during the save; keep dirty so the next pass uploads them.
dirtyRef.current = true;
setDirty(true);
// Newer local edits arrived during the save. Restart the quiet period
// so the follow-up save does not fire on the timer that already elapsed.
markAutosaveDirty();
}
setDraftRestored(false);
return savedMemo;
@@ -990,6 +1009,10 @@ export const RichEditorModal = ({
} finally {
savingRef.current = false;
setSaving(false);
if (closingRef.current) {
closingRef.current = false;
void requestCloseRef.current();
}
}
};
@@ -1002,9 +1025,14 @@ export const RichEditorModal = ({
});
const requestClose = async () => {
if (savingRef.current || uploadingRef.current) {
if (uploadingRef.current) {
return;
}
if (savingRef.current) {
closingRef.current = true;
return;
}
closingRef.current = false;
if (initialFocusTimerRef.current !== null) {
clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
@@ -1015,10 +1043,11 @@ export const RichEditorModal = ({
onClose();
}
};
requestCloseRef.current = requestClose;
useEffect(() => {
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
void requestClose();
void requestCloseRef.current();
return true;
});
return () => subscription.remove();
@@ -1153,14 +1182,21 @@ export const RichEditorModal = ({
}, [dirty, memo, notebookId, tagsText, title]);
useEffect(() => {
if (!memoBaseRef.current || !dirty || !ready || savingRef.current || uploadingRef.current) {
if (!memoBaseRef.current || !shouldScheduleMobileEditorAutosave({
active: true,
closing: closingRef.current,
dirty,
ready,
saving: savingRef.current,
uploading: uploadingRef.current,
})) {
return;
}
const timeout = setTimeout(() => {
void flushEditor().then(save);
}, 1200);
}, MOBILE_EDITOR_AUTOSAVE_DELAY_MS);
return () => clearTimeout(timeout);
}, [dirty, memo, notebookId, ready, tagsText, title]);
}, [autosaveGeneration, dirty, memo, notebookId, ready, tagsText, title]);
return (
<SafeAreaView style={styles.richEditorSafeArea}>
@@ -1170,19 +1206,19 @@ export const RichEditorModal = ({
style={styles.richEditorKeyboardAvoiding}
>
<View style={styles.createMemoHeader}>
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={saving || uploading} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
<ChevronLeft color={saving || uploading ? "#cbd5e1" : "#0f172a"} size={30} />
<Pressable accessibilityLabel="返回" accessibilityRole="button" disabled={uploading} onPress={() => void requestClose()} style={styles.createMemoBackButton}>
<ChevronLeft color={uploading ? "#cbd5e1" : "#0f172a"} size={30} />
</Pressable>
<View style={styles.createMemoHeaderActions}>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, (saving || uploading || dirty) && styles.createMemoStatusActive, error && styles.richEditorStatusError]}>{saveLabel}</Text>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, saveChrome.emphasized && styles.createMemoStatusActive, error && styles.richEditorStatusError]}>{saveChrome.label}</Text>
<Pressable
accessibilityLabel="完成编辑"
accessibilityRole="button"
disabled={saving || uploading || !ready}
disabled={uploading || !ready}
onPress={() => void requestClose()}
style={[styles.createMemoDoneButton, (saving || uploading || !ready) && styles.createMemoDoneButtonDisabled]}
style={[styles.createMemoDoneButton, (uploading || !ready) && styles.createMemoDoneButtonDisabled]}
>
{saving ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, (uploading || !ready) && styles.createMemoDoneTextDisabled]}>完成</Text>}
<Text style={[styles.createMemoDoneText, (uploading || !ready) && styles.createMemoDoneTextDisabled]}>完成</Text>
</Pressable>
</View>
</View>
@@ -1193,8 +1229,7 @@ export const RichEditorModal = ({
autoFocus={initialFocus === "title"}
onChangeText={(value) => {
setTitle(value);
dirtyRef.current = true;
setDirty(true);
markAutosaveDirty();
}}
placeholder={DEFAULT_MEMO_TITLE}
placeholderTextColor="#94a3b8"
@@ -1219,8 +1254,7 @@ export const RichEditorModal = ({
disabled={saving || uploading}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
dirtyRef.current = true;
setDirty(true);
markAutosaveDirty();
}}
selectedTags={parseTags(tagsText)}
title={title}
@@ -1251,8 +1285,7 @@ export const RichEditorModal = ({
}
setNotebookId(nextNotebookId);
setNotebookPickerOpen(false);
dirtyRef.current = true;
setDirty(true);
markAutosaveDirty();
}}
visible={notebookPickerOpen}
/>
@@ -1260,8 +1293,7 @@ export const RichEditorModal = ({
dataScope={createMobileDataScope(session?.baseUrl ?? baseUrl, session?.user?.id)}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
dirtyRef.current = true;
setDirty(true);
markAutosaveDirty();
}}
onClose={() => setTagPickerOpen(false)}
selectedTags={parseTags(tagsText)}
@@ -1159,7 +1159,7 @@ export const MemoDetailModal = ({
<ChevronLeft color={editor.uploading ? "#cbd5e1" : "#0f172a"} size={30} />
</Pressable>
<View style={styles.createMemoHeaderActions}>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, (editor.saving || editor.uploading || editor.dirty) && styles.createMemoStatusActive, editor.error && styles.richEditorStatusError]}>{editor.saveLabel}</Text>
<Text numberOfLines={1} style={[styles.createMemoStatus, styles.richEditorHeaderStatus, editor.saveEmphasized && styles.createMemoStatusActive, editor.error && styles.richEditorStatusError]}>{editor.saveLabel}</Text>
<Pressable
accessibilityLabel="完成编辑"
accessibilityRole="button"
@@ -1167,7 +1167,7 @@ export const MemoDetailModal = ({
onPress={() => void editor.requestClose()}
style={[styles.createMemoDoneButton, (editor.uploading || !editor.ready) && styles.createMemoDoneButtonDisabled]}
>
{editor.saving ? <ActivityIndicator color="#64748b" size="small" /> : <Text style={[styles.createMemoDoneText, (editor.uploading || !editor.ready) && styles.createMemoDoneTextDisabled]}>完成</Text>}
<Text style={[styles.createMemoDoneText, (editor.uploading || !editor.ready) && styles.createMemoDoneTextDisabled]}>完成</Text>
</Pressable>
</View>
</View>
@@ -0,0 +1,246 @@
import type { ComponentType, CSSProperties } from "react";
import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic";
import type { LucideProps } from "lucide-react";
import {
Notebook,
BookOpen,
Folder,
Archive,
Inbox,
Star,
Bookmark,
Lightbulb,
Briefcase,
Building2,
ClipboardList,
Calendar,
Target,
GraduationCap,
FileText,
Presentation,
Network,
Server,
Monitor,
Wifi,
Smartphone,
Code,
Database,
Cpu,
Shield,
Bot,
House,
Heart,
Users,
Gift,
Baby,
PawPrint,
Utensils,
Wrench,
Car,
Activity,
Wallet,
Landmark,
ChartLine,
Bitcoin,
Receipt,
PiggyBank,
Plane,
Map,
Camera,
Music,
Gamepad2,
Bike,
Dumbbell,
Newspaper,
Globe,
Coffee,
} from "lucide-react";
import {
isNotebookColor,
readableNotebookColor,
type NotebookAppearance,
} from "@edgeever/shared";
import { cn } from "@/lib/utils";
export const notebookIconComponents = {
notebook: Notebook,
"book-open": BookOpen,
folder: Folder,
archive: Archive,
inbox: Inbox,
star: Star,
bookmark: Bookmark,
lightbulb: Lightbulb,
briefcase: Briefcase,
"building-2": Building2,
"clipboard-list": ClipboardList,
calendar: Calendar,
target: Target,
"graduation-cap": GraduationCap,
"file-text": FileText,
presentation: Presentation,
network: Network,
server: Server,
monitor: Monitor,
wifi: Wifi,
smartphone: Smartphone,
code: Code,
database: Database,
cpu: Cpu,
shield: Shield,
bot: Bot,
house: House,
heart: Heart,
users: Users,
gift: Gift,
baby: Baby,
"paw-print": PawPrint,
utensils: Utensils,
wrench: Wrench,
car: Car,
activity: Activity,
wallet: Wallet,
landmark: Landmark,
"chart-line": ChartLine,
bitcoin: Bitcoin,
receipt: Receipt,
"piggy-bank": PiggyBank,
plane: Plane,
map: Map,
camera: Camera,
music: Music,
"gamepad-2": Gamepad2,
bike: Bike,
dumbbell: Dumbbell,
newspaper: Newspaper,
globe: Globe,
coffee: Coffee,
};
const availableIconNames = new Set<string>(iconNames);
const dynamicComponents = new globalThis.Map<
string,
ComponentType<LucideProps>
>();
export const getNotebookIcon = (
name: string | null,
): ComponentType<LucideProps> => {
const builtIn =
name && Object.hasOwn(notebookIconComponents, name)
? notebookIconComponents[name as keyof typeof notebookIconComponents]
: undefined;
if (builtIn) return builtIn;
if (!name || !availableIconNames.has(name)) return Notebook;
let component = dynamicComponents.get(name);
if (!component) {
const iconName = name as IconName;
component = (props: LucideProps) => (
<DynamicIcon
{...props}
name={iconName}
fallback={() => <Notebook {...props} />}
/>
);
dynamicComponents.set(name, component);
}
return component;
};
export const notebookRowStyle = (
notebook: NotebookAppearance,
): CSSProperties | undefined =>
notebook.rowInverted
? {
backgroundColor: readableNotebookColor(
notebook.color ?? "#64748b",
false,
),
color: "#fff",
}
: undefined;
export const NotebookAppearanceContent = ({
notebook,
selected = false,
}: {
notebook: NotebookAppearance & { name: string };
selected?: boolean;
}) => {
const Icon = getNotebookIcon(notebook.icon);
const iconInverted = notebook.iconInverted && !notebook.rowInverted;
const iconColored = !notebook.rowInverted && isNotebookColor(notebook.color);
const textColored =
!notebook.rowInverted && isNotebookColor(notebook.textColor);
const iconStyle =
iconColored || iconInverted
? ({
"--notebook-color-light": readableNotebookColor(
notebook.color ?? "#64748b",
false,
),
"--notebook-color-dark": readableNotebookColor(
notebook.color ?? "#64748b",
true,
),
} as CSSProperties)
: undefined;
const textStyle = textColored
? ({
"--notebook-color-light": readableNotebookColor(
notebook.textColor!,
false,
),
"--notebook-color-dark": readableNotebookColor(
notebook.textColor!,
true,
),
} as CSSProperties)
: undefined;
return (
<>
<span
className="flex h-6 w-6 shrink-0 items-center justify-center"
aria-hidden="true"
>
<Icon
data-notebook-icon={notebook.icon ?? "notebook"}
aria-hidden="true"
strokeWidth={2.35}
style={iconStyle}
className={cn(
"shrink-0 transition-colors duration-200",
iconInverted ? "h-6 w-6" : "h-4 w-4",
notebook.rowInverted
? "!text-white"
: iconInverted
? "notebook-inverted-icon"
: iconColored
? "notebook-custom-color"
: selected
? "text-slate-950"
: "text-slate-500",
)}
data-notebook-icon-inverted={iconInverted || undefined}
/>
</span>
<span
data-notebook-name
style={textStyle}
className={cn(
"truncate transition-colors duration-200",
notebook.isBold ? "font-bold" : "font-normal",
notebook.rowInverted
? "!text-white"
: textColored
? "notebook-custom-color"
: selected
? "text-black dark:text-slate-950"
: "text-slate-800 group-hover:text-slate-950",
)}
>
{notebook.name}
</span>
</>
);
};
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { isNotebookColor } from "@edgeever/shared";
import { cn } from "@/lib/utils";
const PALETTE_KEY = "edgeever.notebook-color-palette";
const DEFAULT_PALETTE = ["#2563eb", "#16a34a", "#ca8a04", "#dc2626", "#7c3aed"];
const readPalette = (): string[] => {
try {
const saved: unknown = JSON.parse(
localStorage.getItem(PALETTE_KEY) ?? "null",
);
if (
Array.isArray(saved) &&
saved.length === 5 &&
saved.every(isNotebookColor)
)
return saved;
} catch {
/* Use defaults if local preferences are unavailable. */
}
return [...DEFAULT_PALETTE];
};
export const NotebookColorPicker = ({
label,
value,
fallback,
onChange,
}: {
label: string;
value: string;
fallback: string;
onChange: (color: string) => void;
}) => {
const [palette, setPalette] = useState(readPalette);
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
useEffect(() => {
const refresh = (event: Event) => {
const next: unknown = event instanceof CustomEvent ? event.detail : null;
setPalette(
Array.isArray(next) && next.length === 5 && next.every(isNotebookColor)
? next
: readPalette(),
);
};
window.addEventListener("notebook-palette-change", refresh);
window.addEventListener("storage", refresh);
return () => {
window.removeEventListener("notebook-palette-change", refresh);
window.removeEventListener("storage", refresh);
};
}, []);
const updateColor = (color: string) => {
onChange(color);
if (selectedSlot === null) return;
const next = [...palette];
next[selectedSlot] = color;
setPalette(next);
try {
localStorage.setItem(PALETTE_KEY, JSON.stringify(next));
} catch {
/* Color selection still works when storage is unavailable. */
}
window.dispatchEvent(
new CustomEvent("notebook-palette-change", { detail: next }),
);
};
return (
<div
data-notebook-color-picker={label}
className="flex shrink-0 items-center gap-2"
>
{palette.map((color, index) => (
<button
key={index}
type="button"
aria-label={`${label} ${index + 1}: ${color}`}
aria-pressed={value.toLowerCase() === color.toLowerCase()}
onClick={() => {
setSelectedSlot(index);
onChange(color);
}}
className={cn(
"h-5 w-5 shrink-0 rounded-full border border-black/10 transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2",
value.toLowerCase() === color.toLowerCase() &&
"ring-2 ring-slate-400 ring-offset-2 ring-offset-card",
)}
style={{ backgroundColor: color }}
/>
))}
<input
type="color"
aria-label={label}
value={isNotebookColor(value) ? value : fallback}
onChange={(event) => updateColor(event.target.value)}
className="ml-1 h-8 w-10 cursor-pointer overflow-hidden rounded-md border border-slate-200 bg-card p-1 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-sm [&::-webkit-color-swatch]:border-0"
/>
</div>
);
};
+3
View File
@@ -486,6 +486,7 @@ export const NotebookPane = ({
onSelect,
onCreateNotebook,
onRenameNotebook,
onEditNotebookAppearance,
onDeleteNotebook,
onMoveNotebook,
onMoveMemos,
@@ -520,6 +521,7 @@ export const NotebookPane = ({
onSelect: (notebookId: string) => void;
onCreateNotebook: (parentId?: string | null) => void;
onRenameNotebook: (notebook: Notebook) => void;
onEditNotebookAppearance: (notebook: Notebook) => void;
onDeleteNotebook: (notebook: Notebook) => void;
onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void;
onMoveMemos: (memoIds: string[], targetNotebookId: string) => void;
@@ -859,6 +861,7 @@ export const NotebookPane = ({
onSelect={onSelect}
onCreateNotebook={onCreateNotebook}
onRenameNotebook={onRenameNotebook}
onEditNotebookAppearance={onEditNotebookAppearance}
onDeleteNotebook={onDeleteNotebook}
onMoveNotebook={handleMoveNotebook}
onMoveMemos={onMoveMemos}
+20 -14
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense, useState, useEffect, useRef, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { ChevronDown, MoreHorizontal, Notebook as NotebookIcon, Plus, Pencil, Trash2, Share2, Globe } from "lucide-react";
import { ChevronDown, MoreHorizontal, Palette, Plus, Pencil, Trash2, Share2, Globe } from "lucide-react";
import type { NotebookNode, NotebookDropPosition } from "@/lib/app-helpers";
import {
hasMemoDragData,
@@ -12,6 +12,7 @@ import {
NOTEBOOK_DRAG_MIME,
setMemoDragPreview,
} from "@/lib/app-helpers";
import { NotebookAppearanceContent, notebookRowStyle } from "./NotebookAppearanceContent";
import { cn } from "@/lib/utils";
import { formatNotebookMemoCount, type Notebook } from "@edgeever/shared";
import {
@@ -35,6 +36,7 @@ export const NotebookTreeItem = ({
onSelect,
onCreateNotebook,
onRenameNotebook,
onEditNotebookAppearance,
onDeleteNotebook,
onMoveNotebook,
onMoveMemos,
@@ -53,6 +55,7 @@ export const NotebookTreeItem = ({
onSelect: (notebookId: string) => void;
onCreateNotebook: (parentId?: string | null) => void;
onRenameNotebook: (notebook: Notebook) => void;
onEditNotebookAppearance: (notebook: Notebook) => void;
onDeleteNotebook: (notebook: Notebook) => void;
onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void;
onMoveMemos: (memoIds: string[], targetNotebookId: string) => void;
@@ -183,9 +186,12 @@ export const NotebookTreeItem = ({
<ContextMenuTrigger asChild>
<div
data-notebook-id={node.id}
data-notebook-row-inverted={node.rowInverted || undefined}
className={cn(
"group relative flex h-9 cursor-pointer items-center gap-1 rounded-md px-2 text-xs transition-all duration-200 select-none",
selected
node.rowInverted
? cn("notebook-inverted-row text-white", selected && "notebook-inverted-row-selected")
: selected
? "edgeever-workspace-selection font-medium text-slate-950"
: "text-slate-700 hover:bg-slate-50",
dropPosition === "inside" && "ring-2 ring-slate-300",
@@ -212,7 +218,7 @@ export const NotebookTreeItem = ({
}}
onDragEnd={clearExpandTimer}
onDrop={handleDrop}
style={{ paddingLeft: `${8 + depth * 14}px` }}
style={{ ...notebookRowStyle(node), paddingLeft: `${8 + depth * 14}px` }}
>
{hasChildren ? (
<TooltipProvider><Tooltip><TooltipTrigger asChild>
@@ -278,18 +284,10 @@ export const NotebookTreeItem = ({
}
}}
>
<NotebookIcon className={cn("h-4 w-4 shrink-0 transition-colors duration-200", selected ? "text-slate-950" : "text-slate-500")} />
<span
className={cn(
"truncate transition-colors duration-200",
selected ? "font-semibold text-black dark:text-slate-950" : "font-medium text-slate-800 group-hover:text-slate-950"
)}
>
{node.name}
</span>
<NotebookAppearanceContent notebook={node} selected={selected} />
{publicNotebook ? (
<Globe
className={cn("h-3.5 w-3.5 shrink-0", selected ? "text-slate-700" : "text-slate-500")}
className={cn("h-3.5 w-3.5 shrink-0", node.rowInverted ? "text-white/80" : selected ? "text-slate-700" : "text-slate-500")}
role="img"
aria-label={t("sharing.notebookPublic")}
/>
@@ -297,7 +295,7 @@ export const NotebookTreeItem = ({
<span
className={cn(
"shrink-0 tabular-nums text-xs font-normal transition-colors duration-200",
selected ? "text-slate-900 font-medium" : "text-slate-400 group-hover:text-slate-500"
node.rowInverted ? "text-white/80" : selected ? "text-slate-900 font-medium" : "text-slate-400 group-hover:text-slate-500"
)}
aria-hidden="true"
>
@@ -349,6 +347,10 @@ export const NotebookTreeItem = ({
<Pencil className="h-4 w-4" />
{t("notebookTree.rename")}
</button>
<button className="flex h-9 w-full items-center gap-2 rounded-sm px-2 text-left text-xs outline-none hover:bg-slate-100"
type="button" onClick={event => { event.stopPropagation(); setActionsOpen(false); onEditNotebookAppearance(node); }}>
<Palette className="h-4 w-4" />{t("notebookAppearance.title")}
</button>
<button
className="flex h-9 w-full items-center gap-2 rounded-sm px-2 text-left text-xs outline-none hover:bg-slate-100"
type="button"
@@ -418,6 +420,9 @@ export const NotebookTreeItem = ({
<Pencil className="h-4 w-4" />
{t("notebookTree.rename")}
</ContextMenuItem>
<ContextMenuItem className="flex h-9 items-center gap-2 px-3 text-xs cursor-pointer" onClick={() => onEditNotebookAppearance(node)}>
<Palette className="h-4 w-4" />{t("notebookAppearance.title")}
</ContextMenuItem>
<ContextMenuItem className="flex h-9 items-center gap-2 px-3 text-xs cursor-pointer" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4" />{shareActionLabel}
</ContextMenuItem>
@@ -469,6 +474,7 @@ export const NotebookTreeItem = ({
onSelect={onSelect}
onCreateNotebook={onCreateNotebook}
onRenameNotebook={onRenameNotebook}
onEditNotebookAppearance={onEditNotebookAppearance}
onDeleteNotebook={onDeleteNotebook}
onMoveNotebook={onMoveNotebook}
onMoveMemos={onMoveMemos}
+13 -1
View File
@@ -1,3 +1,5 @@
import { NotebookAppearanceDialog } from "./dialogs/NotebookAppearanceDialog";
import type { NotebookUpdateInput } from "@edgeever/shared";
import { startPosterClientRenderer } from "@/lib/poster-client-render";
import {
lazy,
@@ -253,6 +255,7 @@ export const WorkspaceApp = ({
const [memoDocumentActionRequest, setMemoDocumentActionRequest] = useState<MemoDocumentActionRequest | null>(null);
const [memoDeleteConfirmation, setMemoDeleteConfirmation] = useState<MemoDeleteConfirmation | null>(null);
const [emptyTrashConfirmationOpen, setEmptyTrashConfirmationOpen] = useState(false);
const [appearanceNotebook, setAppearanceNotebook] = useState<Notebook | null>(null);
const [notebookNameDialog, setNotebookNameDialog] = useState<NotebookNameDialogState | null>(null);
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
@@ -1208,7 +1211,7 @@ export const WorkspaceApp = ({
payload,
}: {
notebookId: string;
payload: { name?: string; parentId?: string | null; sortOrder?: number };
payload: NotebookUpdateInput;
}) => repository.updateNotebook(notebookId, payload),
onSuccess: async (data) => {
if (data?.notebook) {
@@ -3274,6 +3277,7 @@ export const WorkspaceApp = ({
onCreateMemo={handleCreateMemo}
onCreateNotebook={handleCreateNotebook}
onRenameNotebook={handleRenameNotebook}
onEditNotebookAppearance={setAppearanceNotebook}
onDeleteNotebook={handleDeleteNotebook}
onMoveNotebook={handleMoveNotebook}
onMoveMemos={handleMoveDraggedMemos}
@@ -3866,6 +3870,14 @@ export const WorkspaceApp = ({
onConfirm={handleConfirmEmptyTrash}
/>
)}
{appearanceNotebook && (
<NotebookAppearanceDialog key={appearanceNotebook.id} notebook={appearanceNotebook}
onCancel={() => setAppearanceNotebook(null)}
onSubmit={async (appearance) => {
await updateNotebookMutation.mutateAsync({ notebookId: appearanceNotebook.id, payload: appearance });
setAppearanceNotebook(null);
}} />
)}
{notebookNameDialog && (
<NotebookNameDialog
dialog={notebookNameDialog}
@@ -0,0 +1,413 @@
import { useId, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import {
Bold,
Check,
ChevronDown,
Contrast,
Palette,
Search,
Shapes,
} from "lucide-react";
import {
isNotebookColor,
type Notebook,
type NotebookAppearance,
} from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { searchAvailableNotebookIcons } from "@/lib/notebook-icon-catalog";
import { NotebookColorPicker } from "../NotebookColorPicker";
import {
NotebookAppearanceContent,
getNotebookIcon,
notebookRowStyle,
} from "../NotebookAppearanceContent";
const SelectedIcon = ({ icon }: { icon: string | null }) => {
const Icon = getNotebookIcon(icon);
return <Icon className="h-5 w-5" strokeWidth={2.35} aria-hidden="true" />;
};
export const NotebookAppearanceDialog = ({
notebook,
onCancel,
onSubmit,
}: {
notebook: Notebook;
onCancel: () => void;
onSubmit: (appearance: NotebookAppearance) => Promise<void>;
}) => {
const { t } = useTranslation();
const id = useId();
const [icon, setIcon] = useState(notebook.icon);
const [color, setColor] = useState(notebook.color ?? "");
const [textColor, setTextColor] = useState(notebook.textColor ?? "");
const [isBold, setIsBold] = useState(notebook.isBold ?? false);
const [iconInverted, setIconInverted] = useState(
notebook.iconInverted ?? false,
);
const [rowInverted, setRowInverted] = useState(notebook.rowInverted ?? false);
const [visibleIconCount, setVisibleIconCount] = useState(40);
const [iconQuery, setIconQuery] = useState("");
const [iconOpen, setIconOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const validColor = color === "" || isNotebookColor(color);
const validTextColor = textColor === "" || isNotebookColor(textColor);
const icons = searchAvailableNotebookIcons(iconQuery);
const appearance = {
icon,
color: isNotebookColor(color) ? color : null,
textColor: isNotebookColor(textColor) ? textColor : null,
isBold,
iconInverted,
rowInverted,
};
const save = async (event: FormEvent) => {
event.preventDefault();
if (!validColor || !validTextColor || saving) return;
setSaving(true);
setFailed(false);
try {
await onSubmit(appearance);
} catch {
setFailed(true);
} finally {
setSaving(false);
}
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !saving) onCancel();
}}
>
<DialogContent
className="max-h-[90dvh] w-[calc(100%-2rem)] max-w-[520px] gap-0 overflow-hidden p-0"
showCloseButton={!saving}
onEscapeKeyDown={(event) => {
if (iconOpen) {
event.preventDefault();
setIconOpen(false);
}
}}
>
<DialogHeader className="border-b border-slate-200 px-5 py-5 pr-12 text-left">
<DialogTitle className="flex items-center gap-2 text-sm leading-6">
<Palette className="h-5 w-5 text-slate-700" />
{t("notebookAppearance.title")}
</DialogTitle>
<DialogDescription className="pt-1 leading-5">
{t("notebookAppearance.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={save} className="min-h-0 overflow-y-auto">
<fieldset
disabled={saving}
className="min-w-0 space-y-5 bg-slate-50/70 px-5 py-5"
>
<legend className="sr-only">{notebook.name}</legend>
<section className="space-y-2">
<h3 className="px-1 text-sm font-medium text-slate-500">
{t("notebookAppearance.iconSection")}
</h3>
<div className="relative rounded-xl border border-slate-200 bg-card">
<div className="relative flex min-h-14 items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
<span className="flex shrink-0 items-center gap-3 text-sm text-slate-700">
<Shapes
className="h-4 w-4 text-slate-500"
aria-hidden="true"
/>
{t("notebookAppearance.icon")}
</span>
<div
className="relative w-[55%] min-w-0"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setIconOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape" && iconOpen) {
event.preventDefault();
event.stopPropagation();
setIconOpen(false);
event.currentTarget.querySelector("button")?.focus();
}
}}
>
<button
type="button"
aria-label={t("notebookAppearance.icon")}
aria-expanded={iconOpen}
onClick={() => {
setIconQuery("");
setVisibleIconCount(40);
setIconOpen((open) => !open);
}}
className="flex h-9 w-full items-center justify-between rounded-md border border-slate-200 bg-card px-3 text-sm"
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<SelectedIcon icon={icon} />
</span>
<span className="truncate">
{icon
? icon.replaceAll("-", " ")
: t("notebookAppearance.standard")}
</span>
</span>
<ChevronDown className="h-4 w-4" />
</button>
{iconOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-[min(280px,calc(100vw-5rem))] rounded-md border border-slate-200 bg-card p-2 shadow-lg">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-slate-400" />
<Input
autoFocus
value={iconQuery}
onChange={(event) => {
setIconQuery(event.target.value);
setVisibleIconCount(40);
}}
placeholder={t("notebookAppearance.search")}
className="h-8 pl-8"
/>
</div>
<div
className="max-h-44 overflow-y-auto"
onScroll={(event) => {
const list = event.currentTarget;
if (
list.scrollHeight -
list.scrollTop -
list.clientHeight <
80
)
setVisibleIconCount((count) =>
Math.min(count + 40, icons.length),
);
}}
role="listbox"
aria-label={t("notebookAppearance.icon")}
>
{icons.slice(0, visibleIconCount).map((item) => {
const Icon = getNotebookIcon(item.id);
return (
<button
key={item.id}
type="button"
role="option"
aria-selected={icon === item.id}
onClick={() => {
setIcon(item.id);
setIconOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-slate-100"
>
<Icon
className="h-5 w-5 shrink-0"
strokeWidth={2.35}
/>{" "}
<span>{item.id.replaceAll("-", " ")}</span>
{icon === item.id && (
<Check className="ml-auto h-4 w-4" />
)}
</button>
);
})}
{icons.length === 0 && (
<p
className="p-2 text-sm text-slate-500"
role="status"
>
{t("notebookAppearance.empty")}
</p>
)}
</div>
<p
className="mt-2 border-t border-slate-100 pt-2 text-right text-xs tabular-nums text-slate-500"
role="status"
>
{Math.min(visibleIconCount, icons.length)} /{" "}
{icons.length}
</p>
</div>
)}
</div>
</div>
<div className="flex min-h-14 flex-wrap items-center justify-between gap-x-3 gap-y-3 px-4 py-3 text-sm">
<span className="flex items-center gap-3 text-slate-700">
<Palette
className="h-4 w-4 shrink-0 text-slate-500"
aria-hidden="true"
/>
{t("notebookAppearance.iconColor")}
</span>
<NotebookColorPicker
label={t("notebookAppearance.iconColor")}
value={color}
fallback="#64748b"
onChange={setColor}
/>
</div>
<div className="flex min-h-14 items-center justify-between gap-3 border-t border-slate-100 px-4 py-3">
<label
htmlFor={`${id}-inverted`}
className="flex items-center gap-3 text-sm text-slate-700"
>
<Contrast
className="h-4 w-4 text-slate-500"
aria-hidden="true"
/>
{t("notebookAppearance.inversion")}
</label>
<select
id={`${id}-inverted`}
className="h-9 w-[55%] min-w-0 rounded-md border border-slate-200 bg-card px-3 text-sm"
value={rowInverted ? "row" : iconInverted ? "icon" : "off"}
onChange={(event) => {
setIconInverted(event.target.value === "icon");
setRowInverted(event.target.value === "row");
}}
>
<option value="off">
{t("notebookAppearance.invertOff")}
</option>
<option value="icon">
{t("notebookAppearance.invertIconOnly")}
</option>
<option value="row">
{t("notebookAppearance.invertWholeRow")}
</option>
</select>
</div>
{!validColor && (
<p role="alert" className="text-xs text-rose-600">
{t("notebookAppearance.invalidColor")}
</p>
)}
</div>
</section>
<section className="space-y-2">
<h3 className="px-1 text-sm font-medium text-slate-500">
{t("notebookAppearance.textSection")}
</h3>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
<div className="flex min-h-14 items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
<label
htmlFor={`${id}-bold`}
className="flex items-center gap-3 text-sm text-slate-700"
>
<Bold
className="h-4 w-4 text-slate-500"
aria-hidden="true"
/>
{t("notebookAppearance.bold")}
</label>
<span className="flex justify-end">
<Switch
id={`${id}-bold`}
checked={isBold}
onCheckedChange={setIsBold}
/>
</span>
</div>
<div className="flex min-h-14 flex-wrap items-center justify-between gap-x-3 gap-y-3 px-4 py-3 text-sm">
<span className="flex items-center gap-3 text-slate-700">
<Palette
className="h-4 w-4 shrink-0 text-slate-500"
aria-hidden="true"
/>
{t("notebookAppearance.textColor")}
</span>
<NotebookColorPicker
label={t("notebookAppearance.textColor")}
value={textColor}
fallback="#334155"
onChange={setTextColor}
/>
</div>
{!validTextColor && (
<p role="alert" className="text-xs text-rose-600">
{t("notebookAppearance.invalidColor")}
</p>
)}
</div>
</section>
<div className="space-y-2">
<p className="px-1 text-sm font-medium text-slate-500">
{t("notebookAppearance.preview")}
</p>
<div
data-notebook-appearance-preview
style={notebookRowStyle(appearance)}
className={`${rowInverted ? "notebook-inverted-row notebook-inverted-row-selected" : "edgeever-workspace-selection"} flex h-12 min-w-0 items-center gap-2 rounded-lg px-4 text-sm`}
>
<NotebookAppearanceContent
notebook={{ ...appearance, name: notebook.name }}
selected
/>
</div>
<p className="text-xs text-slate-500">
{t("notebookAppearance.contrast")}
</p>
</div>
</fieldset>
{failed && (
<p role="alert" className="text-sm text-rose-600">
{t("notebookAppearance.saveError")}
</p>
)}
<DialogFooter className="gap-2 border-t border-slate-200 bg-card px-5 py-4 sm:justify-between">
<Button
type="button"
size="sm"
variant="ghost"
disabled={saving}
onClick={() => {
setIcon(null);
setColor("");
setTextColor("");
setIsBold(false);
setIconInverted(false);
setRowInverted(false);
}}
>
{t("notebookAppearance.reset")}
</Button>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={onCancel}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="solid"
disabled={saving || !validColor || !validTextColor}
>
{saving ? t("common.processing") : t("common.save")}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
+3 -3
View File
@@ -2,8 +2,8 @@ import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = ({ delayDuration = 0, skipDelayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider delayDuration={delayDuration} skipDelayDuration={skipDelayDuration} {...props} />
const TooltipProvider = ({ delayDuration = 0, skipDelayDuration = 0, disableHoverableContent = true, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider delayDuration={delayDuration} skipDelayDuration={skipDelayDuration} disableHoverableContent={disableHoverableContent} {...props} />
);
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-lg border border-[var(--tooltip-border)] bg-[var(--tooltip-bg)] px-2.5 py-1.5 text-xs leading-4 text-[var(--tooltip-fg)] shadow-[var(--tooltip-shadow)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"pointer-events-none z-50 overflow-hidden rounded-lg border border-[var(--tooltip-border)] bg-[var(--tooltip-bg)] px-2.5 py-1.5 text-xs leading-4 text-[var(--tooltip-fg)] shadow-[var(--tooltip-shadow)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
+7
View File
@@ -484,6 +484,13 @@ const syncOutboxItem = async (item: DesktopOutboxItem, stagedRewrites: StagedRes
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,
icon: payload.icon === null || typeof payload.icon === "string" ? payload.icon : undefined,
color: payload.color === null || typeof payload.color === "string" ? payload.color : undefined,
textColor: payload.textColor === null || typeof payload.textColor === "string" ? payload.textColor : undefined,
iconInverted: typeof payload.iconInverted === "boolean" ? payload.iconInverted : undefined,
rowInverted: typeof payload.rowInverted === "boolean" ? payload.rowInverted : undefined,
isBold: typeof payload.isBold === "boolean" ? payload.isBold : undefined,
colorTarget: payload.colorTarget === "icon" || payload.colorTarget === "name" || payload.colorTarget === "both" ? payload.colorTarget : undefined,
});
await acknowledge(item, undefined, data.notebook);
return null;
+5
View File
@@ -147,6 +147,11 @@ const toBackupNotebook = (notebook: Notebook): JsonBackupNotebook => ({
slug: notebook.slug,
icon: notebook.icon,
color: notebook.color,
textColor: notebook.textColor ?? null,
colorTarget: notebook.colorTarget ?? "icon",
isBold: notebook.isBold ?? false,
iconInverted: notebook.iconInverted ?? false,
rowInverted: notebook.rowInverted ?? false,
sortOrder: notebook.sortOrder,
createdAt: notebook.createdAt,
updatedAt: notebook.updatedAt,
+1 -1
View File
@@ -553,7 +553,7 @@ export const getLocalNotebook = async (scope: string, notebookId: string) => {
return result;
};
export const updateLocalNotebook = async (scope: string, notebookId: string, input: Partial<Pick<Notebook, "name" | "parentId" | "sortOrder">>) => {
export const updateLocalNotebook = async (scope: string, notebookId: string, input: Partial<Pick<Notebook, "name" | "parentId" | "sortOrder" | "icon" | "color" | "textColor" | "colorTarget" | "isBold" | "iconInverted" | "rowInverted">>) => {
const notebook = await getLocalNotebook(scope, notebookId);
if (!notebook) return null;
const updated = { ...notebook, ...input, updatedAt: new Date().toISOString() };
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { notebookIconNames, searchAvailableNotebookIcons } from "./notebook-icon-catalog.ts";
test("offers the installed icon collection and retains bilingual search", () => {
expect(notebookIconNames.length).toBeGreaterThan(1900);
expect(new Set(notebookIconNames).size).toBe(notebookIconNames.length);
expect(searchAvailableNotebookIcons(" ALARM clock ")).toContainEqual({ id: "alarm-clock" });
expect(searchAvailableNotebookIcons("heimnetz")).toContainEqual({ id: "network" });
expect(searchAvailableNotebookIcons("personal assistant")).toContainEqual({ id: "bot" });
expect(searchAvailableNotebookIcons("doesnotexist123")).toEqual([]);
});
test("falls back safely for unknown and inherited icon names", async () => {
const { getNotebookIcon } = await import("../components/NotebookAppearanceContent.tsx");
const fallback = getNotebookIcon(null);
for (const name of ["constructor", "toString", "__proto__", "unknown-icon-123"]) {
expect(getNotebookIcon(name)).toBe(fallback);
}
expect(getNotebookIcon("alarm-clock")).toBe(getNotebookIcon("alarm-clock"));
});
+23
View File
@@ -0,0 +1,23 @@
import { iconNames } from "lucide-react/dynamic";
import { NOTEBOOK_ICONS } from "@edgeever/shared";
// Keep the existing bilingual keywords; add every icon from the installed library.
const keywords = new Map<string, string>(
NOTEBOOK_ICONS.map((icon) => [icon.id, icon.keywords]),
);
export const notebookIconNames = [...iconNames].sort();
export const searchAvailableNotebookIcons = (query: string) => {
const words = query
.trim()
.toLocaleLowerCase()
.split(/[\s-]+/)
.filter(Boolean);
return notebookIconNames
.filter((id) =>
words.every((word) =>
`${id.replaceAll("-", " ")} ${keywords.get(id) ?? ""}`.includes(word),
),
)
.map((id) => ({ id }));
};
+2 -1
View File
@@ -1,3 +1,4 @@
import type { NotebookUpdateInput } from "@edgeever/shared";
import { assertLocalMemoSnapshot } from "./local-memo-guard";
import { localizeDefaultMemoTemplate } from "@edgeever/shared/memo-template-seeds";
import { markdownToDoc, type MemoDetail, type MemoRevision, type MemoTemplate, type Notebook, type Resource, type ResourceListItem, type ResourceStorageSummary, type TagSummary, type TiptapDoc } from "@edgeever/shared";
@@ -56,7 +57,7 @@ import { withRepositoryMutationEvents } from "@/lib/repository-events";
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 }>;
updateNotebook(notebookId: string, input: NotebookUpdateInput): Promise<{ notebook: Notebook }>;
deleteNotebook(notebookId: string): Promise<{ ok: true }>;
restoreNotebook(notebookId: string): Promise<{ notebook: Notebook }>;
listTemplates(): Promise<{ templates: MemoTemplate[] }>;
+31
View File
@@ -396,3 +396,34 @@ describe("web sync conflict recovery", () => {
}
});
});
test("replays offline appearance edits and explicit resets without dropping fields", async () => {
const { createLocalNotebook, updateLocalNotebook, getLocalNotebook } = await import("./local-mirror.ts");
const scope = createLocalDataScope("https://demo.edgeever.org", "appearance-user");
const notebook = await createLocalNotebook(scope, { name: "Heimnetz" });
const original = api.updateNotebook;
const previousNavigator = globalThis.navigator;
globalThis.navigator = { onLine: true };
try {
for (const appearance of [
{ icon: "network", color: "#2563eb", textColor: "#dc2626", colorTarget: "both", isBold: true, iconInverted: true, rowInverted: true },
{ icon: null, color: null, textColor: null, colorTarget: "icon", isBold: false, iconInverted: false, rowInverted: false },
]) {
await updateLocalNotebook(scope, notebook.id, appearance);
expect(await getLocalNotebook(scope, notebook.id)).toMatchObject(appearance);
await queueLocalAction(scope, "notebook.update", notebook.id, { notebookId: notebook.id, ...appearance });
let uploaded;
api.updateNotebook = async (id, input) => {
expect(id).toBe(notebook.id);
uploaded = input;
return { notebook: { ...notebook, ...appearance } };
};
expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 1, failed: 0 });
expect(uploaded).toMatchObject(appearance);
expect(await getLocalNotebook(scope, notebook.id)).toMatchObject(appearance);
}
} finally {
api.updateNotebook = original;
globalThis.navigator = previousNavigator;
}
});
+7
View File
@@ -758,6 +758,13 @@ const syncQueueItem = async (item: SyncQueueItem): Promise<SyncQueueResult> => {
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,
icon: payload.icon === null || typeof payload.icon === "string" ? payload.icon : undefined,
color: payload.color === null || typeof payload.color === "string" ? payload.color : undefined,
textColor: payload.textColor === null || typeof payload.textColor === "string" ? payload.textColor : undefined,
iconInverted: typeof payload.iconInverted === "boolean" ? payload.iconInverted : undefined,
rowInverted: typeof payload.rowInverted === "boolean" ? payload.rowInverted : undefined,
isBold: typeof payload.isBold === "boolean" ? payload.isBold : undefined,
colorTarget: payload.colorTarget === "icon" || payload.colorTarget === "name" || payload.colorTarget === "both" ? payload.colorTarget : undefined,
});
return result.notebook;
}
+14
View File
@@ -2985,3 +2985,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
animation-duration: 0.01ms !important;
}
}
/* Custom notebook colors remain legible in both sidebar themes. */
.notebook-custom-color { color: var(--notebook-color-light); }
.notebook-inverted-icon {
color: #fff;
background-color: var(--notebook-color-light);
border-radius: 5px;
padding: 4px;
}
.notebook-inverted-row-selected {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: -3px;
}
:root.dark .notebook-custom-color { color: var(--notebook-color-dark); }
+182 -2
View File
@@ -11,7 +11,7 @@ pub(crate) fn is_inbox_notebook(id: &str, slug: Option<&str>) -> bool {
pub(crate) fn list_notebooks(database: &Connection) -> Result<Value, String> {
let mut statement = database.prepare(
"SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at,
"SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.text_color, n.sort_order, n.created_at, n.updated_at,
COUNT(CASE WHEN m.is_deleted = 0 THEN 1 END) AS memo_count,
MAX(CASE WHEN m.is_deleted = 0 THEN m.updated_at END) AS last_memo_updated_at
FROM notebooks n LEFT JOIN memos m ON m.notebook_id = n.id
@@ -21,6 +21,11 @@ pub(crate) fn list_notebooks(database: &Connection) -> Result<Value, String> {
"id": row.get::<_, String>("id")?, "parentId": row.get::<_, Option<String>>("parent_id")?,
"name": row.get::<_, String>("name")?, "slug": row.get::<_, Option<String>>("slug")?,
"icon": row.get::<_, Option<String>>("icon")?, "color": row.get::<_, Option<String>>("color")?,
"colorTarget": row.get::<_, String>("color_target")?,
"isBold": row.get::<_, i64>("is_bold")? != 0,
"iconInverted": row.get::<_, i64>("icon_inverted")? != 0,
"rowInverted": row.get::<_, i64>("row_inverted")? != 0,
"textColor": row.get::<_, Option<String>>("text_color")?,
"sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?,
"lastMemoUpdatedAt": row.get::<_, Option<String>>("last_memo_updated_at")?,
"createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?,
@@ -35,7 +40,7 @@ fn notebook_value(
include_deleted: bool,
) -> Result<Value, String> {
let mut statement = database.prepare(
"SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at,
"SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.color_target, n.is_bold, n.icon_inverted, n.row_inverted, n.text_color, n.sort_order, n.created_at, n.updated_at,
COUNT(CASE WHEN m.is_deleted = 0 THEN 1 END) AS memo_count,
MAX(CASE WHEN m.is_deleted = 0 THEN m.updated_at END) AS last_memo_updated_at
FROM notebooks n LEFT JOIN memos m ON m.notebook_id = n.id
@@ -45,6 +50,11 @@ fn notebook_value(
"id": row.get::<_, String>("id")?, "parentId": row.get::<_, Option<String>>("parent_id")?,
"name": row.get::<_, String>("name")?, "slug": row.get::<_, Option<String>>("slug")?,
"icon": row.get::<_, Option<String>>("icon")?, "color": row.get::<_, Option<String>>("color")?,
"colorTarget": row.get::<_, String>("color_target")?,
"isBold": row.get::<_, i64>("is_bold")? != 0,
"iconInverted": row.get::<_, i64>("icon_inverted")? != 0,
"rowInverted": row.get::<_, i64>("row_inverted")? != 0,
"textColor": row.get::<_, Option<String>>("text_color")?,
"sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?,
"lastMemoUpdatedAt": row.get::<_, Option<String>>("last_memo_updated_at")?,
"createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?,
@@ -68,6 +78,12 @@ pub(crate) fn create_notebook(database: &Connection, params: &Value) -> Result<V
pub(crate) fn update_notebook(database: &Connection, params: &Value) -> Result<Value, String> {
let notebook_id = string_param(params, "notebookId")?;
// Keep the notebook and its queued sync operation atomic, including rejected payloads.
let transaction = database
.unchecked_transaction()
.map_err(|e| e.to_string())?;
let database = &transaction;
if let Some(name) = params.get("name").and_then(Value::as_str) {
let current_slug: Option<String> = database
.query_row(
@@ -89,8 +105,68 @@ pub(crate) fn update_notebook(database: &Connection, params: &Value) -> Result<V
if let Some(sort_order) = params.get("sortOrder").and_then(Value::as_i64) {
database.execute("UPDATE notebooks SET sort_order = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![sort_order, notebook_id]).map_err(|e| e.to_string())?;
}
for (key, column) in [
("icon", "icon"),
("color", "color"),
("textColor", "text_color"),
("colorTarget", "color_target"),
] {
if let Some(value) = params.get(key) {
let text = value.as_str();
if !value.is_null() && text.is_none() {
return Err(format!("Invalid notebook {key}"));
}
if matches!(key, "color" | "textColor")
&& text.is_some_and(|color| {
color.len() != 7
|| !color.starts_with('#')
|| !color[1..].bytes().all(|c| c.is_ascii_hexdigit())
})
{
return Err(format!("Invalid notebook {key}"));
}
if key == "icon"
&& text.is_some_and(|icon| {
icon.is_empty()
|| icon.len() > 80
|| !icon.as_bytes()[0].is_ascii_lowercase()
|| !icon
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
})
{
return Err("Invalid notebook icon".to_owned());
}
if key == "colorTarget" && !matches!(text, Some("icon" | "name" | "both")) {
return Err("Invalid notebook color target".to_owned());
}
database.execute(
&format!("UPDATE notebooks SET {column} = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0"),
rusqlite::params![text, notebook_id],
).map_err(|e| e.to_string())?;
}
}
if let Some(value) = params.get("isBold") {
let bold = value
.as_bool()
.ok_or_else(|| "Invalid notebook bold value".to_owned())?;
database.execute("UPDATE notebooks SET is_bold = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![i64::from(bold), notebook_id]).map_err(|e| e.to_string())?;
}
if let Some(value) = params.get("iconInverted") {
let inverted = value
.as_bool()
.ok_or_else(|| "Invalid notebook inverted icon value".to_owned())?;
database.execute("UPDATE notebooks SET icon_inverted = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![i64::from(inverted), notebook_id]).map_err(|e| e.to_string())?;
}
if let Some(value) = params.get("rowInverted") {
let inverted = value
.as_bool()
.ok_or_else(|| "Invalid notebook inverted icon value".to_owned())?;
database.execute("UPDATE notebooks SET row_inverted = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![i64::from(inverted), notebook_id]).map_err(|e| e.to_string())?;
}
let notebook = notebook_value(database, &notebook_id, true)?;
enqueue_change(database, "notebook.update", &notebook_id, params)?;
transaction.commit().map_err(|e| e.to_string())?;
Ok(notebook)
}
@@ -480,3 +556,107 @@ pub(crate) fn rewrite_tag(
)?;
Ok(json!({ "ok": true, "updated": updated }))
}
#[cfg(test)]
mod appearance_tests {
use super::*;
#[test]
fn appearance_survives_local_edits_and_sync_and_can_be_disabled() {
let root = std::env::temp_dir().join(now_id("edgeever-bold-test"));
let migrations = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations");
let database = crate::database::open_database(&root, &migrations).unwrap();
let created = create_notebook(&database, &json!({"name": "Heimnetz"})).unwrap();
let id = created["id"].as_str().unwrap();
let bold = update_notebook(&database, &json!({"notebookId": id, "isBold": true, "iconInverted": true, "rowInverted": true, "icon": "house", "color": "#2563eb", "textColor": "#dc2626"})).unwrap();
assert_eq!(bold["isBold"], true);
let renamed =
update_notebook(&database, &json!({"notebookId": id, "name": "Network"})).unwrap();
assert_eq!(renamed["isBold"], true);
assert_eq!(renamed["iconInverted"], true);
assert_eq!(renamed["rowInverted"], true);
assert_eq!(renamed["icon"], "house");
assert_eq!(renamed["color"], "#2563eb");
assert_eq!(renamed["textColor"], "#dc2626");
let before = notebook_value(&database, id, false).unwrap();
let queued: i64 = database
.query_row("SELECT COUNT(*) FROM _edgeever_sidecar_outbox", [], |row| {
row.get(0)
})
.unwrap();
for invalid in [
json!({"rowInverted": "yes"}),
json!({"color": "red"}),
json!({"textColor": 5}),
json!({"icon": "../house"}),
] {
let mut params = invalid;
params["notebookId"] = json!(id);
params["name"] = json!("Must roll back");
assert!(update_notebook(&database, &params).is_err());
assert_eq!(notebook_value(&database, id, false).unwrap(), before);
assert_eq!(
database
.query_row("SELECT COUNT(*) FROM _edgeever_sidecar_outbox", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
queued
);
}
let payload: String = database.query_row(
"SELECT payload_json FROM _edgeever_sidecar_outbox WHERE kind = 'notebook.update' ORDER BY id LIMIT 1",
[], |row| row.get(0),
).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&payload).unwrap()["isBold"],
true
);
let plain =
update_notebook(&database, &json!({"notebookId": id, "isBold": false})).unwrap();
assert_eq!(plain["isBold"], false);
let mut remote = plain.clone();
remote["isBold"] = json!(true);
remote["textColor"] = json!("#16a34a");
remote["iconInverted"] = json!(false);
remote["rowInverted"] = json!(false);
crate::sync::apply_sync_changes(
&database,
&json!({"changes": [{
"entityType": "notebook", "entityId": id, "operation": "upsert", "notebook": remote,
}]}),
)
.unwrap();
assert_eq!(
notebook_value(&database, id, false).unwrap()["isBold"],
true
);
assert_eq!(
notebook_value(&database, id, false).unwrap()["iconInverted"],
false
);
assert_eq!(
notebook_value(&database, id, false).unwrap()["rowInverted"],
false
);
assert_eq!(
notebook_value(&database, id, false).unwrap()["textColor"],
"#16a34a"
);
remote.as_object_mut().unwrap().remove("isBold");
crate::sync::apply_sync_changes(
&database,
&json!({"changes": [{
"entityType": "notebook", "entityId": id, "operation": "upsert", "notebook": remote,
}]}),
)
.unwrap();
assert_eq!(
notebook_value(&database, id, false).unwrap()["isBold"],
false
);
drop(database);
std::fs::remove_dir_all(root).unwrap();
}
}
+3
View File
@@ -547,6 +547,7 @@ mod tests {
slug TEXT,
icon TEXT,
color TEXT,
color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, text_color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT 'now',
@@ -721,6 +722,7 @@ mod tests {
slug TEXT,
icon TEXT,
color TEXT,
color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, text_color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT 'now',
@@ -842,6 +844,7 @@ mod tests {
slug TEXT,
icon TEXT,
color TEXT,
color_target TEXT NOT NULL DEFAULT 'icon', is_bold INTEGER NOT NULL DEFAULT 0, icon_inverted INTEGER NOT NULL DEFAULT 0, row_inverted INTEGER NOT NULL DEFAULT 0, text_color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT 'now',
+1 -1
View File
@@ -897,7 +897,7 @@ fn apply_one_sync_change(
&& active_inbox_exists_except(tx, &entity_id)?
&& !notebook_is_active(tx, &entity_id)?;
let is_deleted = i64::from(keep_inactive);
tx.execute("INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at, is_deleted, deleted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, CASE WHEN ?10 = 1 THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE NULL END) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id, name=excluded.name, slug=excluded.slug, icon=excluded.icon, color=excluded.color, sort_order=excluded.sort_order, updated_at=excluded.updated_at, is_deleted=excluded.is_deleted, deleted_at=excluded.deleted_at", rusqlite::params![entity_id, parent_id, string_param(notebook, "name")?, slug, notebook.get("icon").and_then(Value::as_str), notebook.get("color").and_then(Value::as_str), notebook.get("sortOrder").and_then(Value::as_i64).unwrap_or(0), string_param(notebook, "createdAt")?, string_param(notebook, "updatedAt")?, is_deleted]).map_err(|e| e.to_string())?;
tx.execute("INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at, is_deleted, deleted_at, color_target, is_bold, icon_inverted, text_color, row_inverted) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, CASE WHEN ?10 = 1 THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE NULL END, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id, name=excluded.name, slug=excluded.slug, icon=excluded.icon, color=excluded.color, color_target=excluded.color_target, is_bold=excluded.is_bold, icon_inverted=excluded.icon_inverted, row_inverted=excluded.row_inverted, text_color=excluded.text_color, sort_order=excluded.sort_order, updated_at=excluded.updated_at, is_deleted=excluded.is_deleted, deleted_at=excluded.deleted_at", rusqlite::params![entity_id, parent_id, string_param(notebook, "name")?, slug, notebook.get("icon").and_then(Value::as_str), notebook.get("color").and_then(Value::as_str), notebook.get("sortOrder").and_then(Value::as_i64).unwrap_or(0), string_param(notebook, "createdAt")?, string_param(notebook, "updatedAt")?, is_deleted, notebook.get("colorTarget").and_then(Value::as_str).unwrap_or("icon"), i64::from(notebook.get("isBold").and_then(Value::as_bool).unwrap_or(false)), i64::from(notebook.get("iconInverted").and_then(Value::as_bool).unwrap_or(false)), notebook.get("textColor").and_then(Value::as_str), i64::from(notebook.get("rowInverted").and_then(Value::as_bool).unwrap_or(false))]).map_err(|e| e.to_string())?;
return Ok(());
}
if operation == "delete" {
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE notebooks ADD COLUMN color_target TEXT NOT NULL DEFAULT 'icon'
CHECK (color_target IN ('icon', 'name', 'both'));
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE notebooks ADD COLUMN is_bold INTEGER NOT NULL DEFAULT 0
CHECK (is_bold IN (0, 1));
+1
View File
@@ -0,0 +1 @@
ALTER TABLE notebooks ADD COLUMN text_color TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE notebooks ADD COLUMN icon_inverted INTEGER NOT NULL DEFAULT 0
CHECK (icon_inverted IN (0, 1));
@@ -0,0 +1,2 @@
ALTER TABLE notebooks ADD COLUMN row_inverted INTEGER NOT NULL DEFAULT 0
CHECK (row_inverted IN (0, 1));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "edgeever",
"version": "2.5.0",
"version": "2.7.0",
"packageManager": "bun@1.3.14",
"private": true,
"type": "module",
+2 -1
View File
@@ -1,3 +1,4 @@
import type { NotebookUpdateInput } from "@edgeever/shared";
import {
parsePreparedAiGeneration,
streamDirectAiGeneration,
@@ -1171,7 +1172,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify(payload),
}),
updateNotebook: (notebookId: string, payload: { name?: string; parentId?: string | null; sortOrder?: number }) =>
updateNotebook: (notebookId: string, payload: NotebookUpdateInput) =>
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}`, {
method: "PATCH",
body: JSON.stringify(payload),
+5
View File
@@ -81,6 +81,11 @@ export const JsonBackupNotebookSchema = z.object({
slug: z.string().nullable(),
icon: z.string().nullable(),
color: z.string().nullable(),
textColor: z.string().nullable().optional(),
colorTarget: z.enum(["icon", "name", "both"]).optional(),
isBold: z.boolean().optional(),
iconInverted: z.boolean().optional(),
rowInverted: z.boolean().optional(),
sortOrder: z.number().int(),
createdAt: DateTimeSchema,
updatedAt: DateTimeSchema,
+15 -4
View File
@@ -17,6 +17,7 @@ import { NEW_IMAGE_WIDTH_PERCENT, parseImageWidth } from "./image-display";
import { IMAGE_GALLERY_NODE_TYPE, groupConsecutiveImagesIntoGalleries, normalizeImageGalleries } from "./image-gallery";
import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link";
import { FRONT_MATTER_LANGUAGE, createFrontMatterNode, getFrontMatterSource, splitMarkdownFrontMatter } from "./front-matter";
import { splitMarkdownParseChunks } from "./markdown-block-start";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
@@ -146,6 +147,16 @@ const withDefaultImageWidths = (node: TiptapNode): TiptapNode => {
return { ...next, attrs: { ...next.attrs, width: NEW_IMAGE_WIDTH_PERCENT } };
};
// Long notes are split into pieces. marked otherwise copies the rest of the
// note after every paragraph, and that copy blocks the single-threaded server.
const parseMarkdownBody = (markdown: string): TiptapNode[] => {
const chunks = splitMarkdownParseChunks(markdown);
if (chunks.length <= 1) {
return (markdownManager.parse(markdown) as TiptapDoc).content ?? [];
}
return chunks.flatMap((chunk) => (markdownManager.parse(chunk) as TiptapDoc).content ?? []);
};
export const markdownToDoc = (markdown: string): TiptapDoc => {
const normalized = markdown.replace(/\r\n?/g, "\n");
if (!normalized) {
@@ -153,12 +164,12 @@ export const markdownToDoc = (markdown: string): TiptapDoc => {
}
const header = splitMarkdownFrontMatter(normalized);
const parsed = markdownManager.parse(expandExtraBlankLinesForParse(header?.body ?? normalized)) as TiptapDoc;
const content = parseMarkdownBody(expandExtraBlankLinesForParse(header?.body ?? normalized));
return {
...parsed,
type: "doc",
content: [...(header ? [createFrontMatterNode(header.source)] : []),
...groupConsecutiveImagesIntoGalleries(parsed.content.map(withDefaultImageWidths)),
...(header && !parsed.content.length ? [{ type: "paragraph" }] : [])],
...groupConsecutiveImagesIntoGalleries(content.map(withDefaultImageWidths)),
...(header && !content.length ? [{ type: "paragraph" }] : [])],
};
};
+2 -1
View File
@@ -1,6 +1,7 @@
import type { JSONContent, MarkdownParseHelpers, MarkdownRendererHelpers, MarkdownToken } from "@tiptap/core";
import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details";
import { resolveAttachmentKind, resolveVideoMimeType } from "./attachment-kind";
import { openBlockSource } from "./markdown-block-start";
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
import { parseImageWidth } from "./image-display";
import { alignedImageToHtml, parseImageAlignment, type ImageAlignment } from "./image-alignment";
@@ -525,7 +526,7 @@ export const EdgeEverDetails = Details.extend({
name: DETAILS_NODE_TYPE,
level: "block",
start(source: string) {
const match = /<details\b/i.exec(source);
const match = /<details\b/i.exec(openBlockSource(source));
return match ? match.index : -1;
},
tokenize(source: string, _tokens, lexer) {
+38
View File
@@ -2,6 +2,44 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const enUS = {
notebookAppearance: {
inversion: "Invert",
invertOff: "Off",
invertIconOnly: "Icon only",
invertWholeRow: "Entire entry",
invertIcon: "Invert icon",
bold: "Bold",
iconSection: "Icon",
textSection: "Text",
iconColor: "Icon color",
textColor: "Text color",
"title": "Appearance",
"description": "Customize this notebook. Changes apply only after saving.",
"icon": "Icon",
"search": "Search icons (English or German)",
"all": "All",
"standard": "Default",
"color": "Color",
"customColor": "Custom color",
"colorTarget": "Apply color to",
"iconTarget": "Icon",
"nameTarget": "Name",
"bothTarget": "Icon and name",
"preview": "Preview",
"reset": "Reset to default",
"invalidColor": "Enter a six-digit HEX color, for example #2563eb.",
"empty": "No matching icons.",
"contrast": "Colors adapt for readability in light and dark mode.",
"saveError": "Could not save. Your choices are still here.",
"categories": {
"general": "General",
"work": "Work & learning",
"technology": "Technology",
"life": "Life & people",
"finance": "Finance",
"leisure": "Leisure & travel"
}
},
fontLoad: {"templateLoading": "Loading font…", "templateFallback": "Temporarily using substitute fonts. The original fonts will be restored automatically when loaded.", "loading": "Loading font…", "ready": "Font ready", "error": "Font could not load", "retry": "Retry"},
noteProperties: {"heading": "Note properties", "source": "Show YAML source", "name": "Property name", "type": "Property type", "add": "Add property", "remove": "Remove {{name}}", "invalidName": "Use a unique name that is not a system property.", "invalidYaml": "Properties need attention:", "invalidNumber": "Enter a valid number.", "listHint": "One value per line", "text": "Text", "list": "List", "number": "Number", "checkbox": "Checkbox", "date": "Date"},
poster: {
+38
View File
@@ -2,6 +2,44 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const ja = {
notebookAppearance: {
inversion: "反転",
invertOff: "なし",
invertIconOnly: "アイコンのみ",
invertWholeRow: "項目全体",
invertIcon: "アイコンを反転",
bold: "太字",
iconSection: "アイコン",
textSection: "テキスト",
iconColor: "アイコンの色",
textColor: "テキストの色",
"title": "外観",
"description": "このノートブックの外観を変更します。保存後に反映されます。",
"icon": "アイコン",
"search": "アイコンを検索(英語・ドイツ語)",
"all": "すべて",
"standard": "既定",
"color": "色",
"customColor": "カスタムカラー",
"colorTarget": "色の適用先",
"iconTarget": "アイコン",
"nameTarget": "名前",
"bothTarget": "アイコンと名前",
"preview": "プレビュー",
"reset": "既定に戻す",
"invalidColor": "6桁のHEXカラーを入力してください(例:#2563eb)。",
"empty": "該当するアイコンはありません。",
"contrast": "テーマに合わせて読みやすい色に調整されます。",
"saveError": "保存できませんでした。選択内容は保持されています。",
"categories": {
"general": "一般",
"work": "仕事と学習",
"technology": "テクノロジー",
"life": "暮らしと人",
"finance": "財務",
"leisure": "趣味と旅行"
}
},
fontLoad: {"templateLoading": "フォントを読み込み中…", "templateFallback": "一時的に代替フォントを使用しています。読み込みが完了すると自動的に元に戻ります。", "loading": "フォントを読み込み中…", "ready": "フォントの準備完了", "error": "フォントを読み込めませんでした", "retry": "再試行"},
noteProperties: {"heading": "ノートのプロパティ", "source": "YAML ソースを表示", "name": "プロパティ名", "type": "プロパティの種類", "add": "プロパティを追加", "remove": "{{name}} を削除", "invalidName": "重複しない、システム予約名以外の名前を入力してください。", "invalidYaml": "プロパティを確認してください:", "invalidNumber": "有効な数値を入力してください。", "listHint": "1 行に 1 つの値", "text": "テキスト", "list": "リスト", "number": "数値", "checkbox": "チェックボックス", "date": "日付"},
poster: {
+38
View File
@@ -2,6 +2,44 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const pl = {
notebookAppearance: {
inversion: "Odwrócenie",
invertOff: "Wyłączone",
invertIconOnly: "Tylko ikona",
invertWholeRow: "Cały wpis",
invertIcon: "Odwróć ikonę",
bold: "Pogrubienie",
iconSection: "Ikona",
textSection: "Tekst",
iconColor: "Kolor ikony",
textColor: "Kolor tekstu",
"title": "Wygląd",
"description": "Dostosuj ten notatnik. Zmiany zostaną zastosowane po zapisaniu.",
"icon": "Ikona",
"search": "Szukaj ikon (angielski lub niemiecki)",
"all": "Wszystkie",
"standard": "Domyślne",
"color": "Kolor",
"customColor": "Własny kolor",
"colorTarget": "Zastosuj kolor do",
"iconTarget": "Ikony",
"nameTarget": "Nazwy",
"bothTarget": "Ikony i nazwy",
"preview": "Podgląd",
"reset": "Przywróć domyślne",
"invalidColor": "Wpisz sześciocyfrowy kolor HEX, np. #2563eb.",
"empty": "Brak pasujących ikon.",
"contrast": "Kolory dostosowują się do jasnego i ciemnego motywu.",
"saveError": "Nie udało się zapisać. Wybór został zachowany.",
"categories": {
"general": "Ogólne",
"work": "Praca i nauka",
"technology": "Technologia",
"life": "Życie i ludzie",
"finance": "Finanse",
"leisure": "Czas wolny i podróże"
}
},
fontLoad: {"templateLoading": "Ładowanie czcionki…", "templateFallback": "Tymczasowo używane są czcionki zastępcze. Po załadowaniu oryginalne czcionki zostaną przywrócone automatycznie.", "loading": "Ładowanie czcionki…", "ready": "Czcionka gotowa", "error": "Nie udało się załadować czcionki", "retry": "Ponów próbę"},
noteProperties: {"heading": "Właściwości notatki", "source": "Pokaż źródło YAML", "name": "Nazwa właściwości", "type": "Typ właściwości", "add": "Dodaj właściwość", "remove": "Usuń {{name}}", "invalidName": "Użyj unikalnej nazwy, która nie jest właściwością systemową.", "invalidYaml": "Sprawdź właściwości:", "invalidNumber": "Wprowadź poprawną liczbę.", "listHint": "Jedna wartość w wierszu", "text": "Tekst", "list": "Lista", "number": "Liczba", "checkbox": "Pole wyboru", "date": "Data"},
poster: {
+38
View File
@@ -2,6 +2,44 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const ru = {
notebookAppearance: {
inversion: "Инверсия",
invertOff: "Выключена",
invertIconOnly: "Только значок",
invertWholeRow: "Вся строка",
invertIcon: "Инвертировать значок",
bold: "Жирный шрифт",
iconSection: "Значок",
textSection: "Текст",
iconColor: "Цвет значка",
textColor: "Цвет текста",
"title": "Оформление",
"description": "Настройте этот блокнот. Изменения применятся после сохранения.",
"icon": "Значок",
"search": "Поиск значков (английский или немецкий)",
"all": "Все",
"standard": "По умолчанию",
"color": "Цвет",
"customColor": "Свой цвет",
"colorTarget": "Применить цвет к",
"iconTarget": "Значку",
"nameTarget": "Названию",
"bothTarget": "Значку и названию",
"preview": "Предпросмотр",
"reset": "Сбросить",
"invalidColor": "Введите шестизначный HEX-цвет, например #2563eb.",
"empty": "Значки не найдены.",
"contrast": "Цвета адаптируются для светлой и тёмной темы.",
"saveError": "Не удалось сохранить. Ваш выбор сохранён в диалоге.",
"categories": {
"general": "Общие",
"work": "Работа и учёба",
"technology": "Технологии",
"life": "Жизнь и люди",
"finance": "Финансы",
"leisure": "Отдых и путешествия"
}
},
fontLoad: {"templateLoading": "Loading font…", "templateFallback": "Temporarily using substitute fonts. The original fonts will be restored automatically when loaded.", "loading": "Loading font…", "ready": "Font ready", "error": "Font could not load", "retry": "Retry"},
noteProperties: {"heading": "Свойства заметки", "source": "Показать исходник YAML", "name": "Имя свойства", "type": "Тип свойства", "add": "Добавить свойство", "remove": "Удалить {{name}}", "invalidName": "Укажите уникальное имя, которое не занято системным свойством.", "invalidYaml": "Свойства требуют внимания:", "invalidNumber": "Введите корректное число.", "listHint": "По одному значению на строку", "text": "Текст", "list": "Список", "number": "Число", "checkbox": "Флажок", "date": "Дата"},
poster: {
+38
View File
@@ -2,6 +2,44 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const zhCN = {
notebookAppearance: {
inversion: "反转",
invertOff: "关闭",
invertIconOnly: "仅图标",
invertWholeRow: "整个条目",
invertIcon: "反转图标",
bold: "粗体",
iconSection: "图标",
textSection: "文字",
iconColor: "图标颜色",
textColor: "文字颜色",
"title": "外观",
"description": "自定义此笔记本,保存后生效。",
"icon": "图标",
"search": "搜索图标(英文或德文)",
"all": "全部",
"standard": "默认",
"color": "颜色",
"customColor": "自定义颜色",
"colorTarget": "颜色应用于",
"iconTarget": "图标",
"nameTarget": "名称",
"bothTarget": "图标和名称",
"preview": "预览",
"reset": "恢复默认",
"invalidColor": "请输入六位 HEX 颜色,例如 #2563eb。",
"empty": "没有匹配的图标。",
"contrast": "颜色会根据明暗主题调整以确保清晰。",
"saveError": "保存失败,已保留你的选择。",
"categories": {
"general": "通用",
"work": "工作与学习",
"technology": "技术",
"life": "生活与人际",
"finance": "财务",
"leisure": "休闲与旅行"
}
},
fontLoad: {"templateLoading": "字体加载中…", "templateFallback": "暂时使用替代字体,加载成功后将自动恢复", "loading": "字体加载中…", "ready": "字体已就绪", "error": "字体加载失败", "retry": "重试"},
noteProperties: {"heading": "笔记属性", "source": "显示 YAML 源码", "name": "属性名称", "type": "属性类型", "add": "添加属性", "remove": "删除 {{name}}", "invalidName": "请输入不重复且非系统保留的属性名称。", "invalidYaml": "请检查笔记属性:", "invalidNumber": "请输入有效数字。", "listHint": "每行一个值", "text": "文本", "list": "列表", "number": "数字", "checkbox": "复选框", "date": "日期"},
poster: {
+2 -1
View File
@@ -1,6 +1,7 @@
import { Extension, type JSONContent } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { parseImageWidth } from "./image-display";
import { openBlockSource } from "./markdown-block-start";
export const IMAGE_ALIGNMENTS = ["left", "center", "right"] as const;
export type ImageAlignment = (typeof IMAGE_ALIGNMENTS)[number];
@@ -104,7 +105,7 @@ export const EdgeEverImage = Image.extend({
markdownTokenizer: {
name: "image",
level: "block",
start: (source) => source.search(/<img\b/i),
start: (source) => openBlockSource(source).search(/<img\b/i),
tokenize(source) {
const match = /^<img\b(?:[^"'>]|"[^"]*"|'[^']*')*\/?>[ \t]*(?:\n|$)/i.exec(source);
if (!match) return undefined;
+2 -1
View File
@@ -1,4 +1,5 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { openBlockSource } from "./markdown-block-start";
import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
import { parseImageAlignment } from "./image-alignment";
@@ -92,7 +93,7 @@ export const ImageGallery = Node.create({
name: IMAGE_GALLERY_NODE_TYPE,
level: "block",
start(source: string) {
return source.indexOf("<!-- edgeever:image-gallery ");
return openBlockSource(source).indexOf("<!-- edgeever:image-gallery ");
},
tokenize(source: string, _tokens, lexer) {
const match = /^<!-- edgeever:image-gallery fit=(width|height) layout=(auto|1|2|3) -->\s*\n([\s\S]*?)\n<!-- \/edgeever:image-gallery -->/.exec(source);
+1
View File
@@ -29,6 +29,7 @@ export * from "./note-links";
export * from "./note-prose";
export * from "./note-prose-css";
export * from "./notebooks";
export * from "./notebook-appearance";
export * from "./revision-diff";
export * from "./resource-links";
export * from "./schemas";
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { markdownToDoc } from "./content.ts";
import { openBlockSource, splitMarkdownParseChunks } from "./markdown-block-start.ts";
const paragraph = "分析 `code span` 与 [link](https://example.com/a) 以及 $x_1$ 价格 $12.50 的段落,"
+ "附数组写法 arr[0] 与 arr[1] 的说明。";
describe("open block marker scan", () => {
test("keeps a marker in the current paragraph and ignores the next block", () => {
expect(openBlockSource("See $$\nx^2\n$$")).toBe("See $$\nx^2\n$$");
expect(openBlockSource("See\n\n$$\nx^2\n$$").includes("$$")).toBe(false);
expect(openBlockSource("See\n \n$$\nx^2")).toBe("See");
});
test("display math on the next line still interrupts the paragraph", () => {
const doc = markdownToDoc("See\n$$\nx^2\n$$");
expect(doc.content.map((node) => node.type)).toEqual(["paragraph", "blockMath"]);
expect(doc.content[1]).toEqual({ type: "blockMath", attrs: { latex: "x^2" } });
});
test("splits plain paragraphs and keeps containers intact", () => {
expect(splitMarkdownParseChunks("Alpha.\n\nBeta.", 1)).toEqual(["Alpha.", "\nBeta."]);
expect(splitMarkdownParseChunks("Alpha.\n \nBeta.", 1)).toEqual(["Alpha.", " \nBeta."]);
expect(splitMarkdownParseChunks("Alpha.\n\n\n\nBeta.", 1)).toEqual(["Alpha.", "\n\n\nBeta."]);
expect(splitMarkdownParseChunks("See [name][id]\n\n[id]: https://example.com", 1)).toEqual([
"See [name][id]\n\n[id]: https://example.com",
]);
expect(splitMarkdownParseChunks("- a\n\n- b", 1)).toEqual(["- a\n\n- b"]);
expect(splitMarkdownParseChunks("> a\n>\n> b", 1)).toEqual(["> a\n>\n> b"]);
expect(splitMarkdownParseChunks("```js\nconst a = 1;\n\nconst b = 2;\n```", 1)).toEqual([
"```js\nconst a = 1;\n\nconst b = 2;\n```",
]);
expect(splitMarkdownParseChunks("$$\nx\n\ny\n$$", 1)).toEqual(["$$\nx\n\ny\n$$"]);
expect(splitMarkdownParseChunks("<details>\n<summary>T</summary>\n\nbody\n\n</details>", 1)).toEqual([
"<details>\n<summary>T</summary>\n\nbody\n\n</details>",
]);
});
test("keeps empty paragraphs when a long note is parsed in pieces", () => {
const count = 500;
const doc = markdownToDoc("Alpha.\n\n\n\n".repeat(count));
const empties = (count - 1) * 2 + 3;
expect(doc.content.filter((node) => node.content?.[0]?.text === "Alpha.")).toHaveLength(count);
expect(doc.content.filter((node) => node.type === "paragraph" && !node.content?.length)).toHaveLength(empties);
});
test("a long loose list stays one list", () => {
const items = Array.from({ length: 80 }, (_, index) => `- item ${index} ${"word ".repeat(8)}`.trimEnd());
const doc = markdownToDoc(items.join("\n\n"));
expect(doc.content).toHaveLength(1);
expect(doc.content[0]?.type).toBe("bulletList");
expect(doc.content[0]?.content).toHaveLength(80);
});
test("a fence and display math keep internal blank lines after a long preface", () => {
const preface = `${paragraph}\n\n`.repeat(80);
const doc = markdownToDoc(`${preface}\`\`\`js\nconst a = 1;\n\nconst b = 2;\n\`\`\`\n\nAfter.\n\n$$\nx\n\ny\n$$`);
const code = doc.content.find((node) => node.type === "codeBlock");
expect(code?.content?.[0]).toMatchObject({ type: "text", text: "const a = 1;\n\nconst b = 2;" });
expect(doc.content.at(-1)).toEqual({ type: "blockMath", attrs: { latex: "x\n\ny" } });
expect(doc.content.at(-2)?.content?.[0]).toMatchObject({ type: "text", text: "After." });
});
test("parses a long note of short paragraphs in linear time", () => {
let body = "";
while (Buffer.byteLength(body) < 256 * 1024) body += `${paragraph}\n\n`;
body += "$$\nx^2\n$$";
const started = performance.now();
const doc = markdownToDoc(body);
const elapsed = performance.now() - started;
expect(elapsed).toBeLessThan(2000);
expect(doc.content.at(-1)).toEqual({ type: "blockMath", attrs: { latex: "x^2" } });
expect(doc.content[0]?.content?.some((node) => node.type === "inlineMath")).toBe(true);
expect(JSON.stringify(doc.content[0])).toContain("12.50");
expect(doc.content.filter((node) => node.type === "paragraph").length).toBeGreaterThan(1000);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* marked copies the entire remaining note after every paragraph when a block
* tokenizer registers `start`. Searching only the open paragraph keeps an
* interrupt inside that paragraph from walking the rest of the note.
*/
const OPEN_PARAGRAPH_END = /\n[ ]*\n/;
export const openBlockSource = (source: string): string => {
const boundary = OPEN_PARAGRAPH_END.exec(source);
if (!boundary) return source;
return source.slice(0, boundary.index);
};
/**
* Plain paragraphs above this size are parsed separately. marked's copy of the
* remaining note then stays inside one piece instead of growing with the note.
*/
const PARSE_CHUNK_CHAR_LIMIT = 4096;
const FENCE_LINE = /^[ ]{0,3}(`{3,}|~{3,})(.*)$/;
const FENCE_CLOSE = /^[ ]{0,3}(`{3,}|~{3,})[ ]*$/;
const LIST_LINE = /^[ ]{0,3}(?:[-*+]|\d{1,9}[.)])([ \t]|$)/;
const QUOTE_LINE = /^[ ]{0,3}>/;
const INDENTED_LINE = /^(?:[ ]{4}|\t)\S/;
const TABLE_LINE = /^[ ]{0,3}\|/;
const REFERENCE_DEFINITION = /^[ ]{0,3}\[[^\]\n]+\]:/;
type FenceState = { char: string; length: number };
type LineSpan = { text: string; newline: number };
type ChunkBoundary = { omitStart: number; resume: number };
const isBlankLine = (line: string) => line.trim() === "";
/** A line that can sit on either side of a chunk boundary without joining a container. */
const isChunkAnchor = (line: string) =>
!isBlankLine(line)
&& !LIST_LINE.test(line)
&& !QUOTE_LINE.test(line)
&& !INDENTED_LINE.test(line)
&& !TABLE_LINE.test(line)
&& !REFERENCE_DEFINITION.test(line);
const lineSpans = (markdown: string): LineSpan[] => {
const lines: LineSpan[] = [];
let start = 0;
for (let index = 0; index <= markdown.length; index += 1) {
if (index !== markdown.length && markdown[index] !== "\n") continue;
lines.push({ text: markdown.slice(start, index), newline: index });
start = index + 1;
}
return lines;
};
const dollarPairs = (line: string) => line.split("$$").length - 1;
/**
* Indexes of paragraph separators (`\n\n`) that can be omitted between parse
* chunks. Lists, quotes, tables, fences, display math, galleries, and details
* stay in one piece.
*/
const paragraphChunkBoundaries = (markdown: string): ChunkBoundary[] => {
const lines = lineSpans(markdown);
const boundaries: ChunkBoundary[] = [];
let fence: FenceState | null = null;
let math = false;
let gallery = false;
let details = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]?.text ?? "";
const protectedRegion = Boolean(fence || math || gallery || details > 0);
if (!protectedRegion && index > 0 && isBlankLine(line)) {
const previous = lines[index - 1]?.text ?? "";
if (!isBlankLine(previous) && isChunkAnchor(previous)) {
let runEnd = index;
while (runEnd + 1 < lines.length && isBlankLine(lines[runEnd + 1]?.text ?? "")) runEnd += 1;
const next = lines[runEnd + 1]?.text ?? "";
const omitStart = lines[index - 1]?.newline ?? -1;
if (isChunkAnchor(next) && omitStart >= 0) {
// Drop the first newline. The rest of the run stays with the next
// piece, which keeps the same empty paragraphs as one parse.
boundaries.push({ omitStart, resume: omitStart + 1 });
}
index = runEnd;
}
}
if (fence) {
const close = FENCE_CLOSE.exec(line);
if (close?.[1] && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
continue;
}
if (math) {
if (dollarPairs(line) % 2 === 1) math = false;
continue;
}
if (gallery) {
if (line.includes("<!-- /edgeever:image-gallery -->")) gallery = false;
continue;
}
if (isBlankLine(line)) continue;
const openedFence = FENCE_LINE.exec(line);
if (openedFence?.[1]) {
fence = { char: openedFence[1][0] ?? "`", length: openedFence[1].length };
continue;
}
if (dollarPairs(line) % 2 === 1) {
math = true;
continue;
}
if (line.includes("<!-- edgeever:image-gallery ") && !line.includes("<!-- /edgeever:image-gallery -->")) {
gallery = true;
}
const opened = line.match(/<details\b/gi)?.length ?? 0;
const closed = line.match(/<\/details\b/gi)?.length ?? 0;
details = Math.max(0, details + opened - closed);
}
return boundaries;
};
/** Split a long note into parse pieces. Short notes and unsplittable blocks stay intact. */
export const splitMarkdownParseChunks = (
markdown: string,
maxChars = PARSE_CHUNK_CHAR_LIMIT,
): string[] => {
if (markdown.length <= maxChars) return [markdown];
const boundaries = paragraphChunkBoundaries(markdown);
if (boundaries.length === 0) return [markdown];
const pieces: Array<{ start: number; end: number }> = [];
let cursor = 0;
for (const boundary of boundaries) {
pieces.push({ start: cursor, end: boundary.omitStart });
cursor = boundary.resume;
}
pieces.push({ start: cursor, end: markdown.length });
const chunks: string[] = [];
let groupStart = pieces[0]?.start ?? 0;
let groupEnd = pieces[0]?.end ?? 0;
let groupSize = groupEnd - groupStart;
const flush = () => {
if (groupEnd <= groupStart && chunks.length > 0) return;
chunks.push(markdown.slice(groupStart, groupEnd));
};
for (let index = 1; index < pieces.length; index += 1) {
const piece = pieces[index];
if (!piece) continue;
if (groupSize > 0 && piece.end - groupStart > maxChars) {
flush();
groupStart = piece.start;
groupEnd = piece.end;
groupSize = piece.end - piece.start;
continue;
}
groupEnd = piece.end;
groupSize = groupEnd - groupStart;
}
flush();
return chunks.filter((chunk) => chunk.length > 0);
};
+2 -1
View File
@@ -1,4 +1,5 @@
import { Extension, mergeAttributes, Node } from "@tiptap/core";
import { openBlockSource } from "./markdown-block-start";
export const BLOCK_MATH_NODE_TYPE = "blockMath" as const;
export const INLINE_MATH_NODE_TYPE = "inlineMath" as const;
@@ -152,7 +153,7 @@ export const edgeEverInlineMathMarkdownTokenizer = {
export const edgeEverBlockMathMarkdownTokenizer = {
name: BLOCK_MATH_NODE_TYPE,
level: "block" as const,
start: (source: string) => source.indexOf("$$"),
start: (source: string) => openBlockSource(source).indexOf("$$"),
tokenize: (source: string) => {
if (!source.startsWith("$$") || source.startsWith("$$$")) {
return undefined;
+2 -1
View File
@@ -1,4 +1,5 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { openBlockSource } from "./markdown-block-start";
import type { TiptapDoc, TiptapNode } from "./content";
/** TipTap / ProseMirror node type for note-merge section boundaries. */
@@ -48,7 +49,7 @@ export const MergeDivider = Node.create({
name: MERGE_DIVIDER_NODE_TYPE,
level: "block",
start(src: string) {
return src.indexOf("<!-- edgeever:merge-divider -->");
return openBlockSource(src).indexOf("<!-- edgeever:merge-divider -->");
},
tokenize(src: string) {
const match = MERGE_DIVIDER_TOKENIZER.exec(src);
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { NotebookUpdateSchema } from "./schemas.ts";
import { NOTEBOOK_COLORS, NOTEBOOK_ICONS, readableNotebookColor, searchNotebookIcons } from "./notebook-appearance.ts";
test("finds notebook icons with German and English search terms and categories", () => {
expect(searchNotebookIcons("heimnetz").map(icon => icon.id)).toContain("network");
expect(searchNotebookIcons("personal assistant").map(icon => icon.id)).toContain("bot");
expect(searchNotebookIcons("", "finance").every(icon => icon.category === "finance")).toBe(true);
expect(searchNotebookIcons("heimnetz", "finance")).toEqual([]);
expect(new Set(NOTEBOOK_ICONS.map(icon => icon.id)).size).toBe(NOTEBOOK_ICONS.length);
});
test("validates safe appearance updates and preserves explicit reset values", () => {
expect(NotebookUpdateSchema.parse({ icon: null, color: null, colorTarget: "icon" })).toEqual({ icon: null, color: null, colorTarget: "icon" });
expect(NotebookUpdateSchema.parse({ isBold: false })).toEqual({ isBold: false });
for (const input of [{ isBold: "true" }, { isBold: 1 }, { color: "red" }, { color: "#12345z" }, { colorTarget: "background" }, { icon: "<svg onload=alert(1)>" }]) {
expect(NotebookUpdateSchema.safeParse(input).success).toBe(false);
}
expect(NotebookUpdateSchema.parse({ name: "Renamed" })).toEqual({ name: "Renamed" });
});
const luminance = hex => {
const values = hex.match(/[a-f0-9]{2}/gi).map(value => parseInt(value, 16) / 255)
.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
};
describe("notebook color contrast", () => {
for (const dark of [false, true]) test(`keeps all preset and extreme custom colors readable (${dark ? "dark" : "light"})`, () => {
for (const color of [...NOTEBOOK_COLORS, "#ffffff", "#000000", "#ffff00", "#333638", "#dde3e9"]) {
const result = readableNotebookColor(color, dark);
const a = luminance(result), b = luminance(dark ? "#333638" : "#dde3e9");
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
}
expect(readableNotebookColor("url(unsafe)", dark)).toBe("inherit");
});
});
@@ -0,0 +1,89 @@
import type { Notebook } from "./types";
export type NotebookAppearance = Pick<Notebook, "icon" | "color" | "textColor" | "isBold" | "iconInverted" | "rowInverted">;
export const NOTEBOOK_COLOR_TARGETS = ["icon", "name", "both"] as const;
export const NOTEBOOK_COLORS = ["#475569", "#dc2626", "#ea580c", "#ca8a04", "#65a30d", "#16a34a", "#0d9488", "#0891b2", "#2563eb", "#7c3aed", "#c026d3", "#db2777"] as const;
export const NOTEBOOK_ICON_CATEGORIES = ["general", "work", "technology", "life", "finance", "leisure"] as const;
export const NOTEBOOK_ICONS = [
{ id: "notebook", category: "general", keywords: "notizbuch notizen notes book" },
{ id: "book-open", category: "general", keywords: "buch lesen wissen reading" },
{ id: "folder", category: "general", keywords: "ordner sammlung" },
{ id: "archive", category: "general", keywords: "archiv ablage" },
{ id: "inbox", category: "general", keywords: "eingang sammeln" },
{ id: "star", category: "general", keywords: "stern favorit wichtig" },
{ id: "bookmark", category: "general", keywords: "lesezeichen merken" },
{ id: "lightbulb", category: "general", keywords: "idee gedanke" },
{ id: "briefcase", category: "work", keywords: "arbeit beruf job" },
{ id: "building-2", category: "work", keywords: "firma büro arbeitgeber office" },
{ id: "clipboard-list", category: "work", keywords: "aufgaben liste tasks" },
{ id: "calendar", category: "work", keywords: "kalender termine geburtstag" },
{ id: "target", category: "work", keywords: "ziel plan" },
{ id: "graduation-cap", category: "work", keywords: "lernen studium weiterbildung" },
{ id: "file-text", category: "work", keywords: "dokument lebenslauf vertrag" },
{ id: "presentation", category: "work", keywords: "präsentation vortrag" },
{ id: "network", category: "technology", keywords: "netzwerk heimnetz vlan" },
{ id: "server", category: "technology", keywords: "server dienste homelab" },
{ id: "monitor", category: "technology", keywords: "computer pc gerät" },
{ id: "wifi", category: "technology", keywords: "wlan internet netz" },
{ id: "smartphone", category: "technology", keywords: "handy telefon mobil" },
{ id: "code", category: "technology", keywords: "programmieren software entwicklung" },
{ id: "database", category: "technology", keywords: "datenbank daten" },
{ id: "cpu", category: "technology", keywords: "hardware technik" },
{ id: "shield", category: "technology", keywords: "sicherheit firewall vpn" },
{ id: "bot", category: "technology", keywords: "assistent ki ai personal assistant" },
{ id: "house", category: "life", keywords: "haus wohnen zuhause home" },
{ id: "heart", category: "life", keywords: "liebe gesundheit beziehung" },
{ id: "users", category: "life", keywords: "menschen freunde kontakte familie" },
{ id: "gift", category: "life", keywords: "geschenk geburtstag" },
{ id: "baby", category: "life", keywords: "kind familie" },
{ id: "paw-print", category: "life", keywords: "tier haustier hund katze" },
{ id: "utensils", category: "life", keywords: "essen kochen ernährung rezept" },
{ id: "wrench", category: "life", keywords: "werkzeug wartung reparatur" },
{ id: "car", category: "life", keywords: "auto mobilität fahrzeug" },
{ id: "activity", category: "life", keywords: "gesundheit sport bewegung" },
{ id: "wallet", category: "finance", keywords: "finanzen geld budget" },
{ id: "landmark", category: "finance", keywords: "bank behörde verwaltung" },
{ id: "chart-line", category: "finance", keywords: "aktien kurs etf börse" },
{ id: "bitcoin", category: "finance", keywords: "bitcoin krypto" },
{ id: "receipt", category: "finance", keywords: "rechnung belege steuer" },
{ id: "piggy-bank", category: "finance", keywords: "sparen vorsorge" },
{ id: "plane", category: "leisure", keywords: "reise urlaub flug" },
{ id: "map", category: "leisure", keywords: "karte reise ort" },
{ id: "camera", category: "leisure", keywords: "foto fotografie erinnerung" },
{ id: "music", category: "leisure", keywords: "musik hobby" },
{ id: "gamepad-2", category: "leisure", keywords: "spiel gaming freizeit" },
{ id: "bike", category: "leisure", keywords: "fahrrad rad sport" },
{ id: "dumbbell", category: "leisure", keywords: "training fitness sport" },
{ id: "newspaper", category: "general", keywords: "news nachrichten rss" },
{ id: "globe", category: "general", keywords: "welt internet web" },
{ id: "coffee", category: "leisure", keywords: "kaffee café freizeit" },
] as const;
export const searchNotebookIcons = (query: string, category = "all") => {
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
return NOTEBOOK_ICONS.filter(icon => (category === "all" || icon.category === category) &&
words.every(word => `${icon.id} ${icon.keywords}`.toLocaleLowerCase().includes(word)));
};
export const isNotebookColor = (color: unknown): color is string =>
typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color);
const luminance = (rgb: number[]) => rgb.map(value => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
/** Preserve the selected hue while ensuring text contrast against the sidebar selection. */
export const readableNotebookColor = (color: string, dark: boolean): string => {
if (!isNotebookColor(color)) return "inherit";
const rgb = [1, 3, 5].map(index => parseInt(color.slice(index, index + 2), 16));
const background = luminance(dark ? [51, 54, 56] : [221, 227, 233]);
for (let step = 0; step <= 100; step++) {
const channels = rgb.map(channel => Math.round(channel + ((dark ? 255 : 0) - channel) * step / 100));
const foreground = luminance(channels);
if ((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05) >= 4.5) {
return "#" + channels.map(channel => channel.toString(16).padStart(2, "0")).join("");
}
}
return dark ? "#ffffff" : "#000000";
};
+2 -1
View File
@@ -1,4 +1,5 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { openBlockSource } from "./markdown-block-start";
export const PLUGIN_EMBED_NODE_TYPE = "edgeeverPluginEmbed" as const;
export const PLUGIN_EMBED_MARKDOWN_LANGUAGE = "edgeever-plugin-embed" as const;
@@ -98,7 +99,7 @@ export const PluginEmbed = Node.create({
name: PLUGIN_EMBED_NODE_TYPE,
level: "block",
start(source: string) {
return source.indexOf(`\`\`\`${PLUGIN_EMBED_MARKDOWN_LANGUAGE}`);
return openBlockSource(source).indexOf(`\`\`\`${PLUGIN_EMBED_MARKDOWN_LANGUAGE}`);
},
tokenize(source: string) {
const match = FENCE_PATTERN.exec(source);
+7
View File
@@ -28,6 +28,13 @@ export const NotebookUpdateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
parentId: z.string().trim().min(1).nullable().optional(),
sortOrder: z.number().int().optional(),
icon: z.string().trim().regex(/^[a-z][a-z0-9-]{0,79}$/).nullable().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
colorTarget: z.enum(["icon", "name", "both"]).optional(),
isBold: z.boolean().optional(),
iconInverted: z.boolean().optional(),
rowInverted: z.boolean().optional(),
});
export const MemoCreateSchema = z.object({
+6
View File
@@ -12,6 +12,12 @@ export type Notebook = {
slug: string | null;
icon: string | null;
color: string | null;
textColor?: string | null;
/** Older clients omit this; defaults to coloring the icon. */
colorTarget?: "icon" | "name" | "both";
isBold?: boolean;
iconInverted?: boolean;
rowInverted?: boolean;
sortOrder: number;
memoCount: number;
lastMemoUpdatedAt: string | null;
+18 -18
View File
@@ -1,9 +1,15 @@
diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt
--- a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt
+++ b/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt
@@ -5,10 +5,15 @@
@@ -1,14 +1,21 @@
package expo.modules.sharing
+import android.app.Activity
import android.content.Intent
import android.content.Context
import android.net.Uri
import android.os.Build
+import android.os.Bundle
import android.os.Parcelable
+import androidx.core.content.FileProvider
import androidx.core.net.toUri
@@ -17,7 +23,7 @@ diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/andr
// Emits intent received event to JS. This is because the `SEND` and `SEND_MULTIPLE` intents
// have no uri and do not inform the navigation libraries of a native intent.
@@ -32,22 +37,100 @@
@@ -32,24 +39,102 @@
// Emits intent received event to JS. This is because the `SEND` and `SEND_MULTIPLE` intents
// do not navigation libraries of a native intent. This aligns the behaviour with ios.
internal fun emitShareIntentReceived(reactActivity: ReactActivity, originalIntent: Intent) {
@@ -107,8 +113,8 @@ diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/andr
+ // history launch, not a new share, even after the JS process has been killed.
+ return intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0 &&
+ intent.type != null && allowedShareActions.contains(intent.action)
+}
+
}
+internal fun getShareIntentUris(intent: Intent): List<Uri> {
+ val streams = when (intent.action) {
+ Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))
@@ -121,21 +127,15 @@ diff --git a/android/src/main/java/expo/modules/sharing/ShareIntoUtils.kt b/andr
+
+ val clipData = intent.clipData ?: return emptyList()
+ return (0 until clipData.itemCount).mapNotNull { index -> clipData.getItemAt(index).uri }
}
internal inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(name: String): T? {
diff --git a/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt b/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt
new file mode 100644
--- /dev/null
+++ b/android/src/main/java/expo/modules/sharing/ShareReceiverActivity.kt
@@ -0,0 +1,52 @@
+package expo.modules.sharing
+}
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
internal inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(name: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(name, T::class.java)
@@ -70,3 +155,48 @@
internal val URL.lastPathComponent: String
get() = path.removeSuffix("/").substringAfterLast('/')
+
+/**
+ * Receives ACTION_SEND while the singleTask host is dead.
+16 -19
View File
@@ -1,29 +1,26 @@
{
"version": "2.5.0",
"version": "2.7.0",
"changes": {
"en-US": [
"The web app, the in-app desktop interface, and Android can switch to Russian. A Russian system or browser language selects it when no language has been chosen yet. Text that is not translated yet stays in English, and assistant replies do not follow Russian in this release. Desktop system menus, iOS, and the browser clipper stay as they are. Thanks @Sergmay911 for #668.",
"The desktop app can upload images to a remote PicList or PicGo server. In image-host settings, enter the service address and a server key when the server requires one. The address can be a NAS or another computer, and the path stays /upload. Use HTTPS for a remote server. Those images stay on the external host and are left out of EdgeEver backups. The website keeps the built-in image host.",
"A startup launch of the desktop app opens the main window. The tray-only option is removed. A saved tray-only preference opens the main window on the next launch.",
"Infographic notes use the same assistant session menu as other notes. Past chats can be opened, and a new chat can be started. The graphic being edited stays as its own row in that menu, and its generation history remains on the note.",
"When a request already names the diagram subject, the assistant no longer reads the open note. A request to diagram or convert the open note still uses that note.",
"The marketing site adds a Showcase page with 17 real-world workflows, linked from the top navigation."
"On the web and desktop apps, each notebook can set its own icon, icon color, text color, bold text, and an inverted icon or inverted row. Colors can be a preset or a six-digit HEX value. The settings are included in sync and backups. Existing notebooks keep their current appearance, and no manual migration is required. Thanks @thorufur for #673.",
"On the web and desktop apps, a tooltip closes as soon as the pointer leaves its trigger, including when the pointer moves onto the tooltip. Keyboard focus, Escape, and blur still dismiss it. Interactive hover cards stay as they are. Thanks @thorufur for #674.",
"On Android, the first share from another app reaches EdgeEver after the app process has already exited.",
"While the Android editor saves in the background, the Done button stays a label, and the status no longer cycles between Saving and Saved. Further typing starts the autosave wait again.",
"Saving a long Markdown note no longer freezes the server by reparsing the rest of the note."
],
"zh-CN": [
"Web、桌面端应用内和 Android 可以切换到俄语。系统或浏览器语言是俄语、且还没有手动选过语言时,会自动使用俄语。还没有翻译的文字继续显示英文,助手回复这次仍不跟随俄语。桌面端的系统菜单、iOS 和浏览器剪藏插件这次不变。感谢 @Sergmay911 的贡献(#668)。",
"桌面端可以把图片上传到远程 PicList 或 PicGo。在设置的图床里填写服务地址,服务器要求密钥时再填写服务密钥。地址可以是 NAS 或其他电脑,路径保持为 /upload。远程服务请使用 HTTPS。这些图片留在外部图床,不会进入 EdgeEver 备份。网页版仍然使用内置图床。",
"桌面端开机启动时会打开主窗口。只留在托盘的选项已经去掉。以前选过只留在托盘的,下次启动会改成打开主窗口。",
"信息图笔记和其他笔记使用同一个助手会话菜单,可以打开以往的对话,也可以新开对话。正在编辑的信息图在菜单里单独占一行,它的生成记录仍保存在这篇笔记上。",
"让助手画一张已经写明主题的图时,不再读取当前打开的笔记。要求把当前笔记画成图或转换成图时,仍然使用这篇笔记。",
"官网新增「真机演示」页面,收录 17 个真实使用场景,可以从顶部导航进入。"
"网页和桌面端的每个笔记本可以单独设置图标、图标颜色、文字颜色、粗体,以及只反转图标或反转整行。颜色可以选预设,也可以填写六位 HEX。这些设置会随同步和备份保存。已有笔记本保持原来的外观,不需要手动迁移。感谢 @thorufur(#673)。",
"网页和桌面端的悬停提示会在指针离开触发区域时马上关闭,指针移到提示上也会关闭。键盘聚焦、Escape 和失焦仍然会关闭提示。可以交互的悬停卡片保持原样。感谢 @thorufur(#674)。",
"Android 版在进程已经结束后,第一次从其他应用分享进来的内容可以进入 EdgeEver。",
"Android 编辑器在后台自动保存时,完成按钮保持文字,状态也不再在“保存中”和“已保存”之间来回切换。继续输入会重新等待自动保存。",
"保存较长的 Markdown 笔记时,服务端不再因重复解析整篇内容而卡住。"
],
"ja": [
"Web、デスクトップ版のアプリ内界面、Android でロシア語に切り替えられます。システムまたはブラウザの言語がロシア語で、言語を手動選択していない場合は自動でロシア語になります。未翻訳の文言は英語のままです。アシスタントの返信は今回ロシア語に従いません。デスクトップのシステムメニュー、iOS、ブラウザのクリップ拡張は変わりません。@Sergmay911 の貢献に感謝します(#668)。",
"デスクトップ版は、リモートの PicList または PicGo に画像をアップロードできます。図床の設定でサービスアドレスを入力し、サーバーが鍵を求める場合はサービスキーも入力します。アドレスは NAS や別のパソコンでもよく、パスは /upload のままです。リモートのサーバーには HTTPS を使ってください。これらの画像は外部の画像ホストに残り、EdgeEver のバックアップには含まれません。Web 版はこれまでどおり内蔵の画像ホストを使います。",
"デスクトップ版をログイン時に起動すると、メインウィンドウが開きます。トレイのみの選択肢はなくなりました。以前トレイのみを保存していた場合も、次回の起動からメインウィンドウが開きます。",
"インフォグラフィックのノートでも、他のノートと同じアシスタントのセッションメニューを使えます。過去のチャットを開いたり、新しいチャットを始めたりできます。編集中の図はメニュー内の独立した一行のままで、その生成履歴はこのノートに残ります。",
"図にしてほしい主題を依頼の中ですでに指定している場合、アシスタントは開いているノートを読まなくなります。開いているノートを図にする、または図へ変換する依頼では、これまでどおりそのノートを使います。",
"紹介サイトに「実機デモ」ページを追加しました。17 の実用例を上部のナビゲーションから開けます。"
"Web とデスクトップでは、各ノートブックにアイコン、アイコンの色、文字色、太字、アイコンのみまたは行全体の反転を設定できます。色はプリセットか 6 桁の HEX です。設定は同期とバックアップに含まれます。既存のノートブックの見た目はそのままで、手動の移行は不要です。@thorufur の貢献に感謝します(#673)。",
"Web とデスクトップでは、ポインタがトリガーを離れるとツールチップがすぐ閉じます。ヒントの上に移動した場合も閉じます。キーボードフォーカス、Escape、フォーカス解除でも閉じます。操作できるホバーカードはこれまでどおりです。@thorufur の貢献に感謝します(#674)。",
"Android では、アプリのプロセスが終了したあとの最初の共有も EdgeEver に届きます。",
"Android のエディタがバックグラウンドで保存するとき、完了ボタンは文字のままです。状態が「保存中」と「保存済み」の間で切り替わることもありません。入力を続けると自動保存の待ち時間がやり直されます。",
"長い Markdown ノートを保存しても、残りの本文を繰り返し解析してサーバーが固まることはありません。"
]
}
}
+16 -1
View File
@@ -589,6 +589,21 @@ assert.equal(recoveredUpdate.payload.contentMarkdown, "edited body survives reco
assert.equal(recoveredUpdate.payload.expectedRevision, 1);
assert.equal(recoveredUpdate.status, "pending", "missing-memo edits should recover after memo create acknowledgement");
// Exercise appearance through the public JSON-RPC boundary, including rollback and sync.
const appearanceBook = (await request("notebook.create", { name: "Appearance regression" })).notebook;
const appearance = { icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true, rowInverted: true };
const readAppearance = async () => (await request("notebook.list")).notebooks.find(book => book.id === appearanceBook.id);
const changedAppearance = (await request("notebook.update", { notebookId: appearanceBook.id, ...appearance })).notebook;
for (const [key, value] of Object.entries(appearance)) assert.equal(changedAppearance[key], value);
const appearanceQueue = (await request("sync.outbox.list", { limit: 1000 })).items.find(item => item.kind === "notebook.update" && item.entityId === appearanceBook.id);
assert.ok(appearanceQueue);
for (const [key, value] of Object.entries(appearance)) assert.equal(appearanceQueue.payload[key], value);
await assert.rejects(request("notebook.update", { notebookId: appearanceBook.id, name: "Must roll back", rowInverted: "yes" }), /Invalid notebook/);
assert.equal((await readAppearance()).name, "Appearance regression");
const pulledAppearance = { ...changedAppearance, textColor: "#16a34a", isBold: false, iconInverted: false, rowInverted: false };
await request("sync.apply", { changes: [{ entityType: "notebook", operation: "upsert", entityId: appearanceBook.id, notebook: pulledAppearance }] });
for (const key of Object.keys(appearance)) assert.equal((await readAppearance())[key], pulledAppearance[key]);
child.stdin.end();
await new Promise((resolve) => child.once("close", resolve));
console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.noteKind", "memo.list.tag", "memo.list.subtree", "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.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.apply.renamed-inbox", "sync.outbox", "sync.outbox.notebook-remap", "sync.outbox.notebook-remap-rollback", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard", "notebook.delete.empty-tree", "notebook.delete.not-empty"] }));
console.log(JSON.stringify({ ok: true, checked: ["notebook.appearance.roundtrip-sync-rollback", "memo.create", "memo.list.search", "memo.list.noteKind", "memo.list.tag", "memo.list.subtree", "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.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.apply.renamed-inbox", "sync.outbox", "sync.outbox.notebook-remap", "sync.outbox.notebook-remap-rollback", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard", "notebook.delete.empty-tree", "notebook.delete.not-empty"] }));
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { Database } from "bun:sqlite";
import { fetchEdgeEverApp } from "../apps/api/src/index.ts";
import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter.ts";
import { _electron as electron, expect } from "@playwright/test";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
assert.ok(process.argv[2], "Pass the locally built desktop executable as the first argument.");
const directory = await mkdtemp(join(tmpdir(), "edgeever-desktop-appearance-"));
let application;
let server;
let database;
try {
database = new Database(join(directory, "test-api.sqlite"));
for (const name of (await readdir("migrations")).filter(name => name.endsWith(".sql")).sort()) {
database.exec(await readFile(join("migrations", name), "utf8"));
}
database.query("INSERT OR IGNORE INTO workspaces(id,name,is_personal) VALUES ('ws_default','Personal',1)").run();
const environment = {
storage: createSelfHostedStorageAdapter(database, join(directory, "resources")),
EDGE_EVER_ALLOW_UNAUTHENTICATED: "true",
EDGE_EVER_RUNTIME: "self-hosted-bun",
};
server = Bun.serve({ hostname: "127.0.0.1", port: 0,
fetch: request => fetchEdgeEverApp(request, environment, { waitUntil() {}, passThroughOnException() {} }),
});
const created = await fetch(`http://127.0.0.1:${server.port}/api/v1/notebooks`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Desktop appearance regression" }),
});
assert.equal(created.status, 201);
const { notebook } = await created.json();
await writeFile(join(directory, "instance-url"), `http://127.0.0.1:${server.port}`);
await writeFile(join(directory, "installation-confirmed"), "test");
application = await electron.launch({
executablePath: resolve(process.argv[2]),
args: [`--user-data-dir=${directory}`],
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
timeout: 30000,
});
// Startup can briefly open a renderer-origin migration window before the main window.
let page;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
page = application.windows().find(window => window.url().startsWith("edgeever-app://app/index.html"));
if (page) break;
await new Promise(resolveWait => setTimeout(resolveWait, 100));
}
assert.ok(page, "The packaged main window must open");
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.waitForSelector('[data-edgeever-renderer-ready="true"]', { state: "attached" });
await page.evaluate(() => localStorage.setItem("edgeever.locale.preference", "en-US"));
await page.reload();
await expect(page.getByRole("button", { name: "New notebook", exact: true })).toBeVisible({ timeout: 20000 });
await page.reload();
const row = page.locator(`[data-notebook-id="${notebook.id}"]`);
await expect(row).toBeVisible();
await row.hover();
await row.getByRole("button", { name: "Open actions for Desktop appearance regression", exact: true }).click();
await page.getByRole("button", { name: "Appearance", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Appearance", exact: true });
await dialog.getByRole("button", { name: "Icon", exact: true }).click();
await dialog.getByPlaceholder("Search icons (English or German)").fill("alarm-clock");
await dialog.getByRole("option", { name: "alarm clock", exact: true }).click();
// This icon is loaded dynamically through the packaged application's custom scheme.
await expect(dialog.locator('svg:has(circle[cx="12"][cy="13"][r="8"])').first()).toBeVisible();
await expect(dialog.locator('[data-notebook-color-picker="Icon color"] button')).toHaveCount(5);
await dialog.getByLabel("Icon color", { exact: true }).fill("#2563eb");
await dialog.getByLabel("Text color", { exact: true }).fill("#dc2626");
await dialog.getByRole("switch", { name: "Bold", exact: true }).check();
await dialog.getByLabel("Invert", { exact: true }).selectOption("row");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(row.locator('svg:has(circle[cx="12"][cy="13"][r="8"])')).toBeVisible();
await expect(row).toHaveClass(/notebook-inverted-row/);
await page.reload();
await expect(row.locator('svg:has(circle[cx="12"][cy="13"][r="8"])')).toBeVisible();
const stored = await page.evaluate(async id => {
const { notebooks } = await window.edgeeverDesktop.sidecarRequest("notebook.list");
return notebooks.find(book => book.id === id);
}, notebook.id);
for (const [key, value] of Object.entries({ icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: false, rowInverted: true })) {
assert.equal(stored[key], value);
}
assert.deepEqual(errors, []);
console.log("Packaged desktop appearance: dialog, dynamic icon, palette, colors, bold, full-row inversion and reload passed.");
} finally {
await application?.close();
server?.stop(true);
database?.close();
await rm(directory, { recursive: true, force: true });
}
+146
View File
@@ -0,0 +1,146 @@
import { expect, test } from "@playwright/test";
test("saves notebook appearance, keeps drafts on cancel, and resets across reloads and themes", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("edgeever.locale.preference", "en-US");
if (!localStorage.getItem("edgeever.notebook-color-palette")) localStorage.setItem("edgeever.notebook-color-palette", JSON.stringify(["#2563eb", "#16a34a", "#ca8a04", "#dc2626", "#7c3aed"]));
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/");
const name = `Appearance ${Date.now()}`;
const created = await page.request.post("/api/v1/notebooks", { data: { name } });
expect(created.ok()).toBeTruthy();
const { notebook } = await created.json();
const childResponse = await page.request.post("/api/v1/notebooks", { data: { name: "Child", parentId: notebook.id } });
const { notebook: child } = await childResponse.json();
const readNotebook = async () => (await (await page.request.get("/api/v1/notebooks")).json()).notebooks.find((item: { id: string }) => item.id === notebook.id);
const row = page.locator(`[data-notebook-id="${notebook.id}"]`);
const dialog = page.getByRole("dialog", { name: "Appearance", exact: true });
const open = async () => {
await row.hover();
await row.getByRole("button", { name: `Open actions for ${name}`, exact: true }).click();
await page.getByRole("button", { name: "Appearance", exact: true }).click();
await expect(dialog).toBeVisible();
};
const save = async () => {
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toBeHidden();
};
try {
await page.reload();
await expect(row).toBeVisible();
await row.locator("[data-notebook-tree-button]").click();
await open();
await dialog.getByRole("button", { name: "Icon", exact: true }).click();
await dialog.getByPlaceholder("Search icons (English or German)").fill("alarm-clock");
await dialog.getByRole("option", { name: "alarm clock", exact: true }).click();
await expect(dialog.locator('[data-notebook-color-picker="Icon color"] button')).toHaveCount(5);
await expect(dialog.locator('[data-notebook-color-picker="Text color"] button')).toHaveCount(5);
await dialog.getByRole("button", { name: "Icon color 1: #2563eb", exact: true }).click();
await dialog.getByLabel("Icon color", { exact: true }).fill("#0d9488");
await expect(dialog.getByRole("button", { name: "Icon color 1: #0d9488", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(dialog.getByRole("button", { name: "Text color 1: #0d9488", exact: true })).toBeVisible();
await dialog.getByLabel("Icon color", { exact: true }).evaluate((element) => {
const input = element as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, "#2563eb");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await dialog.getByLabel("Text color", { exact: true }).evaluate((element) => {
const input = element as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, "#dc2626");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await dialog.getByRole("switch", { name: "Bold", exact: true }).check();
await dialog.getByLabel("Invert", { exact: true }).selectOption("icon");
await expect(dialog.locator("[data-notebook-icon]")).toHaveAttribute("data-notebook-icon-inverted", "true");
await expect(dialog.locator("[data-notebook-name]")).toHaveCSS("font-weight", "700");
await expect(dialog.locator("[data-notebook-appearance-preview] [data-notebook-icon]")).toHaveAttribute("data-notebook-icon", "alarm-clock");
await expect(dialog.locator("[data-notebook-name]")).toHaveCSS("color", "rgb(196, 34, 34)");
await expect(dialog.locator("[data-notebook-icon]")).toHaveCSS("width", "24px");
await expect(dialog.locator("[data-notebook-icon]")).toHaveCSS("padding", "4px");
const previewColor = await dialog.locator("[data-notebook-name]").evaluate(element => getComputedStyle(element).color);
await page.screenshot({ path: "test-results/notebook-appearance-dialog.png" });
await save();
await expect.poll(readNotebook).toMatchObject({ icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true });
await page.reload();
await expect(row.locator("[data-notebook-icon]")).toHaveAttribute("data-notebook-icon", "alarm-clock");
await expect(row.locator('[data-notebook-icon] circle[cx="12"][cy="13"]')).toHaveCount(1);
await expect(row.locator("[data-notebook-icon]")).toHaveAttribute("data-notebook-icon-inverted", "true");
await expect(row.locator("[data-notebook-name]")).toHaveCSS("color", previewColor);
await expect(row.locator("[data-notebook-name]")).toHaveCSS("font-weight", "700");
await row.locator("[data-notebook-tree-button]").click();
await expect(row.locator("[data-notebook-name]")).toHaveCSS("font-weight", "700");
// Children have independent settings.
await expect(page.locator(`[data-notebook-id="${child.id}"] [data-notebook-icon]`)).toHaveAttribute("data-notebook-icon", "notebook");
await expect(page.locator(`[data-notebook-id="${child.id}"] [data-notebook-name]`)).toHaveCSS("font-weight", "400");
await page.evaluate(() => document.documentElement.classList.add("dark"));
await expect(row.locator("[data-notebook-name]")).not.toHaveCSS("color", previewColor);
await page.screenshot({ path: "test-results/notebook-appearance-dark.png" });
await open();
await dialog.getByLabel("Text color", { exact: true }).evaluate((element) => {
const input = element as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, "#ffffff");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
expect(await readNotebook()).toMatchObject({ color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true });
// Reset is a draft until Save; Escape discards it.
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Appearance", exact: true }).click();
await dialog.getByRole("button", { name: "Reset to default", exact: true }).click();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
expect(await readNotebook()).toMatchObject({ icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true });
await open();
await dialog.getByLabel("Invert", { exact: true }).selectOption("row");
await expect(dialog.locator("[data-notebook-name]")).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(dialog.locator("[data-notebook-icon]")).not.toHaveAttribute("data-notebook-icon-inverted");
const rowBackground = await dialog.locator("[data-notebook-appearance-preview]").evaluate(element => getComputedStyle(element).backgroundColor);
await save();
await expect.poll(readNotebook).toMatchObject({ rowInverted: true, iconInverted: false });
await page.reload();
await expect(row).toHaveAttribute("data-notebook-row-inverted", "true");
await expect(row).toHaveCSS("background-color", rowBackground);
await expect(row.locator("[data-notebook-name]")).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(row.locator("[data-notebook-icon]")).toHaveCSS("color", "rgb(255, 255, 255)");
// The active row stays recognizable; dark mode preserves the selected background.
await row.locator("[data-notebook-tree-button]").click();
await expect(row).toHaveClass(/notebook-inverted-row-selected/);
await page.evaluate(() => document.documentElement.classList.add("dark"));
await expect(row).toHaveCSS("background-color", rowBackground);
// A nested row spans the same width, with its content still indented.
expect((await page.request.patch(`/api/v1/notebooks/${child.id}`, { data: { rowInverted: true, color: "#2563eb" } })).ok()).toBeTruthy();
await page.reload();
const childRow = page.locator(`[data-notebook-id="${child.id}"]`);
await expect(row).toHaveAttribute("data-notebook-row-inverted", "true");
const expand = row.getByRole("button", { name: `Expand ${name}`, exact: true });
if (await expand.isVisible()) await expand.click();
await expect(childRow).toBeVisible();
await expect(childRow).toHaveAttribute("data-notebook-row-inverted", "true");
const parentBox = await row.boundingBox();
const childBox = await childRow.boundingBox();
expect(childBox?.x).toBe(parentBox?.x);
expect(childBox?.width).toBe(parentBox?.width);
await expect(childRow).toHaveCSS("padding-left", "22px");
await expect(childRow.locator("[data-notebook-name]")).toHaveCSS("color", "rgb(255, 255, 255)");
await page.screenshot({ path: "test-results/notebook-row-inverted.png" });
await open();
await expect(dialog.getByLabel("Invert", { exact: true })).toHaveValue("row");
await dialog.getByRole("button", { name: "Reset to default", exact: true }).click();
await save();
await expect.poll(readNotebook).toMatchObject({ icon: null, color: null, textColor: null, isBold: false, iconInverted: false, rowInverted: false });
await page.reload();
await expect(row.locator("[data-notebook-icon]")).toHaveAttribute("data-notebook-icon", "notebook");
await expect(row.locator(".notebook-custom-color")).toHaveCount(0);
await expect(row.locator("[data-notebook-name]")).toHaveCSS("font-weight", "400");
} finally {
await page.request.delete(`/api/v1/notebooks/${child.id}`);
await page.request.delete(`/api/v1/notebooks/${notebook.id}`);
}
});
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("edgeever.locale.preference", "en-US"));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/");
});
test("dismisses a tooltip when the pointer moves from its trigger onto the hint", async ({ page }) => {
const trigger = page.getByRole("button", { name: "Collapse notebook pane", exact: true });
const hint = page.locator("[data-radix-popper-content-wrapper]").filter({
has: page.getByRole("tooltip", { name: /Collapse notebook pane/ }),
});
await trigger.hover();
await expect(hint).toBeVisible();
const box = await hint.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 8 });
await expect(hint).toBeHidden({ timeout: 1500 });
});
test("keeps keyboard hints available and dismisses them on Escape or blur", async ({ page }) => {
const trigger = page.getByRole("button", { name: "Collapse notebook pane", exact: true });
const hint = page.getByRole("tooltip", { name: /Collapse notebook pane/ });
await trigger.focus();
await expect(hint).toBeAttached();
await expect(trigger).toHaveAttribute("aria-describedby", await hint.getAttribute("id"));
await page.keyboard.press("Escape");
await expect(hint).not.toBeAttached();
await page.keyboard.press("Tab");
await trigger.focus();
await expect(hint).toBeAttached();
await page.keyboard.press("Tab");
await expect(hint).not.toBeAttached();
});
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline";
// Supply the binary and migrations from the PR's base revision, never a real profile.
const oldBinary = process.env.EDGE_EVER_LEGACY_SIDECAR_PATH;
const oldMigrations = process.env.EDGE_EVER_LEGACY_MIGRATIONS_PATH;
assert.ok(oldBinary && oldMigrations, "Set EDGE_EVER_LEGACY_SIDECAR_PATH and EDGE_EVER_LEGACY_MIGRATIONS_PATH");
const directory = await mkdtemp(join(tmpdir(), "edgeever-appearance-upgrade-"));
let active;
const bounded = async (promise, label) => {
let timer;
try {
return await Promise.race([promise, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 10000);
})]);
} finally { clearTimeout(timer); }
};
const start = async (binary, migrations) => {
const child = spawn(binary, ["--data-dir", directory, "--migrations-dir", migrations], { stdio: ["pipe", "pipe", "inherit"] });
const pending = new Map();
let nextId = 0;
const lines = createInterface({ input: child.stdout });
let ready;
const booted = new Promise((resolveReady, reject) => {
ready = resolveReady;
child.once("error", reject);
});
lines.on("line", line => {
const message = JSON.parse(line);
if (message.event === "ready") ready();
const resolveResponse = pending.get(message.id);
if (resolveResponse) { pending.delete(message.id); resolveResponse(message); }
});
const close = async () => {
if (child.exitCode !== null) return;
const closed = new Promise(resolveClosed => child.once("close", resolveClosed));
child.stdin.end();
await bounded(closed, "sidecar shutdown").catch(() => { child.kill(); return closed; });
};
active = { close };
await bounded(booted, "sidecar startup");
return {
close,
request: async (method, params = {}) => {
const id = ++nextId;
const response = new Promise(resolveResponse => pending.set(id, resolveResponse));
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
const message = await bounded(response, method);
if (message.error) throw new Error(message.error.message);
return message.result;
},
};
};
try {
let host = await start(oldBinary, oldMigrations);
const book = (await host.request("notebook.create", { name: "Keep notebook" })).notebook;
const memo = (await host.request("memo.create", { notebookId: book.id, title: "Keep note", contentMarkdown: "Keep **content**", tags: ["keep"] })).memo;
const oldQueue = (await host.request("sync.outbox.list", { limit: 1000 })).items;
await host.close();
const currentBinary = resolve("crates/desktop-sidecar/target/debug/edgeever-sidecar");
host = await start(currentBinary, resolve("migrations"));
const readBook = async () => (await host.request("notebook.list")).notebooks.find(item => item.id === book.id);
const upgraded = await readBook();
assert.equal(upgraded.name, book.name);
for (const key of ["isBold", "iconInverted", "rowInverted"]) assert.equal(upgraded[key], false);
assert.equal(upgraded.textColor, null);
assert.deepEqual((await host.request("memo.get", { memoId: memo.id })).memo, memo);
assert.deepEqual((await host.request("sync.outbox.list", { limit: 1000 })).items, oldQueue);
const appearance = { icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true, rowInverted: true };
await host.request("notebook.update", { notebookId: book.id, ...appearance });
await host.close();
// Additive migrations also allow reopening the database with the old client.
host = await start(oldBinary, oldMigrations);
assert.equal((await host.request("memo.get", { memoId: memo.id })).memo.contentMarkdown, memo.contentMarkdown);
await host.request("notebook.update", { notebookId: book.id, name: "Renamed with old client" });
await host.close();
host = await start(currentBinary, resolve("migrations"));
const reopened = await readBook();
assert.equal(reopened.name, "Renamed with old client");
for (const [key, value] of Object.entries(appearance)) assert.equal(reopened[key], value);
console.log("Notebook appearance upgrade, restart and rollback: passed; notes and pending sync preserved.");
} finally {
await active?.close();
await rm(directory, { recursive: true, force: true });
}
@@ -171,12 +171,27 @@ try {
assert.equal(await localDb.syncQueue.count(), 0);
checked.push("Web offline saves + guarded snapshots + real API synchronization");
const appearance = { icon: "alarm-clock", color: "#2563eb", textColor: "#dc2626", isBold: true, iconInverted: true, rowInverted: true };
await client.updateNotebook(notebook.id, appearance);
await repository.sync();
const nativeDirectory = join(temporary, "desktop");
let native = await startNative(nativePath, nativeDirectory);
attachNative(native);
let desktopContext = makeContext(createDesktopRepository());
const bootstrap = await syncDesktopData();
assert.equal(bootstrap.failed, 0);
const nativeNotebook = (await native.request("notebook.list")).notebooks.find(item => item.id === notebook.id);
for (const [key, value] of Object.entries(appearance)) assert.equal(nativeNotebook[key], value);
const desktopAppearance = { ...appearance, icon: "network", color: "#7c3aed", textColor: "#16a34a", iconInverted: false, rowInverted: false, isBold: false };
await native.request("notebook.update", { notebookId: notebook.id, ...desktopAppearance });
const appearanceSynced = await syncDesktopData();
assert.equal(appearanceSynced.failed, 0);
assert.equal(appearanceSynced.synced, 1);
await repository.sync();
const webNotebook = await localDb.notebooks.get([scope, notebook.id]);
for (const [key, value] of Object.entries(desktopAppearance)) assert.equal(webNotebook[key], value);
checked.push("notebook appearance: real API -> native desktop -> real API -> Web mirror");
for (const { fixture, memo } of notes) assert.equal(fixture.value(await desktopContext[fixture.api].get(memo.id)), "Web offline");
reachable = false;
navigator.onLine = false;