chore: update EdgeEver from v2.14.0

EdgeEver-Upstream-Commit: 355286814e746828688e6b0de139c39d3ecd2b20
This commit is contained in:
edgeever-updater[bot] committed 2026-10-29 08:29:49 +00:00
1 parent 55a447d8f8
commit d04350db18
119 files changed
+5200 -1391

No files matched your search

+3 -3
View File
@@ -81,7 +81,7 @@ EdgeEver は、オープンソースのノートと知識ベースの作業領
<a id="features"></a>
## 機能とワークフローマトリクス
「インスピレーションの収集 · 深い創作 · 知識の蓄積」をシームレスにつなぐオールインワンワークスペース(👉 **[全 17 の実機スクリーンショットと活用ガイドを見る](docs/best-practices.md)**):
「インスピレーションの収集 · 深い創作 · 知識の蓄積」をシームレスにつなぐオールインワンワークスペース(👉 **[全 19 の実機スクリーンショットと活用ガイドを見る](docs/best-practices.md)**):
| モジュール | 基本機能 | 高度な機能 |
| :--- | :--- | :--- |
@@ -324,12 +324,11 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -339,5 +338,6 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+3 -3
View File
@@ -81,7 +81,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s
<a id="features"></a>
## Feature Matrix & Capabilities
An all-in-one workspace connecting inspiration capture, deep creation, and knowledge compounding (👉 **[Explore 17 step-by-step showcases with screenshots](docs/best-practices.md)**):
An all-in-one workspace connecting inspiration capture, deep creation, and knowledge compounding (👉 **[Explore 19 step-by-step showcases with screenshots](docs/best-practices.md)**):
| Module | Core Features | Advanced Features |
| :--- | :--- | :--- |
@@ -325,12 +325,11 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -340,5 +339,6 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+3 -3
View File
@@ -81,7 +81,7 @@ Publiczne demo resetuje się codziennie o 3:00 czasu chińskiego (UTC+8) i przyw
<a id="features"></a>
## Macierz funkcji i zastosowań
Wszechstronny obszar roboczy łączący zbieranie inspiracji, głębokie tworzenie i gromadzenie wiedzy (👉 **[Zobacz wszystkie 17 scenariuszy ze zrzutami ekranu](docs/best-practices.md)**):
Wszechstronny obszar roboczy łączący zbieranie inspiracji, głębokie tworzenie i gromadzenie wiedzy (👉 **[Zobacz wszystkie 19 scenariuszy ze zrzutami ekranu](docs/best-practices.md)**):
| Moduł | Funkcje podstawowe | Funkcje zaawansowane |
| :--- | :--- | :--- |
@@ -325,12 +325,11 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -340,5 +339,6 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+3 -3
View File
@@ -81,7 +81,7 @@ EdgeEver 是一款现代化的开源笔记与个人知识库工作区。它保
<a id="features"></a>
## 核心功能与场景矩阵
一站式连接「灵感捕获 · 深度创作 · 知识沉淀」的全能工作区(👉 **[查看 17 个实机案例演示与最佳实践](docs/best-practices.zh-CN.md)**):
一站式连接「灵感捕获 · 深度创作 · 知识沉淀」的全能工作区(👉 **[查看 19 个实机案例演示与最佳实践](docs/best-practices.zh-CN.md)**):
| 模块 | 基础功能 | 进阶特性 |
| :--- | :--- | :--- |
@@ -329,12 +329,11 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -344,5 +343,6 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+3 -3
View File
@@ -81,7 +81,7 @@ EdgeEver 是一款現代化的開源筆記與個人知識庫工作區。它延
<a id="features"></a>
## 核心功能與場景矩陣
一站式連結「靈感擷取 · 深度創作 · 知識沉澱」的全能工作區(👉 **[查看 17 個實機案例展示與最佳實踐](docs/best-practices.zh-CN.md)**):
一站式連結「靈感擷取 · 深度創作 · 知識沉澱」的全能工作區(👉 **[查看 19 個實機案例展示與最佳實踐](docs/best-practices.zh-CN.md)**):
| 模組 | 基礎功能 | 進階特性 |
| :--- | :--- | :--- |
@@ -329,12 +329,11 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -344,5 +343,6 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+2 -1
View File
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@edgeever/public-network": "workspace:*"
"@edgeever/public-network": "workspace:*",
"content-type": "3.1.1"
}
}
+6 -2
View File
@@ -25,6 +25,7 @@ import {
MAX_IMAGE_UPLOAD_BYTES,
mapResource,
normalizeFilename,
validateRestoredResourceMimeType,
type ResourceRow,
} from "./resource-service";
import { getWorkspaceId, requireScopes, requireUser } from "./request-auth";
@@ -489,6 +490,9 @@ export const registerBackupRoutes = (
return apiError(context, "upload_too_large", "Backup resource size is invalid.", 413);
}
const mimeType = metadata.mimeType ?? file.type ?? "application/octet-stream";
validateRestoredResourceMimeType(metadata.kind, mimeType, file.size);
const filename = normalizeFilename(metadata.filename || file.name) || `${metadata.kind}-${metadata.id}`;
const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`;
const bytes = new Uint8Array(await file.arrayBuffer());
@@ -511,7 +515,7 @@ export const registerBackupRoutes = (
const destination = await resolveObjectStorage(context.env);
await destination.store.put(objectKey, bytes, {
httpMetadata: { contentType: metadata.mimeType ?? file.type ?? "application/octet-stream" },
httpMetadata: { contentType: mimeType },
customMetadata: { memoId: metadata.memoId, resourceId: metadata.id, restored: "true" },
});
@@ -547,7 +551,7 @@ export const registerBackupRoutes = (
objectKey,
destination.configId,
metadata.kind,
metadata.mimeType ?? file.type ?? null,
mimeType,
filename,
bytes.byteLength,
await dependencies.sha256Bytes(bytes),
@@ -137,6 +137,26 @@ describe("database write optimizations", () => {
sqlite.close();
});
test("persists literal tilde ranges through Markdown and JSON updates (#698)", async () => {
const { database, sqlite } = createDatabase();
try {
const memo = getSeedMemo(sqlite);
const actor = { actorType: "user", actorId: "user_owner" };
const source = "成本 1~2~3 元,10~20, 30~40";
const first = await updateMemoRecord(database, memo.workspace_id, memo.id,
{ contentMarkdown: source }, actor, "owner");
expect(first.memo.contentJson.content[0].content).toEqual([{ type: "text", text: source }]);
const second = await updateMemoRecord(database, memo.workspace_id, memo.id,
{ contentJson: first.memo.contentJson }, actor, "owner");
const stored = sqlite.query("SELECT content_json, content_markdown FROM memo_contents WHERE memo_id = ?").get(memo.id);
expect(JSON.parse(stored.content_json)).toEqual(first.memo.contentJson);
expect(markdownToDoc(stored.content_markdown)).toEqual(first.memo.contentJson);
expect(second.memo.contentJson).toEqual(first.memo.contentJson);
} finally {
sqlite.close();
}
});
test("returns an unchanged memo without revision, audit, or search writes", async () => {
const { database, sqlite } = createDatabase();
const memo = getSeedMemo(sqlite);
@@ -334,6 +334,11 @@ describe("public notebook site controls", () => {
expect(tokenHtml).toContain(`<meta name="robots" content="${PUBLIC_PAGE_ROBOTS}">`);
expect(tokenHtml).toContain(`rel="canonical" href="https://example.org/s/docs${suffix}"`);
}
// The stored zh-CN default title means "untitled"; the page falls back to the notebook name.
f.db.run("UPDATE memos SET title = ? WHERE id = 'memo_child'", ["无标题笔记"]);
const untitledHtml = await (await render("/s/docs/memo_child")).text();
expect(untitledHtml).not.toContain("无标题笔记");
expect(untitledHtml).toMatch(/<title>[^<]+ · EdgeEver<\/title>/);
await manage(f, "/memos/memo_child", { excluded: true });
const hidden = await render("/s/docs/memo_child");
expect(hidden.status).toBe(404);
+2 -1
View File
@@ -1,3 +1,4 @@
import { getMemoDisplayTitle } from "@edgeever/shared/memo-title";
import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
import type { DatabaseAdapter } from "./storage-contract";
import { getNotebookSite, publicTreeSql, resolveNotebookSiteToken } from "./notebook-site-service";
@@ -31,7 +32,7 @@ export const renderNotebookSitePage = async (
.bind(site.token, selected).first<{ title: string | null; excerpt: string }>() : null;
if (memoId && !note) status = 404;
if (status === 200) {
title = `${note?.title?.trim() || site.name} · EdgeEver`;
title = `${getMemoDisplayTitle(note?.title, site.name)} · EdgeEver`;
description = note?.excerpt || site.name;
indexable = alias && site.settings.allowIndexing;
const base = site.site_slug ? `/s/${encodeURIComponent(site.site_slug)}` : `/share/notebook/${encodeURIComponent(site.token)}`;
@@ -0,0 +1,424 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseEdgeEverZip, restoreEdgeEverZip } from "@edgeever/shared/backup-archive";
import { createEdgeEverClient } from "../../../packages/client/src/index.ts";
import { fetchEdgeEverApp } from "./index.ts";
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
const fixtures = [];
const html = "<!doctype html><p>Untrusted attachment</p>";
const png = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"));
const executionContext = { waitUntil() {}, passThroughOnException() {} };
afterEach(async () => {
for (const { database, directory, server } of fixtures.splice(0)) {
server?.stop(true);
database.close();
await rm(directory, { recursive: true, force: true });
}
});
const fixture = async () => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-701-test-"));
const database = new Database(":memory:");
const cleanup = { database, directory };
fixtures.push(cleanup);
database.exec("PRAGMA foreign_keys = ON");
const migrations = new URL("../../../migrations/", import.meta.url);
for (const name of (await readdir(migrations)).filter((name) => name.endsWith(".sql")).sort()) {
database.exec(await readFile(new URL(name, migrations), "utf8"));
}
database.run("INSERT OR IGNORE INTO workspaces (id, name, is_personal) VALUES ('ws_default', 'Personal', 1)");
const environment = {
storage: createSelfHostedStorageAdapter(database, directory),
EDGE_EVER_ALLOW_UNAUTHENTICATED: "true",
EDGE_EVER_RUNTIME: "self-hosted-bun",
};
const request = (path, init = {}) => fetchEdgeEverApp(new Request(`http://edgeever.test${path}`, init), environment, executionContext);
const json = async (path, body, method = "POST") => {
const response = await request(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const result = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(result)}`);
return result;
};
const { notebook } = await json("/api/v1/notebooks", { name: "Security regression" });
const { memo } = await json("/api/v1/memos", { notebookId: notebook.id, title: "Restore regression", contentMarkdown: "Original note" });
const metadata = (overrides = {}) => ({
id: "res_security", memoId: memo.id, originalMemoId: null, kind: "image", mimeType: "image/png",
filename: "resource.png", byteSize: png.length, sha256: null, width: 1, height: 1,
createdAt: "2026-08-31T00:00:00.000Z", updatedAt: "2026-08-31T00:00:00.000Z",
archivePath: "resources/resource.png", ...overrides,
});
const restore = (resource, bytes = png, type = resource.mimeType ?? "application/octet-stream") => {
const form = new FormData();
form.set("metadata", JSON.stringify(resource));
form.set("file", new File([bytes], resource.filename ?? "resource", { type }));
return request(`/api/v1/restores/json/resources/${resource.id}`, { method: "PUT", body: form });
};
const resourceRow = () => database.query("SELECT * FROM resources WHERE id = 'res_security'").get();
const serve = () => {
cleanup.server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => fetchEdgeEverApp(request, environment, executionContext) });
return cleanup.server.url.origin;
};
return { directory, database, environment, request, json, notebook, memo, metadata, restore, resourceRow, serve };
};
describe("restored resource security", () => {
for (const mimeType of ["text/html", "application/xhtml+xml", "image/unknown", null]) {
test(`legacy image restore rejects ${mimeType ?? "HTML file MIME fallback"} without replacing existing bytes`, async () => {
const f = await fixture();
expect((await f.restore(f.metadata())).status).toBe(200);
const previous = f.resourceRow();
const response = await f.restore(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length }), html, "text/html");
expect(response.status).toBe(415);
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
expect(f.resourceRow()).toEqual(previous);
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
});
}
for (const mimeType of ["text/html", "application/xhtml+xml"]) {
test(`resumable image restore rejects ${mimeType} before creating a multipart upload`, async () => {
const f = await fixture();
const previousObjects = (await readdir(f.directory, { recursive: true })).sort();
const response = await f.request("/api/v1/restores/json/resources/res_security/uploads", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length })),
});
expect(response.status).toBe(415);
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0);
expect(f.resourceRow()).toBeNull();
expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects);
});
}
for (const mode of ["legacy", "resumable"]) {
for (const mimeType of [null, "", "application/octet-stream"]) {
test(`${mode} preserves unknown image MIME ${JSON.stringify(mimeType)} as a download`, async () => {
const f = await fixture();
const metadata = f.metadata({ mimeType, filename: "unknown.bin" });
if (mode === "legacy") {
expect((await f.restore(metadata, png, "application/octet-stream")).status).toBe(200);
} else {
const client = createEdgeEverClient({ baseUrl: f.serve() });
await client.restoreJsonResource(metadata.id, metadata, new Blob([png]));
}
expect(f.resourceRow().mime_type).toBe(mimeType ?? "application/octet-stream");
const response = await f.request("/api/v1/resources/res_security/blob");
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(png);
});
}
}
for (const mimeType of ["application/pdf", "audio/mpeg", "video/mp4"]) {
test(`untyped attachments with ${mimeType} object metadata keep their download behavior`, async () => {
const f = await fixture();
const token = "s".repeat(43);
const notebookToken = "n".repeat(43);
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
await f.environment.storage.resources.put("legacy-attachment", Uint8Array.of(65, 66));
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
f.environment.storage.resources.get = async (...args) => {
const object = await getObject(...args);
object.writeHttpMetadata = (headers) => headers.set("Content-Type", mimeType);
return object;
};
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-attachment', 'attachment', NULL, 'untitled.bin', 2)", [f.memo.id]);
for (const path of [
"/api/v1/resources/res_security/blob",
`/api/public/shares/${token}/resources/res_security/blob`,
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
]) {
const response = await f.request(path);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe(mimeType);
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.of(65, 66));
}
});
}
test("completion rejects an incompatible restore session persisted before the fix, before assembling or overwriting its object", async () => {
const f = await fixture();
expect((await f.restore(f.metadata())).status).toBe(200);
const previous = f.resourceRow();
const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length });
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
// This is the exact persisted metadata shape accepted by the old restore path.
f.database.run("UPDATE resource_uploads SET kind = 'image', restore_metadata_json = ? WHERE id = ?", [JSON.stringify({ ...metadata, kind: "image" }), upload.id]);
const session = f.database.query("SELECT * FROM resource_uploads WHERE id = ?").get(upload.id);
const part = await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
});
expect(part.status).toBe(200);
const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" });
expect(response.status).toBe(415);
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
expect(f.resourceRow()).toEqual(previous);
expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false);
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
});
for (const mimeType of ["text/html", "application/xhtml+xml", "TEXT/HTML; charset=utf-8", null]) {
test(`legacy image rows with ${mimeType ?? "HTML object metadata"} download through authenticated and both public routes`, async () => {
const f = await fixture();
const token = "s".repeat(43);
const notebookToken = "n".repeat(43);
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
await f.environment.storage.resources.put("legacy-html", new TextEncoder().encode(html), { httpMetadata: { contentType: "text/html" } });
// Unlike R2/S3, the filesystem adapter does not persist HTTP metadata.
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
f.environment.storage.resources.get = async (...args) => {
const object = await getObject(...args);
object.writeHttpMetadata = (headers) => headers.set("Content-Type", "text/html");
return object;
};
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-html', 'image', ?, 'resource.html', ?)", [f.memo.id, mimeType, html.length]);
for (const path of [
"/api/v1/resources/res_security/blob",
`/api/public/shares/${token}/resources/res_security/blob`,
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
]) {
const response = await f.request(path);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(response.headers.get("Content-Type")).toBe(mimeType === "TEXT/HTML; charset=utf-8" ? "text/html; charset=utf-8" : mimeType ?? "text/html");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(await response.text()).toBe(html);
}
});
}
test("legacy image restores accept image MIME parameters and a valid file MIME fallback without rewriting metadata", async () => {
const f = await fixture();
for (const mimeType of ["IMAGE/PNG; charset=binary", null]) {
const response = await f.restore(f.metadata({ mimeType }), png, "image/png");
expect(response.status).toBe(200);
expect(f.resourceRow().mime_type).toBe(mimeType ?? "image/png");
const blob = await f.request("/api/v1/resources/res_security/blob");
expect(blob.headers.get("Content-Disposition")).toStartWith("inline;");
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(png);
}
});
test("HTML remains a downloadable attachment through legacy and resumable restores", async () => {
const f = await fixture();
const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length });
expect((await f.restore(metadata, html)).status).toBe(200);
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
})).status).toBe(200);
await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {});
const blob = await f.request("/api/v1/resources/res_security/blob");
expect(blob.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(await blob.text()).toBe(html);
});
test("resumable image restores validate MIME without rewriting its parameters", async () => {
const f = await fixture();
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", f.metadata({ mimeType: "IMAGE/PNG; charset=binary" }));
expect(f.database.query("SELECT mime_type FROM resource_uploads WHERE id = ?").get(upload.id).mime_type).toBe("IMAGE/PNG; charset=binary");
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(png.length) }, body: png,
})).status).toBe(200);
await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {});
expect(f.resourceRow().mime_type).toBe("IMAGE/PNG; charset=binary");
expect(new Uint8Array(await readFile(join(f.directory, f.resourceRow().object_key)))).toEqual(png);
});
test("SVG encoding parameters and bytes survive both restore paths", async () => {
const mimeType = "image/svg+xml; charset=utf-16le";
const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text>旧备份</text></svg>', "utf16le");
for (const mode of ["legacy", "resumable"]) {
const f = await fixture();
const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length });
if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200);
else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes]));
expect(f.resourceRow().mime_type).toBe(mimeType);
const response = await f.request("/api/v1/resources/res_security/blob");
expect(response.headers.get("Content-Type")).toBe(mimeType);
expect(response.headers.get("Content-Disposition")).toStartWith("inline;");
expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes));
}
});
for (const mode of ["legacy", "resumable"]) {
test(`restores a real v2.12.0-exported archive through the patched ${mode} API`, async () => {
const f = await fixture();
const fixtureDirectory = new URL("../../../tests/fixtures/restore-resource-security/", import.meta.url);
const source = JSON.parse(await readFile(new URL("source.json", fixtureDirectory), "utf8"));
const backup = await parseEdgeEverZip(new Blob([await readFile(new URL("v2.12.0.zip", fixtureDirectory))]));
expect(backup.manifest.edgeeverVersion).toBe("2.12.0");
expect(backup.manifest.buildId).toBe(source.commit);
const client = createEdgeEverClient({ baseUrl: f.serve() });
await restoreEdgeEverZip(backup, {
restoreNotebooks: client.restoreJsonNotebooks,
restoreMemos: client.restoreJsonMemos,
restorePrompts: client.restoreJsonAiPrompts,
createResourceRestoreSink: mode === "resumable" ? client.createJsonResourceRestoreSink : async (_id, metadata) => {
const chunks = [];
return {
write: async (chunk) => { chunks.push(chunk); },
close: async () => {
const response = await f.restore(metadata, await new Blob(chunks).arrayBuffer());
expect(response.status).toBe(200);
},
abort: async () => {},
};
},
});
const restoredMemo = await (await f.request("/api/v1/memos/memo_release")).json();
expect(restoredMemo.memo).toMatchObject(source.memos[0]);
const notebooks = await (await f.request("/api/v1/notebooks")).json();
expect(notebooks.notebooks.find((notebook) => notebook.id === "nb_release")).toMatchObject(source.notebooks[0]);
const revisions = await (await f.request("/api/v1/memos/memo_release/revisions")).json();
const { contentJson, ...revisionSummary } = source.revisions[0];
expect(revisions.revisions).toEqual(expect.arrayContaining([expect.objectContaining(revisionSummary)]));
expect(JSON.parse(f.database.query("SELECT content_json FROM memo_revisions WHERE id = ?").get(revisionSummary.id).content_json)).toEqual(contentJson);
for (const resource of source.resources) {
const row = f.database.query("SELECT * FROM resources WHERE id = ?").get(resource.id);
expect(row).toMatchObject({ memo_id: resource.memoId, kind: resource.kind, mime_type: resource.mimeType ?? "application/octet-stream", filename: resource.filename, byte_size: resource.byteSize, width: resource.width, height: resource.height, created_at: resource.createdAt });
const response = await f.request(resource.url);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toStartWith(resource.kind === "image" && resource.mimeType ? "inline;" : "attachment;");
if (resource.mimeType === "image/svg+xml") expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from(Buffer.from(resource.bytesBase64, "base64")));
}
expect((await (await f.request(`/api/v1/memos/${f.memo.id}`)).json()).memo.contentMarkdown).toBe("Original note");
});
}
});
const ambiguousTypes = [
["image", "image/png; charset=utf-8, text/html"],
["image", 'image/png; note="quoted, value", text/html'],
["image", "image/png,"],
["attachment", "audio/mpeg; ignored=x, text/html"],
["attachment", "video/mp4; ignored=x, application/xhtml+xml"],
["attachment", "application/pdf, text/html"],
["attachment", "not-a-mime"],
["attachment", 'text/plain; note="line\nbreak"'],
["image", 'image/png; note="ok"\r\n; charset=utf-8'],
["attachment", 'audio/mpeg; charset=utf-8; charset="line\nbreak"'],
["attachment", "\r\n"],
];
const seedSharedResource = async (f, { kind, mimeType, filename = "resource.bin", objectMimeType = "application/octet-stream", body = html }) => {
const token = "s".repeat(43);
const notebookToken = "n".repeat(43);
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
await f.environment.storage.resources.put("header-regression", bytes);
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
f.environment.storage.resources.get = async (...args) => {
const object = await getObject(...args);
object.writeHttpMetadata = (headers) => headers.set("Content-Type", objectMimeType);
return object;
};
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'header-regression', ?, ?, ?, ?)", [f.memo.id, kind, mimeType, filename, bytes.length]);
return [
"/api/v1/resources/res_security/blob",
`/api/public/shares/${token}/resources/res_security/blob`,
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
];
};
describe("single-valued resource content types", () => {
for (const [kind, mimeType] of ambiguousTypes) {
for (const mode of ["legacy", "resumable"]) {
test(`${mode} restore rejects the entire ambiguous ${mimeType} declaration`, async () => {
const f = await fixture();
const metadata = f.metadata({ kind, mimeType, filename: "resource.bin", byteSize: html.length });
const previousObjects = (await readdir(f.directory, { recursive: true })).sort();
const response = mode === "legacy"
? await f.restore(metadata, html, "application/octet-stream")
: await f.request("/api/v1/restores/json/resources/res_security/uploads", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(metadata),
});
expect(response.status).toBe(415);
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
expect(f.resourceRow()).toBeNull();
expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0);
expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects);
});
}
test(`legacy ${kind} resource with ${mimeType} downloads on all blob routes`, async () => {
const f = await fixture();
const paths = await seedSharedResource(f, { kind, mimeType });
for (const path of paths) {
const response = await f.request(path);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(await response.text()).toBe(html);
}
});
}
for (const [kind, mimeType] of [ambiguousTypes[0], ambiguousTypes[3], ambiguousTypes[ambiguousTypes.length - 2]]) {
test(`completion revalidates an old ${kind} upload with ambiguous MIME before assembly`, async () => {
const f = await fixture();
expect((await f.restore(f.metadata())).status).toBe(200);
const previous = f.resourceRow();
const metadata = f.metadata({ kind: "attachment", mimeType: "text/plain", filename: "resource.bin", byteSize: html.length });
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
f.database.run("UPDATE resource_uploads SET kind = ?, mime_type = ?, restore_metadata_json = ? WHERE id = ?", [kind, mimeType, JSON.stringify({ ...metadata, kind, mimeType }), upload.id]);
const session = f.database.query("SELECT object_key FROM resource_uploads WHERE id = ?").get(upload.id);
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
})).status).toBe(200);
const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" });
expect(response.status).toBe(415);
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
expect(f.resourceRow()).toEqual(previous);
expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false);
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
});
}
test("ambiguous object MIME and a misleading PDF filename cannot enable inline rendering", async () => {
for (const metadata of [
{ kind: "image", mimeType: null, objectMimeType: ambiguousTypes[0][1] },
{ kind: "attachment", mimeType: null, objectMimeType: ambiguousTypes[3][1] },
{ kind: "image", mimeType: ambiguousTypes[0][1], filename: "resource.pdf" },
]) {
const f = await fixture();
for (const path of await seedSharedResource(f, metadata)) {
const response = await f.request(path);
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
expect(await response.text()).toBe(html);
}
}
});
test("quoted commas, semicolons, escaped quotes and SVG charset remain one safely serialized type", async () => {
const mimeType = 'IMAGE/SVG+XML;\tcharset="utf-16le"; note="a,b; text/html; \\"quoted\\""';
const header = 'image/svg+xml; charset=utf-16le; note="a,b; text/html; \\"quoted\\""';
const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><text>旧备份</text></svg>', "utf16le");
for (const mode of ["legacy", "resumable"]) {
const f = await fixture();
const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length });
if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200);
else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes]));
expect(f.resourceRow().mime_type).toBe(mimeType);
const response = await f.request("/api/v1/resources/res_security/blob");
expect(response.headers.get("Content-Type")).toBe(header);
expect(response.headers.get("Content-Disposition")).toStartWith("inline;");
expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes));
}
});
});
+3 -18
View File
@@ -1,4 +1,4 @@
import { resolveResourceUploadMimeType, setSvgResourceSecurityHeaders, isPdfAttachment, resolvePlayableMediaMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
import { resolveResourceUploadMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
import { zValidator } from "@hono/zod-validator";
import type { Hono } from "hono";
import { auditStatement } from "./audit";
@@ -10,8 +10,7 @@ import { apiError, badRequest, notFound } from "./http-errors";
import { resolveObjectStorage } from "./object-storage";
import {
SUPPORTED_IMAGE_MIME_TYPES,
contentDispositionAttachment,
contentDispositionInline,
setResourceContentHeaders,
mapResource,
mapResourceListItem,
mapResourceStorageSummary,
@@ -294,13 +293,6 @@ export const registerResourceRoutes = (
const headers = new Headers();
object.writeHttpMetadata(headers);
const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename);
headers.set(
"Content-Type",
isPdfAttachment(resource.mime_type, resource.filename)
? "application/pdf"
: playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
);
headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600");
headers.set("Accept-Ranges", "bytes");
if (byteRange.kind === "range") {
@@ -313,14 +305,7 @@ export const registerResourceRoutes = (
} else {
headers.set("Content-Length", String(resource.byte_size));
}
headers.set(
"Content-Disposition",
resource.kind === "image" || isPdfAttachment(resource.mime_type, resource.filename) || playableMimeType
? contentDispositionInline(resource.filename)
: contentDispositionAttachment(resource.filename),
);
headers.set("X-Content-Type-Options", "nosniff");
setSvgResourceSecurityHeaders(headers);
setResourceContentHeaders(headers, resource);
return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 });
});
+56
View File
@@ -1,9 +1,13 @@
import {
SUPPORTED_IMAGE_MIME_TYPES,
isPdfAttachment,
resolvePlayableMediaMimeType,
setSvgResourceSecurityHeaders,
type Resource,
type ResourceListItem,
type ResourceStorageSummary,
} from "@edgeever/shared";
import { format as formatContentType, parse as parseContentType } from "content-type";
import { auditStatement } from "./audit";
import type { AppContext, AuditActor, Bindings } from "./api-context";
import { AppError } from "./app-error";
@@ -105,6 +109,30 @@ export const validateImageUpload = (mimeType: string, size: number) => {
}
};
// HTTP field bytes: HTAB, visible ASCII and obs-text, never other controls or DEL.
const HTTP_FIELD_VALUE_PATTERN = /^[\t\u0020-\u007e\u0080-\u00ff]*$/;
const parseResourceContentType = (value: string) => {
if (!HTTP_FIELD_VALUE_PATTERN.test(value)) return null;
if (!value.trim()) return { type: "application/octet-stream", header: "application/octet-stream" };
try {
// A top-level comma ends the first type. Never silently accept the remainder.
const parsed = parseContentType(value, { comma: true });
if (parsed.index !== value.length) return null;
return { type: parsed.type, header: formatContentType(parsed) };
} catch {
return null;
}
};
export const validateRestoredResourceMimeType = (kind: Resource["kind"], mimeType: string, size: number) => {
const parsed = parseResourceContentType(mimeType);
if (!parsed) {
throw new AppError("unsupported_media_type", "Resource MIME type must contain a single valid media type.", 415);
}
if (kind === "image" && parsed.type !== "application/octet-stream") validateImageUpload(parsed.type, size);
};
export const deleteReleasedResourceObjects = async (
env: Bindings,
resources: { objectKey: string; storageConfigId: string }[],
@@ -472,3 +500,31 @@ const contentDisposition = (kind: "inline" | "attachment", filename: string | nu
export const contentDispositionInline = (filename: string | null) => contentDisposition("inline", filename);
export const contentDispositionAttachment = (filename: string | null) => contentDisposition("attachment", filename);
/** One policy for both the browser MIME and the decision to render it inline. */
export const setResourceContentHeaders = (
headers: Headers,
resource: Pick<ResourceRow, "kind" | "mime_type" | "filename">,
) => {
const sourceMimeType = resource.mime_type ?? headers.get("Content-Type") ?? "";
const parsed = parseResourceContentType(sourceMimeType);
let contentType = "application/octet-stream";
let inline = false;
// Invalid legacy metadata must not be rescued into a preview by its filename.
if (parsed) {
const pdf = isPdfAttachment(resource.mime_type, resource.filename);
const media = resolvePlayableMediaMimeType(resource.mime_type ? parsed.type : null, resource.filename);
if (pdf) contentType = "application/pdf";
else if (media) contentType = resource.mime_type && parsed.type === media ? parsed.header : media;
else contentType = parsed.header;
inline = pdf || Boolean(media)
|| (resource.kind === "image" && SUPPORTED_IMAGE_MIME_TYPES.has(parsed.type));
}
headers.set("Content-Type", contentType);
headers.set("Content-Disposition", inline ? contentDispositionInline(resource.filename) : contentDispositionAttachment(resource.filename));
headers.set("X-Content-Type-Options", "nosniff");
setSvgResourceSecurityHeaders(headers);
};
+20 -8
View File
@@ -17,6 +17,7 @@ import {
MAX_ATTACHMENT_UPLOAD_BYTES,
MAX_IMAGE_UPLOAD_BYTES,
normalizeFilename,
validateRestoredResourceMimeType,
validateAttachmentUpload,
validateImageUpload,
} from "./resource-service";
@@ -185,6 +186,8 @@ export const initiateResourceRestoreUpload = async (
if (!Number.isSafeInteger(metadata.byteSize) || metadata.byteSize <= 0 || metadata.byteSize > maxBytes) {
throw new AppError("upload_too_large", "Backup resource size is invalid.", 413);
}
const mimeType = metadata.mimeType?.trim() || "application/octet-stream";
validateRestoredResourceMimeType(metadata.kind, metadata.mimeType ?? mimeType, metadata.byteSize);
const workspaceId = getWorkspaceId(context);
const memo = await context.env.storage.db.prepare(
@@ -207,7 +210,6 @@ export const initiateResourceRestoreUpload = async (
const uploadId = createId("upload");
const filename = normalizeFilename(metadata.filename || "") || `${metadata.kind}-${metadata.id}`;
const mimeType = metadata.mimeType?.trim() || "application/octet-stream";
const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`;
const destination = await resolveObjectStorage(context.env);
const multipart = await destination.store.createMultipartUpload(
@@ -300,11 +302,7 @@ export const uploadResourcePart = async (
return { partNumber: part.partNumber, byteSize };
};
const completeRestoredResourceUpload = async (
context: AppContext,
upload: ResourceUploadRow,
actor: AuditActor,
): Promise<Resource> => {
const parseRestoredResourceMetadata = (upload: ResourceUploadRow): JsonBackupResource => {
let metadataInput: unknown;
try {
metadataInput = JSON.parse(upload.restore_metadata_json ?? "null");
@@ -316,6 +314,18 @@ const completeRestoredResourceUpload = async (
throw new AppError("invalid_restore_resource", "Restore resource metadata is invalid.", 400);
}
const metadata = parsed.data as JsonBackupResource;
validateRestoredResourceMimeType(
metadata.kind, metadata.mimeType ?? upload.mime_type, upload.byte_size,
);
return metadata;
};
const completeRestoredResourceUpload = async (
context: AppContext,
upload: ResourceUploadRow,
actor: AuditActor,
metadata: JsonBackupResource,
): Promise<Resource> => {
const originalMemo = metadata.originalMemoId
? await context.env.storage.db.prepare(
"SELECT id FROM memos WHERE id = ? AND workspace_id = ? LIMIT 1",
@@ -430,12 +440,14 @@ export const completeResourceUpload = async (
throw new AppError("upload_incomplete", "Resource upload size does not match the expected file size.", 409);
}
// Revalidate sessions persisted by an older server before assembling any bytes.
const restoredMetadata = upload.restore_metadata_json ? parseRestoredResourceMetadata(upload) : null;
const destination = await resolveObjectStorage(context.env, upload.storage_config_id);
const multipart = destination.store.resumeMultipartUpload(upload.object_key, upload.provider_upload_id);
await multipart.complete(parts);
if (upload.restore_metadata_json) {
return completeRestoredResourceUpload(context, upload, actor);
if (restoredMetadata) {
return completeRestoredResourceUpload(context, upload, actor, restoredMetadata);
}
const now = isoNow();
+13
View File
@@ -174,6 +174,19 @@ describe("public memo share pages", () => {
expect(await lockedMarkdown.text()).not.toContain("secret()");
});
test("treats the stored default title as untitled in metadata and markdown", async () => {
const sqlite = createDatabase();
sqlite.query("UPDATE memos SET title = ? WHERE id = 'memo_source'").run("无标题笔记");
sqlite.query("UPDATE memo_contents SET content_markdown = ? WHERE memo_id = 'memo_source'").run("Body text\n");
const html = await (await render(sqlite, `/share/${token}`)).text();
expect(html).not.toContain("无标题笔记");
expect(html).toContain("<title>Shared note · EdgeEver</title>");
expect(html).toContain('<meta property="og:title" content="Shared note">');
expect(html).toContain('<meta name="twitter:title" content="Shared note">');
expect(html).toContain("Body text");
expect(await (await render(sqlite, `/share/${token}.md`)).text()).toBe("Body text\n");
});
test("HEAD keeps the headers and omits the body", async () => {
const response = await renderPublicMemoSharePage(
new Request(`https://note.example/share/${token}.md`, { method: "HEAD" }),
+4 -2
View File
@@ -1,3 +1,4 @@
import { getMemoDisplayTitle } from "@edgeever/shared/memo-title";
import { isValidMemoShareSlug } from "@edgeever/shared/share-slug";
import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
import type { DatabaseAdapter } from "./storage-contract";
@@ -80,7 +81,8 @@ export const PUBLIC_SHARE_MARKDOWN_PASSWORD = "This shared note requires a passw
/** Markdown body for a public note. A title already used as the first heading is kept once. */
export const publicShareMarkdown = (title: string | null, body: string) => {
const heading = (title ?? "").replace(/[\r\n]+/g, " ").trim();
// The stored zh-CN default title means "untitled"; it is not a heading.
const heading = getMemoDisplayTitle(title, "").replace(/[\r\n]+/g, " ").trim();
if (!heading) return body;
const firstLine = body.split(/\r?\n/, 1)[0] ?? "";
if (firstLine.replace(/^#\s+/, "").trim() === heading) return body;
@@ -153,7 +155,7 @@ export const renderPublicMemoSharePage = async (
return new Response(request.method === "HEAD" ? null : publicShareMarkdown(row.title, body), { status: 200, headers });
}
const title = readable ? (row?.title?.replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note";
const title = readable ? (getMemoDisplayTitle(row?.title, "").replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note";
const description = readable
? describeNote(body)
: locked
+3 -17
View File
@@ -1,4 +1,4 @@
import { setSvgResourceSecurityHeaders, accountNoteProseFromRow, collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
import { accountNoteProseFromRow, collectMemoLinkIds, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
import { zValidator } from "@hono/zod-validator";
import type { Hono } from "hono";
import type { AppContext, AppEnv } from "./api-context";
@@ -9,7 +9,7 @@ import { createId, isoNow, parseJsonArray } from "./entity-utils";
import { apiError, notFound } from "./http-errors";
import { resolveObjectStorage } from "./object-storage";
import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth";
import { contentDispositionAttachment, contentDispositionInline } from "./resource-service";
import { setResourceContentHeaders } from "./resource-service";
import {
isShareUnlockBlocked,
nextShareUnlockFailure,
@@ -74,11 +74,6 @@ const mapMemoShare = (row: MemoShareRow, password?: string): MemoShare => ({
...(password ? { password } : {}),
});
const contentDisposition = (kind: SharedResourceRow["kind"], mimeType: string | null, filename: string | null) => {
const inline = kind === "image" || isPdfAttachment(mimeType, filename) || Boolean(resolvePlayableMediaMimeType(mimeType, filename));
return inline ? contentDispositionInline(filename) : contentDispositionAttachment(filename);
};
const resolveShareToken = (c: AppContext, value: string) => resolveMemoShareToken(c.env.storage.db, value);
const shareSlugTaken = (c: AppContext) =>
@@ -141,13 +136,6 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc
const headers = new Headers();
object.writeHttpMetadata(headers);
const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename);
headers.set(
"Content-Type",
isPdfAttachment(resource.mime_type, resource.filename)
? "application/pdf"
: playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
);
headers.set("Accept-Ranges", "bytes");
if (byteRange.kind === "range") {
const length = object.range?.length ?? byteRange.range.length;
@@ -159,10 +147,8 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc
} else {
headers.set("Content-Length", String(resource.byte_size));
}
headers.set("Content-Disposition", contentDisposition(resource.kind, resource.mime_type, resource.filename));
setResourceContentHeaders(headers, resource);
headers.set("Cache-Control", "private, no-store");
headers.set("X-Content-Type-Options", "nosniff");
setSvgResourceSecurityHeaders(headers);
headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 });
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/desktop",
"version": "2.12.0",
"version": "2.14.0",
"desktopName": "EdgeEver",
"private": true,
"description": "Native EdgeEver desktop client",
@@ -209,6 +209,7 @@ test("manager creates named files, restores from history and deletes only the se
});
cleanups.push(() => manager.close());
await manager.setContext({ baseUrl: source.baseUrl, accountId: "local", token: "" });
await manager.save({ ...manager.status().config, retention: 3 });
const id = manager.status().backupSetId;
for (const encrypted of [false, true]) {
await manager.run(encrypted ? "fixture password" : undefined, id, undefined, { name: encrypted ? "Encrypted snapshot.zip" : "Plain snapshot", encrypted });
+39 -1
View File
@@ -22,7 +22,7 @@ test("publishes only completed jobs, retains registered files and isolates accou
const f = await fixture();
try {
let status = f.manager.status();
expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(5);
expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(1);
await f.manager.save({ ...status.config, retention: 3 });
for (let i = 0; i < 4; i++) { await f.manager.run(); await complete(f); }
status = f.manager.status(); expect(status.history).toHaveLength(3);
@@ -38,6 +38,41 @@ test("publishes only completed jobs, retains registered files and isolates accou
} finally { await f.cleanup(); }
});
test("default retention keeps the latest successful archive through failures and reloads", async () => {
const f = await fixture();
try {
await f.manager.run(); await complete(f);
const first = f.manager.status().history[0];
await f.manager.run();
f.workers.at(-1).emit("message", { type: "error", error: { code: "backup_source_changed", message: "Changed" } });
await f.manager.waitForIdle();
expect(f.manager.status().history).toEqual([first]);
expect(await readFile(join(f.manager.status().actualDirectory, first.name), "utf8")).toBe("complete archive");
await f.manager.run(); await complete(f);
const status = f.manager.status();
expect(status.history).toHaveLength(1);
expect(status.history[0].name).not.toBe(first.name);
expect((await readdir(status.actualDirectory)).filter((name) => name.endsWith(".zip"))).toEqual([status.history[0].name]);
await f.manager.setContext(null);
await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" });
expect(f.manager.status().config.retention).toBe(1);
expect(f.manager.status().history).toEqual(status.history);
} finally { await f.cleanup(); }
});
test("loading an existing configuration preserves the previous default of five copies", async () => {
const f = await fixture();
try {
const record = JSON.parse(await readFile(f.manager.statePath(), "utf8"));
record.config.retention = 5;
await writeFile(f.manager.statePath(), JSON.stringify(record));
await f.manager.setContext(null);
await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" });
expect(f.manager.status().config).toEqual(record.config);
expect(f.manager.status().error).toBeNull();
} finally { await f.cleanup(); }
});
test("failure and cancellation preserve success and never rotate; credentials stay out of config", async () => {
const f = await fixture();
try {
@@ -451,6 +486,7 @@ const finishSnapshot = async (f, result = {}) => {
test('named manual backups preserve formats/settings and never overwrite a collision', async () => {
const f = await fixture();
try {
await f.manager.save({ ...f.manager.status().config, retention: 5 });
await f.manager.save({ ...f.manager.status().config, encrypted: true }, 'saved');
const id = f.manager.status().backupSetId;
await f.manager.run(undefined, id, undefined, { name: ' Travel.zip ', encrypted: false });
@@ -481,6 +517,7 @@ test('named manual backups preserve formats/settings and never overwrite a colli
test('history restore/delete are scoped, reject replacements and remove only the selected file', async () => {
const f = await fixture();
try {
await f.manager.save({ ...f.manager.status().config, retention: 5 });
await f.manager.run(); await complete(f);
await f.manager.run(); await complete(f);
const s = f.manager.status(); const [first, second] = s.history;
@@ -508,6 +545,7 @@ test('history restore/delete are scoped, reject replacements and remove only the
test('automatic no-change advances checks, not archive time/retention, and invalidates its baseline correctly', async () => {
const f = await fixture();
try {
await f.manager.save({ ...f.manager.status().config, retention: 5 });
await f.manager.save({ ...f.manager.status().config, enabled: true, interval: 'daily' });
await f.manager.run(); await finishSnapshot(f);
const first = f.manager.status(); const due = Date.parse(first.nextDueAt);
+2 -2
View File
@@ -1,9 +1,9 @@
export const BACKUP_INTERVALS = ["daily", "weekly", "biweekly", "monthly"];
export const BACKUP_RETENTION = [3, 5, 10, 20];
export const BACKUP_RETENTION = [1, 3, 5, 10, 20];
export const backupError = (code, message) => Object.assign(new Error(message), { code });
export const normalizeBackupConfig = (input, directory) => {
if (input === undefined) return { enabled: false, interval: "weekly", retention: 5, directory, encrypted: false };
if (input === undefined) return { enabled: false, interval: "weekly", retention: 1, directory, encrypted: false };
if (!input || typeof input.enabled !== "boolean" || typeof input.encrypted !== "boolean"
|| !BACKUP_INTERVALS.includes(input.interval) || !BACKUP_RETENTION.includes(input.retention)
|| typeof input.directory !== "string" || !input.directory) throw backupError("backup_invalid_configuration", "The backup configuration is invalid.");
@@ -13,7 +13,10 @@ test("calendar schedules clamp month end and preserve successful history when fr
test("accepts the specified defaults and choices, rejects invalid configuration", () => {
const defaults = normalizeBackupConfig(undefined, "/Documents/EdgeEver Backups");
expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 5, directory: "/Documents/EdgeEver Backups", encrypted: false });
expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 1, directory: "/Documents/EdgeEver Backups", encrypted: false });
for (const retention of [1, 3, 5, 10, 20]) {
expect(normalizeBackupConfig({ ...defaults, retention }, defaults.directory).retention).toBe(retention);
}
expect(() => normalizeBackupConfig({ ...defaults, interval: "hourly" }, defaults.directory)).toThrow();
expect(() => normalizeBackupConfig({ ...defaults, retention: 4 }, defaults.directory)).toThrow();
expect(() => normalizeBackupConfig({ ...defaults, directory: "" }, defaults.directory)).toThrow();
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from 'bun:test';
import { createDocument, createWindow } from '@mixmark-io/domino';
import { runInNewContext } from 'node:vm';
import { bundleTestScripts } from './bundle-test-scripts.mjs';
import { readMetaSocial, metaPostCard } from './src/social-clip.ts';
import { platformTarget } from './src/platform-clip.ts';
const url = 'https://www.instagram.com/p/SelectedPost/';
const source = 'https://www.instagram.com/';
const media = { code: 'SelectedPost', user: { username: 'author' }, caption: { text: 'A full caption beyond the preview' }, taken_at: 1700000000,
media_type: 8, carousel_media_count: 3, carousel_media: [1,2,3].map(i => ({ media_type: 1, image_versions2: { candidates: [{url:`https://images.example.test/${i}.jpg`,width:1000,height:1000}] } })) };
const json = value => `<script type="application/json">${JSON.stringify(value)}</script>`;
const feed = `<article><a href="/author/">author</a><a href="/p/SelectedPost/"><time datetime="2026-10-08T08:40:23Z">2 weeks</time></a><img src="https://images.example.test/1.jpg"><p>A full caption... <button>more</button></p><button>Like</button><button>Share</button></article>`;
const [bundle] = bundleTestScripts(['capture-platform']);
async function capture({ html=feed, data=media, status=200, change, contentType='text/html', responseUrl=url, sourceUrl=source }={}) {
const w=createWindow(html,sourceUrl), location={href:sourceUrl};
Object.getPrototypeOf(w.document.querySelectorAll('*'))[Symbol.iterator] ??= Array.prototype[Symbol.iterator];
const calls=[];
let done;
const result = new Promise(resolve=>{done=resolve;});
runInNewContext(bundle, { window:w,document:w.document,location,URL,AbortSignal,setTimeout,clearTimeout,setInterval,clearInterval,
DOMParser:class {parseFromString(html){return createDocument(html);}},
__edgeeverPlatformClipPayload:{requestId:'instagram',url,sourceUrl},
fetch:async (request,init)=>{calls.push({url:request,init});change?.(w.document,location);return {ok:status===200,url:responseUrl,headers:new Headers({'content-type':contentType}),text:async()=>json(data)};},
chrome:{i18n:{getMessage:k=>k},runtime:{sendMessage:async message=>done(message.result)}},
});
return {result:await result,calls};
}
test('Instagram dynamic feed fetches only the selected same-origin detail and keeps all carousel images',async()=>{
const {result,calls}=await capture();
expect(result.ok).toBe(true);expect(result.clip.url).toBe(url);expect(result.clip.author).toBe('author');
expect(result.clip.markdown).toContain(media.caption.text);expect(result.clip.markdown.match(/!\[/g)).toHaveLength(3);
expect(calls).toHaveLength(1);expect(calls[0].url).toBe(url);
expect(calls[0].init.credentials).toBe('same-origin');expect(calls[0].init.redirect).toBe('error');
});
for (const failure of ['wrong-post','login','redirect','http','not-html','incomplete','navigation','recycled','hidden','no-card']) test(`Instagram detail fallback rejects ${failure}`,async()=>{
const {result,calls}=await capture({
data:failure==='wrong-post'?{...media,code:'OtherPost'}:failure==='login'?{login:true}:failure==='incomplete'?{...media,carousel_media:media.carousel_media.slice(0,1)}:media,
status:failure==='http'?403:200,contentType:failure==='not-html'?'application/json':'text/html',
responseUrl:failure==='redirect'?'https://www.instagram.com/accounts/login/':url,
html:failure==='hidden'?feed.replace('<article>','<article hidden>'):failure==='no-card'?'<main>Log in</main>':feed,
change:(doc,location)=>{if(failure==='navigation')location.href='https://www.instagram.com/p/OtherPost/';if(failure==='recycled')doc.querySelector('a[href="/p/SelectedPost/"]').setAttribute('href','/p/OtherPost/');},
});
expect(result.ok).toBe(false);expect(result.clip).toBeUndefined();
expect(calls).toHaveLength(['hidden','no-card'].includes(failure)?0:1);
});
test('Instagram fallback preserves the current host, including the non-www origin',async()=>{
const {result,calls}=await capture({sourceUrl:'https://instagram.com/',responseUrl:'https://instagram.com/p/SelectedPost/'});
expect(result.ok).toBe(true);expect(calls[0].url).toBe('https://instagram.com/p/SelectedPost/');
});
test('Instagram detail card includes media and caption rather than the likes footer or comments',()=>{
const doc=createDocument(`<main><div id="post"><img src="https://images.example.test/1.jpg"><div><a href="/author/">author</a><p>${media.caption.text}</p></div><div id="footer"><a href="/liker/"><img src="https://images.example.test/avatar.jpg">liker</a><button>Like</button><button>Share</button><button>Save</button><a href="/author/p/SelectedPost/"><time datetime="2026-10-08T08:40:23Z">October 8</time></a></div></div><div>COMMENT SENTINEL</div></main>${json(media)}`);
expect(metaPostCard(doc.querySelector('time').parentElement,'instagram',url).id).toBe('post');
const result=readMetaSocial(doc,platformTarget(url));
expect(result.ok).toBe(true);expect(result.clip.markdown).toContain(media.caption.text);
expect(result.clip.markdown).not.toContain('COMMENT SENTINEL');
});
+1 -1
View File
@@ -9,7 +9,7 @@
"dev": "vite --config vite.config.ts",
"lint:firefox": "web-ext lint --source-dir dist-firefox --output text",
"package:firefox": "bun run build:firefox && bun run lint:firefox && web-ext build --source-dir dist-firefox --artifacts-dir web-ext-artifacts --overwrite-dest",
"test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
"test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs instagram-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
},
"dependencies": {
"@mixmark-io/domino": "2.2.0",
+4
View File
@@ -6,6 +6,7 @@ import { readXiaoyuzhou } from "./xiaoyuzhou-clip";
import { readBoss } from "./boss-clip";
import { readXueqiu } from "./xueqiu-clip";
import { readXueqiuFeedPost } from "./xueqiu-feed";
import { readInstagramDetail } from "./instagram-clip";
import { readArticle } from "./article-clip";
import { readJike, jikeExpandButton, jikePostCard } from "./jike-clip";
import { readDouban } from "./douban-clip";
@@ -68,6 +69,9 @@ import { platformHtmlMarkdown, platformTarget, type PlatformLabels, type Platfor
}
if(expected.platform === "xueqiu" && !platformTarget(location.href)) return readXueqiuFeedPost(document,expected,sourceUrl);
const initial = read();
if (expected.platform === "instagram" && !initial.ok && (initial.reason === "unreadable" || initial.reason === "incomplete")) {
return readInstagramDetail(document, expected, sourceUrl, samePage);
}
if (!initial.ok && initial.reason === "incomplete" && ["facebook", "linkedin", "jike"].includes(expected.platform)) {
const card = expected.platform === "jike" ? jikePostCard(document,expected,location.href) : socialPostCard(document,expected);
const expand = expected.platform === "jike" ? jikeExpandButton(document,card) : expected.platform === "facebook" ? facebookExpandButton(card) : linkedinExpandButton(card);
+34
View File
@@ -0,0 +1,34 @@
import { instagramPostCard, readMetaSocial } from "./social-clip";
import { platformTarget, type PlatformRead, type PlatformTarget } from "./platform-clip";
/** Feed items loaded after navigation are absent from the document's JSON.
* Read only the selected post's same-origin HTML, without executing its scripts. */
export const readInstagramDetail = async (
doc: Document, target: PlatformTarget, sourceUrl: string, samePage: () => boolean,
): Promise<PlatformRead> => {
const source = new URL(sourceUrl);
if (target.platform !== "instagram" || source.protocol !== "https:"
|| !["www.instagram.com", "instagram.com"].includes(source.hostname)) return { ok: false, reason: "not-target" };
const card = instagramPostCard(doc, target);
if (!card) return { ok: false, reason: "unreadable" };
const current = () => samePage() && doc.contains(card) && instagramPostCard(doc, target) === card;
try {
const url = new URL(`/p/${target.id}/`, source);
const response = await fetch(url.href, {
credentials: "same-origin", redirect: "error", headers: { Accept: "text/html" },
signal: AbortSignal.timeout(8000),
});
if (!current()) return { ok: false, reason: "changed" };
if (!response.ok || !response.headers.get("content-type")?.includes("text/html")
|| Number(response.headers.get("content-length")) > 10_000_000) return { ok: false, reason: "unreadable" };
if (response.url && (new URL(response.url).origin !== source.origin
|| platformTarget(response.url)?.url !== target.url)) return { ok: false, reason: "changed" };
const html = await response.text();
if (!current()) return { ok: false, reason: "changed" };
if (html.length > 10_000_000) return { ok: false, reason: "unreadable" };
const detail = new DOMParser().parseFromString(html, "text/html");
return readMetaSocial(doc, target, detail);
} catch {
return { ok: false, reason: current() ? "unreadable" : "changed" };
}
};
+25 -4
View File
@@ -235,6 +235,12 @@ export const readBluesky = (doc: Document, target: PlatformTarget): PlatformRead
return saved;
};
const instagramCardHasMedia = (card: Element, base: string) => Array.from(card.querySelectorAll('img[src], video')).some(media => {
if (platformElementHidden(media)) return false;
const link = media.closest('a[href]');
return !link || platformTarget(postLinkUrl(link, base))?.platform === "instagram";
});
/** Bound Meta posts by their timestamp header and rendered action controls. */
export const metaPostCard = (link: Element, platform: string, base: string): Element | null => {
if (!link.querySelector("time[datetime]") || link.closest("[hidden], [aria-hidden='true'], script, style, template")) return null;
@@ -257,7 +263,10 @@ export const metaPostCard = (link: Element, platform: string, base: string): Ele
return socialPlatform(url.href) === platform && (platform === "threads" ? /^\/@[^/]+\/?$/.test(url.pathname) : /^\/[^/]+\/?$/.test(url.pathname));
} catch { return false; }
});
if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3)) return card;
// Instagram's detail timestamp lives in the action footer. Keep walking
// until the card also contains the media, rather than returning that footer.
if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3
&& (platform !== "instagram" || instagramCardHasMedia(card, base)))) return card;
}
return null;
};
@@ -313,8 +322,18 @@ const mediaImages = (item: RecordValue, base: string): string[] | null => {
}
return images.filter(Boolean);
};
export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformRead => {
const media = metaMedia(doc, target);
export const instagramPostCard = (doc: Document, target: PlatformTarget): Element | null => {
if (target.platform !== "instagram") return null;
for (const link of ownPostLinks(doc.documentElement, "instagram")) {
if (!sameTarget(postLinkUrl(link, target.url), target)) continue;
const card = metaPostCard(link, "instagram", target.url);
if (card && !platformElementHidden(card) && instagramCardHasMedia(card, target.url)) return card;
}
return null;
};
export const readMetaSocial = (doc: Document, target: PlatformTarget, detail?: Document): PlatformRead => {
const media = metaMedia(detail || doc, target);
if (!media) return { ok: false, reason: "unreadable" };
const username = text(record(media.user)?.username);
if (target.platform === "threads" && username && username.toLowerCase() !== target.id.split("/")[0]) return { ok:false, reason:"changed" };
@@ -324,7 +343,9 @@ export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformR
if (target.platform === "instagram" && Array.isArray(media.carousel_media)) {
captions.push(...media.carousel_media.map((slide) => text(record(record(slide)?.caption)?.text)));
}
if (!readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
// A fetched, identity-matched detail supplies the full caption for a visible
// feed preview. Do not require that preview to contain the untruncated text.
if (detail ? !instagramPostCard(doc, target) : !readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
const slides = Array.from(new Set(captions.slice(1).filter(Boolean)));
const combined = slides.join("\n\n");
const normalized = (value: string) => value.replace(/\s+/g,"");
+1 -1
View File
@@ -8,5 +8,5 @@
// CI_BUILD_NUMBER into the archive when present.
// Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0).
// Build 49 is reserved for the 1.74.0 App Store submission.
MARKETING_VERSION = 2.12.0
MARKETING_VERSION = 2.14.0
CURRENT_PROJECT_VERSION = 49
+2 -2
View File
@@ -812,7 +812,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 2.12.0;
MARKETING_VERSION = 2.14.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.12.0;
MARKETING_VERSION = 2.14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
File diff suppressed because it is too large. Load diff
+541 -541
View File
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,12 @@ const iosMarkdownManager = createEdgeEverMarkdownManager({
});
describe("iOS document nodes share the web schema", () => {
test("keeps literal ranges and double-tilde strike consistent with the shared codec (#698)", () => {
for (const source of ["成本 1~2~3 元", "10~20, 30~40", "~~a~b~~", "~a~~b~~c~"]) {
expect(iosMarkdownManager.parse(source)).toEqual(markdownToDoc(source));
}
});
test("uses the shared MergeDivider implementation", () => {
expect(MergeDivider).toBe(SharedMergeDivider);
});
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "2.12.0",
"version": "2.14.0",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -32,7 +32,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 249,
"versionCode": 251,
"adaptiveIcon": {
"backgroundColor": "#16A06E",
"backgroundImage": "./assets/icon.png",
+114 -22
View File
@@ -18,6 +18,10 @@ import zhihuClipImg from "../../../../docs/assets/best-practices/original-image/
import zhihuInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/zhihu/zhihu-in-note.png";
import redditClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/reddit/reddit.png";
import redditInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/reddit/reddit-in-note.png";
import insClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/instgram/ins.png";
import insInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/instgram/ins-in-note.png";
import githubClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/github/github.png";
import githubInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/github/github-in-note.png";
import aiMindmapImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-mindmap.png";
import aiFlowchartImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-flowchart.png";
import aiArchitectureImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-architecture.png";
@@ -46,6 +50,8 @@ export type ShowcaseCaseId =
| "twitter"
| "zhihu"
| "reddit"
| "instagram"
| "github"
| "ai-diagrams"
| "ai-infographics"
| "ai-database"
@@ -96,6 +102,14 @@ export const showcaseCaseAssets: Record<ShowcaseCaseId, ShowcaseCaseAssetConfig>
layout: "stack",
images: [redditClipImg, redditInNoteImg],
},
instagram: {
layout: "stack",
images: [insClipImg, insInNoteImg],
},
github: {
layout: "stack",
images: [githubClipImg, githubInNoteImg],
},
"ai-diagrams": {
layout: "stack",
images: [aiMindmapImg, aiFlowchartImg, aiArchitectureImg],
@@ -350,6 +364,45 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
],
},
{
id: "instagram",
number: "09",
title: "Instagram 帖子与轮播多图剪藏",
lead: "使用 EdgeEver Web Clipper 插件在 Instagram 帖子或轮播页面右键点击「保存帖子到 EdgeEver」。",
bullets: [
"提取作者账号、正文描述、发布时间与原文链接,保留话题标签与提及元数据。",
"多图轮播(Carousel)内容完整提取并自动排版为多栏画廊。",
],
media: [
{
caption: "1. 右键保存 Instagram 帖子",
alt: "右键保存 Instagram 帖子到 EdgeEver",
},
{
caption: "2. 包含完整图集与元数据的笔记效果",
alt: "EdgeEver 剪藏后的 Instagram 帖子笔记效果",
},
],
},
{
id: "github",
number: "10",
title: "GitHub 仓库元数据剪藏",
lead: "在 GitHub 仓库页面右键点击「保存仓库信息到 EdgeEver」。",
bullets: [
"提取仓库名称(Owner/Repo)、简介、官方主页、编程语言、许可证与话题标签,生成结构化技术卡片。",
],
media: [
{
caption: "1. 右键保存仓库信息",
alt: "右键保存 GitHub 仓库信息到 EdgeEver",
},
{
caption: "2. 结构化仓库卡片预览",
alt: "EdgeEver 剪藏后的 GitHub 仓库卡片笔记",
},
],
},
],
},
{
@@ -360,7 +413,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "ai-diagrams",
number: "09",
number: "11",
title: "AI 对话生成思维导图、流程图与架构图",
lead: "在 AI 伴随助手(或连接的本地 Agent)中输入自然语言指令,在笔记内直接生成可交互图表。",
bullets: [
@@ -383,7 +436,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "ai-infographics",
number: "10",
number: "12",
title: "AI 生成结构化信息图(时间线、对比矩阵、象限图等)",
lead: "利用内置模板库,在 AI 助手对话中描述要呈现的主题与重点,快速生成结构化视觉笔记。",
bullets: [
@@ -403,7 +456,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "ai-database",
number: "11",
number: "13",
title: "AI 对话构建多维表格",
lead: "在 AI 助手中发送自然语言指令(如“生成自媒体选题库”或“生成员工信息登记表”),自动搭建多维数据表。",
bullets: [
@@ -423,7 +476,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "database-form",
number: "12",
number: "14",
title: "多维表格生成公开收集表单",
lead: "在多维表格顶部点击「表单收集」,将当前数据表转为公开在线表单。",
bullets: [
@@ -443,7 +496,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "acp-agent",
number: "13",
number: "15",
title: "连接本机 Agent (ACP) 实现笔记协同",
lead: "EdgeEver 支持 ACP (Agent Client Protocol) 协议,允许桌面端连接本机运行的各类 AI Agent(如 Codex、Claude Code、DeepSeek Harness、Antigravity 等)。",
bullets: [
@@ -471,7 +524,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "note-export-image",
number: "14",
number: "16",
title: "笔记导出多主题长图与代码卡片",
lead: "使用「分享为图片」功能,将笔记排版为高分辨率长图或卡片。",
bullets: [
@@ -491,7 +544,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "poster-studio",
number: "15",
number: "17",
title: "笔记制作社交媒体海报与封面",
lead: "在笔记操作菜单中选择「制作海报」(或通过「新建 → 海报」),将笔记标题与要点导入海报设计工作台。",
bullets: [
@@ -507,7 +560,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
},
{
id: "wechat-mp-copy",
number: "16",
number: "18",
title: "复制笔记至微信公众号排版",
lead: "在笔记菜单中选择「复制到公众号 / 博客」,将笔记转换为内联 CSS 样式的富文本。",
bullets: [
@@ -534,7 +587,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "plugin-ecosystem",
number: "17",
number: "19",
title: "开放插件 API 与自定义插件开发",
lead: "EdgeEver 内置插件扩展管理,支持通过 GitHub 仓库或 Manifest 地址一键安装,配置在 Web 与桌面端跨设备隔离同步。",
bullets: [
@@ -745,6 +798,45 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
],
},
{
id: "instagram",
number: "09",
title: "Instagram Post & Carousel Clipping",
lead: "Right-click any Instagram post or carousel and select \"Save Post to EdgeEver\" with EdgeEver Web Clipper.",
bullets: [
"Extracts author handle, caption text, timestamp, and source URL while preserving hashtags and mention metadata.",
"Captures full multi-image carousel sets, formatted automatically into a clean multi-column gallery layout.",
],
media: [
{
caption: "1. Right-Click Save Instagram Post",
alt: "Right-click save Instagram post to EdgeEver",
},
{
caption: "2. Note with Metadata & Multi-Image Gallery",
alt: "Instagram post clipped into EdgeEver note",
},
],
},
{
id: "github",
number: "10",
title: "GitHub Repository Metadata Clipping",
lead: "Right-click anywhere on the GitHub repository page and select \"Save Repository Info to EdgeEver\".",
bullets: [
"Extracts repository name (Owner/Repo), description, homepage, language, license, and topic tags into a technical card note.",
],
media: [
{
caption: "1. Right-Click Save Repository Info",
alt: "Right-click save GitHub repo info to EdgeEver",
},
{
caption: "2. Structured Repository Card Note",
alt: "EdgeEver clipped GitHub repository card note effect",
},
],
},
],
},
{
@@ -755,7 +847,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "ai-diagrams",
number: "09",
number: "11",
title: "AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams",
lead: "Prompt the AI Co-pilot (or connected local Agent) with natural language to create editable diagrams directly in notes.",
bullets: [
@@ -778,7 +870,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "ai-infographics",
number: "10",
number: "12",
title: "AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)",
lead: "Use built-in infographic templates (timelines, comparison matrices, quadrants, and hierarchy charts) by prompting the AI Co-pilot with your topic.",
bullets: [
@@ -798,7 +890,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "ai-database",
number: "11",
number: "13",
title: "AI Conversational Database Table Generation",
lead: "Send a prompt to the AI Co-pilot (e.g., \"Create a database table for social media content planning\" or \"Generate an employee onboarding tracker\") to construct complete data tables.",
bullets: [
@@ -818,7 +910,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "database-form",
number: "12",
number: "14",
title: "Public Online Form Collection from Database Tables",
lead: "Click \"Form Collection\" at the top of any database table to generate a public online data entry form.",
bullets: [
@@ -838,7 +930,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "acp-agent",
number: "13",
number: "15",
title: "Connecting Local Agents via ACP for Note Interaction",
lead: "EdgeEver natively supports ACP (Agent Client Protocol), allowing desktop clients to connect with local AI agent environments (such as Codex, Claude Code, DeepSeek Harness, Antigravity, etc.).",
bullets: [
@@ -866,7 +958,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "note-export-image",
number: "14",
number: "16",
title: "Note Export to Long Image & Code Cards",
lead: "Use the \"Share as Image\" feature to render notes into high-resolution long images or cards.",
bullets: [
@@ -886,7 +978,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "poster-studio",
number: "15",
number: "17",
title: "Social Media Poster & Cover Design from Notes",
lead: "Click \"Create Poster\" in any note's action menu (or via \"New → Poster\") to load key takeaways and titles into the Poster Design Studio.",
bullets: [
@@ -902,7 +994,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
},
{
id: "wechat-mp-copy",
number: "16",
number: "18",
title: "Copying Notes to WeChat Official Account & Blogs",
lead: "Select \"Copy to Official Account / Blog\" from the note menu to convert notes into rich text with inline CSS.",
bullets: [
@@ -929,7 +1021,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
cases: [
{
id: "plugin-ecosystem",
number: "17",
number: "19",
title: "Open Plugin API & Custom Plugin Development",
lead: "EdgeEver features an extension manager supporting one-click installation via GitHub repository URLs or Manifest links, with cross-device workspace synchronization between Web and Desktop.",
bullets: [
@@ -968,7 +1060,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCopy> = {
"zh-CN": {
eyebrow: "BEST PRACTICES & SHOWCASE",
title: "EdgeEver 17 个真机场景与实操指南",
title: "EdgeEver 19 个真机场景与实操指南",
description:
"涵盖三栏沉浸写作、微信与全渠道结构化剪藏、AI 对话生成导图/信息图/多维表格、本机 ACP Agent 协同、海报与长图分享及开放插件生态的完整实机演示。",
zoomHint: "点击任意截图可放大查看高清细节",
@@ -981,7 +1073,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
},
"en-US": {
eyebrow: "BEST PRACTICES & SHOWCASE",
title: "17 Real-World EdgeEver Workflows & Showcase",
title: "19 Real-World EdgeEver Workflows & Showcase",
description:
"A visual walkthrough of core EdgeEver workflows across desktop and mobile: three-pane writing, omnichannel clipping, AI diagrams & databases, local ACP agents, poster studio, and open plugins.",
zoomHint: "Click any screenshot to inspect full-resolution details",
@@ -994,7 +1086,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
},
ja: {
eyebrow: "BEST PRACTICES & SHOWCASE",
title: "EdgeEver 17 の実機デモとベストプラクティス",
title: "EdgeEver 19 の実機デモとベストプラクティス",
description:
"クラシックな三ペイン執筆、マルチチャネル Web クリッピング、AI による図解・インフォグラフィック・多次元テーブル生成、ローカル ACP Agent 連携、ポスター作成、プラグイン拡張までの実機ワークフロー集です。",
zoomHint: "スクリーンショットをクリックすると拡大表示できます",
@@ -1007,7 +1099,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
},
pl: {
eyebrow: "BEST PRACTICES & SHOWCASE",
title: "17 praktycznych scenariuszy i prezentacja EdgeEver",
title: "19 praktycznych scenariuszy i prezentacja EdgeEver",
description:
"Wizualny przewodnik po najważniejszych przepływach pracy w EdgeEver na komputerze i telefonie: pisanie w trzech panelach, wycinanie treści z sieci, diagramy i bazy danych AI, lokalni agenci ACP, studio plakatów i wtyczki.",
zoomHint: "Kliknij dowolny zrzut ekranu, aby powiększyć",
+2 -2
View File
@@ -55,7 +55,7 @@ export const jaSiteCopy = {
heading: "セルフホストの知識管理と AI のために作り直した",
subheading:
"広告、端末数の制限、高いサブスクはやめましょう。速さ、開放性、自分で守れるプライバシーを前提にしています。",
showcaseCta: "17 の実機デモとベストプラクティスを見る",
showcaseCta: "19 の実機デモとベストプラクティスを見る",
card1: {
badge: "クラシック UI",
title: "三ペインはそのまま、より軽く速く",
@@ -273,7 +273,7 @@ export const jaSiteCopy = {
cta: "移行ガイドを読む",
},
{
title: "17 の実機デモとベストプラクティス",
title: "19 の実機デモとベストプラクティス",
summary: "三ペイン執筆、マルチチャネル取り込み、AI 図解・多次元テーブル、ローカル ACP 連携、ポスター作成の実機例。",
href: "/guides/best-practices",
cta: "実機デモを見る",
+2 -2
View File
@@ -55,7 +55,7 @@ export const plSiteCopy = {
heading: "Zbudowany od nowa z myślą o samodzielnym hostingu wiedzy i pracy z AI",
subheading:
"Bez reklam, limitów urządzeń i drogich subskrypcji. Każdy szczegół służy szybkiemu, otwartemu i prywatnemu „drugiemu mózgowi”.",
showcaseCta: "Zobacz 17 praktycznych scenariuszy i zrzutów ekranu",
showcaseCta: "Zobacz 19 praktycznych scenariuszy i zrzutów ekranu",
card1: {
badge: "Klasyczny interfejs",
title: "Klasyczny układ trzech paneli – szybszy i lżejszy",
@@ -273,7 +273,7 @@ export const plSiteCopy = {
cta: "Przeczytaj poradnik migracji",
},
{
title: "17 praktycznych scenariuszy",
title: "19 praktycznych scenariuszy",
summary: "Wizualny przegląd pisania w trzech panelach, wycinania treści, diagramów i tabel AI, lokalnych agentów ACP i plakatów.",
href: "/guides/best-practices",
cta: "Zobacz przykłady",
+2 -1
View File
@@ -96,7 +96,8 @@ describe("site locale routing", () => {
collectKeys(bestPracticesPageCopy["zh-CN"]),
);
const cases = bestPracticesPageCopy[locale].categories.flatMap((c) => c.cases);
expect(cases).toHaveLength(17);
// Every configured showcase appears exactly once in each locale.
expect(cases.map((item) => item.id).sort()).toEqual(Object.keys(showcaseCaseAssets).sort());
for (const item of cases) {
const assets = showcaseCaseAssets[item.id];
expect(assets).toBeDefined();
+4 -4
View File
@@ -145,7 +145,7 @@ export const siteCopy = {
eyebrow: "WHY EDGEEVER",
heading: "重新定义自托管知识工作区",
subheading: "告别商业笔记的臃肿广告、设备限制与昂贵订阅。每一个设计细节,都旨在打造流畅、开放、隐私可控且原生支持 AI 的第二大脑。",
showcaseCta: "查看 17 个真机案例演示与最佳实践",
showcaseCta: "查看 19 个真机案例演示与最佳实践",
card1: {
badge: "经典重现",
title: "经典三栏与专注模式,熟悉更轻快",
@@ -362,7 +362,7 @@ export const siteCopy = {
cta: "查看迁移指南",
},
{
title: "17 个真机场景与实操指南",
title: "19 个真机场景与实操指南",
summary: "浏览三栏写作、微信与全渠道剪藏、AI 导图/信息图/多维表格、本机 ACP 协同及海报排版实机演示。",
href: "/guides/best-practices",
cta: "查看实机案例",
@@ -428,7 +428,7 @@ export const siteCopy = {
eyebrow: "WHY EDGEEVER",
heading: "Rebuilt for Self-Hosted Knowledge & AI Workflows",
subheading: "Say goodbye to ads, device caps, and expensive subscriptions. Every detail is crafted for a fast, open, and private second brain.",
showcaseCta: "Explore 17 real-world workflows & screenshots",
showcaseCta: "Explore 19 real-world workflows & screenshots",
card1: {
badge: "Classic UI",
title: "Classic Three-Pane Layout, Faster & Lighter",
@@ -645,7 +645,7 @@ export const siteCopy = {
cta: "Read migration guide",
},
{
title: "17 Real-World Workflows",
title: "19 Real-World Workflows",
summary: "Visual walkthrough of three-pane editing, omnichannel clipping, AI diagrams & databases, local ACP agents, and poster design.",
href: "/guides/best-practices",
cta: "View showcase",
+24 -6
View File
@@ -1,4 +1,5 @@
import { AI_ASSISTANT_LAUNCHER_POSITION } from "@/lib/ai-assistant-launcher";
import { editorClipboardTextSerializer } from "@/lib/editor-clipboard";
import { useRef, useState, useEffect, useLayoutEffect, useCallback, useMemo, lazy, Suspense, type CSSProperties, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
@@ -71,6 +72,7 @@ import {
ImageUploadPlaceholderExtension,
addImageUploadPlaceholder,
createResourceUploadPlaceholder,
getImageUploadPlaceholderPosition,
removeImageUploadPlaceholder,
waitForImageSourceReady,
updateImageUploadPlaceholder,
@@ -1050,7 +1052,7 @@ const RichEditorPane = ({
}
}, [repository, t]);
const insertResourceFiles = useCallback((files: File[]) => {
const insertResourceFiles = useCallback((files: File[], dropPosition?: number) => {
const currentMemo = memoRef.current;
const currentEditor = editorRef.current;
@@ -1067,7 +1069,7 @@ const RichEditorPane = ({
memoRef.current?.id,
);
const interactionVersionAtRequest = editorCanvasInteractionVersionRef.current;
const placeholderPosition = currentEditor.state.selection.from;
const placeholderPosition = dropPosition ?? currentEditor.state.selection.from;
const placeholderByFile = new Map(files.map((file) => [file,
createResourceUploadPlaceholder(file, {
imagePreparing: t("editor.uploadState.imagePreparing"),
@@ -1095,7 +1097,7 @@ const RichEditorPane = ({
// Read the selection only after earlier resource insertions complete.
// Rapid consecutive pastes otherwise race with the same stale cursor.
const insertionTarget = getResourceInsertionTarget(insertionEditor.state.selection);
const insertionTarget = dropPosition ?? getResourceInsertionTarget(insertionEditor.state.selection);
setImageUploadState("uploading");
const imageReadiness: Promise<void>[] = [];
@@ -1182,8 +1184,16 @@ const RichEditorPane = ({
});
if (content.length > 0) {
// Drop anchors follow every document transaction, including edits made
// while this upload was queued. Never fall back to an unrelated caret
// if the user deleted the drop location or reset the document.
const mappedDropPosition = dropPosition === undefined ? undefined
: getImageUploadPlaceholderPosition(activeEditor.state, placeholders[0]!.id);
if (mappedDropPosition === null) {
throw new Error("The attachment drop location no longer exists");
}
const safeInsertionTarget = clampResourceInsertionTarget(
insertionTarget,
mappedDropPosition ?? insertionTarget,
activeEditor.state.doc.content.size,
);
const updateSelection = shouldSelectInsertedResources(
@@ -1277,6 +1287,7 @@ const RichEditorPane = ({
: { type: "doc", content: [{ type: "paragraph" }] },
editable: Boolean(memo && !effectiveReadOnly && editorIsHydratedForCurrentMemo),
editorProps: {
clipboardTextSerializer: editorClipboardTextSerializer,
attributes: {
class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[240px] lg:min-h-[180px]",
spellcheck: spellcheckEnabled ? "true" : "false",
@@ -1424,7 +1435,12 @@ const RichEditorPane = ({
}
event.preventDefault();
insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer));
// Use the same coordinate lookup as ProseMirror's external drop cursor.
// A drop inserts at a point; it must never replace the old text selection.
const dropPosition = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (dropPosition) {
insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer), dropPosition.pos);
}
return true;
},
},
@@ -3956,7 +3972,9 @@ const RichEditorPane = ({
to: stored?.to ?? draft.to,
pos: stored?.pos ?? draft.pos,
});
editor.chain().focus(stored?.from ?? draft.from ?? null, { scrollIntoView: true }).run();
// Keep the selection the insert left after the formula; focusing
// `from` put the caret back in front of it, so typing went there.
editor.chain().focus(null, { scrollIntoView: true }).run();
}}
onRemove={
mathFormulaDraft && typeof mathFormulaDraft.pos === "number"
@@ -1,7 +1,7 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import katex from "katex";
import { Sigma } from "lucide-react";
import { FlaskConical, Sigma } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -13,6 +13,17 @@ import {
import type { MathFormulaDraft, MathFormulaKind } from "@/components/editor/math-formula";
import { MathVisualInput } from "@/components/editor/MathVisualInput";
import { hasMathPlaceholders, renderMathPreview } from "@/components/editor/math-formula-preview";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
CHEMISTRY_EXAMPLES,
insertChemistryExample,
isChemistryLatex,
mathFormulaSubjectOf,
type MathFormulaSubject,
} from "@/components/editor/chemistry-formula";
const SEGMENT_ACTIVE = "flex flex-1 items-center justify-center gap-1.5 rounded-sm bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-950";
const SEGMENT_IDLE = "flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50";
export const MathFormulaDialog = ({
open,
@@ -34,17 +45,40 @@ export const MathFormulaDialog = ({
const [kind, setKind] = useState<MathFormulaKind>(draft?.kind ?? "inline");
const [latex, setLatex] = useState(draft?.latex ?? "");
const [error, setError] = useState<string | null>(null);
const [subject, setSubject] = useState<MathFormulaSubject>(mathFormulaSubjectOf(draft?.latex ?? ""));
const [inputMode, setInputMode] = useState<"visual" | "source">("visual");
const editing = typeof draft?.pos === "number";
useEffect(() => {
if (!open) return;
const initialLatex = draft?.latex ?? "";
setKind(draft?.kind ?? "inline");
setLatex(draft?.latex ?? "");
setLatex(initialLatex);
setError(null);
setInputMode("visual");
setSubject(mathFormulaSubjectOf(initialLatex));
// The visual math editor does not understand \ce or \pu and would rewrite them.
setInputMode(isChemistryLatex(initialLatex) ? "source" : "visual");
}, [draft, open]);
const changeSubject = (next: MathFormulaSubject) => {
setSubject(next);
setError(null);
setInputMode(next === "chemistry" || isChemistryLatex(latex) ? "source" : "visual");
};
const insertExample = (example: string) => {
const input = latexInputRef.current;
const start = input?.selectionStart ?? latex.length;
const end = input?.selectionEnd ?? latex.length;
const next = insertChemistryExample(latex, start, end, example);
setLatex(next.value);
setError(null);
window.requestAnimationFrame(() => {
latexInputRef.current?.focus();
latexInputRef.current?.setSelectionRange(next.caret, next.caret);
});
};
useEffect(() => {
if (open && inputMode === "source") latexInputRef.current?.focus();
}, [inputMode, open]);
@@ -84,13 +118,29 @@ export const MathFormulaDialog = ({
</DialogTitle>
</DialogHeader>
{/* min-w-0: a long formula scrolls inside the preview instead of widening the dialog. */}
<form
className="space-y-4 px-5 py-4"
className="min-w-0 space-y-4 px-5 py-4"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<div className="flex rounded-md border border-slate-200 p-0.5" role="group" aria-label={t("mathFormulaDialog.subjectLabel")}>
{(["math", "chemistry"] as const).map((value) => (
<button
key={value}
type="button"
className={subject === value ? SEGMENT_ACTIVE : SEGMENT_IDLE}
aria-pressed={subject === value}
onClick={() => changeSubject(value)}
>
{value === "math" ? <Sigma className="h-3.5 w-3.5" aria-hidden="true" /> : <FlaskConical className="h-3.5 w-3.5" aria-hidden="true" />}
{value === "math" ? t("mathFormulaDialog.subjectMath") : t("mathFormulaDialog.subjectChemistry")}
</button>
))}
</div>
{!editing && (
<div className="flex rounded-md border border-slate-200 p-0.5">
{(["inline", "block"] as const).map((value) => (
@@ -116,12 +166,14 @@ export const MathFormulaDialog = ({
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
{t(inputMode === "visual" ? "mathFormulaDialog.visualLabel" : "mathFormulaDialog.latexLabel")}
</label>
<Button type="button" variant="ghost" size="sm" onClick={() => {
setInputMode(inputMode === "visual" ? "source" : "visual");
setError(null);
}}>
{t(inputMode === "visual" ? "mathFormulaDialog.sourceMode" : "mathFormulaDialog.visualMode")}
</Button>
{subject === "math" && !isChemistryLatex(latex) ? (
<Button type="button" variant="ghost" size="sm" onClick={() => {
setInputMode(inputMode === "visual" ? "source" : "visual");
setError(null);
}}>
{t(inputMode === "visual" ? "mathFormulaDialog.sourceMode" : "mathFormulaDialog.visualMode")}
</Button>
) : null}
</div>
{inputMode === "visual" ? (open ? (
<MathVisualInput
@@ -142,7 +194,7 @@ export const MathFormulaDialog = ({
rows={3}
spellCheck={false}
className="w-full resize-y rounded-md border border-slate-200 bg-card px-3 py-2 font-mono text-sm text-slate-900 outline-none ring-slate-900/15 placeholder:text-slate-400 focus:border-slate-900 focus:ring-2"
placeholder={t("mathFormulaDialog.latexPlaceholder")}
placeholder={subject === "chemistry" ? "\\ce{2H2 + O2 -> 2H2O}" : t("mathFormulaDialog.latexPlaceholder")}
aria-invalid={Boolean(error) || undefined}
onChange={(event) => {
setLatex(event.target.value);
@@ -151,8 +203,37 @@ export const MathFormulaDialog = ({
/>
)}
{inputMode === "source" && incomplete && <p className="text-xs leading-5 text-slate-500" role="status">{t("mathFormulaDialog.incomplete")}</p>}
{subject === "chemistry" ? (
<p className="text-xs leading-5 text-slate-500">{t("mathFormulaDialog.chemistryHint")}</p>
) : null}
</div>
{subject === "chemistry" ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-slate-600">{t("mathFormulaDialog.chemistryExamplesLabel")}</p>
<div className="flex flex-wrap gap-1.5">
{CHEMISTRY_EXAMPLES.map((example) => (
<Tooltip key={example.key}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs font-normal"
onClick={() => insertExample(example.latex)}
>
{t(`mathFormulaDialog.chemistryExamples.${example.key}`)}
</Button>
</TooltipTrigger>
<TooltipContent>
<span className="font-mono">{example.latex}</span>
</TooltipContent>
</Tooltip>
))}
</div>
</div>
) : null}
<div className="space-y-1.5">
<p id={previewId} className="text-xs font-medium text-slate-600">
{t("mathFormulaDialog.preview")}
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QRCodeSVG } from "qrcode.react";
import { shareQrCodeFileName, shareQrCodePngBlob } from "./ShareQrCode.tsx";
const source = (name) => readFileSync(new URL(`./${name}`, import.meta.url), "utf8");
@@ -37,4 +38,65 @@ describe("share link QR code", () => {
expect(markup).not.toContain("<title");
expect(markup).toContain("<path");
});
test("offers PNG and SVG downloads of a print-size code with a quiet zone", () => {
const component = source("ShareQrCode.tsx");
expect(component).toContain('t("sharing.qrCodeDownloadPng")');
expect(component).toContain('t("sharing.qrCodeDownloadSvg")');
expect(component).toContain("marginSize={QR_DOWNLOAD_MARGIN}");
const markup = renderToStaticMarkup(createElement(QRCodeSVG, {
value: "https://notes.example/share/abc",
size: 1024,
level: "M",
marginSize: 4,
xmlns: "http://www.w3.org/2000/svg",
}));
expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(markup).toContain('width="1024"');
});
test("names the downloaded file after the share address", () => {
expect(shareQrCodeFileName("https://notes.example/share/meeting-notes", "png")).toBe("edgeever-share-qr-meeting-notes.png");
expect(shareQrCodeFileName("https://notes.example/share/notebook/abc_DEF-1", "svg")).toBe("edgeever-share-qr-abc_DEF-1.svg");
expect(shareQrCodeFileName("https://notes.example/s/%3Cx%3E", "png")).toBe("edgeever-share-qr-3Cx3E.png");
expect(shareQrCodeFileName("not a url", "png")).toBe("edgeever-share-qr.png");
});
test("exports 1024 physical pixels at different screen densities without resizing the source", async () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
try {
for (const ratio of [1, 1.25, 2, 3]) {
const sourceCanvas = { width: 1024 * ratio, height: 1024 * ratio };
let drawnSource;
const context = {
imageSmoothingEnabled: true,
drawImage(source) { drawnSource = source; },
};
const exportCanvas = {
width: 0,
height: 0,
getContext: () => context,
toBlob(callback, type) {
callback(new Blob([`${this.width}x${this.height}`], { type }));
},
};
Object.defineProperty(globalThis, "document", {
configurable: true,
value: { createElement: () => exportCanvas },
});
const blob = await shareQrCodePngBlob(sourceCanvas);
expect(blob.type).toBe("image/png");
expect(await blob.text()).toBe("1024x1024");
expect(drawnSource).toBe(sourceCanvas);
expect(context.imageSmoothingEnabled).toBe(false);
expect(sourceCanvas.width).toBe(1024 * ratio);
expect(sourceCanvas.height).toBe(1024 * ratio);
}
} finally {
if (originalDocument) Object.defineProperty(globalThis, "document", originalDocument);
else delete globalThis.document;
}
});
});
@@ -1,8 +1,38 @@
import { ChevronDown, QrCode } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { ChevronDown, Download, QrCode } from "lucide-react";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { canvasToPngBlob, downloadBlob } from "@/lib/infographic-image-export";
// Large enough for print, with the standard four-module quiet zone around it.
const QR_DOWNLOAD_SIZE = 1024;
const QR_DOWNLOAD_MARGIN = 4;
export const shareQrCodePngBlob = (source: HTMLCanvasElement) => {
// QRCodeCanvas scales its backing store by devicePixelRatio. Export physical
// pixels at a fixed size regardless of screen density or browser zoom.
const canvas = document.createElement("canvas");
canvas.width = canvas.height = QR_DOWNLOAD_SIZE;
const context = canvas.getContext("2d");
if (!context) throw new Error("canvas");
context.imageSmoothingEnabled = false;
context.drawImage(source, 0, 0, QR_DOWNLOAD_SIZE, QR_DOWNLOAD_SIZE);
return canvasToPngBlob(canvas);
};
/** File name from the last path segment of the link: the custom address or the token. */
export const shareQrCodeFileName = (url: string, extension: "png" | "svg") => {
let segment = "";
try {
segment = new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "";
} catch {
// Keep the generic name for a value that is not a URL.
}
const safe = segment.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 48);
return `edgeever-share-qr${safe ? `-${safe}` : ""}.${extension}`;
};
/**
* Scannable share link for opening a share on another device. Callers render it
@@ -10,6 +40,19 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
*/
export const ShareQrCode = ({ url, hint }: { url: string; hint: string }) => {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const downloadPng = async () => {
if (!canvasRef.current) return;
downloadBlob(await shareQrCodePngBlob(canvasRef.current), shareQrCodeFileName(url, "png"));
};
const downloadSvg = () => {
if (!svgRef.current) return;
const markup = new XMLSerializer().serializeToString(svgRef.current);
downloadBlob(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }), shareQrCodeFileName(url, "svg"));
};
return (
<Collapsible>
@@ -36,6 +79,38 @@ export const ShareQrCode = ({ url, hint }: { url: string; hint: string }) => {
/>
</div>
<figcaption className="text-center text-xs leading-5 text-muted-foreground">{hint}</figcaption>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => void downloadPng()}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
{t("sharing.qrCodeDownloadPng")}
</Button>
<Button variant="outline" size="sm" onClick={downloadSvg}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
{t("sharing.qrCodeDownloadSvg")}
</Button>
</div>
{/* Download sources: the same code at print size with a quiet zone. */}
<div hidden aria-hidden="true">
<QRCodeCanvas
ref={canvasRef}
value={url}
size={QR_DOWNLOAD_SIZE}
level="M"
marginSize={QR_DOWNLOAD_MARGIN}
bgColor="#ffffff"
fgColor="#000000"
/>
<QRCodeSVG
ref={svgRef}
value={url}
size={QR_DOWNLOAD_SIZE}
level="M"
marginSize={QR_DOWNLOAD_MARGIN}
bgColor="#ffffff"
fgColor="#000000"
xmlns="http://www.w3.org/2000/svg"
/>
</div>
</figure>
</CollapsibleContent>
</Collapsible>
@@ -29,8 +29,7 @@ const ImageGalleryNodeView = ({ editor, node, selected, updateAttributes }: Node
const setGalleryAttributes = useCallback((attributes: { layout?: ImageGalleryLayout; fit?: ImageGalleryFit }) => {
updateAttributes(attributes);
editor.commands.focus(undefined, { scrollIntoView: false });
}, [editor, updateAttributes]);
}, [updateAttributes]);
return (
<NodeViewWrapper
@@ -39,6 +38,7 @@ const ImageGalleryNodeView = ({ editor, node, selected, updateAttributes }: Node
data-image-count={node.childCount}
data-image-gallery-layout={layout}
data-image-gallery-fit={fit}
tabIndex={isEditable ? 0 : undefined}
>
{isEditable ? (
<div
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type Pointer
import { mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react";
import { AlignLeft, AlignCenter, AlignRight, Maximize2 } from "lucide-react";
import { AlignLeft, AlignCenter, AlignRight, Maximize2, MoveHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE, imageAlignmentMargins, parseImageAlignment, type ImageAlignment } from "@edgeever/shared";
import {
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isImageInGallery } from "./image-editing-context";
import { createImageSelectionPlugin, imageSelectionKey } from "./image-selection";
import { createImageDragPlugin } from "./image-drag";
export type ImageMenuRequestDetail = {
element: HTMLElement;
@@ -292,7 +293,8 @@ const ResizableImageNodeView = ({
ref={imageRef}
src={src}
alt={alt}
draggable={false}
draggable={editable}
data-drag-handle={editable ? "" : undefined}
referrerPolicy={getImageReferrerPolicy(src)}
onClick={(event) => {
// A positioned figure does not occupy the full block wrapper. Select
@@ -349,6 +351,19 @@ const ResizableImageNodeView = ({
<TooltipContent side="top">{t(`editor.imageAlignments.${value}`)}</TooltipContent>
</Tooltip>
))}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-image-resize-handle"
aria-label={t("editor.resizeImage")}
onPointerDown={startResize}
>
<MoveHorizontal aria-hidden="true" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{t("editor.resizeImage")}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
{canGroupAdjacentImages ? (
@@ -362,19 +377,6 @@ const ResizableImageNodeView = ({
<span>{t("editor.imageGallery.group")}</span>
</button>
) : null}
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="edgeever-image-resize-handle"
aria-label={t("editor.resizeImage")}
onPointerDown={startResize}
/>
</TooltipTrigger>
<TooltipContent side="top">{t("editor.resizeImage")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</>)}
</div>
<div ref={actionsRef} className="edgeever-image-resource-actions" />
@@ -391,7 +393,7 @@ const ResizableImageNodeView = ({
export const ResizableImage = Image.extend({
addProseMirrorPlugins() {
return [...(this.parent?.() ?? []), createImageSelectionPlugin()];
return [...(this.parent?.() ?? []), createImageSelectionPlugin(), createImageDragPlugin()];
},
addPasteRules() {
return [createMarkdownImagePasteRule(this.type)];
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { CHEMISTRY_EXAMPLES, insertChemistryExample, isChemistryLatex, mathFormulaSubjectOf } from "./chemistry-formula.ts";
import { renderMathPreview } from "./math-formula-preview.ts";
const locales = ["en-US", "pl", "zh-CN", "ja", "ru"].map((name) =>
readFileSync(new URL(`../../../../../packages/shared/src/i18n/${name}.ts`, import.meta.url), "utf8"));
describe("chemistry formulas in the formula dialog", () => {
test("recognizes mhchem formulas so they open in the chemistry mode", () => {
for (const latex of ["\\ce{H2O}", "x + \\ce{CO2}", "\\pu{5 mol}", "\\ce {NaCl}"]) {
expect(isChemistryLatex(latex)).toBe(true);
expect(mathFormulaSubjectOf(latex)).toBe("chemistry");
}
for (const latex of ["", "E = mc^2", "\\cent", "\\cos x", "\\put"]) {
expect(isChemistryLatex(latex)).toBe(false);
expect(mathFormulaSubjectOf(latex)).toBe("math");
}
});
test("every example renders inline and as a display formula, with a label in every locale", () => {
expect(CHEMISTRY_EXAMPLES.length).toBeGreaterThanOrEqual(8);
for (const example of CHEMISTRY_EXAMPLES) {
expect(isChemistryLatex(example.latex)).toBe(true);
expect(renderMathPreview(example.latex, "inline")).toContain('class="katex"');
expect(renderMathPreview(example.latex, "block")).toContain("katex-display");
for (const catalog of locales) expect(catalog).toMatch(new RegExp(`\\n\\s+${example.key}: "`));
}
});
test("the preview renders reactions with arrows and charges instead of an error", () => {
const html = renderMathPreview("\\ce{2H2 + O2 -> 2H2O}", "inline");
expect(html).toContain("katex");
expect(html).not.toContain("#cc0000");
expect(renderMathPreview("\\ce{H2O", "inline")).toBe("");
});
// `|` marks the caret (or `[…]` a selection) in the field before the example is inserted.
const insertAt = (template, example) => {
const start = template.includes("[") ? template.indexOf("[") : template.indexOf("|");
const value = template.replace(/[|[\]]/g, "");
const end = template.includes("]") ? template.indexOf("]") - 1 : start;
const result = insertChemistryExample(value, start, end, example);
return `${result.value.slice(0, result.caret)}|${result.value.slice(result.caret)}`;
};
test("inserts an example into an empty field, at the caret, or inside an open command of the same kind", () => {
expect(insertAt("|", "\\ce{H2O}")).toBe("\\ce{H2O}|");
expect(insertAt("Water:|", "\\ce{H2O}")).toBe("Water: \\ce{H2O}|");
expect(insertAt("a |b", "\\ce{H2O}")).toBe("a \\ce{H2O} |b");
expect(insertAt("\\ce{A -> |2H2O}", "\\pu{1 mol}")).toBe("\\ce{A -> \\pu{1 mol} |2H2O}");
expect(insertAt("\\ce{A + |B}", "\\ce{H2O}")).toBe("\\ce{A + H2O |B}");
expect(insertAt("\\ce{A -> |}", "\\ce{H2O}")).toBe("\\ce{A -> H2O|}");
expect(insertAt("\\pu{5 |}", "\\pu{123 kJ/mol}")).toBe("\\pu{5 123 kJ/mol|}");
expect(insertAt("[\\ce{X}]", "\\pu{1 mol}")).toBe("\\pu{1 mol}|");
});
test("keeps the whole command when the caret is inside a different command", () => {
// Review case: Ion inside \pu used to become \pu{5 SO4^2-} ("Double superscript").
expect(insertAt("\\pu{5 |}", "\\ce{SO4^2-}")).toBe("\\pu{5 \\ce{SO4^2-}|}");
expect(insertAt("\\ce{A -> |}", "\\pu{123 kJ/mol}")).toBe("\\ce{A -> \\pu{123 kJ/mol}|}");
expect(renderMathPreview("\\pu{5 \\ce{SO4^2-}}", "inline")).toContain('class="katex"');
expect(renderMathPreview("\\ce{A -> \\pu{123 kJ/mol}}", "inline")).toContain('class="katex"');
});
test("finds the enclosing command past nested groups and escaped braces", () => {
expect(insertAt("\\ce{^{14}_{6}C + |}", "\\ce{H2O}")).toBe("\\ce{^{14}_{6}C + H2O|}");
expect(insertAt("\\ce{^{14}_{6}C + |}", "\\pu{1 mol}")).toBe("\\ce{^{14}_{6}C + \\pu{1 mol}|}");
// The innermost open command decides: \ce nested inside \pu.
expect(insertAt("\\pu{5 \\ce{A + |}}", "\\ce{H2O}")).toBe("\\pu{5 \\ce{A + H2O|}}");
expect(insertAt("\\pu{5 \\ce{A} |}", "\\ce{H2O}")).toBe("\\pu{5 \\ce{A} \\ce{H2O}|}");
// A closed command before the caret does not count.
expect(insertAt("\\pu{5 mol} and |", "\\ce{H2O}")).toBe("\\pu{5 mol} and \\ce{H2O}|");
expect(insertAt("\\ce{\\{ |}", "\\ce{H2O}")).toBe("\\ce{\\{ H2O|}");
});
test("every example inserted into every kind of context still renders", () => {
const contexts = ["|", "x = |", "\\ce{A -> |}", "\\ce{^{14}_{6}C + |}", "\\pu{5 |}", "\\ce{H2O} and |", "\\pu{5 \\ce{A + |}}"];
for (const context of contexts) {
for (const example of CHEMISTRY_EXAMPLES) {
const result = insertAt(context, example.latex).replace("|", "");
expect({ context, example: example.key, rendered: renderMathPreview(result, "inline") !== "" })
.toEqual({ context, example: example.key, rendered: true });
}
}
});
test("the dialog keeps chemistry out of the visual math editor and uses shadcn tooltips", () => {
const dialog = readFileSync(new URL("../dialogs/MathFormulaDialog.tsx", import.meta.url), "utf8");
expect(dialog).toContain('setInputMode(isChemistryLatex(initialLatex) ? "source" : "visual")');
expect(dialog).toContain('subject === "math" && !isChemistryLatex(latex)');
expect(dialog).toContain("<TooltipContent>");
expect(dialog).not.toMatch(/\btitle=/);
});
});
@@ -0,0 +1,84 @@
export type MathFormulaSubject = "math" | "chemistry";
/** Formulas written with the KaTeX mhchem commands `\ce{…}` or `\pu{…}`. */
export const isChemistryLatex = (latex: string) => /\\(?:ce|pu)\s*\{/.test(latex);
export const mathFormulaSubjectOf = (latex: string): MathFormulaSubject =>
isChemistryLatex(latex) ? "chemistry" : "math";
export type ChemistryExample = {
/** Key under `mathFormulaDialog.chemistryExamples`. */
key: string;
latex: string;
};
/** One-click starting points that cover the mhchem syntax people need most. */
export const CHEMISTRY_EXAMPLES: readonly ChemistryExample[] = [
{ key: "formula", latex: "\\ce{H2O}" },
{ key: "ion", latex: "\\ce{SO4^2-}" },
{ key: "reaction", latex: "\\ce{2H2 + O2 -> 2H2O}" },
{ key: "equilibrium", latex: "\\ce{N2 + 3H2 <=> 2NH3}" },
{ key: "conditions", latex: "\\ce{CaCO3 ->[\\Delta] CaO + CO2}" },
{ key: "gasPrecipitate", latex: "\\ce{Ag+ + Cl- -> AgCl v}" },
{ key: "states", latex: "\\ce{NaCl(s) -> Na+(aq) + Cl-(aq)}" },
{ key: "isotope", latex: "\\ce{^{14}_{6}C}" },
{ key: "hydrate", latex: "\\ce{CuSO4*5H2O}" },
{ key: "units", latex: "\\pu{123 kJ/mol}" },
];
type ChemistryCommand = "ce" | "pu";
const exampleCommand = (example: string) => /^\\(ce|pu)\{([\s\S]*)\}$/.exec(example) as
| (RegExpExecArray & [string, ChemistryCommand, string])
| null;
/**
* The `\ce{…}` or `\pu{…}` whose braces are still open at the end of `before`,
* found by brace depth so nested groups such as `^{14}_{6}` are skipped.
*/
const enclosingCommand = (before: string): ChemistryCommand | null => {
const open: Array<ChemistryCommand | null> = [];
for (let index = 0; index < before.length; index += 1) {
const char = before[index];
if (char === "\\") {
index += 1;
continue;
}
if (char === "{") {
const command = /\\(ce|pu)\s*$/.exec(before.slice(0, index))?.[1] as ChemistryCommand | undefined;
open.push(command ?? null);
} else if (char === "}") {
open.pop();
}
}
for (let level = open.length - 1; level >= 0; level -= 1) {
if (open[level]) return open[level]!;
}
return null;
};
/**
* Inserts an example at the caret. An empty field takes the example as is. A
* caret inside an open command of the same kind (`\ce` in `\ce`, `\pu` in
* `\pu`) gets only the example's content, so the result stays one command.
* Any other position keeps the whole command: `\ce` syntax inside `\pu` (or
* the reverse) is not valid and cannot be rendered.
*/
export const insertChemistryExample = (
value: string,
selectionStart: number,
selectionEnd: number,
example: string,
) => {
if (!value.trim()) return { value: example, caret: example.length };
const before = value.slice(0, selectionStart);
const after = value.slice(selectionEnd);
const parsed = exampleCommand(example);
const sameCommand = parsed !== null && enclosingCommand(before) === parsed[1];
const text = sameCommand ? parsed[2] : example;
// Keep the example apart from the text around it, but not from a closing brace.
const leading = before && !/\s$/.test(before) ? " " : "";
const trailing = after && !/^[\s}]/.test(after) ? " " : "";
const inserted = `${leading}${text}${trailing}`;
return { value: `${before}${inserted}${after}`, caret: before.length + inserted.length };
};
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { EditorState } from "@tiptap/pm/state";
import { history, undo, redo } from "@tiptap/pm/history";
import { ImageGallery } from "@edgeever/shared";
import { createImageDragPlugin, imageDragKey, moveImage } from "./image-drag";
const schema = getSchema([StarterKit, Image.extend({ addAttributes() {
return { ...this.parent(), width: { default: null }, alignment: { default: null } };
} }), ImageGallery]);
const image = (src) => schema.nodes.image.create({ src, alt: `alt ${src}`, title: `title ${src}`, width: 42, alignment: "right" });
const paragraph = (text) => schema.nodes.paragraph.create(null, schema.text(text));
const gallery = (...names) => schema.nodes.edgeeverImageGallery.create({ layout: "3", fit: "height" }, names.map(image));
const state = (...nodes) => EditorState.create({ doc: schema.nodes.doc.create(null, nodes), plugins: [history(), createImageDragPlugin()] });
const move = (before, from, to) => {
const tr = moveImage(before, from, to);
expect(tr).not.toBeNull();
const next = before.applyTransaction(tr).state;
next.doc.check();
return next;
};
const sources = (doc) => { const result = []; doc.descendants((node) => { if (node.type.name === "image") result.push(node.attrs.src); }); return result; };
describe("existing image dragging (#708)", () => {
test("moves between paragraphs in either direction without changing image attributes", () => {
const before = state(paragraph("a"), image("/resources/original"), paragraph("b"));
const after = move(before, 3, 0);
expect(after.doc.firstChild).toBe(before.doc.child(1));
expect(after.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [image("/resources/original"), paragraph("a"), paragraph("b")]).toJSON());
expect(move(after, 0, after.doc.content.size).doc.lastChild).toBe(before.doc.child(1));
});
test("reorders A,B to B,A in a gallery and preserves gallery presentation", () => {
const before = state(gallery("A", "B"));
const after = move(before, 1, 3);
expect(sources(after.doc)).toEqual(["B", "A"]);
expect(after.doc.firstChild.attrs).toEqual(before.doc.firstChild.attrs);
expect(after.doc.firstChild.lastChild).toBe(before.doc.firstChild.firstChild);
expect(move(after, 2, 1).doc.toJSON()).toEqual(before.doc.toJSON());
});
test("moving the only gallery image out removes the empty shell", () => {
const before = state(gallery("A"), paragraph("text"));
const after = move(before, 1, before.doc.content.size);
expect(after.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [paragraph("text"), image("A")]).toJSON());
});
test("moves into and between galleries without placeholders or duplicates", () => {
let next = move(state(gallery("A"), gallery("B", "C")), 1, 5);
expect(next.doc.childCount).toBe(1);
expect(sources(next.doc)).toEqual(["B", "A", "C"]);
next = move(state(image("A"), gallery("B", "C")), 0, 3);
expect(next.doc.childCount).toBe(1);
expect(sources(next.doc)).toEqual(["B", "A", "C"]);
});
test("one undo restores the original document; redo repeats the move", () => {
const before = state(gallery("A"), paragraph("text"), gallery("B", "C"));
let next = move(before, 1, 11);
const moved = next.doc.toJSON();
expect(undo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
expect(next.doc.toJSON()).toEqual(before.doc.toJSON());
expect(redo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
expect(next.doc.toJSON()).toEqual(moved);
});
test("rejects self drops, adjacent no-ops, text positions and invalid positions", () => {
const before = state(image("A"), paragraph("text"));
for (const to of [-1, 0, 1, 3, 999]) expect(moveImage(before, 0, to)).toBeNull();
expect(moveImage(before, 1, 0)).toBeNull();
expect(moveImage(before, -1, 0)).toBeNull();
expect(moveImage(before, 999, 0)).toBeNull();
});
test("undoing a move does not undo the text typed immediately before it", () => {
let next = state(paragraph("a"), image("A"));
next = next.apply(next.tr.insertText(" typed", 2));
const beforeMove = next.doc.toJSON();
next = move(next, next.doc.firstChild.nodeSize, 0);
expect(undo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
expect(next.doc.toJSON()).toEqual(beforeMove);
expect(next.doc.textContent).toBe("a typed");
});
test("leaves external file drops and readonly views to their existing handlers", () => {
const plugin = createImageDragPlugin();
let next = state(image("A"), paragraph("text"));
next = next.apply(next.tr.setMeta(imageDragKey, 0));
const external = { editable: true, state: next, dragging: null };
expect(plugin.props.handleDrop(external, {}, null, true)).toBe(false);
const readonly = { ...external, editable: false, dragging: { slice: next.selection.content(), move: true } };
expect(plugin.props.handleDrop(readonly, {}, null, true)).toBe(false);
});
test("maps the drag source through other edits and cancels if the source is deleted", () => {
let next = state(paragraph("a"), image("A"));
next = next.apply(next.tr.setMeta(imageDragKey, 3));
next = next.apply(next.tr.insertText("hello", 1));
expect(imageDragKey.getState(next)).toBe(8);
next = next.apply(next.tr.delete(8, 9));
expect(imageDragKey.getState(next)).toBeNull();
});
});
@@ -0,0 +1,181 @@
import { NodeSelection, Plugin, PluginKey, type EditorState } from "@tiptap/pm/state";
import { closeHistory } from "@tiptap/pm/history";
import type { EditorView } from "@tiptap/pm/view";
import { IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
import { imageSelectionKey } from "./image-selection";
export const imageDragKey = new PluginKey<number | null>("imageDrag");
/** Move the original node, preserving resource references and every attribute. */
export function moveImage(state: EditorState, from: number, target: number) {
if (from < 0 || from >= state.doc.content.size) return null;
const image = state.doc.nodeAt(from);
if (image?.type.name !== "image" || target < 0 || target > state.doc.content.size) return null;
if (target >= from && target <= from + image.nodeSize) return null;
const $from = state.doc.resolve(from);
const tr = closeHistory(state.tr);
// Deleting a gallery's last child alone would cause schema filling to create
// an empty image. Remove its container in the same transaction instead.
if ($from.parent.type.name === IMAGE_GALLERY_NODE_TYPE && $from.parent.childCount === 1) {
tr.delete($from.before(), $from.after());
} else {
tr.delete(from, from + image.nodeSize);
}
const position = tr.mapping.map(target);
const $target = tr.doc.resolve(position);
if (!$target.parent.canReplaceWith($target.index(), $target.index(), image.type)) return null;
tr.insert(position, image);
tr.setSelection(NodeSelection.create(tr.doc, position));
return tr.setMeta(imageSelectionKey, null).setMeta(imageDragKey, null).scrollIntoView();
}
type DropTarget = { position: number; left: number; top: number; width: number; height: number };
function imagePosition(view: EditorView, element: Element) {
const figure = element.closest(".edgeever-image-node");
if (!figure || !view.dom.contains(figure)) return null;
const position = view.posAtDOM(figure, 0);
return view.state.doc.nodeAt(position)?.type.name === "image" ? { figure, position } : null;
}
function dropTarget(view: EditorView, event: DragEvent): DropTarget | null {
const element = event.target instanceof Element ? event.target : null;
let hit = element && imagePosition(view, element);
// Grid gaps belong to the nearest image, rather than the gallery container.
const galleryElement = element?.closest("[data-edgeever-image-gallery]");
if (!hit && galleryElement && view.dom.contains(galleryElement)) {
let distance = Infinity;
for (const figure of galleryElement.querySelectorAll(".edgeever-image-node")) {
const rect = figure.getBoundingClientRect();
const dx = Math.max(rect.left - event.clientX, 0, event.clientX - rect.right);
const dy = Math.max(rect.top - event.clientY, 0, event.clientY - rect.bottom);
const candidateDistance = dx * dx + dy * dy;
if (candidateDistance < distance) {
distance = candidateDistance;
hit = imagePosition(view, figure);
}
}
}
if (hit) {
const rect = hit.figure.getBoundingClientRect();
const gallery = hit.figure.closest("[data-edgeever-image-gallery]");
// A one-column gallery uses top/bottom edges; a grid uses left/right edges.
const horizontal = gallery && Array.from(gallery.querySelectorAll(".edgeever-image-node")).some((sibling) => {
const other = sibling.getBoundingClientRect();
return Math.abs(other.left - rect.left) > 1 && other.top < rect.bottom && other.bottom > rect.top;
});
const after = horizontal ? event.clientX > rect.left + rect.width / 2 : event.clientY > rect.top + rect.height / 2;
return {
position: hit.position + (after ? 1 : 0),
left: horizontal ? (after ? rect.right : rect.left) : rect.left,
top: horizontal ? rect.top : (after ? rect.bottom : rect.top),
width: horizontal ? 3 : rect.width,
height: horizontal ? rect.height : 3,
};
}
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!coords) return null;
const $pos = view.state.doc.resolve(coords.pos);
const imageType = view.state.schema.nodes.image;
// Snap to block boundaries instead of splitting the paragraph under the mouse.
for (let depth = $pos.depth; depth > 0; depth -= 1) {
const before = $pos.before(depth);
const parent = view.state.doc.resolve(before);
if (!parent.parent.canReplaceWith(parent.index(), parent.index(), imageType)) continue;
const dom = view.nodeDOM(before);
if (!(dom instanceof HTMLElement)) continue;
const rect = dom.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
return { position: after ? $pos.after(depth) : before, left: rect.left, top: after ? rect.bottom : rect.top, width: rect.width, height: 3 };
}
if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), imageType)) {
const rect = view.dom.getBoundingClientRect();
const caret = view.coordsAtPos(coords.pos);
return { position: coords.pos, left: rect.left, top: caret.top, width: rect.width, height: 3 };
}
return null;
}
export function createImageDragPlugin() {
let marker: HTMLDivElement | null = null;
const clearMarker = () => { marker?.remove(); marker = null; };
const reset = (view: EditorView) => {
clearMarker();
if (imageDragKey.getState(view.state) !== null) view.dispatch(view.state.tr.setMeta(imageDragKey, null));
};
return new Plugin<number | null>({
key: imageDragKey,
state: {
init: () => null,
apply(tr, previous) {
const requested = tr.getMeta(imageDragKey) as number | null | undefined;
if (requested !== undefined) return requested;
if (previous === null) return null;
const mapped = tr.mapping.mapResult(previous);
return mapped.deleted || tr.doc.nodeAt(mapped.pos)?.type.name !== "image" ? null : mapped.pos;
},
},
props: {
handleDOMEvents: {
dragstart(view, event) {
if (!view.editable || !(event.target instanceof Element)) return false;
const hit = imagePosition(view, event.target);
if (!hit || !event.target.closest("[data-drag-handle]")) return false;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, hit.position))
.setMeta(imageDragKey, hit.position).setMeta(imageSelectionKey, null));
return false; // Let ProseMirror serialize the node and start its internal drag.
},
dragover(view, event) {
const from = imageDragKey.getState(view.state);
if (!view.editable || from == null || !view.dragging) { clearMarker(); return false; }
const target = dropTarget(view, event);
if (!target || !moveImage(view.state, from, target.position)) { clearMarker(); return false; }
event.preventDefault();
if (!marker) {
marker = view.dom.ownerDocument.createElement("div");
marker.className = "edgeever-image-drop-indicator";
marker.setAttribute("aria-hidden", "true");
view.dom.ownerDocument.body.appendChild(marker);
}
Object.assign(marker.style, { left: `${target.left}px`, top: `${target.top}px`, width: `${target.width}px`, height: `${target.height}px` });
return true;
},
dragleave(view, event) {
if (!(event.relatedTarget instanceof globalThis.Node) || !view.dom.contains(event.relatedTarget)) clearMarker();
return false;
},
dragend(view) { reset(view); return false; },
drop() { clearMarker(); return false; },
},
handleDrop(view, event, _slice, moved) {
const from = imageDragKey.getState(view.state);
if (!view.editable || from == null || !view.dragging) return false;
if (!moved) { reset(view); return false; } // Preserve the editor's modifier-key copy gesture.
const target = dropTarget(view, event);
const transaction = target && moveImage(view.state, from, target.position);
if (transaction) {
view.dispatch(transaction);
// Keep the drag separate from subsequent typing in the undo history.
view.dispatch(closeHistory(view.state.tr));
view.focus();
} else reset(view);
return true; // Invalid/self drops must never fall through to insert a copy.
},
},
view(view) {
const clear = () => clearMarker();
view.dom.ownerDocument.addEventListener("drop", clear);
view.dom.ownerDocument.addEventListener("dragend", clear);
return {
update(next) { if (imageDragKey.getState(next.state) == null || !next.editable) clearMarker(); },
destroy() {
clearMarker();
view.dom.ownerDocument.removeEventListener("drop", clear);
view.dom.ownerDocument.removeEventListener("dragend", clear);
},
};
},
});
}
@@ -7,6 +7,7 @@ import {
createFileUploadPlaceholder,
createImageUploadPlaceholderPlugin,
createResourceUploadPlaceholder,
getImageUploadPlaceholderPosition,
removeImageUploadPlaceholder,
updateImageUploadPlaceholder,
} from "./image-upload-placeholder.ts";
@@ -128,6 +129,7 @@ describe("resource upload placeholder", () => {
?.find(undefined, undefined, (spec) => spec.id === placeholder.id) ?? [];
expect(initialDecorations).toHaveLength(1);
expect(initialDecorations[0]?.from).toBe(1);
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBe(1);
editor.view.dispatch(editor.state.tr.insertText("A", 1));
const mappedDecorations = IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY
@@ -135,8 +137,48 @@ describe("resource upload placeholder", () => {
?.find(undefined, undefined, (spec) => spec.id === placeholder.id) ?? [];
expect(mappedDecorations).toHaveLength(1);
expect(mappedDecorations[0]?.from).toBe(2);
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBe(2);
removeImageUploadPlaceholder(editor, placeholder);
expect(IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY.getState(editor.state)?.find()).toHaveLength(0);
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBeNull();
});
});
// #709: dropped files use a mapped point, never the previous replacement selection.
describe("drop upload anchors", () => {
const makeEditor = () => {
const schema = new Schema({ nodes: {
doc: { content: "block+" },
paragraph: { content: "inline*", group: "block" },
text: { group: "inline" },
} });
const editor = {
state: EditorState.create({ schema,
doc: schema.node("doc", null, [schema.node("paragraph", null, schema.text("before after"))]),
plugins: [createImageUploadPlaceholderPlugin()],
}),
view: { dispatch(tr) { editor.state = editor.state.apply(tr); } },
};
return editor;
};
test("retains distinct queued drop points through edits before and between them", () => {
const editor = makeEditor();
addImageUploadPlaceholder(editor, { ...placeholder, id: "first" }, 5);
addImageUploadPlaceholder(editor, { ...placeholder, id: "second" }, 10);
editor.view.dispatch(editor.state.tr.insertText("ABC", 1));
expect(getImageUploadPlaceholderPosition(editor.state, "first")).toBe(8);
expect(getImageUploadPlaceholderPosition(editor.state, "second")).toBe(13);
editor.view.dispatch(editor.state.tr.insertText("XY", 9));
expect(getImageUploadPlaceholderPosition(editor.state, "first")).toBe(8);
expect(getImageUploadPlaceholderPosition(editor.state, "second")).toBe(15);
});
test("does not revive an anchor whose containing content was deleted", () => {
const editor = makeEditor();
addImageUploadPlaceholder(editor, placeholder, 5);
editor.view.dispatch(editor.state.tr.delete(2, 8));
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBeNull();
});
});
@@ -1,5 +1,5 @@
import { Extension, type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { SUPPORTED_PASTE_IMAGE_TYPES } from "./editor-pane-helpers";
@@ -223,3 +223,8 @@ export const waitForImageSourceReady = async (source: string, timeoutMs = 4_000)
if (image.complete) finish();
});
};
/** Resolve an upload anchor after all edits made since the file was dropped. */
export const getImageUploadPlaceholderPosition = (state: EditorState, id: string): number | null =>
IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY.getState(state)
?.find(undefined, undefined, (spec) => spec.id === id)[0]?.from ?? null;
@@ -1,4 +1,5 @@
import katex from "katex";
import "katex/contrib/mhchem";
import type { MathFormulaKind } from "./math-formula";
export const hasMathPlaceholders = (latex: string) => /\\placeholder\b/.test(latex);
@@ -118,4 +118,53 @@ describe("math formula commands", () => {
expect(applyMathFormula(editor, { kind: "inline", latex: " " })).toBe(false);
editor.destroy();
});
test("continues after a newly inserted formula instead of in front of it (#719)", () => {
const inline = createEditor({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }] });
inline.commands.setTextSelection(11);
expect(applyMathFormula(inline, { kind: "inline", latex: "E = mc^2" })).toBe(true);
inline.commands.insertContent({ type: "text", text: "AFTER" });
expect(inline.getJSON().content[0].content).toEqual([
{ type: "text", text: "Energy is " },
{ type: "inlineMath", attrs: { latex: "E = mc^2" } },
{ type: "text", text: "AFTER" },
]);
inline.destroy();
for (const content of [
[{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }],
[{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }, { type: "paragraph", content: [{ type: "text", text: "next" }] }],
]) {
const block = createEditor({ type: "doc", content });
block.commands.setTextSelection(11);
expect(applyMathFormula(block, { kind: "block", latex: "E = mc^2" })).toBe(true);
// Typing must not replace the selected formula.
block.commands.insertContent({ type: "text", text: "AFTER" });
const types = block.getJSON().content.map((node) => node.type === "blockMath" ? "blockMath" : (node.content ?? []).map((child) => child.text).join(""));
expect(types).toEqual(content.length === 1 ? ["Energy is ", "blockMath", "AFTER"] : ["Energy is ", "blockMath", "AFTER", "next"]);
expect(block.getJSON().content.find((node) => node.type === "blockMath")?.attrs).toEqual({ latex: "E = mc^2" });
block.destroy();
}
});
test("continues after an edited formula instead of in front of it or replacing it (#719)", () => {
const editor = createEditor({ type: "doc", content: [
{ type: "paragraph", content: [{ type: "text", text: "A " }, { type: "inlineMath", attrs: { latex: "x" } }, { type: "text", text: " B" }] },
{ type: "blockMath", attrs: { latex: "y" } },
{ type: "paragraph", content: [{ type: "text", text: "C" }] },
] });
const outline = () => editor.getJSON().content.map((node) => node.type === "blockMath"
? `[block:${node.attrs.latex}]`
: (node.content ?? []).map((child) => child.text ?? `[inline:${child.attrs.latex}]`).join(""));
expect(applyMathFormula(editor, { kind: "inline", latex: "z^2", pos: 3 })).toBe(true);
editor.commands.insertContent({ type: "text", text: "Q" });
expect(outline()).toEqual(["A [inline:z^2]Q B", "[block:y]", "C"]);
const blockPos = editor.state.doc.child(0).nodeSize;
expect(applyMathFormula(editor, { kind: "block", latex: "w", pos: blockPos })).toBe(true);
editor.commands.insertContent({ type: "text", text: "R" });
expect(outline()).toEqual(["A [inline:z^2]Q B", "[block:w]", "R", "C"]);
editor.destroy();
});
});
+42 -4
View File
@@ -1,4 +1,5 @@
import type { Editor } from "@tiptap/core";
import { NodeSelection, TextSelection, type Transaction } from "@tiptap/pm/state";
import {
BLOCK_MATH_NODE_TYPE,
INLINE_MATH_NODE_TYPE,
@@ -36,14 +37,40 @@ export const resolveMathFormulaTarget = (editor: Editor, pos?: number): MathForm
return null;
};
/**
* Puts the caret right after the formula at `formulaPos`, so the next key
* continues the text instead of landing in front of it or replacing it. A
* display formula continues in an empty paragraph after it, so typing does not
* join the following text either.
*/
const placeCaretAfterFormula = (tr: Transaction, formulaPos: number) => {
const node = tr.doc.nodeAt(formulaPos);
if (!node) return;
const after = formulaPos + node.nodeSize;
if (node.type.name !== BLOCK_MATH_NODE_TYPE) {
tr.setSelection(TextSelection.create(tr.doc, after));
return;
}
const next = tr.doc.resolve(after).nodeAfter;
if (next?.type.name !== "paragraph" || next.content.size > 0) {
tr.insert(after, tr.doc.type.schema.nodes.paragraph!.create());
}
tr.setSelection(TextSelection.create(tr.doc, after + 1));
};
export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
const latex = draft.latex.trim();
if (!latex) return false;
if (typeof draft.pos === "number") {
return draft.kind === "inline"
? editor.commands.updateInlineMath({ latex, pos: draft.pos })
: editor.commands.updateBlockMath({ latex, pos: draft.pos });
const pos = draft.pos;
const chain = editor.chain();
return (draft.kind === "inline" ? chain.updateInlineMath({ latex, pos }) : chain.updateBlockMath({ latex, pos }))
.command(({ tr }) => {
placeCaretAfterFormula(tr, tr.mapping.map(pos));
return true;
})
.run();
}
const from = draft.from ?? editor.state.selection.from;
@@ -52,7 +79,18 @@ export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
? { type: INLINE_MATH_NODE_TYPE, attrs: { latex } }
: { type: BLOCK_MATH_NODE_TYPE, attrs: { latex } };
return editor.chain().insertContentAt(from === to ? from : { from, to }, content).run();
return editor.chain()
.insertContentAt(from === to ? from : { from, to }, content)
.command(({ tr }) => {
// An inline formula already leaves the caret after it; a display formula
// is left selected as a node.
const { selection } = tr;
if (selection instanceof NodeSelection && selection.node.type.name === BLOCK_MATH_NODE_TYPE) {
placeCaretAfterFormula(tr, selection.from);
}
return true;
})
.run();
};
export const deleteMathFormula = (editor: Editor, target: Pick<MathFormulaTarget, "kind" | "pos">) => {
@@ -105,7 +105,8 @@ export const DesktopScheduledBackupCard = () => {
<label className="grid gap-1.5 text-xs font-medium text-slate-700">
<span>{t("desktopBackup.retention")}</span>
<select className="h-8 rounded-md border border-input bg-card px-2.5 text-xs text-slate-800 outline-none focus:border-slate-900 focus:ring-1 focus:ring-slate-900" value={config.retention} onChange={(event) => patch({ retention: Number(event.target.value) as DesktopBackupConfig["retention"] })}>
{([3, 5, 10, 20] as const).map((count) => <option key={count} value={count}>{t("desktopBackup.copies", { count })}</option>)}
{(config.retention === 10 || config.retention === 20) ? <option value={config.retention} disabled>{t("desktopBackup.copies", { count: config.retention })}</option> : null}
{([1, 3, 5] as const).map((count) => <option key={count} value={count}>{t("desktopBackup.copies", { count })}</option>)}
</select>
</label>
</div>
+5 -5
View File
@@ -83,9 +83,10 @@ export const useWorkspaceQueuedSync = ({
const hasRemote = current.notebooks.some((notebook) => notebook.id === remoteId);
return {
...current,
notebooks: hasRemote
notebooks: (hasRemote
? current.notebooks.filter((notebook) => notebook.id !== temporaryId)
: current.notebooks.map((notebook) => notebook.id === temporaryId ? { ...notebook, id: remoteId } : notebook),
: current.notebooks.map((notebook) => notebook.id === temporaryId ? { ...notebook, id: remoteId } : notebook))
.map((notebook) => notebook.parentId === temporaryId ? { ...notebook, parentId: remoteId } : notebook),
};
});
setSelectedNotebookId((current) => current === temporaryId ? remoteId : current);
@@ -146,7 +147,6 @@ export const useWorkspaceQueuedSync = ({
if (!result || !["memo.merge", "notebook.create", "notebook.update", "template.create", "template.update", "resource.create"].includes(item.kind)) return;
const {
deleteLocalMemo,
deleteLocalNotebook,
deleteLocalTemplate,
getLocalMemo,
putLocalMemo: putMemo,
@@ -168,8 +168,8 @@ export const useWorkspaceQueuedSync = ({
if (source) await putMemo(localDataScope, { ...source, mergedIntoMemoId: (result as MemoDetail).id });
}));
} else if (item.kind === "notebook.create" && "name" in result && temporaryId) {
await deleteLocalNotebook(localDataScope, temporaryId);
await putLocalNotebook(localDataScope, result as Notebook);
// The sync core committed the mirror, references, mapping and
// acknowledgement together before notifying the live UI.
notifyNotebookIdRemapped(temporaryId, (result as Notebook).id);
} else if (item.kind === "notebook.update" && "name" in result) {
await putLocalNotebook(localDataScope, result as Notebook);
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
import { getTextBetween } from "@tiptap/core";
import { Schema } from "@tiptap/pm/model";
import { AllSelection, EditorState, TextSelection } from "@tiptap/pm/state";
import { CellSelection, tableNodes } from "@tiptap/pm/tables";
import { editorClipboardTextSerializer, serializeEditorTextRange } from "./editor-clipboard.ts";
const schema = new Schema({
nodes: {
doc: { content: "block+" },
text: { group: "inline" },
paragraph: { content: "inline*", group: "block" },
blockquote: { content: "block+", group: "block" },
bulletList: { content: "listItem+", group: "block" },
listItem: { content: "paragraph block*" },
codeBlock: { content: "text*", group: "block", code: true },
hardBreak: { inline: true, group: "inline", toText: () => "\n" },
...tableNodes({ tableGroup: "block", cellContent: "block+" }),
},
});
const node = (type, content = []) => schema.node(type, null, content);
const p = (text) => node("paragraph", text ? [schema.text(text)] : []);
const item = (text) => node("listItem", [p(text)]);
const copy = (doc, selection = new AllSelection(doc)) => editorClipboardTextSerializer(
selection.content(), { state: EditorState.create({ doc, selection }) },
);
test("quoted lists reproduce accumulated default whitespace and copy with one newline per text block", () => {
const doc = node("doc", [node("blockquote", [
p("4 个月的数据:"),
node("bulletList", [item("GitHub 突破 3,000 Star"), item("全服第一")]),
p("鸣谢背后的神仙战友:"),
node("bulletList", [item("感谢小伙伴"), item("感谢波兰和德国老哥")]),
])]);
expect(getTextBetween(doc, { from: 0, to: doc.content.size })).toContain("\n\n\n\n");
expect(copy(doc)).toBe("4 个月的数据:\nGitHub 突破 3,000 Star\n全服第一\n鸣谢背后的神仙战友:\n感谢小伙伴\n感谢波兰和德国老哥");
});
test("nested lists and quotes do not create leading or repeated separators", () => {
const doc = node("doc", [node("blockquote", [node("bulletList", [
node("listItem", [p("Parent"), node("bulletList", [item("Child")])]), item("Next"),
])])]);
expect(copy(doc)).toBe("Parent\nChild\nNext");
});
test("intentional empty paragraphs, hard breaks and code whitespace survive", () => {
const doc = node("doc", [p("First"), p(""),
node("paragraph", [schema.text("A"), node("hardBreak"), schema.text("B")]),
node("codeBlock", [schema.text(" code\n\n indented")]), p(""),
]);
expect(copy(doc)).toBe("First\n\nA\nB\n code\n\n indented\n");
});
test("partial selections copy only selected text, including across paragraphs", () => {
const doc = node("doc", [p("Alpha"), p("Beta")]);
expect(copy(doc, TextSelection.create(doc, 3, 10))).toBe("pha\nBe");
expect(serializeEditorTextRange(doc, 2, 4)).toBe("lp");
});
test("table cell selections omit unselected cells and keep document order", () => {
const cell = (text) => node("table_cell", [p(text)]);
const doc = node("doc", [node("table", [
node("table_row", [cell("A"), cell("skip1")]),
node("table_row", [cell("B"), cell("skip2")]),
])]);
const positions = [];
doc.descendants((n, pos) => { if (n.type.name === "table_cell") positions.push(pos); });
expect(copy(doc, CellSelection.create(doc, positions[2], positions[0]))).toBe("A\nB");
});
+36
View File
@@ -0,0 +1,36 @@
import { getTextSerializersFromSchema, type TextSerializer } from "@tiptap/core";
import type { Node } from "@tiptap/pm/model";
import type { EditorProps } from "@tiptap/pm/view";
/** Container nodes (quotes, lists, items) must not each add a blank line. */
export const serializeEditorTextRange = (doc: Node, from: number, to: number): string => {
const serializers: Partial<Record<string, TextSerializer>> = getTextSerializersFromSchema(doc.type.schema);
let text = "";
let firstBlock = true;
doc.nodesBetween(from, to, (node, pos, parent, index) => {
const serializer = serializers[node.type.name];
if (node.isBlock && (node.isTextblock || serializer || (node.isLeaf && node.type.spec.leafText))) {
if (!firstBlock) text += "\n";
firstBlock = false;
}
if (serializer && parent) {
text += serializer({ node, pos, parent, index, range: { from, to } });
return false;
}
if (node.isText) {
text += node.text!.slice(Math.max(from, pos) - pos, to - pos);
} else if (node.isLeaf && node.type.spec.leafText) {
text += node.type.spec.leafText(node);
}
});
return text;
};
export const editorClipboardTextSerializer: NonNullable<EditorProps["clipboardTextSerializer"]> = (_slice, view) => {
const { doc, selection } = view.state;
// Cell selections expose disjoint ranges: do not include the cells between them.
return [...selection.ranges]
.sort((a, b) => a.$from.pos - b.$from.pos)
.map(({ $from, $to }) => serializeEditorTextRange(doc, $from.pos, $to.pos))
.join("\n");
};
+4 -4
View File
@@ -10,7 +10,7 @@ const GANTT_SOURCE = `gantt
Compatibility fallback :fallback, 2026-07-30, 1d`;
describe("Mermaid renderer fallback", () => {
test("routes the reported subgraph to Mermaid even when the compact renderer would succeed", async () => {
test("keeps the reported Chinese subgraph on the compact renderer", async () => {
const source = `flowchart LR
subgraph 日志开启建议
A[生产环境] -->|不开启| B[查询日志]
@@ -31,12 +31,12 @@ describe("Mermaid renderer fallback", () => {
source,
renderBeautiful: () => {
compactRenderCount += 1;
return renderMermaidSVG(source);
return compactRender.stdout;
},
renderOfficial: () => '<svg data-renderer="mermaid"></svg>',
});
expect(compactRenderCount).toBe(0);
expect(svg).toContain('data-renderer="mermaid"');
expect(compactRenderCount).toBe(1);
expect(svg).toBe(compactRender.stdout);
});
test("does not route a comment mentioning subgraphs to the official renderer", async () => {
+2 -5
View File
@@ -9,14 +9,9 @@ interface MermaidRenderFallbackOptions {
}
export const renderMermaidWithFallback = async ({
source = "",
renderBeautiful,
renderOfficial,
}: MermaidRenderFallbackOptions) => {
// The compact renderer offsets subgraph nodes without moving their edge endpoints.
// It can return a valid SVG with disconnected arrows, so exceptions are insufficient.
if (/^\s*subgraph\b/m.test(source)) return renderOfficial();
try {
return await renderBeautiful();
} catch {
@@ -40,6 +35,8 @@ export const renderThemedMermaidSvg = (
) => renderMermaidWithFallback({
source,
renderBeautiful: async () => {
// Repository dependency patches preserve CJK group IDs and ELK edge offsets
// (upstream PRs #132 and #152). Keep supported subgraphs on this renderer.
const { renderMermaidSVG, THEMES } = await (compactModule ??= import("beautiful-mermaid"));
return scaleMermaidSvg(renderMermaidSVG(source, {
...THEMES[theme], ...palette, transparent: true, font, padding,
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
test("patched compact renderer preserves Chinese groups and cross-container edge geometry in source and browser bundles", () => {
// ELK must initialize without the partial window globals installed by other suites.
const result = spawnSync(process.execPath, ["--eval", `
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseMermaid, renderMermaidSVG } from "beautiful-mermaid";
const entry = import.meta.resolve("beautiful-mermaid");
const { layoutGraphSync } = await import(new URL("./layout.ts", entry).href);
const browser = await import(new URL("../dist/index.js", entry).href);
const onBoundary = (p, n) => {
const near = (a, b) => Math.abs(a - b) < 1.5;
return ((near(p.x, n.x) || near(p.x, n.x + n.width)) && p.y >= n.y - 1.5 && p.y <= n.y + n.height + 1.5)
|| ((near(p.y, n.y) || near(p.y, n.y + n.height)) && p.x >= n.x - 1.5 && p.x <= n.x + n.width + 1.5);
};
for (const direction of ["LR", "RL", "TD", "BT"]) {
for (const body of [
'subgraph 日志开启建议\\nA[生产环境] -->|不开启| B[查询日志]\\nC[开发测试] -->|按需开启| B\\nE[审计场景] -->|按需开启| B\\nend',
'subgraph outer["Outer"]\\na[A]\\nsubgraph inner["Inner"]\\nb[B]\\nend\\nend\\na -->|yes| b',
'subgraph 外层["外层分组"]\\na[A]\\nsubgraph 内层["内层分组"]\\nb[B]\\nend\\nend\\na -->|yes| b',
'a[A] -->|yes| b[B]',
]) {
const source = 'flowchart ' + direction + '\\n' + body;
const graph = parseMermaid(source);
for (const group of graph.subgraphs) assert(group.id, 'empty Chinese group id');
const positioned = layoutGraphSync(graph);
const nodes = new Map(positioned.nodes.map(n => [n.id, n]));
for (const edge of positioned.edges) {
assert(onBoundary(edge.points[0], nodes.get(edge.source)), direction + ': detached start');
assert(onBoundary(edge.points.at(-1), nodes.get(edge.target)), direction + ': detached end');
}
assert.equal(renderMermaidSVG(source), browser.renderMermaidSVG(source), 'Bun and browser patches differ');
}
}
// Exercise the actual demo without rewriting its topology or saved content.
const seed = readFileSync(new URL('../../../api/src/demo-seed-data.ts', import.meta.url), 'utf8');
const start = seed.indexOf('flowchart TD', seed.indexOf('## 2. 全景架构与生态联动'));
const source = seed.slice(start, seed.indexOf('\\n' + String.fromCharCode(92, 96).repeat(3), start));
const svg = renderMermaidSVG(source);
assert.equal(svg, browser.renderMermaidSVG(source));
assert(svg.includes('全端客户端矩阵'));
assert(svg.includes('AI 智能体与外部生态'));
console.log('geometry and source/browser parity passed');
`], { cwd: import.meta.dir, encoding: "utf8", timeout: 30_000 });
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout).toContain("geometry and source/browser parity passed");
});
+13 -4
View File
@@ -127,12 +127,21 @@ export const normalizeMermaidSvgForViewer = (
) => {
if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return svg;
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
const root = document.documentElement;
if (root.tagName.toLowerCase() !== "svg" || document.querySelector("parsererror")) return svg;
const parser = new DOMParser();
const document = parser.parseFromString(svg, "image/svg+xml");
let root: Element | null = document.documentElement;
if (document.querySelector("parsererror")) {
// Official Mermaid labels can contain HTML void tags such as <br>. Inline
// SVG accepts them, but a standalone SVG image requires well-formed XML.
// Parse in an inert HTML document, then serialize the SVG with XHTML
// namespaces and closed void tags before handing it to the image viewer.
root = parser.parseFromString(svg, "text/html").body.firstElementChild;
if (root?.namespaceURI !== "http://www.w3.org/2000/svg") return svg;
}
if (root?.tagName.toLowerCase() !== "svg") return svg;
// Mermaid emits width="100%", which gives Blob-backed images incorrect intrinsic dimensions.
root.setAttribute("width", String(presentation.width));
root.setAttribute("height", String(presentation.height));
return new XMLSerializer().serializeToString(document);
return new XMLSerializer().serializeToString(root);
};
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, expect, test } from "bun:test";
import "fake-indexeddb/auto";
const { localDb } = await import("./local-db.ts");
const { api } = await import("./api.ts");
const { createLocalNotebook, createLocalMemo, getLocalMemo } = await import("./local-mirror.ts");
const { queueLocalAction, queueMemoCreate, syncQueuedChanges } = await import("./sync-queue.ts");
const { acknowledgeNotebookId, notebookRemapTables, repairQueuedNotebookReferences } = await import("./notebook-id-remap.ts");
const scope = "notebook-remap-test";
const original = { createNotebook: api.createNotebook, createMemo: api.createMemo };
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
afterEach(async () => {
Object.assign(api, original);
if (navigatorDescriptor) Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
else delete globalThis.navigator;
await Promise.all(localDb.tables.map((table) => table.clear()));
});
const online = () => Object.defineProperty(globalThis, "navigator", { configurable: true, value: { onLine: true } });
const enqueueNotebook = async (name, parentId) => {
const notebook = await createLocalNotebook(scope, { name, parentId });
await queueLocalAction(scope, "notebook.create", notebook.id, { name, parentId, temporaryId: notebook.id });
return notebook;
};
const enqueueMemo = async (notebookId) => {
const memo = await createLocalMemo(scope, { notebookId, title: "Keep my note" });
await queueMemoCreate(scope, { temporaryId: memo.id, notebookId, title: memo.title, tags: [], createdAt: memo.createdAt, updatedAt: memo.updatedAt });
return memo;
};
test("slow notebook creation remaps existing and in-flight child writes without a UI callback", async () => {
online();
const parent = await enqueueNotebook("parent");
const child = await enqueueNotebook("child", parent.id);
const memo = await enqueueMemo(child.id);
let lateMemo;
const sent = [];
api.createNotebook = async (input) => {
sent.push(input);
if (input.name === "parent") {
// User keeps writing while the parent's network request is in flight.
lateMemo = await enqueueMemo(parent.id);
return { notebook: { ...parent, id: "nb_parent" } };
}
expect(input.parentId).toBe("nb_parent");
return { notebook: { ...child, id: "nb_child", parentId: "nb_parent" } };
};
api.createMemo = async (input) => {
expect(["nb_parent", "nb_child"]).toContain(input.notebookId);
return { memo: { ...memo, ...input, id: `memo_${input.notebookId}`, revision: 1, contentHash: "hash" } };
};
// Equal timestamps can put children before parents; subsequent runs must settle.
for (let index = 0; index < 4; index++) await syncQueuedChanges({ scope });
expect(await localDb.syncQueue.count()).toBe(0);
expect(sent).toHaveLength(2);
expect((await getLocalMemo(scope, memo.id)).notebookId).toBe("nb_child");
expect((await getLocalMemo(scope, lateMemo.id)).notebookId).toBe("nb_parent");
expect(await localDb.notebooks.get([scope, parent.id])).toBeUndefined();
expect((await localDb.notebooks.get([scope, "nb_child"])).parentId).toBe("nb_parent");
});
test("failed parents and unmapped legacy orphans stay local without invalid API requests", async () => {
online();
const parent = await enqueueNotebook("parent");
const memo = await enqueueMemo(parent.id);
await enqueueNotebook("child", parent.id);
await enqueueMemo("local_legacy_parent_without_mapping");
api.createNotebook = async () => { throw new Error("offline"); };
api.createMemo = async () => { throw new Error("must not send a temporary notebook id"); };
const result = await syncQueuedChanges({ scope });
expect(result).toMatchObject({ attempted: 1, failed: 1 });
expect((await localDb.syncQueue.get(`memo.create:${memo.id}`)).attemptCount).toBe(0);
expect((await getLocalMemo(scope, memo.id)).title).toBe("Keep my note");
});
test("persisted mappings repair delayed errors, batch targets and stale-tab writes after reopening", async () => {
const parent = await createLocalNotebook(scope, { name: "parent" });
await localDb.transaction("rw", notebookRemapTables, () => acknowledgeNotebookId(scope, parent.id, { ...parent, id: "nb_parent" }));
localDb.close();
await localDb.open();
const kinds = ["memo.create", "memo.moveBatch", "memo.merge", "notebook.create", "notebook.update", "notebook.delete"];
for (const kind of kinds) {
await queueLocalAction(scope, kind, parent.id, {
notebookId: parent.id, parentId: parent.id, notebookIds: [parent.id, "nb_other"],
});
}
await queueLocalAction("another-account", "memo.moveBatch", "other", { notebookId: parent.id });
await localDb.syncQueue.toCollection().modify({ status: "error", lastError: "Notebook not found", nextAttemptAt: "2099-01-01T00:00:00Z" });
await repairQueuedNotebookReferences(scope);
const repaired = (await localDb.syncQueue.toArray()).filter((item) => item.scope === scope);
expect(repaired).toHaveLength(kinds.length);
for (const item of repaired) {
expect(item).toMatchObject({ status: "pending", nextAttemptAt: null, lastError: null });
expect(item.payload).toMatchObject({ notebookId: "nb_parent", parentId: "nb_parent", notebookIds: ["nb_parent", "nb_other"] });
if (item.kind === "notebook.update" || item.kind === "notebook.delete") expect(item.memoId).toBe("nb_parent");
}
expect((await localDb.syncQueue.toArray()).find((item) => item.scope === "another-account").payload.notebookId).toBe(parent.id);
});
test("acknowledgement failure rolls back notebook mapping, local relationships and queue rewrites", async () => {
online();
const parent = await enqueueNotebook("parent");
const memo = await enqueueMemo(parent.id);
const child = await enqueueNotebook("child", parent.id);
api.createNotebook = async () => ({ notebook: { ...parent, id: "nb_parent" } });
const failDelete = () => { throw new Error("injected acknowledgement failure"); };
localDb.syncQueue.hook("deleting", failDelete);
try {
expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 0, failed: 1 });
} finally {
localDb.syncQueue.hook("deleting").unsubscribe(failDelete);
}
expect(await localDb.syncMeta.count()).toBe(0);
expect(await localDb.notebooks.get([scope, "nb_parent"])).toBeUndefined();
expect(await localDb.notebooks.get([scope, parent.id])).toBeDefined();
expect((await localDb.notebooks.get([scope, child.id])).parentId).toBe(parent.id);
expect((await getLocalMemo(scope, memo.id)).notebookId).toBe(parent.id);
expect((await localDb.syncQueue.get(`memo.create:${memo.id}`)).payload.notebookId).toBe(parent.id);
});
test("remapping preserves drafts, conflicts, in-flight claims and content containing the temporary id", async () => {
const parent = await createLocalNotebook(scope, { name: "parent" });
const memo = await enqueueMemo(parent.id);
const draft = { memoId: memo.id, title: "Latest unsaved edit", contentJson: { type: "doc", content: [] }, tagsText: "", updatedAt: new Date().toISOString() };
await localDb.drafts.put(draft);
await queueLocalAction(scope, "notebook.update", parent.id, { notebookId: parent.id, name: parent.id });
const conflict = (await localDb.syncQueue.toArray()).find((item) => item.kind === "notebook.update");
await localDb.syncQueue.update(conflict.id, { status: "conflict", lastError: "Keep this conflict" });
await queueLocalAction(scope, "memo.moveBatch", "claimed", { notebookId: parent.id });
const claimed = (await localDb.syncQueue.toArray()).find((item) => item.kind === "memo.moveBatch");
await localDb.syncQueue.update(claimed.id, { status: "syncing", claimId: "another-tab" });
await localDb.transaction("rw", notebookRemapTables, () => acknowledgeNotebookId(scope, parent.id, { ...parent, id: "nb_parent" }));
expect(await localDb.drafts.get(memo.id)).toEqual(draft);
expect(await localDb.syncQueue.get(conflict.id)).toMatchObject({ status: "conflict", lastError: "Keep this conflict", payload: { notebookId: "nb_parent", name: parent.id } });
expect(await localDb.syncQueue.get(claimed.id)).toMatchObject({ status: "syncing", claimId: "another-tab", payload: { notebookId: parent.id } });
});
+79
View File
@@ -0,0 +1,79 @@
import type { Notebook } from "@edgeever/shared";
import { localDb, type SyncQueueItem } from "@/lib/local-db";
// Keep notebook mappings separate from the memo mappings observed by editors.
const mappingPrefix = "notebook-id:";
export const notebookRemapTables = [localDb.syncQueue, localDb.syncMeta, localDb.notebooks, localDb.memos];
const notebookReferences = (item: SyncQueueItem) => {
const payload = item.payload as Record<string, unknown>;
return [payload.notebookId, payload.parentId,
...(Array.isArray(payload.notebookIds) ? payload.notebookIds : []),
...(item.kind === "notebook.update" || item.kind === "notebook.delete" ? [item.memoId] : []),
].filter((id): id is string => typeof id === "string");
};
export const hasTemporaryNotebookDependency = (item: SyncQueueItem) =>
notebookReferences(item).some((id) => id.startsWith("local_"));
// Called inside a transaction, including the create acknowledgement when present.
const rewriteNotebookReferences = async (scope: string, temporaryId: string, remoteId: string) => {
for (const item of await localDb.syncQueue.toArray()) {
if (item.scope !== scope || item.status === "syncing" || !notebookReferences(item).includes(temporaryId)) continue;
const payload = { ...item.payload } as Record<string, unknown>;
for (const field of ["notebookId", "parentId"]) {
if (payload[field] === temporaryId) payload[field] = remoteId;
}
if (Array.isArray(payload.notebookIds)) {
payload.notebookIds = payload.notebookIds.map((id) => id === temporaryId ? remoteId : id);
}
await localDb.syncQueue.put({
...item,
memoId: item.kind.startsWith("notebook.") && item.memoId === temporaryId ? remoteId : item.memoId,
payload,
// Dependency errors can retry immediately; preserve genuine conflicts.
...(item.status === "error" ? {
status: "pending" as const, nextAttemptAt: null, lastError: null,
lastErrorCode: null, lastErrorDetails: null, claimId: null,
} : {}),
});
}
await localDb.memos.where("scope").equals(scope).filter((memo) => memo.notebookId === temporaryId)
.modify((memo) => { memo.notebookId = remoteId; });
await localDb.notebooks.where("scope").equals(scope).filter((notebook) => notebook.parentId === temporaryId)
.modify({ parentId: remoteId });
};
export const acknowledgeNotebookId = async (scope: string, temporaryId: string, notebook: Notebook) => {
if (temporaryId === notebook.id) return;
await localDb.syncMeta.put({
scope, key: `${mappingPrefix}${temporaryId}`, value: notebook.id, updatedAt: new Date().toISOString(),
});
await rewriteNotebookReferences(scope, temporaryId, notebook.id);
await localDb.notebooks.delete([scope, temporaryId]);
await localDb.notebooks.put({ ...notebook, scope });
};
// Replay persisted mappings after reload, including writes from a stale UI/tab.
export const repairQueuedNotebookReferences = async (scope?: string, itemId?: string) => {
await localDb.transaction("rw", notebookRemapTables, async () => {
const item = itemId ? await localDb.syncQueue.get(itemId) : undefined;
if (itemId && (!item || !hasTemporaryNotebookDependency(item))) return;
const queued = item ? [item] : await localDb.syncQueue.toArray();
const references = new Set(queued
.filter((entry) => !scope || entry.scope === scope)
.flatMap((entry) => notebookReferences(entry)
.filter((id) => id.startsWith("local_"))
.map((id) => JSON.stringify([entry.scope, id]))));
if (references.size === 0) return;
const mappings = scope
? await localDb.syncMeta.where("scope").equals(scope).toArray()
: await localDb.syncMeta.toArray();
for (const mapping of mappings) {
if (!mapping.key.startsWith(mappingPrefix)) continue;
const temporaryId = mapping.key.slice(mappingPrefix.length);
if (!references.has(JSON.stringify([mapping.scope, temporaryId]))) continue;
await rewriteNotebookReferences(mapping.scope, temporaryId, mapping.value);
}
});
};
+2 -1
View File
@@ -400,7 +400,8 @@ 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 notebook = { ...await createLocalNotebook(scope, { name: "Heimnetz" }), id: "nb_appearance" };
await localDb.notebooks.put({ ...notebook, scope });
const original = api.updateNotebook;
const previousNavigator = globalThis.navigator;
globalThis.navigator = { onLine: true };
+13 -1
View File
@@ -33,6 +33,7 @@ import { isBrowserOffline } from "@/lib/network-status";
import { parseTagsText } from "@/lib/utils";
import { createClientUuid } from "@/lib/client-id";
import { notebookDeleteIdsFromPayload } from "@/lib/notebook-delete";
import { acknowledgeNotebookId, hasTemporaryNotebookDependency, notebookRemapTables, repairQueuedNotebookReferences } from "@/lib/notebook-id-remap";
export type { SyncQueueSummary, SyncRunResult } from "@edgeever/shared";
export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null;
@@ -390,6 +391,7 @@ const runQueuedChanges = async (options: {
}
const now = new Date();
await repairQueuedNotebookReferences(options.scope);
const candidates = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray())
.filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
@@ -413,6 +415,9 @@ const runQueuedChanges = async (options: {
}
for (const candidate of items) {
// Creates earlier in this batch may have resolved this dependency. Also
// repair actions written by another tab since the initial queue snapshot.
await repairQueuedNotebookReferences(candidate.scope, candidate.id);
const item = await claimQueueItem(candidate.id);
if (!item) {
continue;
@@ -487,6 +492,8 @@ const claimQueueItem = (id: string) =>
if (!item || (item.status !== "pending" && item.status !== "error")) {
return null;
}
if (hasTemporaryNotebookDependency(item)) return null;
if (["memo.update", "memo.delete", "memo.restore"].includes(item.kind) && item.memoId.startsWith("local_")) return null;
const claimId = createClientUuid();
const claimedItem: SyncQueueItem = {
@@ -515,13 +522,18 @@ const deletedMemoIdsFromAction = (item: SyncQueueItem): string[] => {
};
const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResult): Promise<MemoUpdateAcknowledgement> =>
localDb.transaction("rw", [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
localDb.transaction("rw", item.kind === "notebook.create"
? [...notebookRemapTables, localDb.drafts]
: [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
const current = await localDb.syncQueue.get(item.id);
const memo = result && item.kind === "memo.update" && "contentHash" in result
? result as MemoDetail
: null;
if (current?.claimId === item.claimId && current.status === "syncing") {
if (item.kind === "notebook.create" && result && "name" in result && item.scope) {
await acknowledgeNotebookId(item.scope, item.memoId, result as Notebook);
}
await localDb.syncQueue.delete(item.id);
// A successful delete supersedes older conflicted edits to those notes.
// Keep the draft until the server has confirmed the delete, so a failed
+1
View File
@@ -10,6 +10,7 @@ import {
type NoteProsePaletteColors,
} from "@edgeever/shared";
import katex from "katex";
import "katex/contrib/mhchem";
import { common, createLowlight } from "lowlight";
import { toCanvas } from "html-to-image";
import { marked } from "marked";
+97 -46
View File
@@ -1981,12 +1981,12 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
height: auto;
object-fit: contain;
margin: 1rem 0;
border: 1px solid var(--brand-green-border);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--brand-green);
outline: 2px solid rgb(var(--slate-500-rgb) / 0.6);
outline-offset: 3px;
}
@@ -1998,7 +1998,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
height: clamp(12rem, 42vw, 28rem);
overflow: hidden;
margin: 1rem 0;
border: 1px solid var(--brand-green-border);
border: 1px solid hsl(var(--border));
border-radius: 8px;
background:
linear-gradient(110deg, transparent 24%, rgb(255 255 255 / 0.72) 42%, transparent 60%) -18rem 0 / 18rem 100% no-repeat,
@@ -2038,9 +2038,9 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
transform: translateX(-50%);
border: 1px solid rgb(var(--slate-300-rgb) / 0.8);
border-radius: 999px;
background: rgb(255 255 255 / 0.92);
background: hsl(var(--card));
padding: 0.4rem 0.75rem;
color: #475569;
color: hsl(var(--foreground));
box-shadow: 0 4px 16px rgb(15 23 42 / 0.12);
font-size: 0.75rem;
font-weight: 600;
@@ -2051,8 +2051,8 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
.ProseMirror .edgeever-image-upload-placeholder__spinner {
width: 0.85rem;
height: 0.85rem;
border: 2px solid rgb(var(--brand-green-rgb) / 0.2);
border-top-color: var(--brand-green);
border: 2px solid rgb(var(--slate-500-rgb) / 0.2);
border-top-color: rgb(var(--slate-500-rgb));
border-radius: 999px;
animation: edgeever-image-placeholder-spin 700ms linear infinite;
}
@@ -2065,7 +2065,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
min-height: 3.5rem;
margin: 0.5rem 0;
padding: 0.75rem 1rem;
border: 1px solid var(--brand-green-border);
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: rgb(var(--slate-100-rgb) / 0.55);
box-shadow: 0 1px 3px rgb(15 23 42 / 0.08);
@@ -2077,7 +2077,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
flex: none;
width: 1.4rem;
height: 1.4rem;
color: var(--brand-green);
color: rgb(var(--slate-500-rgb));
}
.ProseMirror .edgeever-file-upload-placeholder__icon svg {
@@ -2136,8 +2136,24 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
margin: 0;
}
.ProseMirror .edgeever-image-node img[draggable="true"] {
cursor: grab;
}
.ProseMirror .edgeever-image-node img[draggable="true"]:active {
cursor: grabbing;
}
.edgeever-image-drop-indicator {
position: fixed;
z-index: 100;
pointer-events: none;
background: hsl(var(--foreground));
border-radius: 2px;
}
.ProseMirror .edgeever-image-node.is-selected img {
outline: 2px solid var(--brand-green);
outline: 2px solid rgb(var(--slate-500-rgb) / 0.6);
outline-offset: 3px;
}
@@ -2151,11 +2167,11 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
margin-top: 0.5rem;
align-items: center;
gap: 0.35rem;
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
padding: 0.3rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
line-height: 1;
color: hsl(var(--foreground));
font-family: var(--ui-font-family, Inter, ui-sans-serif, system-ui, sans-serif);
@@ -2175,7 +2191,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
.ProseMirror .edgeever-image-resource-actions:not(:empty) {
width: 100%;
border-top: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border-top: 1px solid hsl(var(--border));
padding-top: 0.3rem;
}
@@ -2184,11 +2200,11 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
right: 0.5rem;
top: 0.5rem;
z-index: 2;
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
background: hsl(var(--card));
padding: 0.3rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
line-height: 1;
opacity: 0;
pointer-events: none;
@@ -2286,12 +2302,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
}
.ProseMirror .edgeever-image-gallery__group + .edgeever-image-gallery__group {
border-inline-start: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border-inline-start: 1px solid hsl(var(--border));
padding-inline-start: 0.25rem;
}
.ProseMirror .edgeever-image-gallery__toolbar {
position: relative;
position: absolute;
bottom: 100%;
inset-inline-start: 0;
z-index: 10;
visibility: hidden;
pointer-events: none;
display: flex;
flex-wrap: wrap;
width: fit-content;
@@ -2299,11 +2320,26 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
margin-bottom: 0.5rem;
align-items: center;
gap: 0.25rem;
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
background: hsl(var(--card));
padding: 0.3rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
}
/* Bridge the gap so the pointer can reach the floating toolbar. */
.ProseMirror .edgeever-image-gallery__toolbar::after {
content: "";
position: absolute;
inset-inline: 0;
top: 100%;
height: 0.5rem;
}
.ProseMirror .edgeever-image-gallery:hover > .edgeever-image-gallery__toolbar,
.ProseMirror .edgeever-image-gallery:focus-within > .edgeever-image-gallery__toolbar {
visibility: visible;
pointer-events: auto;
}
.ProseMirror .edgeever-image-gallery__button {
@@ -2311,21 +2347,24 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
height: 2rem;
border-radius: 6px;
padding: 0 0.5rem;
color: #475569;
color: hsl(var(--foreground));
font-size: 0.75rem;
font-weight: 650;
line-height: 1;
}
.ProseMirror .edgeever-image-gallery__button:hover,
.ProseMirror .edgeever-image-gallery__button:hover {
background: var(--workspace-hover);
}
.ProseMirror .edgeever-image-gallery__button:focus-visible,
.ProseMirror .edgeever-image-gallery__button.is-active {
background: var(--brand-green-muted);
color: var(--brand-green-text);
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-gallery__button:focus-visible {
outline: 2px solid var(--brand-green);
outline: 2px solid rgb(var(--slate-500-rgb));
outline-offset: 1px;
}
@@ -2350,17 +2389,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
align-items: center;
justify-content: center;
border-radius: 6px;
color: #475569;
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-preview-button:hover,
.ProseMirror .edgeever-image-preview-button:focus-visible {
background: var(--brand-green-muted);
color: var(--brand-green-text);
background: var(--workspace-hover);
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-preview-button:focus-visible {
outline: 2px solid var(--brand-green);
outline: 2px solid rgb(var(--slate-500-rgb));
outline-offset: 2px;
}
@@ -2393,17 +2432,22 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
align-items: center;
justify-content: center;
border-radius: 6px;
color: #475569;
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-alignment:hover {
background: var(--workspace-hover);
}
.ProseMirror .edgeever-image-alignment:hover,
.ProseMirror .edgeever-image-alignment.is-active {
background: var(--brand-green-muted);
color: var(--brand-green-text);
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-alignment:focus-visible {
outline: 2px solid var(--brand-green);
.ProseMirror .edgeever-image-alignment:focus-visible,
.ProseMirror .edgeever-image-preset:focus-visible,
.ProseMirror .edgeever-image-resize-handle:focus-visible {
outline: 2px solid rgb(var(--slate-500-rgb));
outline-offset: 2px;
}
@@ -2414,29 +2458,36 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
align-items: center;
justify-content: center;
border-radius: 6px;
color: #475569;
color: hsl(var(--foreground));
font-size: 0.75rem;
font-weight: 700;
}
.ProseMirror .edgeever-image-preset:hover,
.ProseMirror .edgeever-image-preset:hover {
background: var(--workspace-hover);
}
.ProseMirror .edgeever-image-preset.is-active {
background: var(--brand-green-muted);
color: var(--brand-green-text);
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
color: hsl(var(--foreground));
}
.ProseMirror .edgeever-image-resize-handle {
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
width: 2rem;
height: 2rem;
flex: 0 0 2rem;
align-items: center;
justify-content: center;
border-radius: 6px;
background:
linear-gradient(135deg, transparent 46%, var(--brand-green) 48%, var(--brand-green) 54%, transparent 56%),
linear-gradient(135deg, transparent 62%, rgba(var(--brand-green-600-rgb), 0.8) 64%, rgba(var(--brand-green-600-rgb), 0.8) 70%, transparent 72%);
background: transparent;
color: hsl(var(--foreground));
cursor: ew-resize;
touch-action: none;
}
.ProseMirror .edgeever-image-resize-handle:hover {
background-color: var(--brand-green-muted);
background-color: var(--workspace-hover);
}
.ProseMirror.edgeever-note-rich-editor p.is-empty::before {
@@ -4,14 +4,19 @@ import { readFileSync } from "node:fs";
const globals = readFileSync(new URL("./globals.css", import.meta.url), "utf8");
describe("image gallery layout", () => {
test("reserves a wrapping toolbar row instead of overlaying image controls", () => {
test("floats the wrapping toolbar above the images and reveals it on hover or keyboard focus", () => {
const toolbar = globals.match(/\.ProseMirror \.edgeever-image-gallery__toolbar \{([^}]+)\}/)?.[1];
expect(toolbar).toContain("position: relative;");
expect(toolbar).toContain("position: absolute;");
expect(toolbar).toContain("bottom: 100%;");
expect(toolbar).toContain("flex-wrap: wrap;");
expect(toolbar).toContain("max-width: 100%;");
expect(toolbar).toContain("margin-bottom: 0.5rem;");
expect(toolbar).not.toContain("position: absolute");
expect(toolbar).toContain("visibility: hidden;");
expect(toolbar).toContain("pointer-events: none;");
expect(toolbar).not.toContain("opacity: 0");
expect(globals).toMatch(
/\.ProseMirror \.edgeever-image-gallery:hover > \.edgeever-image-gallery__toolbar,\s*\.ProseMirror \.edgeever-image-gallery:focus-within > \.edgeever-image-gallery__toolbar \{\s*visibility: visible;\s*pointer-events: auto;/,
);
});
test("fills React image node grid cells without exposing a persistent background", () => {
+10 -3
View File
@@ -100,6 +100,7 @@
"version": "0.1.3",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"content-type": "3.1.1",
},
},
"apps/desktop": {
@@ -317,6 +318,7 @@
"@zip.js/zip.js": "2.24.0",
"js-base64": "3.9.3",
"katex": "0.18.2",
"marked": "17.0.6",
"yaml": "2.8.3",
"zod": "4.6.6",
},
@@ -335,12 +337,13 @@
},
},
"patchedDependencies": {
"wrangler@4.112.0": "patches/wrangler@4.112.0.patch",
"miniflare@4.20260714.0": "patches/miniflare@4.20260714.0.patch",
"expo-sharing@57.0.22": "patches/expo-sharing@57.0.22.patch",
"@joplin/turndown-plugin-gfm@1.0.68": "patches/@joplin%2Fturndown-plugin-gfm@1.0.68.patch",
"miniflare@4.20260714.0": "patches/miniflare@4.20260714.0.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"npm@11.21.0": "patches/npm@11.21.0.patch",
"expo-sharing@57.0.22": "patches/expo-sharing@57.0.22.patch",
"wrangler@4.112.0": "patches/wrangler@4.112.0.patch",
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch",
},
"overrides": {
"dompurify": "3.4.17",
@@ -2179,6 +2182,8 @@
"connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="],
"content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
@@ -4497,6 +4502,8 @@
"@edgeever/mobile/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"@edgeever/shared/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"@electron/fuses/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+53 -18
View File
@@ -15,21 +15,23 @@ A practical guide to core workflows, key features, and real-world usage scenario
- [6. X (Twitter) Post & Quote Clipping](#6-x-twitter-post--quote-clipping)
- [7. Zhihu Answer & Column Article Clipping](#7-zhihu-answer--column-article-clipping)
- [8. Reddit Discussion Post Clipping](#8-reddit-discussion-post-clipping)
- [9. Instagram Post & Carousel Clipping](#9-instagram-post--carousel-clipping)
- [10. GitHub Repository Metadata Clipping](#10-github-repository-metadata-clipping)
### AI-Powered Structuring & Multi-Dimensional Data
- [9. AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams](#9-ai-conversational-generation-of-mind-maps-flowcharts--architecture-diagrams)
- [10. AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)](#10-ai-generation-of-structured-infographics-timelines-matrices--quadrants)
- [11. AI Conversational Database Table Generation](#11-ai-conversational-database-table-generation)
- [12. Public Online Form Collection from Database Tables](#12-public-online-form-collection-from-database-tables)
- [13. Connecting Local Agents via ACP for Note Interaction](#13-connecting-local-agents-via-acp-for-note-interaction)
- [11. AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams](#11-ai-conversational-generation-of-mind-maps-flowcharts--architecture-diagrams)
- [12. AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)](#12-ai-generation-of-structured-infographics-timelines-matrices--quadrants)
- [13. AI Conversational Database Table Generation](#13-ai-conversational-database-table-generation)
- [14. Public Online Form Collection from Database Tables](#14-public-online-form-collection-from-database-tables)
- [15. Connecting Local Agents via ACP for Note Interaction](#15-connecting-local-agents-via-acp-for-note-interaction)
### Visual Styling & Omnichannel Publishing
- [14. Note Export to Long Image & Code Cards](#14-note-export-to-long-image--code-cards)
- [15. Social Media Poster & Cover Design from Notes](#15-social-media-poster--cover-design-from-notes)
- [16. Copying Notes to WeChat Official Account & Blogs](#16-copying-notes-to-wechat-official-account--blogs)
- [16. Note Export to Long Image & Code Cards](#16-note-export-to-long-image--code-cards)
- [17. Social Media Poster & Cover Design from Notes](#17-social-media-poster--cover-design-from-notes)
- [18. Copying Notes to WeChat Official Account & Blogs](#18-copying-notes-to-wechat-official-account--blogs)
### Open Ecosystem & Extensibility
- [17. Open Plugin API & Custom Plugin Development](#17-open-plugin-api--custom-plugin-development)
- [19. Open Plugin API & Custom Plugin Development](#19-open-plugin-api--custom-plugin-development)
---
@@ -151,9 +153,42 @@ Right-click on the post title or body and select **"Save Selection to EdgeEver"*
---
### 9. Instagram Post & Carousel Clipping
Right-click any Instagram post or carousel and select **"Save Post to EdgeEver"** using the EdgeEver Web Clipper extension.
- Extracts author handle, full caption text, publish timestamp, topic tags, and source URL.
- Fully extracts multi-image carousel sets and video covers, automatically formatted into a multi-column gallery layout.
| 1. Right-Click Save Instagram Post |
| :---: |
| ![Right-click save Instagram post to EdgeEver](assets/best-practices/original-image/web-clipper/instgram/ins.png) |
| 2. Note with Metadata & Multi-Image Gallery |
| :---: |
| ![EdgeEver clipped Instagram post note effect](assets/best-practices/original-image/web-clipper/instgram/ins-in-note.png) |
---
### 10. GitHub Repository Metadata Clipping
Right-click anywhere on the GitHub repository page and select **"Save Repository Info to EdgeEver"** using EdgeEver Web Clipper.
- Extracts repository name (`Owner/Repo`), description, homepage URL, primary language, license, and topic tags into a structured technical card.
| 1. Right-Click Save Repository Info |
| :---: |
| ![Right-click save GitHub repo info to EdgeEver](assets/best-practices/original-image/web-clipper/github/github.png) |
| 2. Structured Repository Card Note |
| :---: |
| ![EdgeEver clipped GitHub repository card note effect](assets/best-practices/original-image/web-clipper/github/github-in-note.png) |
---
## AI-Powered Structuring & Multi-Dimensional Data
### 9. AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams
### 11. AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams
Prompt the AI Co-pilot (or connected local Agent) with natural language (e.g., "Generate a mind map / flowchart / architecture diagram about LLMs") to create editable diagrams directly in notes.
@@ -173,7 +208,7 @@ Prompt the AI Co-pilot (or connected local Agent) with natural language (e.g., "
---
### 10. AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)
### 12. AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)
Use built-in infographic templates (timelines, comparison matrices, quadrants, and hierarchy charts) by prompting the AI Co-pilot with your topic.
@@ -190,7 +225,7 @@ Use built-in infographic templates (timelines, comparison matrices, quadrants, a
---
### 11. AI Conversational Database Table Generation
### 13. AI Conversational Database Table Generation
Send a prompt to the AI Co-pilot (e.g., "Create a database table for social media content planning" or "Generate an employee onboarding tracker") to construct complete data tables.
@@ -207,7 +242,7 @@ Send a prompt to the AI Co-pilot (e.g., "Create a database table for social medi
---
### 12. Public Online Form Collection from Database Tables
### 14. Public Online Form Collection from Database Tables
Click **"Form Collection"** at the top of any database table to generate a public online data entry form.
@@ -220,7 +255,7 @@ Click **"Form Collection"** at the top of any database table to generate a publi
---
### 13. Connecting Local Agents via ACP for Note Interaction
### 15. Connecting Local Agents via ACP for Note Interaction
EdgeEver natively supports **ACP (Agent Client Protocol)**, allowing desktop clients to connect with local AI agent environments (such as Codex, Claude Code, DeepSeek Harness, Antigravity, etc.).
@@ -235,7 +270,7 @@ EdgeEver natively supports **ACP (Agent Client Protocol)**, allowing desktop cli
## Visual Styling & Omnichannel Publishing
### 14. Note Export to Long Image & Code Cards
### 16. Note Export to Long Image & Code Cards
Use the "Share as Image" feature to render notes into high-resolution long images or cards.
@@ -248,7 +283,7 @@ Use the "Share as Image" feature to render notes into high-resolution long image
---
### 15. Social Media Poster & Cover Design from Notes
### 17. Social Media Poster & Cover Design from Notes
Click **"Create Poster"** in any note's action menu (or via "New → Poster") to load key takeaways and titles into the Poster Design Studio.
@@ -261,7 +296,7 @@ Click **"Create Poster"** in any note's action menu (or via "New → Poster") to
---
### 16. Copying Notes to WeChat Official Account & Blogs
### 18. Copying Notes to WeChat Official Account & Blogs
Select **"Copy to Official Account / Blog"** (复制到公众号 / 博客) from the note menu to convert notes into rich text with inline CSS.
@@ -279,7 +314,7 @@ Select **"Copy to Official Account / Blog"** (复制到公众号 / 博客) from
## Open Ecosystem & Extensibility
### 17. Open Plugin API & Custom Plugin Development
### 19. Open Plugin API & Custom Plugin Development
EdgeEver features an extension manager supporting one-click installation via GitHub repository URLs or Manifest links, with cross-device workspace synchronization between Web and Desktop.
+53 -18
View File
@@ -15,21 +15,23 @@
- [6. X (Twitter) 推文与引用剪藏](#6-x-twitter-推文与引用剪藏)
- [7. 知乎回答与文章剪藏](#7-知乎回答与文章剪藏)
- [8. Reddit 讨论帖剪藏](#8-reddit-讨论帖剪藏)
- [9. Instagram 帖子与轮播多图剪藏](#9-instagram-帖子与轮播多图剪藏)
- [10. GitHub 仓库元数据剪藏](#10-github-仓库元数据剪藏)
### AI 增强与多维数据组织
- [9. AI 对话生成思维导图、流程图与架构图](#9-ai-对话生成思维导图流程图与架构图)
- [10. AI 生成结构化信息图(时间线、对比矩阵、象限图等)](#10-ai-生成结构化信息图时间线对比矩阵象限图等)
- [11. AI 对话构建多维表格](#11-ai-对话构建多维表格)
- [12. 多维表格生成公开收集表单](#12-多维表格生成公开收集表单)
- [13. 连接本机 Agent (ACP) 实现笔记协同](#13-连接本机-agent-acp-实现笔记协同)
- [11. AI 对话生成思维导图、流程图与架构图](#11-ai-对话生成思维导图流程图与架构图)
- [12. AI 生成结构化信息图(时间线、对比矩阵、象限图等)](#12-ai-生成结构化信息图时间线对比矩阵象限图等)
- [13. AI 对话构建多维表格](#13-ai-对话构建多维表格)
- [14. 多维表格生成公开收集表单](#14-多维表格生成公开收集表单)
- [15. 连接本机 Agent (ACP) 实现笔记协同](#15-连接本机-agent-acp-实现笔记协同)
### 排版与多渠道分发
- [14. 笔记导出多主题长图与代码卡片](#14-笔记导出多主题长图与代码卡片)
- [15. 笔记制作社交媒体海报与封面](#15-笔记制作社交媒体海报与封面)
- [16. 复制笔记至微信公众号排版](#16-复制笔记至微信公众号排版)
- [16. 笔记导出多主题长图与代码卡片](#16-笔记导出多主题长图与代码卡片)
- [17. 笔记制作社交媒体海报与封面](#17-笔记制作社交媒体海报与封面)
- [18. 复制笔记至微信公众号排版](#18-复制笔记至微信公众号排版)
### 开放生态与插件扩展
- [17. 开放插件 API 与自定义插件开发](#17-开放插件-api-与自定义插件开发)
- [19. 开放插件 API 与自定义插件开发](#19-开放插件-api-与自定义插件开发)
---
@@ -151,9 +153,42 @@ EdgeEver 采用「目录树 · 笔记列表 · 编辑区」三栏布局:
---
### 9. Instagram 帖子与轮播多图剪藏
使用 EdgeEver Web Clipper 插件在 Instagram 帖子或轮播页面右键点击「保存帖子到 EdgeEver」。
- 提取作者账号、正文描述、发布时间与原文链接,保留话题标签与提及元数据。
- 完整提取轮播(Carousel)多图与视频封面,并在笔记内自动排版为多栏画廊。
| 1. 右键保存 Instagram 帖子 |
| :---: |
| ![右键保存 Instagram 帖子到 EdgeEver](assets/best-practices/original-image/web-clipper/instgram/ins.png) |
| 2. 包含完整图集与元数据的笔记效果 |
| :---: |
| ![EdgeEver 剪藏后的 Instagram 帖子笔记效果](assets/best-practices/original-image/web-clipper/instgram/ins-in-note.png) |
---
### 10. GitHub 仓库元数据剪藏
在 GitHub 仓库页面右键点击「保存仓库信息到 EdgeEver」。
- 提取仓库名称(Owner/Repo)、简介、官方主页、编程语言、许可证与话题标签,自动生成结构化技术卡片。
| 1. 右键保存仓库信息 |
| :---: |
| ![右键保存 GitHub 仓库信息到 EdgeEver](assets/best-practices/original-image/web-clipper/github/github.png) |
| 2. 结构化仓库卡片预览 |
| :---: |
| ![EdgeEver 剪藏后的 GitHub 仓库卡片笔记](assets/best-practices/original-image/web-clipper/github/github-in-note.png) |
---
## AI 增强与多维数据组织
### 9. AI 对话生成思维导图、流程图与架构图
### 11. AI 对话生成思维导图、流程图与架构图
在 AI 伴随助手(或连接的本地 Agent)中输入自然语言指令(如“帮我生成一个关于大语言模型的思维导图 / 流程图 / 架构图”),在笔记内直接生成可交互图表。
@@ -173,7 +208,7 @@ EdgeEver 采用「目录树 · 笔记列表 · 编辑区」三栏布局:
---
### 10. AI 生成结构化信息图(时间线、对比矩阵、象限图等)
### 12. AI 生成结构化信息图(时间线、对比矩阵、象限图等)
利用内置模板库,在 AI 助手对话中描述要呈现的主题与重点,快速生成结构化视觉笔记。
@@ -190,7 +225,7 @@ EdgeEver 采用「目录树 · 笔记列表 · 编辑区」三栏布局:
---
### 11. AI 对话构建多维表格
### 13. AI 对话构建多维表格
在 AI 助手中发送自然语言指令(如“生成自媒体选题库”或“生成员工信息登记表”),自动搭建多维数据表。
@@ -207,7 +242,7 @@ EdgeEver 采用「目录树 · 笔记列表 · 编辑区」三栏布局:
---
### 12. 多维表格生成公开收集表单
### 14. 多维表格生成公开收集表单
在多维表格顶部点击「表单收集」,将当前数据表转为公开在线表单。
@@ -220,7 +255,7 @@ EdgeEver 采用「目录树 · 笔记列表 · 编辑区」三栏布局:
---
### 13. 连接本机 Agent (ACP) 实现笔记协同
### 15. 连接本机 Agent (ACP) 实现笔记协同
EdgeEver 支持 **ACP (Agent Client Protocol)** 协议,允许桌面端连接本机运行的各类 AI Agent(如 Codex、Claude Code、DeepSeek Harness、Antigravity 等)。
@@ -235,7 +270,7 @@ EdgeEver 支持 **ACP (Agent Client Protocol)** 协议,允许桌面端连接
## 排版与多渠道分发
### 14. 笔记导出多主题长图与代码卡片
### 16. 笔记导出多主题长图与代码卡片
使用「分享为图片」功能,将笔记排版为高分辨率长图或卡片。
@@ -248,7 +283,7 @@ EdgeEver 支持 **ACP (Agent Client Protocol)** 协议,允许桌面端连接
---
### 15. 笔记制作社交媒体海报与封面
### 17. 笔记制作社交媒体海报与封面
在笔记操作菜单中选择「制作海报」(或通过「新建 → 海报」),将笔记标题与要点导入海报设计工作台。
@@ -261,7 +296,7 @@ EdgeEver 支持 **ACP (Agent Client Protocol)** 协议,允许桌面端连接
---
### 16. 复制笔记至微信公众号排版
### 18. 复制笔记至微信公众号排版
在笔记菜单中选择「复制到公众号 / 博客」,将笔记转换为内联 CSS 样式的富文本。
@@ -279,7 +314,7 @@ EdgeEver 支持 **ACP (Agent Client Protocol)** 协议,允许桌面端连接
## 开放生态与插件扩展
### 17. 开放插件 API 与自定义插件开发
### 19. 开放插件 API 与自定义插件开发
EdgeEver 内置插件扩展管理,支持通过 GitHub 仓库或 Manifest 地址一键安装,配置在 Web 与桌面端跨设备隔离同步。
@@ -0,0 +1,20 @@
# Chemical formulas and equations in "Insert formula"
[简体中文](2026-10-28-chemistry-formulas.zh-CN.md)
## Problem and outcome
Implements [Issue #717](https://github.com/tianma-if/edgeever/issues/717). Chemistry had to be written by hand with subscripts, superscripts, arrow commands and `\mathrm{}`. Note math now understands the KaTeX `mhchem` extension: `\ce{…}` for formulas and equations and `\pu{…}` for quantities with units. The "Insert formula" dialog has a Math / Chemistry switch with one-click examples.
## Behavior
| ID | Behavior | Acceptance |
| --- | --- | --- |
| C01 | `katex/contrib/mhchem` is registered by the shared math extensions, so `\ce` and `\pu` render in the web and desktop editor, the Android and iOS editors, public share pages and notebook sites. The dialog preview and the WeChat copy register it as well. `trust: false` still applies; commands such as `\href` inside `\ce` stay inert text. | Shared tests render every example inline and as a display formula through the shared module only, and fail without the registration. |
| C02 | Formulas are stored unchanged as inline or display math (`$\ce{H2O}$`, `$$…$$`). There is no data, API or migration change. | Markdown round-trip test. |
| C03 | The dialog's Math / Chemistry switch is shown for new and existing formulas. Chemistry uses the LaTeX source field, because the visual math editor would rewrite `\ce`; a formula containing `\ce` or `\pu` opens in this mode, and the visual editor toggle is hidden while the source contains them. | Browser test: insert, edit and reopen inline and display chemistry. |
| C04 | Ten examples (formula, ion, reaction, equilibrium, conditions on the arrow, gas/precipitate, states of matter, isotope, hydrate, units) insert at the caret; inside an open `\ce{…}` only the example's content is inserted. Each example has a Tooltip with its source. Copy is in all shipped locales. | Unit tests for insertion and labels; browser test in `pl-PL` at 1280 px and 390 px. |
## Risk and rollback
The editor bundle grows by the mhchem module (about 34 KB minified). Older app versions show existing `\ce` formulas as render errors until they update; the stored text is unchanged. The committed iOS editor bundle (`apps/ios/EditorBundle`) has to be rebuilt in the usual iOS release step to include the extension. Rollback is a revert; stored chemistry formulas then show as render errors again.
@@ -0,0 +1,20 @@
# “插入公式”支持化学式与化学方程式
[English](2026-10-28-chemistry-formulas.md)
## 问题与结果
实现 [Issue #717](https://github.com/tianma-if/edgeever/issues/717)。此前化学式只能手动用上下标、箭头命令和 `\mathrm{}` 拼写。笔记中的公式现在支持 KaTeX 的 `mhchem` 扩展:`\ce{…}` 用于化学式和化学方程式,`\pu{…}` 用于带单位的物理量。“插入公式”对话框新增“数学 / 化学”切换,并提供一键示例。
## 行为
| ID | 行为 | 验收 |
| --- | --- | --- |
| C01 | 共享公式扩展注册 `katex/contrib/mhchem`,因此 `\ce` 和 `\pu` 可在 Web 与桌面编辑器、Android 与 iOS 编辑器、公开分享页和笔记本站点中渲染。对话框预览和公众号复制同样注册该扩展。`trust: false` 仍然生效,`\ce` 中的 `\href` 等命令只作为无效文本显示。 | 共享测试仅通过共享模块渲染所有示例(行内与独立公式),移除注册后测试失败。 |
| C02 | 公式按原样保存为行内或独立公式(`$\ce{H2O}$`、`$$…$$`),没有数据、API 或迁移变化。 | Markdown 往返测试。 |
| C03 | 新建和编辑公式时都显示“数学 / 化学”切换。化学模式使用 LaTeX 源码输入,因为可视化数学编辑器会改写 `\ce`;包含 `\ce` 或 `\pu` 的公式会以该模式打开,源码中包含它们时隐藏可视化编辑器切换。 | 浏览器测试:插入、编辑并重新打开行内和独立化学公式。 |
| C04 | 十个示例(化学式、离子、反应、平衡、反应条件、气体/沉淀、物态、同位素、结晶水合物、单位)插入到光标处;光标位于未闭合的 `\ce{…}` 中时只插入示例内容。每个示例都有显示源码的 Tooltip。所有已发布语言均有文案。 | 插入逻辑与文案的单元测试;`pl-PL` 下 1280 px 和 390 px 的浏览器测试。 |
## 风险与回滚
编辑器包增加 mhchem 模块(压缩后约 34 KB)。旧版本应用在更新前会把已有的 `\ce` 公式显示为渲染错误,保存的文本不变。仓库中提交的 iOS 编辑器包(`apps/ios/EditorBundle`)需要在常规 iOS 发布步骤中重新构建才会包含该扩展。回滚即 revert;回滚后已保存的化学公式会重新显示为渲染错误。
@@ -0,0 +1,40 @@
# Issue 701: restore resource XSS protection
[简体中文](2026-10-28-restore-resource-xss.zh-CN.md)
## Problem and outcome
[Issue #701](https://github.com/tianma-if/edgeever/issues/701) reports that restore accepts `kind=image` with `mimeType=text/html`, while resource responses trust `kind` to enable inline rendering. Close both the write-time mismatch and the read-time exposure of previously stored resources. Work starts from official `main` at `b8496806` in a separate worktree.
The change affects API resource restore and resource response headers shared by Cloudflare and Docker. Backups explicitly labeling an image as an incompatible type such as HTML are rejected; valid old backups must still restore. The existing nullable MIME contract and unknown/binary fallback remain supported, with unknown types downloading instead of rendering inline. HTML and other arbitrary attachments remain downloadable. There is no migration or deletion of existing data. Rollback reverts the code without rewriting resources; reverting also reintroduces the vulnerability. Native-platform runtime and deployment acceptance are separate from the local checks below.
The backup schema explicitly permits null MIME, the released exporter preserves it, and resumable restore already falls back to `application/octet-stream`. Rejecting that safe fallback would break legitimate old archives without contributing to XSS protection; response-time MIME checks provide the download boundary.
Compare image MIME case-insensitively without parameters, but retain the stored MIME and its parameters as before. Stripping parameters from the stored value is unnecessary for validation and would discard encoding information such as SVG `charset`.
A local browser check found a remaining parser mismatch: a value starting with an image/audio type can contain an unquoted comma and a second HTML type. The old first-semicolon check permits inline delivery, while Chromium interprets the response as HTML even with `nosniff`. Validate and serialize the whole field using the portable `content-type@3.1.1` parser/formatter instead of splitting strings. Check the original HTTP-field characters before trimming/parsing: permit HTAB, visible ASCII and obs-text only, rejecting other controls, DEL and non-byte characters even inside ignored or duplicate parameters. The compatibility risk is rejecting multi-valued or unserializable MIME declarations and changing response-header casing/quoting; valid SVG encoding parameters and the stored backup metadata remain intact. Reverting this correction restores the bypass; rollback requires only code/dependency changes, not data migration.
## Allowed changes and acceptance
| ID | Behavior | Acceptance |
| --- | --- | --- |
| S1 | Legacy restore and resumable restore initialization/completion validate the effective MIME of resources marked `image` against the existing PNG/JPEG/GIF/WebP/AVIF/SVG allowlist, while preserving empty/unspecified and `application/octet-stream` MIME accepted by the original restore contract. Explicit incompatible types return the existing `415 unsupported_media_type` error. Legacy restore includes the existing uploaded-file MIME fallback when metadata MIME is null. Image MIME comparison ignores casing and parameters without rewriting stored MIME metadata or parameters; non-image attachment MIME metadata and size limits keep their existing contracts. | First add failing isolated API regressions for explicit HTML/XHTML, null metadata with an HTML file type, and multipart restore including an upload session created before the fix. Assert rejection without a new resource/object, preserving existing resources. Verify empty/null/binary image MIME still restores through both APIs and downloads; include null MIME in the released-exporter archive fixture. Preserve SVG encoding parameters and bytes through both restore paths. |
| S2 | Authenticated, public-note and public-notebook blob responses only use the image-specific inline branch when the effective response MIME is allowlisted. Existing malformed resources, including a null database MIME falling back to HTML object metadata, download instead. PDF/audio/video preview rules still use database MIME and filename, not object-metadata fallbacks; `nosniff`, SVG sandbox CSP, range handling and access checks stay unchanged. | Seed isolated legacy rows and verify the real response headers through all three routes, including storage-metadata fallback and SVG/PDF/media previews. Untyped binary attachments must not gain inline previews solely from PDF/media object metadata. |
| S3 | Add only regressions and compatibility evidence needed for S1/S2. | Verify rejection and ordinary restore through the actual app/database/blob adapter; restore a valid archive produced by the previous released implementation, preserving note/resource data and bytes. Run affected tests, `bun run typecheck`, `bun run typecheck:mobile` and `bun run build:web`. |
| S4 | Parse nonempty restore MIME for both images and attachments with `content-type` in comma-stop mode, require full-field consumption, and require successful MIME formatting. Reject type lists, invalid media types or unserializable parameters with `415 unsupported_media_type` before legacy object writes, multipart initialization or completion. Preserve existing null/empty/binary fallbacks and raw stored MIME metadata. Both resource response implementations share one Content-Type/disposition policy: serialize a single MIME with safe quoting; malformed/multi-valued database or storage metadata becomes `application/octet-stream` plus attachment disposition, even when a filename suggests PDF/media. Valid PDF/media preview classification still uses database MIME/filename, not storage-only MIME. SVG sandbox and `nosniff` apply after serialization. Add only the API dependency `content-type@3.1.1` and its lockfile update. | Add failing real-route regressions for image/audio/video type lists, old pending uploads, all three blob routes and storage-MIME fallback. Cover legitimate quoted commas/semicolons, escaped quotes and SVG charset without rewriting stored data. Confirm in the local browser that malformed resources download and valid quoted SVG remains SVG; use harmless content without script execution. Reuse unaffected tests and run affected API tests, typechecks, Web build and Worker bundling. |
## Unchanged boundaries
- No backup-format, schema, migration, authentication, API-token, permission, upload-size, retry or fallback changes beyond S1/S2/S4.
- Do not forbid HTML attachments, sanitize stored note bodies, delete existing resources, or operate on another user's instance/data.
- Deliver through focused commits and the existing contribution branch/PR #711 linked to #701. Keep the PR in Draft; no automatic ready transition, merge, deployment or release. Keep the original #657 checkout and its uncommitted changes untouched.
## Completion check
- S1/S4: all three restore stages reject image/type mismatches, top-level MIME lists, invalid media types and illegal original header characters before object writes or assembly. Hidden/duplicate parameters cannot conceal controls. Null/empty/binary MIME and valid SVG charset/quoted parameters remain supported without rewriting stored metadata.
- S2/S4: both response implementations share one parser/serializer and inline decision; invalid database/storage MIME becomes binary download even with misleading PDF/media filenames. Valid metadata retains the existing PDF/media preview boundary, SVG sandbox, `nosniff`, ranges and access checks.
- Acceptance: 64 security/compatibility regressions pass. The other 87 affected archive/resource/share tests also pass and remain applicable, for 151 distinct tests. The released-exporter [v2.12.0 fixture](../../tests/fixtures/restore-resource-security/README.md) restores through legacy and resumable APIs with notes, revisions and attachment bytes preserved.
- [Chromium acceptance](evidence/2026-10-28-restore-resource-xss/README.md): all three historical image/audio/video type-list samples emit successful binary download events; a quoted UTF-16LE SVG retains its Chinese text, and a quoted PNG decodes at its original width. Content is synthetic and script-free. Other browsers/native engines and live Cloudflare/R2 were not exercised.
- Main/mobile typechecks, Web build, Worker bundling, affected-file oxlint and `git diff --check` pass; frozen-lockfile installation succeeds. The only dependency change is `content-type@3.1.1` in the API, with a minimal lockfile entry.
- Scope review: S4 closes the demonstrated browser-parser bypass and rejects raw illegal bytes that the lenient parser can ignore. Removed duplicated Content-Type/disposition decisions from the two handlers. Latest correctness, simplification and design-boundary review has no unresolved substantive findings within this scope.
- PR #711 remains Draft. No deployment or native-platform runtime verification was performed. The original #657 working files were not edited.
@@ -0,0 +1,40 @@
# Issue 701:备份恢复资源的 XSS 防护
[English](2026-10-28-restore-resource-xss.md)
## 问题与目标
[Issue #701](https://github.com/tianma-if/edgeever/issues/701) 指出恢复接口接受 `kind=image` 与 `mimeType=text/html` 的组合,而资源响应相信 `kind` 并允许内联渲染。修复写入时的不一致,同时阻断已有异常资源的读取风险。在独立工作区基于官方 `main` 的 `b8496806` 实施。
影响范围是 Cloudflare 和 Docker 共用的 API 资源恢复及响应头。旧备份若明确把图片标为 HTML 等不相容类型,会被拒绝;合法旧备份必须仍可恢复。保留既有的可空 MIME 契约和未知/二进制回退,未知类型转为下载而非内联渲染。HTML 等普通附件仍可下载。不迁移或删除现有数据。回滚只需回退代码,无需改写资源;回退也会重新引入漏洞。原生平台运行及部署验收与下述本地检查分开。
备份 Schema 明确允许 MIME 为 null,正式版导出器会保留该值,分片恢复原本回退为 `application/octet-stream`。拒绝这个安全回退会使合法旧备份无法恢复,却无助于阻断 XSS;下载边界由读取时的 MIME 校验保证。
图片 MIME 比较忽略大小写及参数,但入库 MIME 及其参数继续保持原值。校验无需改写存储值,剥离参数反而会丢失 SVG `charset` 等编码信息。
本地浏览器检查发现一个解析差异:以图片/音频类型开头的值,可以在未加引号的逗号后追加 HTML 类型;原先按第一个分号截取的校验会允许内联输出,Chromium 即使收到 `nosniff` 仍会当作 HTML 解析。改用可跨运行时使用的 `content-type@3.1.1` 解析和格式化完整字段,不再拆字符串判断。去空白或解析前,先校验原字段只含 HTAB、可见 ASCII 及 obs-text;其他控制字符、DEL 及非字节字符一律拒绝,即使藏在会被忽略或重复的参数中。兼容风险是多类型或无法安全格式化的声明会被拒绝,响应头的大小写和引号形式可能规范化;合法 SVG 编码参数及已存备份元数据保留。回退会恢复这个绕过,回滚只涉及代码及依赖,不需要迁移数据。
## 允许的变更与验收
| 编号 | 行为 | 验收 |
| --- | --- | --- |
| S1 | 旧式恢复与分片恢复的初始化、完成阶段,对标记为 `image` 的资源按既有 PNG/JPEG/GIF/WebP/AVIF/SVG 白名单校验实际生效的 MIME,同时保留原恢复契约接受的空/未指定 MIME 和 `application/octet-stream`。明确不相容的类型返回既有的 `415 unsupported_media_type`。旧式恢复须覆盖元数据 MIME 为 null 时沿用文件 MIME 的路径。图片 MIME 比较忽略大小写及参数,但不改写入库 MIME 元数据或参数;普通附件的 MIME 元数据及大小限制保持原契约。 | 先补充隔离 API 失败用例:显式 HTML/XHTML、元数据为 null 而文件为 HTML、分片恢复及修复前已创建的分片会话。断言拒绝时不新增资源或对象,已有资源不变。验证空/null/二进制图片 MIME 仍可经两条 API 恢复并作为下载输出,正式版导出器生成的备份样例也须包含 null MIME。两条恢复路径均须保留 SVG 编码参数及字节。 |
| S2 | 登录后、公开笔记及公开笔记本的 blob 响应,按图片内联的分支仅在实际响应 MIME 属于白名单时生效。已有异常资源改为下载,覆盖数据库 MIME 为 null 而存储元数据为 HTML 的路径。PDF/音视频预览继续依据数据库 MIME 及文件名判断,不使用对象元数据回退;`nosniff`、SVG sandbox CSP、范围读取和权限检查保持不变。 | 在隔离数据中写入历史异常记录,经三个真实路由核对响应头,并覆盖存储元数据回退、SVG/PDF/音视频预览。未指定类型的二进制附件不得仅因对象元数据为 PDF/音视频而新增内联预览。 |
| S3 | 仅补充 S1/S2 所需的回归及兼容证据。 | 经过真实应用、数据库及 blob 适配器验证拒绝和正常恢复;恢复由上一个正式版本实现生成的合法备份,核对笔记、资源数据及字节。运行受影响测试、`bun run typecheck`、`bun run typecheck:mobile` 和 `bun run build:web`。 |
| S4 | 对图片及普通附件的非空恢复 MIME,使用 `content-type` 的遇逗号停止模式解析,要求完整消费字段且可成功格式化。类型列表、无效媒体类型或无法格式化的参数,须在旧式对象写入、分片初始化或完成前返回 `415 unsupported_media_type`。保留既有 null/空/二进制回退及原始入库 MIME 元数据。两个资源响应实现共用 Content-Type/下载策略:安全序列化单一 MIME;数据库或存储元数据中格式错误/多值的类型统一按 `application/octet-stream` 下载,即使文件名看似 PDF/音视频。合法 PDF/音视频预览仍依据数据库 MIME/文件名,不使用仅存在于对象存储的类型。序列化后继续设置 SVG sandbox 及 `nosniff`。仅新增 API 依赖 `content-type@3.1.1` 及对应 lockfile 变化。 | 先补真实路由失败用例,覆盖图片/音频/视频类型列表、旧分片会话、三条 blob 路由及存储 MIME 回退。覆盖合法引号内的逗号/分号、转义引号及 SVG charset,并核对原始存储数据不变。用不含脚本的本地页面验证异常资源触发下载、合法带引号参数的 SVG 仍按 SVG 解析。复用未受影响测试,运行受影响 API 测试、类型检查、Web 构建及 Worker 打包。 |
## 保持不变的边界
- 除 S1/S2/S4 外,不改变备份格式、Schema、Migration、认证、API Token、权限、上传大小、重试或 fallback。
- 不禁止 HTML 附件,不清洗笔记正文,不删除历史资源,不操作他人的实例或数据。
- 通过独立提交及现有贡献分支/PR #711 交付,关联 #701;保持 Draft,不自动转为 Ready、合并、部署或发版。原 #657 工作区及其未提交改动保持原样。
## 完成核对
- S1/S4:三个恢复阶段均在对象写入或合并前拒绝图片/类型不一致、顶层 MIME 列表、无效媒体类型及原字段中的非法头字符。被忽略或重复的参数不能藏入控制字符;null/空/二进制 MIME、合法 SVG charset 和引号参数继续支持,入库元数据不改写。
- S2/S4:两个响应实现共用解析、序列化及内联判断;数据库/存储 MIME 非法时统一下载二进制,即使文件名看似 PDF/音视频。合法元数据保留原 PDF/音视频预览边界、SVG sandbox、`nosniff`、范围读取和权限检查。
- 验收:64 项安全/兼容回归通过;其他 87 项受影响的备份/资源/分享测试也已通过且仍适用,共 151 项。正式版原导出器生成的 [v2.12.0 样例](../../tests/fixtures/restore-resource-security/README.zh-CN.md) 经旧式及分片 API 恢复,笔记、历史版本及附件字节保留。
- [Chromium 验收](evidence/2026-10-28-restore-resource-xss/README.zh-CN.md):三种历史图片/音频/视频类型列表均触发成功的二进制下载事件;带引号参数的 UTF-16LE SVG 保留中文文字,PNG 按原始宽度解码。内容均为无脚本的合成数据,未验证其他浏览器/原生引擎或线上 Cloudflare/R2。
- 主项目/移动端类型检查、Web 构建、Worker 打包、受影响文件的 oxlint 及 `git diff --check` 均通过;冻结 lockfile 安装成功。依赖仅新增 API 的 `content-type@3.1.1` 及最小 lockfile 条目。
- 范围核对:S4 阻断已证实的浏览器解析绕过,并拒绝宽松解析器可能忽略的原始非法字节;两个处理器中重复的 Content-Type/下载判断已移除。最新正确性、简化及设计边界复审在本范围内无未处理实质问题。
- PR #711 保持 Draft;未部署,未做原生平台运行验证,没有编辑原 #657 工作区文件。
@@ -0,0 +1,13 @@
# Resource MIME browser acceptance
[简体中文](README.zh-CN.md)
[`browser-results.json`](browser-results.json) records Chromium checks through the real EdgeEver API, using isolated SQLite/filesystem storage on loopback. The content was synthetic and contained no scripts or credentials.
- Historical image, audio and video records with multiple declared MIME types produced `application/octet-stream` attachment responses. Chromium emitted successful download events for all three rather than parsing their HTML bodies as pages.
- A quoted UTF-16LE SVG retained its charset and quoted comma/semicolon parameter, remained `image/svg+xml` with sandbox CSP, and exposed the expected `旧备份` text in the SVG document.
- A PNG with a quoted comma parameter remained `image/png` and decoded to its original one-pixel width.
The [API regressions](../../../../apps/api/src/resource-restore-security.test.mjs) additionally cover all three blob routes, rejected legacy/resumable restores, old upload completion, storage metadata, hidden control characters, and unchanged stored metadata. Run `bun test apps/api/src/resource-restore-security.test.mjs` to repeat those checks.
The browser checks do not exercise a deployed Cloudflare/R2 instance or other browser/native engines. The Worker bundle is checked separately. The temporary browser task and fixture service are closed after acceptance.
@@ -0,0 +1,13 @@
# 资源 MIME 浏览器验收
[English](README.md)
[`browser-results.json`](browser-results.json) 保存 Chromium 经真实 EdgeEver API 的检查结果,使用独立的本机回环地址及 SQLite/文件存储。内容均为合成数据,不含脚本或凭据。
- 历史图片、音频、视频记录中声明多个 MIME 类型时,响应统一为 `application/octet-stream` 附件。Chromium 对三者均发出成功的下载事件,没有将 HTML 内容解析为网页。
- 带引号参数的 UTF-16LE SVG 保留 charset 和参数内的逗号/分号,仍按 `image/svg+xml` 解析并带 sandbox CSP;SVG 文档中读到了预期的 `旧备份` 文字。
- 参数内带引号逗号的 PNG 仍按 `image/png` 解析,实际解码宽度为原始的一像素。
[API 回归](../../../../apps/api/src/resource-restore-security.test.mjs) 还覆盖三条 blob 路由、旧式/分片恢复拒绝、旧上传完成阶段、存储元数据、隐藏控制字符,以及入库元数据保持原样。运行 `bun test apps/api/src/resource-restore-security.test.mjs` 可重复这些检查。
浏览器检查未覆盖已部署的 Cloudflare/R2 实例或其他浏览器/原生引擎;Worker 打包单独检查。临时浏览器任务及样例服务在验收后关闭。
@@ -0,0 +1,72 @@
{
"engine": "Chromium via ego-browser",
"content": "Synthetic local data; no script payloads or credentials",
"results": [
{
"id": "bad_image",
"sourceMime": "image/png; charset=utf-8, text/html",
"status": 200,
"contentType": "application/octet-stream",
"disposition": "attachment; filename=\"image.bin\"; filename*=UTF-8''image.bin",
"csp": null,
"download": {
"filename": "image.bin",
"failure": null
}
},
{
"id": "bad_audio",
"sourceMime": "audio/mpeg; ignored=x, text/html",
"status": 200,
"contentType": "application/octet-stream",
"disposition": "attachment; filename=\"audio.bin\"; filename*=UTF-8''audio.bin",
"csp": null,
"download": {
"filename": "audio.bin",
"failure": null
}
},
{
"id": "bad_video",
"sourceMime": "video/mp4; ignored=x, application/xhtml+xml",
"status": 200,
"contentType": "application/octet-stream",
"disposition": "attachment; filename=\"video.bin\"; filename*=UTF-8''video.bin",
"csp": null,
"download": {
"filename": "video.bin",
"failure": null
}
},
{
"id": "quoted_svg",
"sourceMime": "IMAGE/SVG+XML; charset=\"utf-16le\"; note=\"a,b; text/html\"",
"status": 200,
"contentType": "image/svg+xml; charset=utf-16le; note=\"a,b; text/html\"",
"disposition": "inline; filename=\"encoded.svg\"; filename*=UTF-8''encoded.svg",
"csp": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; sandbox",
"document": {
"contentType": "image/svg+xml",
"namespace": "http://www.w3.org/2000/svg",
"svgText": "旧备份",
"marker": false,
"imageWidth": null
}
},
{
"id": "quoted_png",
"sourceMime": "IMAGE/PNG; note=\"a,b; text/html\"",
"status": 200,
"contentType": "image/png; note=\"a,b; text/html\"",
"disposition": "inline; filename=\"pixel.png\"; filename*=UTF-8''pixel.png",
"csp": null,
"document": {
"contentType": "image/png",
"namespace": "http://www.w3.org/1999/xhtml",
"svgText": null,
"marker": false,
"imageWidth": 1
}
}
]
}
+1 -1
View File
@@ -31,7 +31,7 @@ Desktop Settings → Data and backup uses one “Backup and restore” card inst
| Automatic backups | Initially off; enabling a valid configuration schedules the first run |
| Frequency | Daily, weekly, every two weeks, or monthly; weekly by default |
| Destination | `EdgeEver Backups` under the OS Documents directory; change through the native directory picker |
| Keep recent | 3, 5, 10, or 20 archives; 5 by default |
| Keep recent | 1, 3, or 5 archives; 1 by default. Existing 10- and 20-archive settings remain valid and are displayed as a disabled option until changed |
| Password encryption | Optional and initially off; enabling it requires entering and confirming a separate backup password |
The top actions are “Back up now,” “Restore from file,” and “Open folder.” Below them, show the last actual successful archive, latest check result, and next scheduled check or automatic-off state. Running tasks can be cancelled. Manual runs allow an editable filename and one-off plain/encrypted output, using the saved destination and retention without changing automatic settings; they remain available when scheduling is off.
@@ -31,7 +31,7 @@
| 自动备份 | 首次关闭;用户开启且配置有效后安排首次备份 |
| 频率 | 每天、每周、每两周、每月;默认每周 |
| 保存目录 | 系统 Documents 目录下的 `EdgeEver Backups`,允许通过原生目录选择器更改 |
| 保留最近 | 3、5、10、20;默认 5 份 |
| 保留最近 | 1、3、5;默认 1 份。已有的 10、20 份配置继续有效,修改前以不可选选项显示 |
| 密码加密 | 可选,首次关闭;开启时输入并确认独立的备份密码 |
顶部提供“立即备份”“从文件恢复”“打开文件夹”;其下显示上次实际成功备份、最近检查结果,以及下次计划检查或自动备份关闭状态。运行时可取消。手动备份可编辑文件名,并为本次选择明文或加密;沿用保存目录和保留规则,不修改自动备份配置,自动关闭时仍可使用。
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "edgeever",
"version": "2.12.0",
"version": "2.14.0",
"packageManager": "bun@1.3.14",
"private": true,
"type": "module",
@@ -234,6 +234,7 @@
"npm@11.21.0": "patches/npm@11.21.0.patch",
"@joplin/turndown-plugin-gfm@1.0.68": "patches/@joplin%2Fturndown-plugin-gfm@1.0.68.patch",
"wrangler@4.112.0": "patches/wrangler@4.112.0.patch",
"miniflare@4.20260714.0": "patches/miniflare@4.20260714.0.patch"
"miniflare@4.20260714.0": "patches/miniflare@4.20260714.0.patch",
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch"
}
}
+2
View File
@@ -7,6 +7,7 @@
".": "./src/index.ts",
"./diagram-canvas": "./src/diagram-canvas.ts",
"./public-demo": "./src/public-demo.ts",
"./memo-title": "./src/memo-title.ts",
"./share-slug": "./src/share-slug.ts",
"./ai-generation": "./src/ai-generation.ts",
"./diagram-layout": "./src/diagram-layout.ts",
@@ -62,6 +63,7 @@
"@zip.js/zip.js": "2.24.0",
"js-base64": "3.9.3",
"katex": "0.18.2",
"marked": "17.0.6",
"yaml": "2.8.3",
"zod": "4.6.6"
}
+1 -1
View File
@@ -207,7 +207,7 @@ export const JsonBackupResourceMetadataSchema = JsonBackupMemoSchema.shape.resou
export type DesktopBackupConfig = {
enabled: boolean;
interval: "daily" | "weekly" | "biweekly" | "monthly";
retention: 3 | 5 | 10 | 20;
retention: 1 | 3 | 5 | 10 | 20;
directory: string;
encrypted: boolean;
};
+4 -10
View File
@@ -4,6 +4,7 @@ import {
type CreateEdgeEverDocumentExtensionsOptions,
} from "./document-extensions";
import { MERGE_DIVIDER_NODE_TYPE } from "./merge-divider";
import { DEFAULT_MEMO_TITLE } from "./memo-title";
import { PDF_ATTACHMENT_NODE_TYPE, upgradeStandalonePdfLinks } from "./pdf-attachment";
import { FILE_ATTACHMENT_NODE_TYPE, upgradeStandaloneFileLinks } from "./file-attachment";
import {
@@ -18,6 +19,7 @@ import { IMAGE_GALLERY_NODE_TYPE, groupConsecutiveImagesIntoGalleries, normalize
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";
import { createEdgeEverMarked } from "./markdown-options";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
@@ -77,16 +79,7 @@ export type TiptapDoc = {
content: TiptapNode[];
};
export const DEFAULT_MEMO_TITLE = "无标题笔记";
/**
* Title to show for a note. Untitled notes are stored with the zh-CN default
* title, so it is replaced by the reader's localized label like an empty one.
*/
export const getMemoDisplayTitle = (title: string | null | undefined, untitledLabel: string) => {
const trimmed = title?.trim() ?? "";
return trimmed && trimmed !== DEFAULT_MEMO_TITLE ? trimmed : untitledLabel;
};
export { DEFAULT_MEMO_TITLE, getMemoDisplayTitle } from "./memo-title";
export const resolveMergedMemoTitle = (
inputTitle: string | null | undefined,
@@ -112,6 +105,7 @@ export const emptyDoc = (): TiptapDoc => ({
export const createEdgeEverMarkdownManager = (
options: CreateEdgeEverDocumentExtensionsOptions,
) => new MarkdownManager({
marked: createEdgeEverMarked(),
extensions: createEdgeEverDocumentExtensions({
...options,
markdown: true,
+2 -1
View File
@@ -14,6 +14,7 @@ import { EmptyExternalLink } from "./empty-external-link";
import { EdgeEverLink } from "./editor-link";
import { createEdgeEverTaskItem } from "./task-item-input";
import { MathCodeFence } from "./mathematics-markdown";
import { createEdgeEverMarked } from "./markdown-options";
export type CreateEdgeEverDocumentExtensionsOptions = {
mathematics: AnyExtension[];
@@ -59,6 +60,6 @@ export const createEdgeEverDocumentExtensions = (
...options.mathematics,
MathCodeFence,
...(options.markdown
? [Markdown.configure({ markedOptions: { gfm: true } })]
? [Markdown.configure({ marked: createEdgeEverMarked(), markedOptions: { gfm: true } })]
: []),
];
+19
View File
@@ -2876,6 +2876,8 @@ export const enUS = {
qrCodeLabel: "QR code for the share link",
qrCodeHint: "Scan with a phone camera to open this note.",
qrCodeNotebookHint: "Scan with a phone camera to open this notebook.",
qrCodeDownloadPng: "Download PNG",
qrCodeDownloadSvg: "Download SVG",
contentLabel: "Shared note content",
siteHideContents: "Hide contents",
siteShowContents: "Show contents",
@@ -3097,6 +3099,23 @@ export const enUS = {
loading: "Loading formula editor…",
loadError: "The visual editor could not load. You can continue with LaTeX.",
incomplete: "Type in an empty slot; press Tab for the next one",
subjectLabel: "Formula type",
subjectMath: "Math",
subjectChemistry: "Chemistry",
chemistryHint: "Write chemistry with \\ce{…} and quantities with \\pu{…}: -> reaction, <=> equilibrium, ^ gas, v precipitate.",
chemistryExamplesLabel: "Insert an example",
chemistryExamples: {
formula: "Formula",
ion: "Ion",
reaction: "Reaction",
equilibrium: "Equilibrium",
conditions: "Conditions on the arrow",
gasPrecipitate: "Gas / precipitate",
states: "States of matter",
isotope: "Isotope",
hydrate: "Hydrate",
units: "Units",
},
},
noteLinkPicker: {
title: "Link a note",
+19
View File
@@ -2865,6 +2865,8 @@ export const ja = {
qrCodeLabel: "共有リンクの QR コード",
qrCodeHint: "スマートフォンのカメラで読み取ると、このノートを開けます。",
qrCodeNotebookHint: "スマートフォンのカメラで読み取ると、このノートブックを開けます。",
qrCodeDownloadPng: "PNG をダウンロード",
qrCodeDownloadSvg: "SVG をダウンロード",
contentLabel: "共有されたノートの内容",
siteHideContents: "目次を隠す",
siteShowContents: "目次を表示",
@@ -3086,6 +3088,23 @@ export const ja = {
loading: "数式エディターを読み込み中…",
loadError: "ビジュアルエディターを読み込めませんでした。LaTeX で編集できます。",
incomplete: "空欄に入力し、Tab キーで次の空欄へ移動",
subjectLabel: "数式の種類",
subjectMath: "数学",
subjectChemistry: "化学",
chemistryHint: "化学式は \\ce{…}、単位付きの量は \\pu{…} で書きます:-> 反応、<=> 平衡、^ 気体、v 沈殿。",
chemistryExamplesLabel: "例を挿入",
chemistryExamples: {
formula: "化学式",
ion: "イオン",
reaction: "反応",
equilibrium: "平衡",
conditions: "矢印上の条件",
gasPrecipitate: "気体 / 沈殿",
states: "状態",
isotope: "同位体",
hydrate: "水和物",
units: "単位",
},
},
noteLinkPicker: {
title: "ノートをリンク",
+19
View File
@@ -2865,6 +2865,8 @@ export const pl = {
qrCodeLabel: "Kod QR linku udostępniania",
qrCodeHint: "Zeskanuj aparatem telefonu, aby otworzyć tę notatkę.",
qrCodeNotebookHint: "Zeskanuj aparatem telefonu, aby otworzyć ten notes.",
qrCodeDownloadPng: "Pobierz PNG",
qrCodeDownloadSvg: "Pobierz SVG",
contentLabel: "Treść udostępnionej notatki",
siteHideContents: "Ukryj spis treści",
siteShowContents: "Pokaż spis treści",
@@ -3086,6 +3088,23 @@ export const pl = {
loading: "Ładowanie edytora wzorów…",
loadError: "Nie udało się załadować edytora wizualnego. Możesz użyć LaTeX.",
incomplete: "Wpisz w pustym polu; Tab przechodzi do następnego",
subjectLabel: "Rodzaj wzoru",
subjectMath: "Matematyka",
subjectChemistry: "Chemia",
chemistryHint: "Wzory chemiczne zapisuj w \\ce{…}, a wielkości z jednostkami w \\pu{…}: -> reakcja, <=> równowaga, ^ gaz, v osad.",
chemistryExamplesLabel: "Wstaw przykład",
chemistryExamples: {
formula: "Wzór",
ion: "Jon",
reaction: "Reakcja",
equilibrium: "Równowaga",
conditions: "Warunki nad strzałką",
gasPrecipitate: "Gaz / osad",
states: "Stany skupienia",
isotope: "Izotop",
hydrate: "Hydrat",
units: "Jednostki",
},
},
noteLinkPicker: {
title: "Połącz z notatką",
+19
View File
@@ -2876,6 +2876,8 @@ export const ru = {
qrCodeLabel: "QR-код ссылки доступа",
qrCodeHint: "Наведите камеру телефона, чтобы открыть эту заметку.",
qrCodeNotebookHint: "Наведите камеру телефона, чтобы открыть этот блокнот.",
qrCodeDownloadPng: "Скачать PNG",
qrCodeDownloadSvg: "Скачать SVG",
contentLabel: "Содержимое общей заметки",
siteHideContents: "Скрыть оглавление",
siteShowContents: "Показать оглавление",
@@ -3097,6 +3099,23 @@ export const ru = {
loading: "Загрузка редактора формул…",
loadError: "Визуальный редактор не загрузился. Можно продолжить в LaTeX.",
incomplete: "Введите текст в пустой слот; Tab — следующий",
subjectLabel: "Тип формулы",
subjectMath: "Математика",
subjectChemistry: "Химия",
chemistryHint: "Химические формулы пишите в \\ce{…}, величины с единицами — в \\pu{…}: -> реакция, <=> равновесие, ^ газ, v осадок.",
chemistryExamplesLabel: "Вставить пример",
chemistryExamples: {
formula: "Формула",
ion: "Ион",
reaction: "Реакция",
equilibrium: "Равновесие",
conditions: "Условия над стрелкой",
gasPrecipitate: "Газ / осадок",
states: "Агрегатные состояния",
isotope: "Изотоп",
hydrate: "Кристаллогидрат",
units: "Единицы",
},
},
noteLinkPicker: {
title: "Ссылка на заметку",
+19
View File
@@ -2874,6 +2874,8 @@ export const zhCN = {
qrCodeLabel: "分享链接二维码",
qrCodeHint: "用手机相机扫码即可打开这篇笔记。",
qrCodeNotebookHint: "用手机相机扫码即可打开这个笔记本。",
qrCodeDownloadPng: "下载 PNG",
qrCodeDownloadSvg: "下载 SVG",
contentLabel: "分享的笔记内容",
siteHideContents: "收起目录",
siteShowContents: "展开目录",
@@ -3095,6 +3097,23 @@ export const zhCN = {
loading: "正在加载公式编辑器…",
loadError: "可视化编辑器暂时无法加载,可继续使用 LaTeX。",
incomplete: "在空框中输入,按 Tab 切换到下一处",
subjectLabel: "公式类型",
subjectMath: "数学",
subjectChemistry: "化学",
chemistryHint: "化学式写在 \\ce{…} 中,带单位的量写在 \\pu{…} 中:-> 反应,<=> 平衡,^ 气体,v 沉淀。",
chemistryExamplesLabel: "插入示例",
chemistryExamples: {
formula: "化学式",
ion: "离子",
reaction: "反应",
equilibrium: "平衡",
conditions: "反应条件",
gasPrecipitate: "气体 / 沉淀",
states: "物态",
isotope: "同位素",
hydrate: "结晶水合物",
units: "单位",
},
},
noteLinkPicker: {
title: "引用笔记",
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { EditorState, NodeSelection, TextSelection } from "@tiptap/pm/state";
import { closeHistory, history, undo, redo } from "@tiptap/pm/history";
import { ImageGallery, createImageGalleryCleanupPlugin, TRANSIENT_IMAGE_UPLOAD_PREFIX } from "./image-gallery";
import { docToMarkdown, markdownToDoc } from "./content";
const schema = getSchema([StarterKit, Image, ImageGallery]);
const image = (src) => schema.nodes.image.create({ src });
const paragraph = (text) => schema.nodes.paragraph.create(null, text ? schema.text(text) : null);
const gallery = (...sources) => schema.nodes.edgeeverImageGallery.create({ layout: "3", fit: "height" }, sources.map(image));
const editor = (nodes) => {
let state = EditorState.create({ schema, doc: schema.nodes.doc.create(null, nodes), plugins: [history(), createImageGalleryCleanupPlugin()] });
return {
get state() { return state; },
dispatch(transaction) {
state = state.applyTransaction(transaction).state;
state.doc.check();
},
};
};
// NodeView.deleteNode() uses deleteRange(), which dispatches tr.delete(from,to).
const deleteImage = (editor, position) => editor.dispatch(editor.state.tr.delete(position, position + 1));
describe("live gallery deletion (#707)", () => {
test("deleting both images leaves editable empty content and empty Markdown", () => {
const e = editor([gallery("/one.jpg", "/two.jpg")]);
deleteImage(e, 1);
expect(e.state.doc.firstChild.firstChild.attrs.src).toBe("/two.jpg");
expect(e.state.doc.firstChild.attrs).toMatchObject({ layout: "3", fit: "height" });
deleteImage(e, 1);
expect(e.state.doc.toJSON()).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
expect(e.state.selection).toBeInstanceOf(TextSelection);
expect(docToMarkdown(e.state.doc.toJSON())).toBe("");
expect(markdownToDoc(docToMarkdown(e.state.doc.toJSON()))).toEqual(e.state.doc.toJSON());
e.dispatch(e.state.tr.insertText("still editable"));
expect(e.state.doc.textContent).toBe("still editable");
});
test("last-image deletion and cleanup undo and redo as one edit", () => {
const e = editor([gallery("/one.jpg", "/two.jpg")]);
deleteImage(e, 1);
e.dispatch(closeHistory(e.state.tr));
const before = e.state.doc.toJSON();
deleteImage(e, 1);
expect(undo(e.state, e.dispatch)).toBe(true);
expect(e.state.doc.toJSON()).toEqual(before);
expect(redo(e.state, e.dispatch)).toBe(true);
expect(docToMarkdown(e.state.doc.toJSON())).toBe("");
});
test("keyboard node-selection deletion preserves neighboring prose", () => {
const e = editor([paragraph("before"), gallery("/one.jpg"), paragraph("after")]);
const position = e.state.doc.firstChild.nodeSize + 1;
e.dispatch(e.state.tr.setSelection(NodeSelection.create(e.state.doc, position)));
e.dispatch(e.state.tr.deleteSelection());
expect(e.state.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [paragraph("before"), paragraph("after")]).toJSON());
});
test("range deletion of all gallery children removes the shell", () => {
const e = editor([gallery("/one.jpg", "/two.jpg")]);
e.dispatch(e.state.tr.delete(1, 3));
expect(e.state.doc.firstChild.type.name).toBe("paragraph");
expect(docToMarkdown(e.state.doc.toJSON())).toBe("");
});
test("nested gallery deletion leaves its container editable", () => {
const e = editor([schema.nodes.blockquote.create(null, gallery("/one.jpg")), paragraph("outside")]);
deleteImage(e, 2);
expect(e.state.doc.firstChild.type.name).toBe("blockquote");
expect(e.state.doc.firstChild.firstChild.type.name).toBe("paragraph");
expect(e.state.doc.lastChild.textContent).toBe("outside");
});
test("multiple emptied galleries are removed without shifting other deletions", () => {
const e = editor([gallery("/one.jpg"), paragraph("keep"), gallery("/two.jpg")]);
const secondPosition = e.state.doc.child(0).nodeSize + e.state.doc.child(1).nodeSize + 1;
e.dispatch(e.state.tr.delete(secondPosition, secondPosition + 1).delete(1, 2));
expect(e.state.doc.toJSON()).toEqual(schema.nodes.doc.create(null, paragraph("keep")).toJSON());
});
test("pending upload sources survive removal of another image", () => {
const pending = `${TRANSIENT_IMAGE_UPLOAD_PREFIX}pending`;
const e = editor([gallery("/one.jpg", pending)]);
deleteImage(e, 1);
expect(e.state.doc.firstChild.type.name).toBe("edgeeverImageGallery");
expect(e.state.doc.firstChild.firstChild.attrs.src).toBe(pending);
});
test("standalone image deletion keeps its normal behavior", () => {
const e = editor([image("/one.jpg")]);
deleteImage(e, 0);
expect(e.state.doc.toJSON()).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
});
});
+32
View File
@@ -1,4 +1,5 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { openBlockSource } from "./markdown-block-start";
import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content";
import { parseImageAlignment } from "./image-alignment";
@@ -33,6 +34,10 @@ export const ImageGallery = Node.create({
defining: true,
isolating: true,
addProseMirrorPlugins() {
return [createImageGalleryCleanupPlugin()];
},
addAttributes() {
return {
fit: {
@@ -154,6 +159,33 @@ const hasUsableImageSource = (node: TiptapNode | TiptapTextNode) =>
typeof node.attrs?.src === "string" &&
node.attrs.src.trim().length > 0;
/** Keep node-view, keyboard and range deletions in the same undoable edit. */
export const createImageGalleryCleanupPlugin = () => new Plugin({
key: new PluginKey("edgeever-image-gallery-cleanup"),
appendTransaction(transactions, _oldState, state) {
if (!transactions.some((transaction) => transaction.docChanged)) return null;
const emptyGalleries: Array<{ from: number; to: number }> = [];
state.doc.descendants((node, position) => {
if (node.type.name !== IMAGE_GALLERY_NODE_TYPE) return;
// image+ makes ProseMirror insert an image with src=null when its last
// child is deleted. Remove that shell from the live document as well as
// saved snapshots. Pending uploads have a nonempty src and stay intact.
if (!node.content.content.some((image) =>
typeof image.attrs.src === "string" && image.attrs.src.trim().length > 0
)) {
emptyGalleries.push({ from: position, to: position + node.nodeSize });
}
return false;
});
if (!emptyGalleries.length) return null;
const transaction = state.tr;
for (const { from, to } of emptyGalleries.reverse()) transaction.delete(from, to);
return transaction;
},
});
type NormalizedNodes = {
changed: boolean;
nodes: Array<TiptapNode | TiptapTextNode>;
+22
View File
@@ -0,0 +1,22 @@
import { Marked, type marked } from "marked";
/** EdgeEver uses double tildes for strike; single tildes belong to prose. */
// TipTap types the injected instance as its callable singleton, but uses the
// Marked class's Lexer/use API. Each consumer owns its tokenizer registrations.
export const createEdgeEverMarked = () => new Marked({
gfm: true,
tokenizer: {
del(src: string) {
// undefined rejects this token; false delegates to marked's tokenizer.
if (/^~(?!~)/.test(src)) return undefined;
return false;
},
},
hooks: {
emStrongMask(src: string) {
// Only change marked's scan copy, keeping offsets and original text intact.
// Otherwise a lone tilde inside ~~a~b~~ can hide its real closing delimiter.
return src.replace(/(?<!~)~(?!~)/g, "a");
},
},
}) as unknown as typeof marked;
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { Editor } from "@tiptap/core";
import { createEdgeEverDocumentExtensions } from "./document-extensions.ts";
import { createEdgeEverMarkdownManager, docToMarkdown, docToText, markdownToDoc } from "./content.ts";
import { createEdgeEverMarkdownMathematics } from "./mathematics-markdown.ts";
const strikeTexts = (doc) => {
const texts = [];
const visit = (node) => {
if (node.marks?.some((mark) => mark.type === "strike")) texts.push(node.text);
node.content?.forEach(visit);
};
visit(doc);
return texts;
};
describe("double-tilde strikethrough (#698)", () => {
test.each(["成本 1~2~3 元", "10~20, 30~40", "第一期~第十六期", "~25s and ~30s", "~deleted~", "🙂 1~2~3 中文"])("keeps prose literal: %s", (source) => {
const doc = markdownToDoc(source);
expect(docToText(doc)).toBe(source);
expect(strikeTexts(doc)).toEqual([]);
expect(markdownToDoc(docToMarkdown(doc))).toEqual(doc);
});
test.each(["~~a~b~~", "~~a ~ b~~", "~~1~2~3~~", "~~删除~~"])("retains double-tilde strike: %s", (source) => {
const doc = markdownToDoc(source);
expect(strikeTexts(doc)).toHaveLength(1);
expect(markdownToDoc(docToMarkdown(doc))).toEqual(doc);
});
test("keeps mixed, escaped, and longer delimiter runs distinct", () => {
const doc = markdownToDoc("~a~~b~~c~ and \\~x\\~ and inline ~~~literal~~~");
expect(doc.content[0].content.map((node) => node.text).join("")).toBe("~abc~ and ~x~ and inline ~~~literal~~~");
expect(strikeTexts(doc)).toEqual(["b"]);
expect(markdownToDoc(docToMarkdown(doc))).toEqual(doc);
});
test("preserves code, links, math, emphasis, and table cells", () => {
const source = "**1~2~3** `1~2~3` [10~20](https://example.com/~user) $x~y$\n\n~~~text\n1~2~3\n~~~\n\n| range | strike |\n| --- | --- |\n| 1~2~3 | ~~a~b~~ |";
const doc = markdownToDoc(source);
expect(strikeTexts(doc)).toEqual(["a~b"]);
expect(doc.content[0].content).toContainEqual({ type: "text", text: "1~2~3", marks: [{ type: "code" }] });
expect(doc.content[0].content).toContainEqual({ type: "inlineMath", attrs: { latex: "x~y" } });
expect(doc.content[1].content[0].text).toBe("1~2~3");
expect(strikeTexts(markdownToDoc(docToMarkdown(doc)))).toEqual(["a~b"]);
const nested = markdownToDoc("~~**1~2~3**~~").content[0].content[0];
expect(nested.text).toBe("1~2~3");
expect(nested.marks.map((mark) => mark.type).sort()).toEqual(["bold", "strike"]);
});
test("keeps long chunked notes literal", () => {
const doc = markdownToDoc("成本 1~2~3 元\n\n".repeat(1500).trimEnd());
expect(doc.content).toHaveLength(1500);
expect(strikeTexts(doc)).toEqual([]);
});
test("isolates repeated managers and editor Markdown commands", () => {
const options = { mathematics: createEdgeEverMarkdownMathematics(), markdown: true };
const first = createEdgeEverMarkdownManager(options);
const second = createEdgeEverMarkdownManager(options);
expect(first.instance).not.toBe(second.instance);
const editor = new Editor({ extensions: createEdgeEverDocumentExtensions(options), content: { type: "doc", content: [{ type: "paragraph" }] } });
try {
for (const source of ["1~2~3", "~~a~b~~", "~a~~b~~c~"]) {
editor.commands.setContent(source, { contentType: "markdown" });
const expected = markdownToDoc(source);
expect(editor.getJSON()).toEqual(expected);
expect(first.parse(source)).toEqual(expected);
expect(second.parse(source)).toEqual(expected);
expect(markdownToDoc(editor.getMarkdown())).toEqual(expected);
}
} finally {
editor.destroy();
}
});
});
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import katex from "katex";
// Only the shared math module is imported: it must register mhchem for every client.
import { createEdgeEverMathematics } from "./mathematics.ts";
import { docToMarkdown, markdownToDoc } from "./content.ts";
const options = { throwOnError: true, strict: "warn", trust: false };
describe("chemistry formulas", () => {
test("the shared math extensions register \\ce and \\pu on KaTeX", () => {
expect(createEdgeEverMathematics()).toHaveLength(2);
for (const latex of [
"\\ce{H2O}",
"\\ce{SO4^2-}",
"\\ce{2H2 + O2 -> 2H2O}",
"\\ce{N2 + 3H2 <=> 2NH3}",
"\\ce{CaCO3 ->[\\Delta] CaO + CO2}",
"\\ce{Ag+ + Cl- -> AgCl v}",
"\\ce{NaCl(s) -> Na+(aq) + Cl-(aq)}",
"\\ce{^{14}_{6}C}",
"\\ce{CuSO4*5H2O}",
"\\pu{123 kJ/mol}",
]) {
expect(katex.renderToString(latex, options)).toContain('class="katex"');
expect(katex.renderToString(latex, { ...options, displayMode: true })).toContain("katex-display");
}
});
test("keeps links and other untrusted commands inert inside \\ce", () => {
const html = katex.renderToString("\\ce{\\href{javascript:alert(1)}{x}}", options);
expect(html).not.toMatch(/<a[\s>]/);
expect(html).not.toContain('href="javascript');
});
test("stores chemistry as ordinary inline and display math in Markdown", () => {
const markdown = "Water $\\ce{H2O}$ forms:\n\n$$\\ce{2H2 + O2 -> 2H2O}$$\n";
const doc = markdownToDoc(markdown);
const nodes = JSON.stringify(doc);
expect(nodes).toContain('"latex":"\\\\ce{H2O}"');
expect(nodes).toContain('"latex":"\\\\ce{2H2 + O2 -> 2H2O}"');
const roundTrip = docToMarkdown(doc);
expect(roundTrip).toContain("$\\ce{H2O}$");
expect(roundTrip).toContain("$$\n\\ce{2H2 + O2 -> 2H2O}\n$$");
expect(markdownToDoc(roundTrip)).toEqual(doc);
});
});
+3
View File
@@ -2,6 +2,9 @@ import { InputRule, PasteRule } from "@tiptap/core";
import { BlockMath, InlineMath } from "@tiptap/extension-mathematics";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection, type Transaction } from "@tiptap/pm/state";
// Registers \ce{...} and \pu{...} (chemical formulas, equations and units) on
// the KaTeX instance the math nodes render with, on every client.
import "katex/contrib/mhchem";
import {
edgeEverBlockMathMarkdownTokenizer,
edgeEverInlineMathMarkdownTokenizer,
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more