diff --git a/.gitignore b/.gitignore index 95509164..93d6a781 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.ja.md b/README.ja.md index 192d4262..ae8bbed3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 同期機能を有料でアンロックして自前ストレージを用意する必要があります。 diff --git a/README.md b/README.md index 6d6b9b00..22fa7f21 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.pl-PL.md b/README.pl-PL.md index 6d325345..913267cf 100644 --- a/README.pl-PL.md +++ b/README.pl-PL.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 31cab8d4..c26a4fd9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 同步特性并自备存储。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 4fdef1d1..393bcdba 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -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 同步特性並自備儲存。 diff --git a/apps/api/src/backup-restore.test.mjs b/apps/api/src/backup-restore.test.mjs index 9e6b4a42..15f5e36f 100644 --- a/apps/api/src/backup-restore.test.mjs +++ b/apps/api/src/backup-restore.test.mjs @@ -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"], diff --git a/apps/api/src/backup-service.ts b/apps/api/src/backup-service.ts index 62d089c7..2e30ba1b 100644 --- a/apps/api/src/backup-service.ts +++ b/apps/api/src/backup-service.ts @@ -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, diff --git a/apps/api/src/companion-learning.test.mjs b/apps/api/src/companion-learning.test.mjs index e5482d55..2a67aa2d 100644 --- a/apps/api/src/companion-learning.test.mjs +++ b/apps/api/src/companion-learning.test.mjs @@ -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"); diff --git a/apps/api/src/notebook-service.test.mjs b/apps/api/src/notebook-service.test.mjs index 5ddaa4cb..b8769b51 100644 --- a/apps/api/src/notebook-service.test.mjs +++ b/apps/api/src/notebook-service.test.mjs @@ -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(); } + }); +}); diff --git a/apps/api/src/notebook-service.ts b/apps/api/src/notebook-service.ts index 8c5e3db4..2f87e66e 100644 --- a/apps/api/src/notebook-service.ts +++ b/apps/api/src/notebook-service.ts @@ -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), ]); diff --git a/apps/api/src/sync-routes.test.mjs b/apps/api/src/sync-routes.test.mjs index ca1c5af7..ebc00315 100644 --- a/apps/api/src/sync-routes.test.mjs +++ b/apps/api/src/sync-routes.test.mjs @@ -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 ( diff --git a/apps/api/src/sync-routes.ts b/apps/api/src/sync-routes.ts index 7bf65e3d..b6a4c6d6 100644 --- a/apps/api/src/sync-routes.ts +++ b/apps/api/src/sync-routes.ts @@ -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(), 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(); })), ]); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5aa0ad13..49a617f1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/desktop", - "version": "2.5.0", + "version": "2.7.0", "desktopName": "EdgeEver", "private": true, "description": "Native EdgeEver desktop client", diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index 712032c9..38780b17 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -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 diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 5bbbc669..c702422e 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -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; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index f76ab11b..2d3ef1bc 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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", diff --git a/apps/mobile/src/hooks/mobile-editor-save-chrome.test.ts b/apps/mobile/src/hooks/mobile-editor-save-chrome.test.ts new file mode 100644 index 00000000..d7c79ee5 --- /dev/null +++ b/apps/mobile/src/hooks/mobile-editor-save-chrome.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/hooks/mobile-editor-save-chrome.ts b/apps/mobile/src/hooks/mobile-editor-save-chrome.ts new file mode 100644 index 00000000..960eef61 --- /dev/null +++ b/apps/mobile/src/hooks/mobile-editor-save-chrome.ts @@ -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; diff --git a/apps/mobile/src/hooks/useMobileRichEditor.ts b/apps/mobile/src/hooks/useMobileRichEditor.ts index 32f71735..73c58f71 100644 --- a/apps/mobile/src/hooks/useMobileRichEditor.ts +++ b/apps/mobile/src/hooks/useMobileRichEditor.ts @@ -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(null); const [resourceTarget, setResourceTarget] = useState(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, diff --git a/apps/mobile/src/lib/share-receiver-activity.test.ts b/apps/mobile/src/lib/share-receiver-activity.test.ts index 376949dd..37160b7b 100644 --- a/apps/mobile/src/lib/share-receiver-activity.test.ts +++ b/apps/mobile/src/lib/share-receiver-activity.test.ts @@ -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); diff --git a/apps/mobile/src/screens/WorkspaceEditors.tsx b/apps/mobile/src/screens/WorkspaceEditors.tsx index 9af98dca..efcdf053 100644 --- a/apps/mobile/src/screens/WorkspaceEditors.tsx +++ b/apps/mobile/src/screens/WorkspaceEditors.tsx @@ -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(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>(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 ( @@ -1170,19 +1206,19 @@ export const RichEditorModal = ({ style={styles.richEditorKeyboardAvoiding} > - void requestClose()} style={styles.createMemoBackButton}> - + void requestClose()} style={styles.createMemoBackButton}> + - {saveLabel} + {saveChrome.label} void requestClose()} - style={[styles.createMemoDoneButton, (saving || uploading || !ready) && styles.createMemoDoneButtonDisabled]} + style={[styles.createMemoDoneButton, (uploading || !ready) && styles.createMemoDoneButtonDisabled]} > - {saving ? : 完成} + 完成 @@ -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)} diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index 8a2c3789..4a398481 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -1159,7 +1159,7 @@ export const MemoDetailModal = ({ - {editor.saveLabel} + {editor.saveLabel} void editor.requestClose()} style={[styles.createMemoDoneButton, (editor.uploading || !editor.ready) && styles.createMemoDoneButtonDisabled]} > - {editor.saving ? : 完成} + 完成 diff --git a/apps/web/src/components/NotebookAppearanceContent.tsx b/apps/web/src/components/NotebookAppearanceContent.tsx new file mode 100644 index 00000000..cb6b39c5 --- /dev/null +++ b/apps/web/src/components/NotebookAppearanceContent.tsx @@ -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(iconNames); +const dynamicComponents = new globalThis.Map< + string, + ComponentType +>(); + +export const getNotebookIcon = ( + name: string | null, +): ComponentType => { + 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) => ( + } + /> + ); + 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 ( + <> + + + {notebook.name} + + + ); +}; diff --git a/apps/web/src/components/NotebookColorPicker.tsx b/apps/web/src/components/NotebookColorPicker.tsx new file mode 100644 index 00000000..5addaf87 --- /dev/null +++ b/apps/web/src/components/NotebookColorPicker.tsx @@ -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(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 ( +
+ {palette.map((color, index) => ( +
+ ); +}; diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index 1a274c1a..c9d4c4e9 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -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} diff --git a/apps/web/src/components/NotebookTreeItem.tsx b/apps/web/src/components/NotebookTreeItem.tsx index 4df072fa..04d4aedf 100644 --- a/apps/web/src/components/NotebookTreeItem.tsx +++ b/apps/web/src/components/NotebookTreeItem.tsx @@ -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 = ({
{hasChildren ? ( @@ -278,18 +284,10 @@ export const NotebookTreeItem = ({ } }} > - - - {node.name} - + {publicNotebook ? ( @@ -297,7 +295,7 @@ export const NotebookTreeItem = ({
+ +
+ + + +
+
+ + +
+ {!validColor && ( +

+ {t("notebookAppearance.invalidColor")} +

+ )} + + +
+

+ {t("notebookAppearance.textSection")} +

+
+
+ + + + +
+
+ + + +
+ {!validTextColor && ( +

+ {t("notebookAppearance.invalidColor")} +

+ )} +
+
+
+

+ {t("notebookAppearance.preview")} +

+
+ +
+

+ {t("notebookAppearance.contrast")} +

+
+ + {failed && ( +

+ {t("notebookAppearance.saveError")} +

+ )} + + +
+ + +
+
+ + + + ); +}; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index d11cdcef..23bfdc8e 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -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) => ( - +const TooltipProvider = ({ delayDuration = 0, skipDelayDuration = 0, disableHoverableContent = true, ...props }: React.ComponentProps) => ( + ); 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} diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index dcde6309..82d5443d 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -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; diff --git a/apps/web/src/lib/json-backup.ts b/apps/web/src/lib/json-backup.ts index 38c2825a..b8e0ffb4 100644 --- a/apps/web/src/lib/json-backup.ts +++ b/apps/web/src/lib/json-backup.ts @@ -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, diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts index 32652b43..a41dde27 100644 --- a/apps/web/src/lib/local-mirror.ts +++ b/apps/web/src/lib/local-mirror.ts @@ -553,7 +553,7 @@ export const getLocalNotebook = async (scope: string, notebookId: string) => { return result; }; -export const updateLocalNotebook = async (scope: string, notebookId: string, input: Partial>) => { +export const updateLocalNotebook = async (scope: string, notebookId: string, input: Partial>) => { const notebook = await getLocalNotebook(scope, notebookId); if (!notebook) return null; const updated = { ...notebook, ...input, updatedAt: new Date().toISOString() }; diff --git a/apps/web/src/lib/notebook-icon-catalog.test.mjs b/apps/web/src/lib/notebook-icon-catalog.test.mjs new file mode 100644 index 00000000..f28b7e63 --- /dev/null +++ b/apps/web/src/lib/notebook-icon-catalog.test.mjs @@ -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")); +}); diff --git a/apps/web/src/lib/notebook-icon-catalog.ts b/apps/web/src/lib/notebook-icon-catalog.ts new file mode 100644 index 00000000..866ca16f --- /dev/null +++ b/apps/web/src/lib/notebook-icon-catalog.ts @@ -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( + 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 })); +}; diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index 0682ddaf..a6c209f2 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -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[] }>; diff --git a/apps/web/src/lib/sync-queue.test.mjs b/apps/web/src/lib/sync-queue.test.mjs index 590f68ea..cc47b199 100644 --- a/apps/web/src/lib/sync-queue.test.mjs +++ b/apps/web/src/lib/sync-queue.test.mjs @@ -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; + } +}); diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index cb9d747b..ed6724e4 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -758,6 +758,13 @@ const syncQueueItem = async (item: SyncQueueItem): Promise => { 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; } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 5249090d..5b7555fc 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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); } diff --git a/crates/desktop-sidecar/src/catalog.rs b/crates/desktop-sidecar/src/catalog.rs index a27c2ba2..176b73fa 100644 --- a/crates/desktop-sidecar/src/catalog.rs +++ b/crates/desktop-sidecar/src/catalog.rs @@ -11,7 +11,7 @@ pub(crate) fn is_inbox_notebook(id: &str, slug: Option<&str>) -> bool { pub(crate) fn list_notebooks(database: &Connection) -> Result { let mut statement = database.prepare( - "SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at, + "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 { "id": row.get::<_, String>("id")?, "parentId": row.get::<_, Option>("parent_id")?, "name": row.get::<_, String>("name")?, "slug": row.get::<_, Option>("slug")?, "icon": row.get::<_, Option>("icon")?, "color": row.get::<_, Option>("color")?, + "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>("text_color")?, "sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?, "lastMemoUpdatedAt": row.get::<_, Option>("last_memo_updated_at")?, "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?, @@ -35,7 +40,7 @@ fn notebook_value( include_deleted: bool, ) -> Result { let mut statement = database.prepare( - "SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at, + "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>("parent_id")?, "name": row.get::<_, String>("name")?, "slug": row.get::<_, Option>("slug")?, "icon": row.get::<_, Option>("icon")?, "color": row.get::<_, Option>("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>("text_color")?, "sortOrder": row.get::<_, i64>("sort_order")?, "memoCount": row.get::<_, i64>("memo_count")?, "lastMemoUpdatedAt": row.get::<_, Option>("last_memo_updated_at")?, "createdAt": row.get::<_, String>("created_at")?, "updatedAt": row.get::<_, String>("updated_at")?, @@ -68,6 +78,12 @@ pub(crate) fn create_notebook(database: &Connection, params: &Value) -> Result Result { 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 = database .query_row( @@ -89,8 +105,68 @@ pub(crate) fn update_notebook(database: &Connection, params: &Value) -> Result 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, ¬ebook_id, true)?; enqueue_change(database, "notebook.update", ¬ebook_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, ¶ms).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::(&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(); + } +} diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index e1a64917..35745054 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -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', diff --git a/crates/desktop-sidecar/src/sync.rs b/crates/desktop-sidecar/src/sync.rs index 93b36279..6c9667ee 100644 --- a/crates/desktop-sidecar/src/sync.rs +++ b/crates/desktop-sidecar/src/sync.rs @@ -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" { diff --git a/migrations/0073_notebook_appearance.sql b/migrations/0073_notebook_appearance.sql new file mode 100644 index 00000000..9dce5588 --- /dev/null +++ b/migrations/0073_notebook_appearance.sql @@ -0,0 +1,2 @@ +ALTER TABLE notebooks ADD COLUMN color_target TEXT NOT NULL DEFAULT 'icon' + CHECK (color_target IN ('icon', 'name', 'both')); diff --git a/migrations/0074_notebook_bold.sql b/migrations/0074_notebook_bold.sql new file mode 100644 index 00000000..95d80b4e --- /dev/null +++ b/migrations/0074_notebook_bold.sql @@ -0,0 +1,2 @@ +ALTER TABLE notebooks ADD COLUMN is_bold INTEGER NOT NULL DEFAULT 0 + CHECK (is_bold IN (0, 1)); diff --git a/migrations/0075_notebook_text_color.sql b/migrations/0075_notebook_text_color.sql new file mode 100644 index 00000000..a66cab5e --- /dev/null +++ b/migrations/0075_notebook_text_color.sql @@ -0,0 +1 @@ +ALTER TABLE notebooks ADD COLUMN text_color TEXT; diff --git a/migrations/0076_notebook_icon_inverted.sql b/migrations/0076_notebook_icon_inverted.sql new file mode 100644 index 00000000..68e899cf --- /dev/null +++ b/migrations/0076_notebook_icon_inverted.sql @@ -0,0 +1,2 @@ +ALTER TABLE notebooks ADD COLUMN icon_inverted INTEGER NOT NULL DEFAULT 0 + CHECK (icon_inverted IN (0, 1)); diff --git a/migrations/0077_notebook_row_inverted.sql b/migrations/0077_notebook_row_inverted.sql new file mode 100644 index 00000000..6b3131cd --- /dev/null +++ b/migrations/0077_notebook_row_inverted.sql @@ -0,0 +1,2 @@ +ALTER TABLE notebooks ADD COLUMN row_inverted INTEGER NOT NULL DEFAULT 0 + CHECK (row_inverted IN (0, 1)); diff --git a/package.json b/package.json index 1cae3b24..1c357fd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "edgeever", - "version": "2.5.0", + "version": "2.7.0", "packageManager": "bun@1.3.14", "private": true, "type": "module", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 55417753..651bf95c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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(`/api/v1/notebooks/${notebookId}`, { method: "PATCH", body: JSON.stringify(payload), diff --git a/packages/shared/src/backup.ts b/packages/shared/src/backup.ts index 8488d024..1253e82c 100644 --- a/packages/shared/src/backup.ts +++ b/packages/shared/src/backup.ts @@ -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, diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 60c94aa0..174ba3be 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -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" }] : [])], }; }; diff --git a/packages/shared/src/details.ts b/packages/shared/src/details.ts index 421124fa..a9eed081 100644 --- a/packages/shared/src/details.ts +++ b/packages/shared/src/details.ts @@ -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 = / source.search(/ openBlockSource(source).search(/]|"[^"]*"|'[^']*')*\/?>[ \t]*(?:\n|$)/i.exec(source); if (!match) return undefined; diff --git a/packages/shared/src/image-gallery.ts b/packages/shared/src/image-gallery.ts index 80776813..8af7b6fd 100644 --- a/packages/shared/src/image-gallery.ts +++ b/packages/shared/src/image-gallery.ts @@ -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("\s*\n([\s\S]*?)\n/.exec(source); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 943d9af7..0f2b8944 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/markdown-block-start.test.mjs b/packages/shared/src/markdown-block-start.test.mjs new file mode 100644 index 00000000..259b8447 --- /dev/null +++ b/packages/shared/src/markdown-block-start.test.mjs @@ -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("
\nT\n\nbody\n\n
", 1)).toEqual([ + "
\nT\n\nbody\n\n
", + ]); + }); + + 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); + }); +}); diff --git a/packages/shared/src/markdown-block-start.ts b/packages/shared/src/markdown-block-start.ts new file mode 100644 index 00000000..4157365f --- /dev/null +++ b/packages/shared/src/markdown-block-start.ts @@ -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("")) 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("")) { + gallery = true; + } + const opened = line.match(/ { + 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); +}; diff --git a/packages/shared/src/mathematics-markdown.ts b/packages/shared/src/mathematics-markdown.ts index b457e5cc..79cb8b4b 100644 --- a/packages/shared/src/mathematics-markdown.ts +++ b/packages/shared/src/mathematics-markdown.ts @@ -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; diff --git a/packages/shared/src/merge-divider.ts b/packages/shared/src/merge-divider.ts index 6546f97f..20babd04 100644 --- a/packages/shared/src/merge-divider.ts +++ b/packages/shared/src/merge-divider.ts @@ -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(""); + return openBlockSource(src).indexOf(""); }, tokenize(src: string) { const match = MERGE_DIVIDER_TOKENIZER.exec(src); diff --git a/packages/shared/src/notebook-appearance.test.mjs b/packages/shared/src/notebook-appearance.test.mjs new file mode 100644 index 00000000..e910b65b --- /dev/null +++ b/packages/shared/src/notebook-appearance.test.mjs @@ -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: "" }]) { + 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"); + }); +}); diff --git a/packages/shared/src/notebook-appearance.ts b/packages/shared/src/notebook-appearance.ts new file mode 100644 index 00000000..e5c887ac --- /dev/null +++ b/packages/shared/src/notebook-appearance.ts @@ -0,0 +1,89 @@ +import type { Notebook } from "./types"; + +export type NotebookAppearance = Pick; +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"; +}; diff --git a/packages/shared/src/plugin-embed.ts b/packages/shared/src/plugin-embed.ts index b33a0eda..0779eb03 100644 --- a/packages/shared/src/plugin-embed.ts +++ b/packages/shared/src/plugin-embed.ts @@ -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); diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 47993968..2011e47c 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -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({ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c71c8034..22cd209f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; diff --git a/patches/expo-sharing@57.0.22.patch b/patches/expo-sharing@57.0.22.patch index bfaddb7c..41d4ef0b 100644 --- a/patches/expo-sharing@57.0.22.patch +++ b/patches/expo-sharing@57.0.22.patch @@ -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 { + val streams = when (intent.action) { + Intent.ACTION_SEND -> listOfNotNull(intent.getParcelableExtraCompat(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 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 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. diff --git a/release-summary.json b/release-summary.json index 98826d94..149b59ca 100644 --- a/release-summary.json +++ b/release-summary.json @@ -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 ノートを保存しても、残りの本文を繰り返し解析してサーバーが固まることはありません。" ] } } diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs index 6f9782cb..6f0683ad 100644 --- a/scripts/test-desktop-sidecar.mjs +++ b/scripts/test-desktop-sidecar.mjs @@ -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"] })); diff --git a/tests/desktop-notebook-appearance.integration.mjs b/tests/desktop-notebook-appearance.integration.mjs new file mode 100644 index 00000000..c7fb28e8 --- /dev/null +++ b/tests/desktop-notebook-appearance.integration.mjs @@ -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 }); +} diff --git a/tests/e2e/notebook-appearance.spec.ts b/tests/e2e/notebook-appearance.spec.ts new file mode 100644 index 00000000..d70d20ff --- /dev/null +++ b/tests/e2e/notebook-appearance.spec.ts @@ -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}`); + } +}); diff --git a/tests/e2e/tooltip-dismissal.spec.ts b/tests/e2e/tooltip-dismissal.spec.ts new file mode 100644 index 00000000..b9cd1d9e --- /dev/null +++ b/tests/e2e/tooltip-dismissal.spec.ts @@ -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(); +}); diff --git a/tests/notebook-appearance-upgrade.integration.mjs b/tests/notebook-appearance-upgrade.integration.mjs new file mode 100644 index 00000000..284fa12f --- /dev/null +++ b/tests/notebook-appearance-upgrade.integration.mjs @@ -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 }); +} diff --git a/tests/plugin-structured-sync.integration.mjs b/tests/plugin-structured-sync.integration.mjs index 9bd90380..a8e2cba8 100644 --- a/tests/plugin-structured-sync.integration.mjs +++ b/tests/plugin-structured-sync.integration.mjs @@ -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;