mirror of
https://github.com/zadam/trilium.git
synced 2026-03-09 21:50:24 +01:00
Compare commits
164 Commits
feature/im
...
autocomple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e0df70d7 | ||
|
|
0ef7dd8fc2 | ||
|
|
bd1f6b7a0f | ||
|
|
cc24c2a4dc | ||
|
|
eaa6da73ca | ||
|
|
84ea5d3401 | ||
|
|
65095713cc | ||
|
|
8f6b565673 | ||
|
|
c0dd59458b | ||
|
|
d363d2016e | ||
|
|
702df56a8a | ||
|
|
91677eb7da | ||
|
|
e74f619d06 | ||
|
|
1c79beaa57 | ||
|
|
42a2b1bc13 | ||
|
|
8393b916c1 | ||
|
|
656796d18d | ||
|
|
0fe00b77cf | ||
|
|
150f88cc56 | ||
|
|
1b013e6888 | ||
|
|
2dfc4514b0 | ||
|
|
d652f67364 | ||
|
|
5e54d098c5 | ||
|
|
ec95303c31 | ||
|
|
07aafe7e89 | ||
|
|
dc7acbb70e | ||
|
|
0dcb8b3ff8 | ||
|
|
e4ddff01ca | ||
|
|
015c1161d4 | ||
|
|
ca0c6076c5 | ||
|
|
80a02f88be | ||
|
|
430833bedb | ||
|
|
dc80d83964 | ||
|
|
5f7ade45f4 | ||
|
|
8b36a7ab1e | ||
|
|
fd18276693 | ||
|
|
0becfc16ba | ||
|
|
d480d1f6ba | ||
|
|
f5c9a71ba0 | ||
|
|
c177a8a464 | ||
|
|
c826564c9e | ||
|
|
ccb13fa6b9 | ||
|
|
69e374138f | ||
|
|
3156b2cb59 | ||
|
|
d6217ffed4 | ||
|
|
fc90c6af9d | ||
|
|
a1118419ec | ||
|
|
8599785ee8 | ||
|
|
99ba192a44 | ||
|
|
b86d3587ac | ||
|
|
b2a0baf56a | ||
|
|
22f37817e5 | ||
|
|
6b4fe03625 | ||
|
|
f44b47ec23 | ||
|
|
8d667e838a | ||
|
|
f32385de2e | ||
|
|
90796fc4fa | ||
|
|
4960c49cb2 | ||
|
|
b112e8b56b | ||
|
|
83095130f6 | ||
|
|
d005c0ef2d | ||
|
|
c135578626 | ||
|
|
9a6e20029e | ||
|
|
39bd4ccea1 | ||
|
|
ea7aac2030 | ||
|
|
e7f98f08d0 | ||
|
|
8ac9daa5d3 | ||
|
|
0b506c6327 | ||
|
|
d2b62540ec | ||
|
|
64418c7fec | ||
|
|
8c1a58e64f | ||
|
|
b27fd31c1f | ||
|
|
f18a531924 | ||
|
|
3cabb4b661 | ||
|
|
5c88b1c6b8 | ||
|
|
c2adc43780 | ||
|
|
7eaa5352ba | ||
|
|
17e3e3187b | ||
|
|
2ad7cd3a49 | ||
|
|
39aa8d61c2 | ||
|
|
1a3ea977b7 | ||
|
|
4cd8f9a1e6 | ||
|
|
87ce6d1231 | ||
|
|
8fdbeacf77 | ||
|
|
f4f775a1c9 | ||
|
|
fe1154cb2d | ||
|
|
638f479ff3 | ||
|
|
70436bdb04 | ||
|
|
575ecaae07 | ||
|
|
d277e6db94 | ||
|
|
25efcd12d0 | ||
|
|
10129321be | ||
|
|
72710a8f6b | ||
|
|
6a7c5c04d8 | ||
|
|
5d89591dea | ||
|
|
a88bf5a87b | ||
|
|
bbe5d3506e | ||
|
|
c2993d4e7d | ||
|
|
17ba479182 | ||
|
|
a465014bbe | ||
|
|
5dfe253ef6 | ||
|
|
ae7ca6021f | ||
|
|
c389697acd | ||
|
|
c13c3e0f4a | ||
|
|
82c042d045 | ||
|
|
9145ba1690 | ||
|
|
d60653ee17 | ||
|
|
dae8613b4e | ||
|
|
2f8e2c40be | ||
|
|
d85225a0dc | ||
|
|
0cb66df2b2 | ||
|
|
92e0578cb6 | ||
|
|
2eee06786e | ||
|
|
19053dcb3b | ||
|
|
e10c30c59f | ||
|
|
c356159664 | ||
|
|
579be68ca1 | ||
|
|
a6326a682e | ||
|
|
4595a3a5dd | ||
|
|
ee21185e64 | ||
|
|
6d0676c37d | ||
|
|
1d4768a581 | ||
|
|
d086bb7fcb | ||
|
|
2607c4a32e | ||
|
|
624333a2ef | ||
|
|
d4acb37f21 | ||
|
|
6c1a1e9812 | ||
|
|
9a13641f9b | ||
|
|
699e0624c9 | ||
|
|
47ceb0d4d2 | ||
|
|
15c42f4a09 | ||
|
|
bf8401bb26 | ||
|
|
f234433c63 | ||
|
|
1b70101123 | ||
|
|
d610c63c28 | ||
|
|
5e820a407f | ||
|
|
62610979b7 | ||
|
|
700e99e854 | ||
|
|
7767116b3d | ||
|
|
0206e8247b | ||
|
|
5476fe3df9 | ||
|
|
d9a4581d37 | ||
|
|
8d9c888481 | ||
|
|
11e4b672d1 | ||
|
|
bace3daadc | ||
|
|
dee5380e60 | ||
|
|
bc6a6fd860 | ||
|
|
e928337fe9 | ||
|
|
432f86ea4b | ||
|
|
5d2daecee0 | ||
|
|
7c8eb311af | ||
|
|
4ac22678df | ||
|
|
5057c02176 | ||
|
|
d301e56216 | ||
|
|
3c22ab8c9c | ||
|
|
0212398815 | ||
|
|
db0c515bad | ||
|
|
9b4f8c5003 | ||
|
|
85d8c4c8fa | ||
|
|
5afab6938a | ||
|
|
a437169ad5 | ||
|
|
f632d3aeb6 | ||
|
|
513fffcb1a | ||
|
|
c80bb9657c |
272
.agents/migration_plan_autocomplete.md
Normal file
272
.agents/migration_plan_autocomplete.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Migration Plan: `autocomplete.js` → `@algolia/autocomplete-js`
|
||||
|
||||
> Issue: https://github.com/TriliumNext/Trilium/issues/5134
|
||||
>
|
||||
> 目标:将旧的 `autocomplete.js@0.38.1`(jQuery 插件)迁移到 `@algolia/autocomplete-js`(独立组件)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态总览
|
||||
|
||||
### 两个库的架构差异
|
||||
| | 旧 `autocomplete.js` | 新 `@algolia/autocomplete-js` |
|
||||
|---|---|---|
|
||||
| 模式 | jQuery 插件,**增强已有 `<input>`** | 独立组件,**传入容器 `<div>`,自己创建 `<input>`** |
|
||||
| 初始化 | `$el.autocomplete(config, [datasets])` | `autocomplete({ container, getSources, ... })` 返回 `api` |
|
||||
| 操作 | `$el.autocomplete("open"/"close"/"val")` | `api.setIsOpen()`, `api.setQuery()`, `api.refresh()` |
|
||||
| 销毁 | `$el.autocomplete("destroy")` | `api.destroy()` |
|
||||
| 事件 | jQuery 事件 `autocomplete:selected` | `onSelect` 回调、`onStateChange` 回调 |
|
||||
| DOM | 增强已有 input,添加 `.aa-input` 类 | 替换容器内容为自己的 DOM(`.aa-Form`、`.aa-Panel` 等) |
|
||||
|
||||
### 关键迁移原则
|
||||
1. **不使用 wrapper/适配层**,直接在各 service 中调用 `autocomplete()` API
|
||||
2. 消费者代码需要适配:传入容器 `<div>` 而非 `<input>`,通过 API/回调读写值
|
||||
3. 增量迁移:每个使用点独立迁移,逐一验证
|
||||
4. **优先保留旧版业务逻辑与交互语义**:迁移时默认以旧版 `autocomplete.js` 行为为准,不主动重设计状态流或交互。
|
||||
5. **只有在新旧包能力或生命周期模型存在冲突、无法直接一一映射时,才允许添加补丁逻辑**;这类补丁的目标不是“接近”,而是尽可能恢复与旧版完全相同的 behavior。
|
||||
|
||||
### 涉及的功能区域
|
||||
1. **属性名称自动补全** — `attribute_autocomplete.ts` → `attribute_detail.ts`、`RelationMap.tsx`
|
||||
2. **标签值自动补全** — `attribute_autocomplete.ts` → `attribute_detail.ts`
|
||||
3. **笔记搜索自动补全** — `note_autocomplete.ts` → `NoteAutocomplete.tsx`、`attribute_detail.ts`
|
||||
4. **关闭弹出窗口** — `dialog.ts`、`entrypoints.ts`、`tab_manager.ts`
|
||||
5. **CKEditor 提及** — 不使用 autocomplete.js,**无需迁移**
|
||||
|
||||
---
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### Step 0: 安装新依赖 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/package.json` — 添加 `@algolia/autocomplete-js@1.19.6`,暂时保留 `autocomplete.js`
|
||||
|
||||
**验证方式:**
|
||||
- ✅ 新依赖安装成功
|
||||
|
||||
---
|
||||
|
||||
### Step 1: 迁移属性名称自动补全 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — 将 `initAttributeNameAutocomplete()` 完全使用 **Headless API (`@algolia/autocomplete-core`)** 重写,移除遗留的 jQuery autocomplete 调用。
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 维持原有 `<input>` 模型不变,仅需增加 `onValueChange` 处理回调。
|
||||
- `apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx` — 维持原有回调逻辑,新旧无感替换。
|
||||
- `apps/client/src/stylesheets/style.css` — 增加自定义 Headless 渲染面板样式 (`.aa-core-panel`,`.aa-core-list` 等)。
|
||||
|
||||
**架构说明:**
|
||||
由于 Trilium 依赖同一页面同时运行多个 autocomplete 生命周期(边栏属性列表,底部编辑器等),原生 `@algolia/autocomplete-js` 会因为单例 DOM 冲突强行报错 "doesn't support multiple instances running at the same time"。
|
||||
解决方案是退化使用纯状态机的 `@algolia/autocomplete-core`,自己进行 DOM 劫持与面板渲染。
|
||||
- `requestAnimationFrame`:针对下拉层自动跟踪光标位置,适配面板的高频大小变化
|
||||
- 事件阻断:拦截了选择时候的 `Enter` 返回键事件气泡,避免误触外层 Dialog 销毁。
|
||||
|
||||
**验证方式:**
|
||||
- ✅ 打开一个笔记 → 点击属性面板弹出 "Label detail" → 输入属性名称时正常显示下拉自动补全框
|
||||
- ✅ 放大/缩小/变形整个面板,下拉菜单粘连位置准确
|
||||
- ✅ 键盘上下方向键可高亮,按 Enter 可选中当前项填充,且对话框不关闭
|
||||
- ✅ 关系图 (Relation map) 创建关系时,关系名输入框的自动补全同样工作正常
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 迁移标签值自动补全 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — 移除旧有的 jQuery `$el.autocomplete` 初始化,整体复用封装的 `@algolia/autocomplete-core` Headless 架构流。在内部设计了一套针对 Label Name 值更变时的 `cachedAttributeName` 以及 `getItems` 数据惰性更新机制。
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 取消监听不标准的 jQuery 强盗冒泡事件 `autocomplete:closed`,改为直接在配置中传入清晰的 `onValueChange` 回调函数。同时解决了所有输入遗留 Bug。
|
||||
|
||||
**说明与优化点:**
|
||||
与 Step 1 类似,同样完全剔除了所有的残旧依赖与 jQuery 控制流,在此基础上还针对值类型的特异性做了几个高级改动:
|
||||
1. **取消内存破坏型重建 (Fix Memory Leak)**:旧版本在每次触发聚焦 (Focus) 时都会发送摧毁指令强扫 DOM。新架构下只要容器保持存活就仅仅使用 `.refresh()` 接口来控制界面弹出与数据隐式获取。
|
||||
2. **惰性与本地缓存 (Local Fast CACHE)**:如果关联的属性名 (Attribute Name) 没有被更改,再次打开提示面板时将以 0ms 的延迟抛出旧缓存 `cachedAttributeValues`。一旦属性名被修改,则重新发起服务端网络请求。
|
||||
3. **彻底分离逻辑**:删除了文件中的 `still using old autocomplete.js` 遗留注释,此时 `attribute_autocomplete.ts` 文件内已经 100% 运行在崭新的 Autocomplete 体系上。
|
||||
|
||||
**验证方式:**
|
||||
- ✅ 打开属性面板 → 点击或输入任意已有 Label 类型的 Name → 切换到值输入框 → 能瞬间弹出相应的旧值提示列表。
|
||||
- ✅ 在旧值提示列表中用上下方向键选取并回车 → 能实现无缝填充并将更变保存回右侧详细侧边栏。
|
||||
- ✅ 解决回车冲突:确认选择时系统发出的事件能干净落回所属宿主 DOM 且并不抢占外层组件快捷键。
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 迁移笔记搜索自动补全核心 (拆分为 4 个增量阶段)
|
||||
|
||||
由于搜索自动补全模块(`note_autocomplete.ts`)承载了系统最为复杂的交互、多态分发与 UI,我们将其拆分为 4 个逐步可验证的子阶段:
|
||||
|
||||
#### Step 3.1: 基础骨架与核心接口联通 (Headless 骨架) ✅ 完成
|
||||
**目标:** 用 `@algolia/autocomplete-core` 完全接管旧版的 `$el.autocomplete` 初始化,打通搜索接口。
|
||||
**工作内容:**
|
||||
- 在 `initNoteAutocomplete()` 中引入基于 `instanceMap` 的单例验证逻辑与 DOM 隔离。
|
||||
- 建立 `getSources`,实现调用 `server.get("api/search/autocomplete", ...)`。
|
||||
- 只做极其简单的 UI(比如简单的 `ul > li` text)将获取到的 `title` 渲染出来,确保网络流程畅通。
|
||||
**完成情况与验证 (**`apps/client/src/services/note_autocomplete.ts`**):**
|
||||
- ✅ 彻底移除了原依赖于 jQuery `autocomplete.js` 的各种初始化配置与繁复的字符串 DOM 拼接节点。
|
||||
- ✅ 实现了对 `Jump to Note (Ctrl+J)` 等真实组件的向下兼容事件 (`autocomplete:noteselected`) 无缝派发反馈。
|
||||
- ✅ 在跳往某个具体笔记或在新建 Relation 面板选用特定目标笔记时,基础请求和简装提示版均工作正常。
|
||||
|
||||
#### Step 3.2: 复杂 UI 渲染重构与匹配高亮 (模板渲染) ✅ 基本完成
|
||||
**目标:** 实现与原版相同级别(甚至更好)的视觉体验(例如笔记图标、上级路径显示、搜索词高亮标红等)。
|
||||
**工作内容:**
|
||||
- 重写原有的基于字符串或 jQuery 的构建 DOM 模板代码(专门处理带 `notePath` `icon` `isSearch` 判断等数据)。
|
||||
- 将 DOM 构建系统集成到 `onStateChange` 的渲染函数里,通过 `innerHTML` 拼装或 DOM 手工建立实现原生高性能面板。
|
||||
- 引入对应的样式 (`style.css`) 补全排版缺漏。
|
||||
**验证方式:** 下拉出的搜索面板变得非常美观,与系统的 Dark/Light 色调融合;笔记标题对应的图标出现,匹配的字样高亮突出。
|
||||
**当前验证结果:**
|
||||
- ✅ `Ctrl+J / Jump to Note`:UI 渲染、recent notes、键盘/鼠标高亮联动、删空回 recent notes 等核心交互已基本恢复。
|
||||
- ✅ `attribute_detail.ts` 等依赖 jQuery 事件的目标笔记选择入口,抽查结果正常。
|
||||
- ⚠️ React 侧消费者尚未完成迁移验收。抽查 `Move to` 时发现功能不正常,这部分应归入 **Step 5** 继续处理,而不是视为 Step 3.2 已全链路完成。
|
||||
|
||||
#### Step 3.3: 差异化分发逻辑与对外事件抛出 (交互改造) ✅ 基本完成
|
||||
**目标:** 支持该组件的多态性。它能在搜笔记之外搜命令(`>` 起手)、甚至是外部链接。同时能够被外部组件监听到选择动作。
|
||||
**工作内容:**
|
||||
- 在选择项(`onSelect`)的回调中,根据用户选的是“系统命令”、“外部链接”还是“普通笔记”走截然不同的行为逻辑。
|
||||
- 对外派发事件:原本通过 `$el.trigger("autocomplete:noteselected")` 的逻辑需要保留,以保证那些使用了搜索框的组件(例如右侧关系面板)依然能顺利收到选中反馈。
|
||||
**验证方式:** 选中某个建议项时能够真正实现页面的调转/关系绑定;输入 `>` 开头能够列举出所有快捷命令(如 Toggle Dark mode)。
|
||||
**当前验证结果:**
|
||||
- ✅ 选择分发已按旧版语义迁移:`command`、`external-link`、`create-note`、`search-notes` 与普通 note 走独立分支。
|
||||
- ✅ `autocomplete:noteselected`、`autocomplete:externallinkselected`、`autocomplete:commandselected` 三类对外事件均已保留。
|
||||
- ✅ 鼠标点击和键盘回车现在统一走同一套 `handleSuggestionSelection()` 分发逻辑,不再额外误抛 `autocomplete:noteselected`。
|
||||
- ✅ `Ctrl+J / Jump to Note` 与 `attribute_detail.ts` 的普通 note 选择链路已抽查通过。
|
||||
- ⚠️ React 消费方整体仍应放在 **Step 5** 继续验收;`Move to` 等问题不属于 Step 3.3 本身已完成的范围。
|
||||
|
||||
#### Step 3.4: 特殊键盘事件拦截与附带按钮包容 (终极打磨) ✅ 基本完成
|
||||
**目标:** 解决在旧 jQuery 中强绑定的 IME(中日韩等输入法)防抖问题,并恢复如 `Shift+Enter`、周边附加按钮(清除等)的正常运作。
|
||||
**工作内容:**
|
||||
- 将旧的输入法合成事件 (`compositionstart` / `compositionend`) 判断逻辑迁移到新的 `onInput` / `onKeyDown` 外围保护之中。
|
||||
- 重构对 `Shift+Enter` (唤起全文搜索)、`Ctrl+Enter` 等组合快捷键的劫持处理。
|
||||
- 修正周边辅助控件(例如搜索栏自带的 “最近笔记(钟表)”、“清除栏(X)” 按钮)因为 DOM 结构调整可能引发的影响。
|
||||
**验证方式:** 中文拼音输入法敲打途中不会错误地发起网络搜索;各种组合回车热键重新生效,整个搜索系统重回巅峰状态。
|
||||
**当前验证结果:**
|
||||
- ✅ `compositionstart` / `compositionend` 已恢复旧版保护逻辑:合成期间不发起搜索,结束后按“清空再恢复 query”的语义重新跑一次。
|
||||
- ✅ `Shift+Enter` 与 `Ctrl+Enter` 的快捷分发仍保留,并已按旧版语义接回全文搜索 / `search-notes`。
|
||||
- ✅ `autocomplete:opened` / `autocomplete:closed` 事件已重新补回,`readonly` 与“关闭时空输入框清理”逻辑重新对齐旧版。
|
||||
- ✅ 清空按钮、最近笔记按钮、全文搜索按钮都继续走 service 内部统一入口,而不是分散拼状态。
|
||||
- ⚠️ 这一步仍以 `note_autocomplete.ts` 核心行为为主;React 消费方的问题继续留在 **Step 5**。
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 迁移辅助函数 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/note_autocomplete.ts` — `clearText`, `setText`, `showRecentNotes` 等函数
|
||||
|
||||
**说明:**
|
||||
这些函数使用旧库的操作 API(`$el.autocomplete("val", value)` 等),需要改为新库的 `api.setQuery()` / `api.setIsOpen()` / `api.refresh()`。
|
||||
这一步与 **Step 3.4** 有交叉,但并不重复:
|
||||
- **Step 3.4** 关注的是 IME、快捷键、按钮点击后的交互语义是否与旧版一致
|
||||
- **Step 4** 关注的是 helper 函数本身是否已经彻底切到新 API,而不再依赖旧版 `.autocomplete("...")`
|
||||
|
||||
**当前完成情况:**
|
||||
- ✅ `clearText()` 已改为通过 headless instance 清空 query、关闭面板并触发 `change`
|
||||
- ✅ `setText()` 已改为通过 `showQuery()` 驱动 `setQuery()` / `refresh()`
|
||||
- ✅ `showRecentNotes()` 已改为走 `openRecentNotes()`,不再依赖旧版 `.autocomplete("open")`
|
||||
- ✅ `showAllCommands()` 已改为直接设置 `">"` query 打开命令面板
|
||||
- ✅ `fullTextSearch()` 已改为使用新状态流重跑全文搜索
|
||||
|
||||
**验证方式:**
|
||||
- 最近笔记按钮 → 下拉菜单正常打开
|
||||
- 清除按钮 → 输入框被清空
|
||||
- Shift+Enter → 触发全文搜索
|
||||
|
||||
---
|
||||
|
||||
### Step 5: 迁移 `NoteAutocomplete.tsx` (React/Preact 组件)
|
||||
**文件变更:**
|
||||
- `apps/client/src/widgets/react/NoteAutocomplete.tsx` — 传入容器 `<div>`,管理 `api` 生命周期
|
||||
|
||||
**验证方式:**
|
||||
- 关系属性的目标笔记选择正常工作
|
||||
- `noteId` 和 `text` props 的动态更新正确
|
||||
|
||||
**当前状态:**
|
||||
- ⚠️ 尚未完成。虽然底层 `note_autocomplete.ts` 已经切到新实现,但 React 消费方仍需逐一验收。
|
||||
- ⚠️ 已抽查 `Move to`,当前功能不正常,说明 Step 5 仍存在待修复问题。
|
||||
|
||||
---
|
||||
|
||||
### Step 6: 迁移"关闭弹窗"逻辑 + `attribute_detail.ts` 引用
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/dialog.ts` — 替换 `$(".aa-input").autocomplete("close")`
|
||||
- `apps/client/src/components/entrypoints.ts` — 替换 `$(".aa-input").autocomplete("close")`
|
||||
- `apps/client/src/components/tab_manager.ts` — 替换 `$(".aa-input").autocomplete("close")`
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 更新 `.algolia-autocomplete` 选择器
|
||||
|
||||
**说明:**
|
||||
需要一个全局的"关闭所有 autocomplete"机制。方案:维护一个全局 `Set<AutocompleteApi>`,在各处调用时遍历关闭。可以放在 `note_autocomplete.ts` 中导出。
|
||||
|
||||
**验证方式:**
|
||||
- autocomplete 弹窗打开时切换标签页 → 弹窗自动关闭
|
||||
- autocomplete 弹窗打开时打开对话框 → 弹窗自动关闭
|
||||
- 点击 autocomplete 下拉菜单时属性面板不应关闭
|
||||
|
||||
---
|
||||
|
||||
### Step 7: 更新 CSS 样式
|
||||
**文件变更:**
|
||||
- `apps/client/src/stylesheets/style.css`(第 895-961 行)
|
||||
|
||||
**说明:**
|
||||
新库使用的 CSS 类名:
|
||||
- `.aa-Autocomplete` — 容器
|
||||
- `.aa-Form` — 搜索表单(含 input)
|
||||
- `.aa-Input` — 输入框
|
||||
- `.aa-Panel` — 下拉面板
|
||||
- `.aa-List` — 列表
|
||||
- `.aa-Item` — 列表项
|
||||
- `.aa-Item[aria-selected="true"]` — 选中项
|
||||
|
||||
**验证方式:**
|
||||
- 下拉菜单样式正常(亮色/暗色模式)
|
||||
- 选中项高亮正确
|
||||
|
||||
---
|
||||
|
||||
### Step 8: 更新类型声明
|
||||
**文件变更:**
|
||||
- `apps/client/src/types.d.ts` — 移除 `AutoCompleteConfig`、`AutoCompleteArg`、jQuery `.autocomplete()` 方法
|
||||
|
||||
**验证方式:**
|
||||
- TypeScript 编译无错误
|
||||
|
||||
---
|
||||
|
||||
### Step 9: 移除旧库和 Polyfill
|
||||
**文件变更:**
|
||||
- `apps/client/package.json` — 移除 `"autocomplete.js": "0.38.1"`
|
||||
- `apps/client/src/desktop.ts` — 移除 `import "autocomplete.js/index_jquery.js";`
|
||||
- `apps/client/src/mobile.ts` — 移除 `import "autocomplete.js/index_jquery.js";`
|
||||
- `apps/client/src/runtime.ts` — 移除 jQuery polyfill
|
||||
- `apps/client/src/index.ts` — 移除 jQuery polyfill
|
||||
|
||||
**验证方式:**
|
||||
- 完整回归测试
|
||||
- 构建无错误
|
||||
|
||||
---
|
||||
|
||||
### Step 10: 更新 E2E 测试
|
||||
**文件变更:**
|
||||
- `apps/server-e2e/src/support/app.ts`
|
||||
- `apps/server-e2e/src/layout/split_pane.spec.ts`
|
||||
|
||||
**验证方式:**
|
||||
- E2E 测试全部通过
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系图
|
||||
|
||||
```
|
||||
Step 0 (安装新库) ✅
|
||||
├── Step 1 (属性名称 autocomplete) ← 最简单,优先迁移
|
||||
├── Step 2 (标签值 autocomplete)
|
||||
├── Step 3 (笔记搜索 autocomplete 核心) ← 最复杂
|
||||
│ ├── Step 4 (辅助函数)
|
||||
│ └── Step 5 (React 组件)
|
||||
├── Step 6 (关闭弹窗 + attribute_detail 引用)
|
||||
└── Step 7 (CSS 样式)
|
||||
└── Step 8 (类型声明)
|
||||
└── Step 9 (移除旧库) ← 最后执行
|
||||
└── Step 10 (E2E 测试)
|
||||
```
|
||||
|
||||
## 风险点
|
||||
1. **消费者代码需要改动**:新库要求传入容器而非 input,消费者需要调整 HTML 模板和值的读写方式。
|
||||
2. **自定义事件兼容性**:旧库通过 jQuery 事件与外部交互,新库使用回调,`attribute_detail.ts` 等消费者中的事件监听需要更新。
|
||||
3. **IME 输入处理**:新库原生支持 `ignoreCompositionEvents` 选项,但需要验证行为是否与旧的手动处理一致。
|
||||
4. **CSS 类名变化**:多处代码通过 `.aa-input`、`.algolia-autocomplete` 定位元素,需要统一更新为新的 `.aa-*` 类名。
|
||||
5. **全局关闭机制**:旧代码通过 `$(".aa-input").autocomplete("close")` 关闭所有实例,新库需要手动维护实例注册表。
|
||||
27
.github/workflows/dev.yml
vendored
27
.github/workflows/dev.yml
vendored
@@ -40,11 +40,32 @@ jobs:
|
||||
- name: Run the client-side tests
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
path: apps/client/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run the server-side tests
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
path: apps/server/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run CKEditor e2e tests
|
||||
run: |
|
||||
pnpm run --filter=ckeditor5-mermaid test
|
||||
pnpm run --filter=ckeditor5-math test
|
||||
|
||||
- name: Run the rest of the tests
|
||||
run: pnpm run --filter=\!client --filter=\!server test"
|
||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -69,7 +90,7 @@ jobs:
|
||||
- name: Trigger server build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
@@ -106,7 +127,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
|
||||
18
.github/workflows/main-docker.yml
vendored
18
.github/workflows/main-docker.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -175,13 +175,13 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -229,17 +229,17 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@v0.4
|
||||
uses: imjasonh/setup-crane@v0.5
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.20.2",
|
||||
"archiver": "7.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -16,6 +16,7 @@
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-js": "1.19.6",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
@@ -28,14 +29,20 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.16.1",
|
||||
"@univerjs/preset-sheets-core": "0.16.1",
|
||||
"@univerjs/preset-sheets-data-validation": "0.16.1",
|
||||
"@univerjs/preset-sheets-filter": "0.16.1",
|
||||
"@univerjs/preset-sheets-find-replace": "0.16.1",
|
||||
"@univerjs/preset-sheets-note": "0.16.1",
|
||||
"@univerjs/preset-sheets-sort": "0.16.1",
|
||||
"@univerjs/presets": "0.16.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"autocomplete.js": "0.38.1",
|
||||
@@ -47,28 +54,28 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.14",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.33",
|
||||
"katex": "0.16.38",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.9.1",
|
||||
"mind-elixir": "5.9.2",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-i18next": "16.5.6",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "5.2.1",
|
||||
"rrule": "2.8.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"tabulator-tables": "6.4.0",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,114 +1,447 @@
|
||||
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
||||
import { createAutocomplete } from "@algolia/autocomplete-core";
|
||||
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import server from "./server.js";
|
||||
|
||||
interface InitOptions {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NameItem extends BaseItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InitAttributeNameOptions {
|
||||
/** The <input> element where the user types */
|
||||
$el: JQuery<HTMLElement>;
|
||||
attributeType?: AttributeType | (() => AttributeType);
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
/** Called when the user selects a value or the panel closes */
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $el - element on which to init autocomplete
|
||||
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
|
||||
* @param open - should the autocomplete be opened after init?
|
||||
*/
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
|
||||
if (!$el.hasClass("aa-input")) {
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: true,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "name",
|
||||
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
|
||||
cache: false,
|
||||
source: async (term, cb) => {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
|
||||
const result = names.map((name) => ({ name }));
|
||||
|
||||
cb(result);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
}
|
||||
interface ManagedInstance {
|
||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
||||
panelEl: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
|
||||
if ($el.hasClass("aa-input")) {
|
||||
// we reinit every time because autocomplete seems to have a bug where it retains state from last
|
||||
// open even though the value was reset
|
||||
$el.autocomplete("destroy");
|
||||
}
|
||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
||||
|
||||
let attributeName = "";
|
||||
if (nameCallback) {
|
||||
attributeName = nameCallback();
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dropdown panel DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (attributeName.trim() === "") {
|
||||
function createPanelEl(): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "aa-core-panel";
|
||||
panel.style.display = "none";
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderItems(panelEl: HTMLElement, items: NameItem[], activeItemId: number | null, onSelect: (item: NameItem) => void): void {
|
||||
panelEl.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
panelEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||
|
||||
if (attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: false, // handled manually
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
cache: false,
|
||||
source: async function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
const list = document.createElement("ul");
|
||||
list.className = "aa-core-list";
|
||||
items.forEach((item, index) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "aa-core-item";
|
||||
if (index === activeItemId) {
|
||||
li.classList.add("aa-core-item--active");
|
||||
}
|
||||
li.textContent = item.name;
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
onSelect(item);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
panelEl.appendChild(list);
|
||||
}
|
||||
|
||||
function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void {
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
const top = `${rect.bottom}px`;
|
||||
const left = `${rect.left}px`;
|
||||
const width = `${rect.width}px`;
|
||||
|
||||
panelEl.style.position = "fixed";
|
||||
if (panelEl.style.top !== top) panelEl.style.top = top;
|
||||
if (panelEl.style.left !== left) panelEl.style.left = left;
|
||||
if (panelEl.style.width !== width) panelEl.style.width = width;
|
||||
if (panelEl.style.display !== "block") panelEl.style.display = "block";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
// Already initialized — just open if requested
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const panelEl = createPanelEl();
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
|
||||
let rafId: number | null = null;
|
||||
function startPositioning() {
|
||||
if (rafId !== null) return;
|
||||
const update = () => {
|
||||
positionPanel(panelEl, inputEl);
|
||||
rafId = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
}
|
||||
function stopPositioning() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: 0,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
{
|
||||
sourceId: "attribute-names",
|
||||
getItems() {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
return server
|
||||
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
|
||||
.then((names) => names.map((name) => ({ name })));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
// Render items
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(panelEl, items, activeId, (item) => {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
});
|
||||
startPositioning();
|
||||
} else {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Wire up the input events
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
const onInput = (e: Event) => {
|
||||
handlers.onChange(e as any);
|
||||
};
|
||||
const onFocus = (e: Event) => {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
};
|
||||
const onBlur = () => {
|
||||
// Delay to allow mousedown on panel items
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && isPanelOpen && hasActiveItem) {
|
||||
// Prevent the enter key from propagating to parent dialogs
|
||||
// (which might interpret it as "submit" or "save and close")
|
||||
e.stopPropagation();
|
||||
// We shouldn't preventDefault here because we want handlers.onKeyDown
|
||||
// to process it properly. OnSelect will correctly close the panel.
|
||||
}
|
||||
handlers.onKeyDown(e as any);
|
||||
};
|
||||
|
||||
inputEl.addEventListener("input", onInput);
|
||||
inputEl.addEventListener("focus", onFocus);
|
||||
inputEl.addEventListener("blur", onBlur);
|
||||
inputEl.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
inputEl.removeEventListener("input", onInput);
|
||||
inputEl.removeEventListener("focus", onFocus);
|
||||
inputEl.removeEventListener("blur", onBlur);
|
||||
inputEl.removeEventListener("keydown", onKeyDown);
|
||||
stopPositioning();
|
||||
if (panelEl.parentElement) {
|
||||
panelEl.parentElement.removeChild(panelEl);
|
||||
}
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (headless autocomplete-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LabelValueInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
syncQueryFromInputValue(inst.autocomplete);
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const panelEl = createPanelEl();
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
let isSelecting = false;
|
||||
|
||||
let rafId: number | null = null;
|
||||
function startPositioning() {
|
||||
if (rafId !== null) return;
|
||||
const update = () => {
|
||||
positionPanel(panelEl, inputEl);
|
||||
rafId = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
}
|
||||
function stopPositioning() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedAttributeName = "";
|
||||
let cachedAttributeValues: NameItem[] = [];
|
||||
|
||||
const handleSelect = (item: NameItem) => {
|
||||
isSelecting = true;
|
||||
inputEl.value = item.name;
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
isSelecting = false;
|
||||
|
||||
setTimeout(() => {
|
||||
// Preserve the legacy contract: several consumers still commit the
|
||||
// selected value from their existing Enter key handlers instead of
|
||||
// listening to the autocomplete selection event directly.
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: null,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
{
|
||||
sourceId: "attribute-values",
|
||||
async getItems() {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (!attributeName.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
|
||||
cachedAttributeName = attributeName;
|
||||
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
|
||||
cachedAttributeValues = values.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
|
||||
},
|
||||
getItemInputValue({ item }) {
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
handleSelect(item);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state }) {
|
||||
isPanelOpen = state.isOpen;
|
||||
hasActiveItem = state.activeItemId !== null;
|
||||
|
||||
const collections = state.collections;
|
||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
||||
const activeId = state.activeItemId ?? null;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(panelEl, items, activeId, handleSelect);
|
||||
startPositioning();
|
||||
} else {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
const onInput = (e: Event) => {
|
||||
if (!isSelecting) {
|
||||
handlers.onChange(e as any);
|
||||
}
|
||||
};
|
||||
const onFocus = (e: Event) => {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (attributeName !== cachedAttributeName) {
|
||||
cachedAttributeName = "";
|
||||
cachedAttributeValues = [];
|
||||
}
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
handlers.onFocus(e as any);
|
||||
};
|
||||
const onBlur = () => {
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 50);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && isPanelOpen && hasActiveItem) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
handlers.onKeyDown(e as any);
|
||||
};
|
||||
|
||||
inputEl.addEventListener("input", onInput);
|
||||
inputEl.addEventListener("focus", onFocus);
|
||||
inputEl.addEventListener("blur", onBlur);
|
||||
inputEl.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
inputEl.removeEventListener("input", onInput);
|
||||
inputEl.removeEventListener("focus", onFocus);
|
||||
inputEl.removeEventListener("blur", onBlur);
|
||||
inputEl.removeEventListener("keydown", onKeyDown);
|
||||
stopPositioning();
|
||||
if (panelEl.parentElement) {
|
||||
panelEl.parentElement.removeChild(panelEl);
|
||||
}
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
|
||||
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
|
||||
const instance = instanceMap.get(inputEl);
|
||||
if (instance) {
|
||||
instance.cleanup();
|
||||
instanceMap.delete(inputEl);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initAttributeNameAutocomplete,
|
||||
initLabelValueAutocomplete
|
||||
destroyAutocomplete,
|
||||
initLabelValueAutocomplete,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
await renderText(entity, $renderedContent, options);
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
@@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: "PUT",
|
||||
type: method,
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
|
||||
@@ -960,6 +960,153 @@ table.promoted-attributes-in-tooltip th {
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
|
||||
|
||||
.aa-core-panel {
|
||||
z-index: 10000;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.aa-core-panel.aa-dropdown-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-panel--contained {
|
||||
position: static !important;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.aa-core-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-core-item {
|
||||
cursor: pointer;
|
||||
padding: 7px 16px;
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.aa-core-item--active {
|
||||
color: var(--active-item-text-color);
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
.aa-core-item .note-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aa-core-item .icon,
|
||||
.aa-core-item .command-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.aa-core-item .text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-primary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
font-size: 1.02em;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.65;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-attributes {
|
||||
padding-inline-start: 14px;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-shortcut,
|
||||
.aa-core-item kbd.command-shortcut {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-text-color);
|
||||
font-family: inherit !important;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aa-core-item .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.aa-core-item .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aa-core-item .command-name {
|
||||
font-weight: bold;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.aa-core-item .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.aa-core-item .search-result-title b,
|
||||
.aa-core-item .search-result-path b,
|
||||
.aa-core-item .search-result-attributes b,
|
||||
.aa-core-item .command-name b,
|
||||
.aa-core-item .command-description b {
|
||||
color: var(--admonition-warning-accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.aa-core-item .aa-core-separator {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jump-to-note-results .aa-core-panel--contained {
|
||||
max-height: calc(80vh - 200px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
float: inline-end;
|
||||
background: none;
|
||||
|
||||
@@ -1535,7 +1535,8 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天"
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
|
||||
@@ -1488,20 +1488,21 @@
|
||||
"mermaid-diagram": "Mermaid Diagramm",
|
||||
"canvas": "Leinwand",
|
||||
"web-view": "Webansicht",
|
||||
"mind-map": "Mind Map",
|
||||
"mind-map": "Mindmap",
|
||||
"file": "Datei",
|
||||
"image": "Bild",
|
||||
"launcher": "Starter",
|
||||
"doc": "Dokument",
|
||||
"widget": "Widget",
|
||||
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"geo-map": "Geo-Karte",
|
||||
"beta-feature": "Beta",
|
||||
"book": "Sammlung",
|
||||
"ai-chat": "KI Chat",
|
||||
"ai-chat": "KI-Chat",
|
||||
"task-list": "Aufgabenliste",
|
||||
"new-feature": "Neu",
|
||||
"collections": "Sammlungen"
|
||||
"collections": "Sammlungen",
|
||||
"spreadsheet": "Tabelle"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Notiz schützen",
|
||||
|
||||
@@ -1548,7 +1548,8 @@
|
||||
"task-list": "Lista de tareas",
|
||||
"book": "Colección",
|
||||
"new-feature": "Nuevo",
|
||||
"collections": "Colecciones"
|
||||
"collections": "Colecciones",
|
||||
"spreadsheet": "Hoja de cálculo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteger la nota",
|
||||
@@ -1650,7 +1651,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
|
||||
"search_now": "Buscar ahora"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurar barra de lanzamiento"
|
||||
|
||||
@@ -1571,7 +1571,8 @@
|
||||
"ai-chat": "Comhrá AI",
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin"
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -1717,7 +1717,8 @@
|
||||
"task-list": "Elenco delle attività",
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA"
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
|
||||
@@ -600,7 +600,8 @@
|
||||
"task-list": "タスクリスト",
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット"
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
"collapseExpand": "свернуть/развернуть узел",
|
||||
"notSet": "не установлено",
|
||||
"goBackForwards": "назад / вперед в истории",
|
||||
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
|
||||
"showJumpToNoteDialog": "Перейти к <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Перейти к\" окно</a>",
|
||||
"scrollToActiveNote": "прокрутка к активной заметке",
|
||||
"jumpToParentNote": "переход к родительской заметке",
|
||||
"collapseWholeTree": "свернуть все дерево заметок",
|
||||
@@ -471,7 +471,7 @@
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -594,7 +594,8 @@
|
||||
"display-week-numbers": "Отображать номера недель",
|
||||
"hide-weekends": "Скрыть выходные",
|
||||
"raster": "Растр",
|
||||
"show-scale": "Показать масштаб"
|
||||
"show-scale": "Показать масштаб",
|
||||
"show-labels": "Показать названия маркеров"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"note_completion_enabled": "Включить автодополнение",
|
||||
@@ -782,7 +783,13 @@
|
||||
"shared-indicator-tooltip": "Эта заметка опубликована",
|
||||
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
|
||||
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
|
||||
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
|
||||
"subtree-hidden-moved-title": "Добавлено в {{title}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
|
||||
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
|
||||
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
|
||||
},
|
||||
"quick-search": {
|
||||
"no-results": "Результаты не найдены",
|
||||
@@ -826,7 +833,9 @@
|
||||
"mind-map": "Mind Map",
|
||||
"geo-map": "Географическая карта",
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1153,7 +1162,8 @@
|
||||
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
|
||||
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
|
||||
"actions_executed": "Действия выполнены.",
|
||||
"view_options": "Просмотреть опции:"
|
||||
"view_options": "Просмотреть опции:",
|
||||
"option": "опция"
|
||||
},
|
||||
"ancestor": {
|
||||
"depth_label": "глубина",
|
||||
@@ -1403,7 +1413,8 @@
|
||||
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
|
||||
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
|
||||
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
|
||||
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
|
||||
},
|
||||
"sync_2": {
|
||||
"timeout_unit": "миллисекунд",
|
||||
@@ -1713,7 +1724,8 @@
|
||||
"delete_this_note": "Удалить эту заметку",
|
||||
"insert_child_note": "Вставить дочернюю заметку",
|
||||
"note_revisions": "История изменений",
|
||||
"content_language_switcher": "Язык содержимого: {{language}}"
|
||||
"content_language_switcher": "Язык содержимого: {{language}}",
|
||||
"backlinks": "Ссылки"
|
||||
},
|
||||
"svg_export_button": {
|
||||
"button_title": "Экспортировать диаграмму как SVG"
|
||||
@@ -1790,7 +1802,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
|
||||
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
|
||||
"search_not_executed": "Поиск ещё не выполнен.",
|
||||
"search_now": "Искать сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"search_placeholder": "поиск заметки по ее названию",
|
||||
@@ -1988,10 +2001,12 @@
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
|
||||
"print_report_error_title": "Не удалось напечатать",
|
||||
"print_report_stack_trace": "Трассировка стека"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
|
||||
"drag_locked_title": "Защищено от изменения",
|
||||
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
|
||||
},
|
||||
@@ -2007,7 +2022,9 @@
|
||||
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} заметок"
|
||||
"total_notes": "{{count}} заметок",
|
||||
"prev_page": "Предыдущая страница",
|
||||
"next_page": "Следующая страница"
|
||||
},
|
||||
"status_bar": {
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
@@ -2137,5 +2154,49 @@
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Доступно для {{platform}}"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
|
||||
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
|
||||
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
|
||||
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
|
||||
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
|
||||
"disabled_button_enable": "Включить заметки для рендера"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
|
||||
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
|
||||
"create_button": "Создать веб-просмотр",
|
||||
"invalid_url_title": "Неверный адрес",
|
||||
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
|
||||
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
|
||||
"disabled_button_enable": "Включить просмотр веб-страниц"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Набор иконок",
|
||||
"type_backend_script": "Бэкенд скрипт",
|
||||
"type_frontend_script": "Фронтенд скрипт",
|
||||
"type_widget": "Виджет",
|
||||
"type_app_css": "Пользовательский CSS",
|
||||
"type_render_note": "Заметка для рендера",
|
||||
"type_web_view": "Просмотр веб-страницы",
|
||||
"type_app_theme": "Пользовательская тема",
|
||||
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
|
||||
"menu_docs": "Открытая документация",
|
||||
"menu_execute_now": "Выполнить скрипт сейчас",
|
||||
"menu_run": "Выполнять автоматически",
|
||||
"menu_run_disabled": "Вручную",
|
||||
"menu_run_backend_startup": "При запуске бэкенда",
|
||||
"menu_run_hourly": "Ежечасно",
|
||||
"menu_run_daily": "Ежедневно",
|
||||
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
|
||||
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
|
||||
"menu_change_to_widget": "Изменить виджет",
|
||||
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
|
||||
"menu_theme_base": "Базовая тема"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Узнать больше"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
"title": "Om Trilium Notes",
|
||||
"homepage": "Hemsida:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:"
|
||||
"db_version": "DB version:",
|
||||
"sync_version": "Sync version:",
|
||||
"build_date": "Bygg datum:",
|
||||
"build_revision": "Bygg version:",
|
||||
"data_directory": "Data sökväg:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritiskt fel",
|
||||
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Misslyckades att starta widget",
|
||||
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
|
||||
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Misslyckades att starta ett anpassat skript",
|
||||
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Misslyckades att hämta widget-listan från servern"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Misslyckades att renderera en anpassad React-widget"
|
||||
},
|
||||
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
|
||||
"open-script-note": "Öppna skriptanteckning",
|
||||
"scripting-error": "Fel i anpassat skript: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Infoga länk",
|
||||
"help_on_links": "Hjälp om länkar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1496,7 +1496,8 @@
|
||||
"task-list": "任務列表",
|
||||
"new-feature": "新增",
|
||||
"collections": "集合",
|
||||
"ai-chat": "AI 聊天"
|
||||
"ai-chat": "AI 聊天",
|
||||
"spreadsheet": "試算表"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保護筆記",
|
||||
|
||||
@@ -40,6 +40,19 @@ export default function NoteDetail() {
|
||||
const widgetRequestId = useRef(0);
|
||||
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
|
||||
|
||||
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
|
||||
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => noteContext?.isActive() ?? false);
|
||||
useEffect(() => {
|
||||
if (!hasTabBeenActive && noteContext?.isActive()) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
}, [ noteContext, hasTabBeenActive ]);
|
||||
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId === ntxId && !hasTabBeenActive) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
});
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
@@ -49,7 +62,7 @@ export default function NoteDetail() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!type) return;
|
||||
if (!type || !hasTabBeenActive) return;
|
||||
const requestId = ++widgetRequestId.current;
|
||||
|
||||
if (!noteTypesToRender[type]) {
|
||||
@@ -68,7 +81,7 @@ export default function NoteDetail() {
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type, noteTypesToRender ]);
|
||||
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
@@ -247,9 +260,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setCachedProps(props);
|
||||
} else {
|
||||
// Do nothing, keep the old props.
|
||||
}
|
||||
// When not visible, keep the old props to avoid re-rendering in the background.
|
||||
}, [ props, isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
@@ -260,7 +272,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
height: isFullHeight ? "100%" : ""
|
||||
}}
|
||||
>
|
||||
{ <Element {...cachedProps} /> }
|
||||
<Element {...cachedProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,7 +378,8 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
attributeAutocompleteService.initAttributeNameAutocomplete({
|
||||
$el: this.$inputName,
|
||||
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
||||
open: true
|
||||
open: true,
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputValue.on("change", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("focus", () => {
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: this.$inputValue,
|
||||
open: true,
|
||||
nameCallback: () => String(this.$inputName.val())
|
||||
nameCallback: () => String(this.$inputName.val()),
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface PromptDialogOptions {
|
||||
shown?: PromptShownDialogCallback;
|
||||
callback?: (value: string | null) => void;
|
||||
readOnly?: boolean;
|
||||
submitWithCtrlEnter?: boolean;
|
||||
}
|
||||
|
||||
export default function PromptDialog() {
|
||||
@@ -69,7 +70,7 @@ export default function PromptDialog() {
|
||||
submitValue.current = null;
|
||||
opts.current = undefined;
|
||||
}}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" kind="primary" />}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut={opts.current?.submitWithCtrlEnter ? "ctrl+return" : "Enter"} kind="primary" />}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
@@ -78,6 +79,13 @@ export default function PromptDialog() {
|
||||
inputRef={answerRef}
|
||||
currentValue={value} onChange={setValue}
|
||||
readOnly={opts.current?.readOnly}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (opts.current?.submitWithCtrlEnter && (e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitValue.current = answerRef.current?.value || value;
|
||||
setShown(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
|
||||
@@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
|
||||
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
|
||||
case "canvas":
|
||||
case "mindMap":
|
||||
case "mermaid": {
|
||||
case "mermaid":
|
||||
case "spreadsheet": {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
return <img
|
||||
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}
|
||||
|
||||
@@ -143,7 +143,7 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
isFullHeight: true
|
||||
},
|
||||
spreadsheet: {
|
||||
view: () => import("./type_widgets/Spreadsheet"),
|
||||
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
|
||||
className: "note-detail-spreadsheet",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface SavedData {
|
||||
mime: string;
|
||||
content: string;
|
||||
position: number;
|
||||
encoding?: "base64";
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
const noteType = useNoteProperty(note, "type") ?? "";
|
||||
const [viewType] = useNoteLabel(note, "viewType");
|
||||
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
|
||||
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
|
||||
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
|
||||
const isContentAvailable = note.isContentAvailable();
|
||||
|
||||
@@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N
|
||||
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <NoteAction
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./MindMap.css";
|
||||
import nodeMenu from "@mind-elixir/node-menu";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { snapdom } from "@zumer/snapdom";
|
||||
import { default as VanillaMindElixir,MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME, DARK_THEME } from "mind-elixir";
|
||||
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
|
||||
import { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
@@ -154,6 +154,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
const apiRef = useRef<MindElixirInstance>(null);
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const colorScheme = useColorScheme();
|
||||
const defaultColorScheme = useRef(colorScheme);
|
||||
|
||||
function reinitialize() {
|
||||
if (!containerRef.current) return;
|
||||
@@ -162,7 +163,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
el: containerRef.current,
|
||||
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
|
||||
editable,
|
||||
theme: LIGHT_THEME
|
||||
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
|
||||
});
|
||||
|
||||
if (editable) {
|
||||
@@ -188,7 +189,11 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
|
||||
if (!apiRef.current) return;
|
||||
const newTheme = colorScheme === "dark" ? DARK_THEME : LIGHT_THEME;
|
||||
if (apiRef.current.theme === newTheme) return; // Avoid unnecessary theme changes, which can be expensive to render.
|
||||
apiRef.current.changeTheme(newTheme);
|
||||
try {
|
||||
apiRef.current.changeTheme(newTheme);
|
||||
} catch (e) {
|
||||
console.warn("Failed to change mind map theme:", e);
|
||||
}
|
||||
}, [ colorScheme ]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import "@univerjs/preset-sheets-core/lib/index.css";
|
||||
import "./Spreadsheet.css";
|
||||
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
||||
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
|
||||
import { CommandType, createUniver, FUniver, IDisposable, IWorkbookData, LocaleType, mergeLocales } from '@univerjs/presets';
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { useColorScheme, useEditorSpacedUpdate } from "../react/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: Parameters<FUniver["createWorkbook"]>[0];
|
||||
}
|
||||
|
||||
export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<FUniver>();
|
||||
|
||||
useInitializeSpreadsheet(containerRef, apiRef);
|
||||
useDarkMode(apiRef);
|
||||
usePersistence(note, noteContext, apiRef);
|
||||
|
||||
return <div ref={containerRef} className="spreadsheet" />;
|
||||
}
|
||||
|
||||
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { univerAPI } = createUniver({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(
|
||||
UniverPresetSheetsCoreEnUS
|
||||
),
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container: containerRef.current,
|
||||
})
|
||||
]
|
||||
});
|
||||
apiRef.current = univerAPI;
|
||||
return () => univerAPI.dispose();
|
||||
}, [ apiRef, containerRef ]);
|
||||
}
|
||||
|
||||
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// React to dark mode.
|
||||
useEffect(() => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
univerAPI.toggleDarkMode(colorScheme === 'dark');
|
||||
}, [ colorScheme, apiRef ]);
|
||||
}
|
||||
|
||||
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>) {
|
||||
const changeListener = useRef<IDisposable>(null);
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
noteType: "spreadsheet",
|
||||
note,
|
||||
noteContext,
|
||||
getData() {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return undefined;
|
||||
const content = {
|
||||
version: 1,
|
||||
workbook: workbook.save()
|
||||
};
|
||||
return {
|
||||
content: JSON.stringify(content)
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
|
||||
// Dispose the existing workbook.
|
||||
const existingWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (existingWorkbook) {
|
||||
univerAPI.disposeUnit(existingWorkbook.getId());
|
||||
}
|
||||
|
||||
let workbookData: Partial<IWorkbookData> = {};
|
||||
if (newContent) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(newContent) as unknown;
|
||||
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
|
||||
const persistedData = parsedContent as PersistedData;
|
||||
workbookData = persistedData.workbook;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse spreadsheet content", e);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = univerAPI.createWorkbook(workbookData);
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
}
|
||||
changeListener.current = workbook.onCommandExecuted(command => {
|
||||
if (command.type !== CommandType.MUTATION) return;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
changeListener.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
<PdfViewer
|
||||
iframeRef={iframeRef}
|
||||
tabIndex={300}
|
||||
pdfUrl={`../../api/notes/${note.noteId}/open`}
|
||||
pdfUrl={new URL(`${window.glob.baseApiUrl}notes/${note.noteId}/open`, window.location.href).pathname}
|
||||
onLoad={() => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (win) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
interface FontDefinition {
|
||||
name: string;
|
||||
@@ -10,11 +11,11 @@ interface FontDefinition {
|
||||
|
||||
const FONTS: FontDefinition[] = [
|
||||
{name: "Inter", url: Inter},
|
||||
]
|
||||
];
|
||||
|
||||
interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabIndex"> {
|
||||
iframeRef?: RefObject<HTMLIFrameElement>;
|
||||
/** Note: URLs are relative to /pdfjs/web. */
|
||||
/** Note: URLs are relative to /pdfjs/web, ideally use absolute paths (but without domain name) to avoid issues with some proxies. */
|
||||
pdfUrl: string;
|
||||
onLoad?(): void;
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
|
||||
ref={iframeRef}
|
||||
class="pdf-preview"
|
||||
style={{width: "100%", height: "100%"}}
|
||||
src={`pdfjs/web/viewer.html?file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
onLoad={() => {
|
||||
injectStyles();
|
||||
onLoad?.();
|
||||
@@ -63,7 +64,7 @@ function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
const fontStyles = doc.createElement("style");
|
||||
fontStyles.textContent = FONTS.map(injectFont).join("\n");
|
||||
doc.head.appendChild(fontStyles);
|
||||
|
||||
|
||||
}, [ iframeRef ]);
|
||||
|
||||
// React to changes.
|
||||
@@ -107,4 +108,4 @@ function injectFont(font: FontDefinition) {
|
||||
src: url('${font.url}');
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +416,7 @@ function useRelationCreation({ mapApiRef, jsPlumbApiRef }: { mapApiRef: RefObjec
|
||||
if (!originalEvent || !mapApiRef.current) return;
|
||||
|
||||
const name = await dialog.prompt({
|
||||
submitWithCtrlEnter: true,
|
||||
message: t("relation_map.specify_new_relation_name"),
|
||||
shown: ({ $answer }) => {
|
||||
if (!$answer) {
|
||||
|
||||
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import "./Spreadsheet.css";
|
||||
import "@univerjs/preset-sheets-core/lib/index.css";
|
||||
import "@univerjs/preset-sheets-sort/lib/index.css";
|
||||
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
|
||||
import "@univerjs/preset-sheets-find-replace/lib/index.css";
|
||||
import "@univerjs/preset-sheets-note/lib/index.css";
|
||||
import "@univerjs/preset-sheets-filter/lib/index.css";
|
||||
import "@univerjs/preset-sheets-data-validation/lib/index.css";
|
||||
|
||||
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
|
||||
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
||||
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
|
||||
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
|
||||
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
|
||||
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
|
||||
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
|
||||
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
|
||||
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
|
||||
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
|
||||
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
|
||||
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
|
||||
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
|
||||
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import usePersistence from "./persistence";
|
||||
|
||||
export default function Spreadsheet(props: TypeWidgetProps) {
|
||||
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
|
||||
|
||||
// Use readOnly as key to force full remount (and data reload) when it changes.
|
||||
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
|
||||
}
|
||||
|
||||
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<FUniver>();
|
||||
|
||||
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
|
||||
useDarkMode(apiRef);
|
||||
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
|
||||
useSearchIntegration(apiRef);
|
||||
|
||||
// Focus the spreadsheet when the note is focused.
|
||||
useTriliumEvent("focusOnDetail", () => {
|
||||
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
|
||||
if (focusable instanceof HTMLElement) {
|
||||
focusable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="spreadsheet" />;
|
||||
}
|
||||
|
||||
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { univerAPI } = createUniver({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(
|
||||
sheetsCoreEnUS,
|
||||
sheetsFindReplaceEnUS,
|
||||
sheetsNoteEnUS,
|
||||
UniverPresetSheetsFilterEnUS,
|
||||
UniverPresetSheetsSortEnUS,
|
||||
UniverPresetSheetsDataValidationEnUS,
|
||||
UniverPresetSheetsConditionalFormattingEnUS,
|
||||
),
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container: containerRef.current,
|
||||
toolbar: !readOnly,
|
||||
contextMenu: !readOnly,
|
||||
formulaBar: !readOnly,
|
||||
footer: readOnly ? false : undefined,
|
||||
menu: {
|
||||
"sheet.contextMenu.permission": { hidden: true },
|
||||
"sheet-permission.operation.openPanel": { hidden: true },
|
||||
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
|
||||
},
|
||||
}),
|
||||
UniverSheetsFindReplacePreset(),
|
||||
UniverSheetsNotePreset(),
|
||||
UniverSheetsFilterPreset(),
|
||||
UniverSheetsSortPreset(),
|
||||
UniverSheetsDataValidationPreset(),
|
||||
UniverSheetsConditionalFormattingPreset()
|
||||
]
|
||||
});
|
||||
apiRef.current = univerAPI;
|
||||
return () => univerAPI.dispose();
|
||||
}, [ apiRef, containerRef, readOnly ]);
|
||||
}
|
||||
|
||||
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// React to dark mode.
|
||||
useEffect(() => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
univerAPI.toggleDarkMode(colorScheme === 'dark');
|
||||
}, [ colorScheme, apiRef ]);
|
||||
}
|
||||
|
||||
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
|
||||
useTriliumEvent("findInText", () => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
// Open find/replace panel and populate the search term.
|
||||
univerAPI.executeCommand("ui.operation.open-find-dialog");
|
||||
});
|
||||
}
|
||||
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../../components/note_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: Parameters<FUniver["createWorkbook"]>[0];
|
||||
}
|
||||
|
||||
interface SpreadsheetViewState {
|
||||
activeSheetId?: string;
|
||||
cursorRow?: number;
|
||||
cursorCol?: number;
|
||||
scrollRow?: number;
|
||||
scrollCol?: number;
|
||||
}
|
||||
|
||||
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
|
||||
const changeListener = useRef<IDisposable>(null);
|
||||
const pendingContent = useRef<string | null>(null);
|
||||
|
||||
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
|
||||
const state: SpreadsheetViewState = {};
|
||||
try {
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return state;
|
||||
|
||||
const activeSheet = workbook.getActiveSheet();
|
||||
state.activeSheetId = activeSheet?.getSheetId();
|
||||
|
||||
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
|
||||
if (currentCell) {
|
||||
state.cursorRow = currentCell.actualRow;
|
||||
state.cursorCol = currentCell.actualColumn;
|
||||
}
|
||||
|
||||
const scrollState = activeSheet?.getScrollState?.();
|
||||
if (scrollState) {
|
||||
state.scrollRow = scrollState.sheetViewStartRow;
|
||||
state.scrollCol = scrollState.sheetViewStartColumn;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when reading state from a workbook being disposed.
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
|
||||
try {
|
||||
if (state.activeSheetId) {
|
||||
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
|
||||
if (targetSheet) {
|
||||
workbook.setActiveSheet(targetSheet);
|
||||
}
|
||||
}
|
||||
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
|
||||
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
|
||||
}
|
||||
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
|
||||
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when restoring state (e.g. sheet no longer exists).
|
||||
}
|
||||
}
|
||||
|
||||
function applyContent(univerAPI: FUniver, newContent: string) {
|
||||
const viewState = saveViewState(univerAPI);
|
||||
|
||||
// Dispose the existing workbook.
|
||||
const existingWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (existingWorkbook) {
|
||||
univerAPI.disposeUnit(existingWorkbook.getId());
|
||||
}
|
||||
|
||||
let workbookData: Partial<IWorkbookData> = {};
|
||||
if (newContent) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(newContent) as unknown;
|
||||
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
|
||||
const persistedData = parsedContent as PersistedData;
|
||||
workbookData = persistedData.workbook;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse spreadsheet content", e);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = univerAPI.createWorkbook(workbookData);
|
||||
if (readOnly) {
|
||||
workbook.disableSelection();
|
||||
const permission = workbook.getPermission();
|
||||
permission.setWorkbookEditPermission(workbook.getId(), false);
|
||||
permission.setPermissionDialogVisible(false);
|
||||
}
|
||||
|
||||
restoreViewState(workbook, viewState);
|
||||
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
}
|
||||
changeListener.current = workbook.onCommandExecuted(command => {
|
||||
if (command.type !== CommandType.MUTATION) return;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
function isContainerVisible() {
|
||||
const el = containerRef.current;
|
||||
if (!el) return false;
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0;
|
||||
}
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
noteType: "spreadsheet",
|
||||
note,
|
||||
noteContext,
|
||||
async getData() {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return undefined;
|
||||
const content = {
|
||||
version: 1,
|
||||
workbook: workbook.save()
|
||||
};
|
||||
|
||||
const attachments: SavedData["attachments"] = [];
|
||||
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
|
||||
if (canvasEl) {
|
||||
const dataUrl = canvasEl.toDataURL("image/png");
|
||||
const base64 = dataUrl.split(",")[1];
|
||||
attachments.push({
|
||||
role: "image",
|
||||
title: "spreadsheet-export.png",
|
||||
mime: "image/png",
|
||||
content: base64,
|
||||
position: 0,
|
||||
encoding: "base64"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(content),
|
||||
attachments
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
|
||||
// Defer content application if the container is hidden (zero size),
|
||||
// since the spreadsheet library cannot calculate layout in that state.
|
||||
if (!isContainerVisible()) {
|
||||
pendingContent.current = newContent;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, newContent);
|
||||
},
|
||||
});
|
||||
|
||||
// Apply pending content once the container becomes visible (non-zero size).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pendingContent.current === null || !isContainerVisible()) return;
|
||||
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
const content = pendingContent.current;
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, content);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
|
||||
}, [ containerRef ]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
changeListener.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -120,7 +120,11 @@ export default defineConfig(() => ({
|
||||
environment: "happy-dom",
|
||||
setupFiles: [
|
||||
"./src/test/setup.ts"
|
||||
]
|
||||
],
|
||||
reporters: [
|
||||
"verbose",
|
||||
["html", { outputFile: "./test-output/vitest/html/index.html" }]
|
||||
],
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "src/main.ts",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.6.1",
|
||||
"electron": "40.8.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/edit-docs",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"private": true,
|
||||
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
|
||||
"dependencies": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.6.1",
|
||||
"electron": "40.8.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Nginx Proxy Manager (for testing reverse proxy setups)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start Trilium on the host (default port 8080):
|
||||
```bash
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
2. Start Nginx Proxy Manager:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
|
||||
- Email: `admin@example.com`
|
||||
- Password: `changeme`
|
||||
(You'll be asked to change these on first login.)
|
||||
|
||||
4. Add a proxy host:
|
||||
- **Domain Names**: `localhost`
|
||||
- **Scheme**: `http`
|
||||
- **Forward Hostname / IP**: `host.docker.internal`
|
||||
- **Forward Port**: `8080`
|
||||
- Enable **Websockets Support** (required for Trilium sync)
|
||||
|
||||
5. Access Trilium through NPM at **http://localhost:8090**.
|
||||
|
||||
## With a subpath
|
||||
|
||||
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
|
||||
|
||||
```nginx
|
||||
location /trilium/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://host.docker.internal:8080/;
|
||||
proxy_cookie_path / /trilium/;
|
||||
proxy_read_timeout 90;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
nginx-proxy-manager:
|
||||
image: "jc21/nginx-proxy-manager:latest"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Public HTTP port
|
||||
- "8090:80"
|
||||
# Admin panel
|
||||
- "8081:81"
|
||||
volumes:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
# Use host network mode so NPM can reach Trilium on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
npm_letsencrypt:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"main": "./src/main.ts",
|
||||
@@ -55,9 +55,9 @@
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -81,15 +81,15 @@
|
||||
"csrf-csrf": "3.2.2",
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "4.0.1",
|
||||
"electron": "40.6.1",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "40.8.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
@@ -98,7 +98,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.14",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -106,9 +106,9 @@
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.0",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
|
||||
@@ -86,8 +86,9 @@ export default async function buildApp() {
|
||||
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));
|
||||
app.use(`/icon.png`, express.static(path.join(publicAssetsDir, "icon.png")));
|
||||
|
||||
const sessionParser = (await import("./routes/session_parser.js")).default;
|
||||
const { default: sessionParser, startSessionCleanup } = await import("./routes/session_parser.js");
|
||||
app.use(sessionParser);
|
||||
startSessionCleanup();
|
||||
app.use(favicon(path.join(assetsDir, isDev ? "icon-dev.ico" : "icon.ico")));
|
||||
|
||||
if (openID.isOpenIDEnabled())
|
||||
@@ -98,16 +99,16 @@ export default async function buildApp() {
|
||||
custom.register(app);
|
||||
error_handlers.register(app);
|
||||
|
||||
// triggers sync timer
|
||||
await import("./services/sync.js");
|
||||
const { startSyncTimer } = await import("./services/sync.js");
|
||||
startSyncTimer();
|
||||
|
||||
// triggers backup timer
|
||||
await import("./services/backup.js");
|
||||
|
||||
// trigger consistency checks timer
|
||||
await import("./services/consistency_checks.js");
|
||||
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
|
||||
startConsistencyChecks();
|
||||
|
||||
await import("./services/scheduler.js");
|
||||
const { startScheduler } = await import("./services/scheduler.js");
|
||||
startScheduler();
|
||||
|
||||
startScheduledCleanup();
|
||||
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"go-to-next-note-title": "К следующей заметке",
|
||||
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
|
||||
"zen-mode": "Режим \"Дзен\"",
|
||||
"command-palette": "Открыть панель команд"
|
||||
"command-palette": "Открыть панель команд",
|
||||
"tab-switcher-title": "Переключатель вкладок"
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Закладки",
|
||||
@@ -313,7 +314,7 @@
|
||||
"title": "Настройка",
|
||||
"heading": "Настройка Trilium",
|
||||
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
|
||||
"sync-from-desktop": "У меня уже есть приложение ПК, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
|
||||
"init-in-progress": "Идет инициализация документа",
|
||||
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
|
||||
@@ -397,8 +398,8 @@
|
||||
"clipped-from": "Эта заметка изначально была вырезана из {{- url}}"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронизация с приложения ПК",
|
||||
"description": "Эту настройку необходимо инициировать из приложения для ПК:",
|
||||
"heading": "Синхронизация с настольной версией",
|
||||
"description": "Это настройку нужно выполнить с помощью настольной версии:",
|
||||
"step1": "Откройте приложение Trilium Notes на ПК.",
|
||||
"step2": "В меню Trilium выберите «Параметры».",
|
||||
"step3": "Нажмите на категорию «Синхронизация».",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Gå till föregående anteckning i historiken",
|
||||
"forward-in-note-history": "Gå till nästa anteckning i historiken",
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog",
|
||||
"open-command-palette": "Öppna kommandomenyn",
|
||||
"quick-search": "Öppna snabbsökning",
|
||||
"search-in-subtree": "Sök anteckningar nedåt i anteckningshierarkin",
|
||||
"expand-subtree": "Expandera hierarkin under denna anteckning",
|
||||
"collapse-tree": "Stänger anteckningshierarkin",
|
||||
"collapse-subtree": "Stänger hierarkin under aktuell anteckning",
|
||||
"sort-child-notes": "Sortera underordnade anteckningar",
|
||||
"creating-and-moving-notes": "Skapa och flytta anteckningar",
|
||||
"create-note-after": "Skapa ny anteckning efter aktiv anteckning",
|
||||
"create-note-into": "Skapa ny anteckning underordnad aktiv anteckning",
|
||||
"create-note-into-inbox": "Skapa en anteckning i inboxen (om angiven) eller som daganteckning",
|
||||
"delete-note": "Radera anteckning",
|
||||
"move-note-up": "Flytta anteckning uppåt",
|
||||
"move-note-down": "Flytta anteckning nedåt",
|
||||
"scroll-to-active-note": "Bläddra i anteckningshierarkin till aktiv anteckning",
|
||||
"move-note-up-in-hierarchy": "Flytta anteckning uppåt i hierarkin",
|
||||
"move-note-down-in-hierarchy": "Flytta anteckning neråt i hierarkin",
|
||||
"edit-note-title": "Hoppa från träd till anteckning och redigera titel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
if (!image) {
|
||||
res.set("Content-Type", "image/png");
|
||||
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
renderSvgAttachment(image, res, "mermaid-export.svg");
|
||||
} else if (image.type === "mindMap") {
|
||||
renderSvgAttachment(image, res, "mindmap-export.svg");
|
||||
} else if (image.type === "spreadsheet") {
|
||||
renderPngAttachment(image, res, "spreadsheet-export.png");
|
||||
} else {
|
||||
res.set("Content-Type", image.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
@@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
|
||||
if (attachment) {
|
||||
res.set("Content-Type", "image/png");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(attachment.getContent());
|
||||
} else {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
|
||||
const attachment = becca.getAttachment(req.params.attachmentId);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { dayjs } from "@triliumnext/commons";
|
||||
import type { Application } from "express";
|
||||
import { SessionData } from "express-session";
|
||||
import supertest, { type Response } from "supertest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import cls from "../services/cls.js";
|
||||
import { type SQLiteSessionStore } from "./session_parser.js";
|
||||
@@ -20,6 +20,10 @@ describe("Login Route test", () => {
|
||||
({ sessionStore, CLEAN_UP_INTERVAL } = (await import("./session_parser.js")));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return the login page, when using a GET request", async () => {
|
||||
|
||||
// RegExp for login page specific string in HTML
|
||||
|
||||
@@ -113,11 +113,13 @@ const sessionParser: express.RequestHandler = session({
|
||||
store: sessionStore
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
// Clean up expired sesions.
|
||||
const now = Date.now();
|
||||
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
|
||||
console.log("Cleaning up expired sessions: ", result.changes);
|
||||
}, CLEAN_UP_INTERVAL);
|
||||
export function startSessionCleanup() {
|
||||
setInterval(() => {
|
||||
// Clean up expired sessions.
|
||||
const now = Date.now();
|
||||
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
|
||||
console.log("Cleaning up expired sessions: ", result.changes);
|
||||
}, CLEAN_UP_INTERVAL);
|
||||
}
|
||||
|
||||
export default sessionParser;
|
||||
|
||||
@@ -71,6 +71,27 @@ function getAttributeNames(type: string, nameLike: string) {
|
||||
[type, `%${nameLike}%`]
|
||||
);
|
||||
|
||||
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
|
||||
if (type === "relation" || type === "label") {
|
||||
const prefix = `${type}:`;
|
||||
const defNames = sql.getColumn<string>(
|
||||
/*sql*/`SELECT DISTINCT name
|
||||
FROM attributes
|
||||
WHERE isDeleted = 0
|
||||
AND type = 'label'
|
||||
AND name LIKE ?`,
|
||||
[`${prefix}%${nameLike}%`]
|
||||
);
|
||||
for (const dn of defNames) {
|
||||
if (dn.startsWith(prefix)) {
|
||||
const stripped = dn.substring(prefix.length);
|
||||
if (!names.includes(stripped)) {
|
||||
names.push(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of BUILTIN_ATTRIBUTES) {
|
||||
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
|
||||
names.push(attr.name);
|
||||
|
||||
@@ -953,12 +953,14 @@ function runEntityChangesChecks() {
|
||||
consistencyChecks.findEntityChangeIssues();
|
||||
}
|
||||
|
||||
sqlInit.dbReady.then(() => {
|
||||
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
|
||||
export function startConsistencyChecks() {
|
||||
sqlInit.dbReady.then(() => {
|
||||
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
|
||||
|
||||
// kickoff checks soon after startup (to not block the initial load)
|
||||
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
|
||||
});
|
||||
// kickoff checks soon after startup (to not block the initial load)
|
||||
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
runOnDemandChecks,
|
||||
|
||||
@@ -24,6 +24,7 @@ async function testImport(fileName: string, mimetype: string) {
|
||||
const rootNote = becca.getNote("root");
|
||||
if (!rootNote) {
|
||||
reject("Missing root note.");
|
||||
return;
|
||||
}
|
||||
|
||||
const importedNote = single.importSingleFile(
|
||||
|
||||
@@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
|
||||
if (attachments?.length > 0) {
|
||||
const existingAttachmentsByTitle = toMap(note.getAttachments(), "title");
|
||||
|
||||
for (const { attachmentId, role, mime, title, position, content } of attachments) {
|
||||
for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) {
|
||||
const decodedContent = encoding === "base64" && typeof content === "string"
|
||||
? Buffer.from(content, "base64")
|
||||
: content;
|
||||
|
||||
const existingAttachment = existingAttachmentsByTitle.get(title);
|
||||
if (attachmentId || !existingAttachment) {
|
||||
note.saveAttachment({ attachmentId, role, mime, title, content, position });
|
||||
note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position });
|
||||
} else {
|
||||
existingAttachment.role = role;
|
||||
existingAttachment.mime = mime;
|
||||
existingAttachment.position = position;
|
||||
if (content) {
|
||||
existingAttachment.setContent(content, { forceSave: true });
|
||||
if (decodedContent) {
|
||||
existingAttachment.setContent(decodedContent, { forceSave: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,39 +35,41 @@ function runNotesWithLabel(runAttrValue: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
|
||||
// is also checked before importing the demo.zip, so no need to do it again.
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
console.log("Checking hidden subtree.");
|
||||
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
|
||||
}
|
||||
|
||||
// Periodic checks.
|
||||
sqlInit.dbReady.then(() => {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
setTimeout(
|
||||
cls.wrap(() => runNotesWithLabel("backendStartup")),
|
||||
10 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("hourly")),
|
||||
3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("daily")),
|
||||
24 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
export function startScheduler() {
|
||||
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
|
||||
// is also checked before importing the demo.zip, so no need to do it again.
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
console.log("Checking hidden subtree.");
|
||||
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
|
||||
}
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
// Periodic checks.
|
||||
sqlInit.dbReady.then(() => {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
setTimeout(
|
||||
cls.wrap(() => runNotesWithLabel("backendStartup")),
|
||||
10 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("hourly")),
|
||||
3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("daily")),
|
||||
24 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
}
|
||||
|
||||
function checkProtectedSessionExpiration() {
|
||||
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");
|
||||
|
||||
@@ -50,7 +50,7 @@ async function initDbConnection() {
|
||||
|
||||
await migrationService.migrateIfNecessary();
|
||||
|
||||
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
||||
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
||||
|
||||
sql.execute(`
|
||||
CREATE TABLE IF NOT EXISTS "user_data"
|
||||
|
||||
@@ -446,15 +446,17 @@ function getOutstandingPullCount() {
|
||||
return outstandingPullCount;
|
||||
}
|
||||
|
||||
becca_loader.beccaLoaded.then(() => {
|
||||
setInterval(cls.wrap(sync), 60000);
|
||||
export function startSyncTimer() {
|
||||
becca_loader.beccaLoaded.then(() => {
|
||||
setInterval(cls.wrap(sync), 60000);
|
||||
|
||||
// kickoff initial sync immediately, but should happen after initial consistency checks
|
||||
setTimeout(cls.wrap(sync), 5000);
|
||||
// kickoff initial sync immediately, but should happen after initial consistency checks
|
||||
setTimeout(cls.wrap(sync), 5000);
|
||||
|
||||
// called just so ws.setLastSyncedPush() is called
|
||||
getLastSyncedPush();
|
||||
});
|
||||
// called just so ws.setLastSyncedPush() is called
|
||||
getLastSyncedPush();
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
sync,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
import ejs from "ejs";
|
||||
import escapeHtml from "escape-html";
|
||||
@@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) {
|
||||
result.isEmpty = true;
|
||||
} else if (note.type === "webView") {
|
||||
renderWebView(note, result);
|
||||
} else if (note.type === "spreadsheet") {
|
||||
renderSpreadsheet(result);
|
||||
} else {
|
||||
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
|
||||
}
|
||||
@@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpreadsheet(result: Result) {
|
||||
if (typeof result.content !== "string" || !result.content?.trim()) {
|
||||
result.isEmpty = true;
|
||||
} else {
|
||||
result.content = renderSpreadsheetToHtml(result.content);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWebView(note: SNote | BNote, result: Result) {
|
||||
const url = note.getLabelValue("webViewSrc");
|
||||
if (!url) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Application, NextFunction,Request, Response } from "express";
|
||||
import supertest from "supertest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
@@ -23,6 +23,10 @@ describe("Share API test", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cannotSetHeadersCount = 0;
|
||||
});
|
||||
|
||||
@@ -19,16 +19,18 @@ export default defineConfig(() => ({
|
||||
exclude: [
|
||||
"spec/build-checks/**",
|
||||
],
|
||||
hookTimeout: 20000,
|
||||
hookTimeout: 20_000,
|
||||
testTimeout: 40_000,
|
||||
reporters: [
|
||||
"verbose"
|
||||
"verbose",
|
||||
["html", { outputFile: "./test-output/vitest/html/index.html" }]
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
reporter: [ "text", "html" ]
|
||||
},
|
||||
pool: "vmForks",
|
||||
maxWorkers: 3
|
||||
pool: "forks",
|
||||
maxWorkers: 6
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"devDependencies": {
|
||||
"@wxt-dev/auto-icons": "1.1.1",
|
||||
"wxt": "0.20.18"
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.14",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.28.4",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.6",
|
||||
"react-i18next": "16.5.4"
|
||||
"react-i18next": "16.5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"code_title": "Code Notizen",
|
||||
"canvas_title": "Leinwand",
|
||||
"mermaid_title": "Mermaid Diagramm",
|
||||
"mindmap_title": "Mind Map",
|
||||
"mindmap_title": "Mindmap",
|
||||
"text_description": "Die Notizen werden mit einem visuellen Editor (WYSIWYG) bearbeitet, der Tabellen, Bilder, mathematische Ausdrücke und Code-Blöcke mit Syntaxhervorhebung unterstützt. Formatieren Sie den Text schnell mit einer Markdown-ähnlichen Syntax oder mit Slash-Befehlen.",
|
||||
"code_description": "Große Quellcode- oder Skriptdateien werden mit einem speziellen Editor bearbeitet, der Syntaxhervorhebung für viele Programmiersprachen und diverse Farbschemata bietet.",
|
||||
"title": "Verschiedene Darstellungsformen für Ihre Informationen",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"get_started": "Начало работы",
|
||||
"github": "GitHub",
|
||||
"dockerhub": "Docker Hub",
|
||||
"screenshot_alt": "Скриншот приложения Trilium Notes для ПК"
|
||||
"screenshot_alt": "Скриншот приложения Trilium Notes для настольного приложения"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Структура",
|
||||
@@ -202,6 +202,7 @@
|
||||
"title": "Ресурсы",
|
||||
"icon_packs": "Наборы иконок",
|
||||
"download": "Скачать",
|
||||
"website": "Сайт"
|
||||
"website": "Сайт",
|
||||
"icon_packs_intro": "Расширьте выбор значков для заметок, используя набор иконок. Подробнее о наборах иконок смотрите в <DocumentationLink>официальной документации</DocumentationLink>."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
"get-started": {
|
||||
"title": "Kom igång",
|
||||
"desktop_title": "Ladda ner skrivbordsprogrammet (v{{version}})",
|
||||
"architecture": "Arkitektur:"
|
||||
"architecture": "Arkitektur:",
|
||||
"older_releases": "Se äldre versioner",
|
||||
"server_title": "Skapa en server för åtkomst på flera enheter"
|
||||
},
|
||||
"hero_section": {
|
||||
"title": "Organisera dina tankar. Skapa din personliga kunskapsbank.",
|
||||
"subtitle": "Trilium är en lösning med öppen källkod som möjliggör anteckningar och organisering av en personlig kunskapsbank. Använd den lokalt på ditt skrivbord, eller synka till en självhostad server för att komma åt dina anteckningar överallt.",
|
||||
"screenshot_alt": "Skärmdump av Trilium Notes skrivbordsapplikation",
|
||||
"get_started": "Kom igång",
|
||||
"github": "GitHub",
|
||||
"dockerhub": "Docker Hub"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Organisation",
|
||||
"note_structure_title": "Anteckningsstruktur",
|
||||
"note_structure_description": "Anteckningar kan sorteras hierarkiskt. Det behövs inga mappar eftersom varje anteckning kan innehålla underordnade anteckningar. En enskild anteckning kan placeras på flera ställen samtidigt i hierarkin.",
|
||||
"attributes_title": "Hantera etiketter och relationer",
|
||||
"attributes_description": "Använd relationer mellan anteckningar eller lägg till etiketter för enkel kategorisering. Använd framhävda attribut för att ange strukturerad information som sedan kan visas i tabeller och tavlor.",
|
||||
"hoisting_title": "Arbetsyta och fokusområde",
|
||||
"hoisting_description": "Separera enkelt privata- och jobbanteckningar genom att gruppera dem på en arbetsyta, vilket fokuserar anteckningshierarkin att enbart visa en viss grupp av anteckningar."
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"title": "Produktivitet och säkerhet",
|
||||
"revisions_title": "Anteckningshistorik",
|
||||
"revisions_content": "Anteckningar sparas regelbundet i bakgrunden och versioner kan användas för att söka- eller ångra oavsiktliga ändringar. En version kan också skapas manuellt."
|
||||
}
|
||||
}
|
||||
|
||||
45
docs/README-sv.md
vendored
45
docs/README-sv.md
vendored
@@ -37,40 +37,39 @@ antecknings app med fokus på att bygga en stor personlig kunskapsbas.
|
||||
## Ladda ner
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) –
|
||||
stabil version, rekommenderas för dom flesta användare.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) –
|
||||
unstable development version, updated daily with the latest features and
|
||||
fixes.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) -
|
||||
ostabil utvecklings version, uppdaterad dagligen med de senaste funktionerna
|
||||
och fixarna.
|
||||
|
||||
## 📚 Documentation
|
||||
## 📚 Dokumentation
|
||||
|
||||
**Visit our comprehensive documentation at
|
||||
**Läs mer i vår omfattande dokumentation på
|
||||
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
|
||||
Our documentation is available in multiple formats:
|
||||
- **Online Documentation**: Browse the full documentation at
|
||||
Vår dokumentation är tillgänglig i flera format:
|
||||
- **Webb dokumentation**: Läs hela dokumentationen på
|
||||
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
|
||||
- **In-App Help**: Press `F1` within Trilium to access the same documentation
|
||||
directly in the application
|
||||
- **GitHub**: Navigate through the [User Guide](./User%20Guide/User%20Guide/) in
|
||||
this repository
|
||||
- **I-appen-hjälp**: Tryck `F1` i Trilium för att läsa samma dokumentation inuti
|
||||
programmet
|
||||
- **GitHub**: Läs [användarhandboken](./User%20Guide/User%20Guide/) i denna repo
|
||||
|
||||
### Quick Links
|
||||
- [Getting Started Guide](https://docs.triliumnotes.org/)
|
||||
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
|
||||
### Snabblänkar
|
||||
- [Snabbstartsguide](https://docs.triliumnotes.org/)
|
||||
- [Installationsanvisning](https://docs.triliumnotes.org/user-guide/setup)
|
||||
- [Docker
|
||||
Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
|
||||
- [Upgrading
|
||||
- [Uppdaterar
|
||||
TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
|
||||
- [Basic Concepts and
|
||||
Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Patterns of Personal Knowledge
|
||||
Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
- [Grundläggande koncept och
|
||||
funktioner](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Modeller av personlig
|
||||
kunskapsbas](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
|
||||
## 🎁 Features
|
||||
## 🎁 Funktioner
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed
|
||||
into multiple places in the tree (see
|
||||
[cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Anteckningar kan sorteras som en trädstruktur. En enskild anteckning kan
|
||||
placeras på fler än en plats i trädet (se
|
||||
[kloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Rich WYSIWYG note editor including e.g. tables, images and
|
||||
[math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown
|
||||
[autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
|
||||
|
||||
130
docs/Release Notes/!!!meta.json
vendored
130
docs/Release Notes/!!!meta.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"appVersion": "0.101.3",
|
||||
"appVersion": "0.102.0",
|
||||
"files": [
|
||||
{
|
||||
"isClone": false,
|
||||
@@ -61,6 +61,32 @@
|
||||
"attachments": [],
|
||||
"dirFileName": "Release Notes",
|
||||
"children": [
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "4FTGCuCiG7s7",
|
||||
"notePath": [
|
||||
"hD3V4hiu2VW4",
|
||||
"4FTGCuCiG7s7"
|
||||
],
|
||||
"title": "v0.102.1",
|
||||
"notePosition": 10,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "template",
|
||||
"value": "wyurrlcDl416",
|
||||
"isInheritable": false,
|
||||
"position": 60
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "v0.102.1.md",
|
||||
"attachments": []
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "d582eD4RY4OM",
|
||||
@@ -69,7 +95,7 @@
|
||||
"d582eD4RY4OM"
|
||||
],
|
||||
"title": "v0.102.0",
|
||||
"notePosition": 10,
|
||||
"notePosition": 20,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -95,7 +121,7 @@
|
||||
"IlBzLeN3MJhw"
|
||||
],
|
||||
"title": "v0.101.3",
|
||||
"notePosition": 20,
|
||||
"notePosition": 30,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -121,7 +147,7 @@
|
||||
"vcBthaXcwAm6"
|
||||
],
|
||||
"title": "v0.101.2",
|
||||
"notePosition": 30,
|
||||
"notePosition": 40,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -147,7 +173,7 @@
|
||||
"AgUcrU9nFXuW"
|
||||
],
|
||||
"title": "v0.101.1",
|
||||
"notePosition": 40,
|
||||
"notePosition": 50,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -173,7 +199,7 @@
|
||||
"uYwlZ594eyJu"
|
||||
],
|
||||
"title": "v0.101.0",
|
||||
"notePosition": 50,
|
||||
"notePosition": 60,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -199,7 +225,7 @@
|
||||
"iPGKEk7pwJXK"
|
||||
],
|
||||
"title": "v0.100.0",
|
||||
"notePosition": 60,
|
||||
"notePosition": 70,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -225,7 +251,7 @@
|
||||
"7HKMTjmopLcM"
|
||||
],
|
||||
"title": "v0.99.5",
|
||||
"notePosition": 70,
|
||||
"notePosition": 80,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -251,7 +277,7 @@
|
||||
"RMBaNYPsRpIr"
|
||||
],
|
||||
"title": "v0.99.4",
|
||||
"notePosition": 80,
|
||||
"notePosition": 90,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -277,7 +303,7 @@
|
||||
"yuroLztFfpu5"
|
||||
],
|
||||
"title": "v0.99.3",
|
||||
"notePosition": 90,
|
||||
"notePosition": 100,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -303,7 +329,7 @@
|
||||
"z207sehwMJ6C"
|
||||
],
|
||||
"title": "v0.99.2",
|
||||
"notePosition": 100,
|
||||
"notePosition": 110,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -329,7 +355,7 @@
|
||||
"WGQsXq2jNyTi"
|
||||
],
|
||||
"title": "v0.99.1",
|
||||
"notePosition": 110,
|
||||
"notePosition": 120,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -355,7 +381,7 @@
|
||||
"cyw2Yue9vXf3"
|
||||
],
|
||||
"title": "v0.99.0",
|
||||
"notePosition": 120,
|
||||
"notePosition": 130,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -381,7 +407,7 @@
|
||||
"QOJwjruOUr4k"
|
||||
],
|
||||
"title": "v0.98.1",
|
||||
"notePosition": 130,
|
||||
"notePosition": 140,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -407,7 +433,7 @@
|
||||
"PLUoryywi0BC"
|
||||
],
|
||||
"title": "v0.98.0",
|
||||
"notePosition": 140,
|
||||
"notePosition": 150,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -433,7 +459,7 @@
|
||||
"lvOuiWsLDv8F"
|
||||
],
|
||||
"title": "v0.97.2",
|
||||
"notePosition": 150,
|
||||
"notePosition": 160,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -459,7 +485,7 @@
|
||||
"OtFZ6Nd9vM3n"
|
||||
],
|
||||
"title": "v0.97.1",
|
||||
"notePosition": 160,
|
||||
"notePosition": 170,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -485,7 +511,7 @@
|
||||
"SJZ5PwfzHSQ1"
|
||||
],
|
||||
"title": "v0.97.0",
|
||||
"notePosition": 170,
|
||||
"notePosition": 180,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -511,7 +537,7 @@
|
||||
"mYXFde3LuNR7"
|
||||
],
|
||||
"title": "v0.96.0",
|
||||
"notePosition": 180,
|
||||
"notePosition": 190,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -537,7 +563,7 @@
|
||||
"jthwbL0FdaeU"
|
||||
],
|
||||
"title": "v0.95.0",
|
||||
"notePosition": 190,
|
||||
"notePosition": 200,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -563,7 +589,7 @@
|
||||
"7HGYsJbLuhnv"
|
||||
],
|
||||
"title": "v0.94.1",
|
||||
"notePosition": 200,
|
||||
"notePosition": 210,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -589,7 +615,7 @@
|
||||
"Neq53ujRGBqv"
|
||||
],
|
||||
"title": "v0.94.0",
|
||||
"notePosition": 210,
|
||||
"notePosition": 220,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -615,7 +641,7 @@
|
||||
"VN3xnce1vLkX"
|
||||
],
|
||||
"title": "v0.93.0",
|
||||
"notePosition": 220,
|
||||
"notePosition": 230,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -633,7 +659,7 @@
|
||||
"WRaBfQqPr6qo"
|
||||
],
|
||||
"title": "v0.92.7",
|
||||
"notePosition": 230,
|
||||
"notePosition": 240,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -659,7 +685,7 @@
|
||||
"a2rwfKNmUFU1"
|
||||
],
|
||||
"title": "v0.92.6",
|
||||
"notePosition": 240,
|
||||
"notePosition": 250,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -677,7 +703,7 @@
|
||||
"fEJ8qErr0BKL"
|
||||
],
|
||||
"title": "v0.92.5-beta",
|
||||
"notePosition": 250,
|
||||
"notePosition": 260,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -695,7 +721,7 @@
|
||||
"kkkZQQGSXjwy"
|
||||
],
|
||||
"title": "v0.92.4",
|
||||
"notePosition": 260,
|
||||
"notePosition": 270,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -713,7 +739,7 @@
|
||||
"vAroNixiezaH"
|
||||
],
|
||||
"title": "v0.92.3-beta",
|
||||
"notePosition": 270,
|
||||
"notePosition": 280,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -731,7 +757,7 @@
|
||||
"mHEq1wxAKNZd"
|
||||
],
|
||||
"title": "v0.92.2-beta",
|
||||
"notePosition": 280,
|
||||
"notePosition": 290,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -749,7 +775,7 @@
|
||||
"IykjoAmBpc61"
|
||||
],
|
||||
"title": "v0.92.1-beta",
|
||||
"notePosition": 290,
|
||||
"notePosition": 300,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -767,7 +793,7 @@
|
||||
"dq2AJ9vSBX4Y"
|
||||
],
|
||||
"title": "v0.92.0-beta",
|
||||
"notePosition": 300,
|
||||
"notePosition": 310,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -785,7 +811,7 @@
|
||||
"3a8aMe4jz4yM"
|
||||
],
|
||||
"title": "v0.91.6",
|
||||
"notePosition": 310,
|
||||
"notePosition": 320,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -803,7 +829,7 @@
|
||||
"8djQjkiDGESe"
|
||||
],
|
||||
"title": "v0.91.5",
|
||||
"notePosition": 320,
|
||||
"notePosition": 330,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -821,7 +847,7 @@
|
||||
"OylxVoVJqNmr"
|
||||
],
|
||||
"title": "v0.91.4-beta",
|
||||
"notePosition": 330,
|
||||
"notePosition": 340,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -839,7 +865,7 @@
|
||||
"tANGQDvnyhrj"
|
||||
],
|
||||
"title": "v0.91.3-beta",
|
||||
"notePosition": 340,
|
||||
"notePosition": 350,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -857,7 +883,7 @@
|
||||
"hMoBfwSoj1SC"
|
||||
],
|
||||
"title": "v0.91.2-beta",
|
||||
"notePosition": 350,
|
||||
"notePosition": 360,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -875,7 +901,7 @@
|
||||
"a2XMSKROCl9z"
|
||||
],
|
||||
"title": "v0.91.1-beta",
|
||||
"notePosition": 360,
|
||||
"notePosition": 370,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -893,7 +919,7 @@
|
||||
"yqXFvWbLkuMD"
|
||||
],
|
||||
"title": "v0.90.12",
|
||||
"notePosition": 370,
|
||||
"notePosition": 380,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -911,7 +937,7 @@
|
||||
"veS7pg311yJP"
|
||||
],
|
||||
"title": "v0.90.11-beta",
|
||||
"notePosition": 380,
|
||||
"notePosition": 390,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -929,7 +955,7 @@
|
||||
"sq5W9TQxRqMq"
|
||||
],
|
||||
"title": "v0.90.10-beta",
|
||||
"notePosition": 390,
|
||||
"notePosition": 400,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -947,7 +973,7 @@
|
||||
"yFEGVCUM9tPx"
|
||||
],
|
||||
"title": "v0.90.9-beta",
|
||||
"notePosition": 400,
|
||||
"notePosition": 410,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -965,7 +991,7 @@
|
||||
"o4wAGqOQuJtV"
|
||||
],
|
||||
"title": "v0.90.8",
|
||||
"notePosition": 410,
|
||||
"notePosition": 420,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -998,7 +1024,7 @@
|
||||
"i4A5g9iOg9I0"
|
||||
],
|
||||
"title": "v0.90.7-beta",
|
||||
"notePosition": 420,
|
||||
"notePosition": 430,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1016,7 +1042,7 @@
|
||||
"ThNf2GaKgXUs"
|
||||
],
|
||||
"title": "v0.90.6-beta",
|
||||
"notePosition": 430,
|
||||
"notePosition": 440,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1034,7 +1060,7 @@
|
||||
"G4PAi554kQUr"
|
||||
],
|
||||
"title": "v0.90.5-beta",
|
||||
"notePosition": 440,
|
||||
"notePosition": 450,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1061,7 +1087,7 @@
|
||||
"zATRobGRCmBn"
|
||||
],
|
||||
"title": "v0.90.4",
|
||||
"notePosition": 450,
|
||||
"notePosition": 460,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1079,7 +1105,7 @@
|
||||
"sCDLf8IKn3Iz"
|
||||
],
|
||||
"title": "v0.90.3",
|
||||
"notePosition": 460,
|
||||
"notePosition": 470,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1097,7 +1123,7 @@
|
||||
"VqqyBu4AuTjC"
|
||||
],
|
||||
"title": "v0.90.2-beta",
|
||||
"notePosition": 470,
|
||||
"notePosition": 480,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1115,7 +1141,7 @@
|
||||
"RX3Nl7wInLsA"
|
||||
],
|
||||
"title": "v0.90.1-beta",
|
||||
"notePosition": 480,
|
||||
"notePosition": 490,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1133,7 +1159,7 @@
|
||||
"GyueACukPWjk"
|
||||
],
|
||||
"title": "v0.90.0-beta",
|
||||
"notePosition": 490,
|
||||
"notePosition": 500,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1151,7 +1177,7 @@
|
||||
"kzjHexDTTeVB"
|
||||
],
|
||||
"title": "v0.48",
|
||||
"notePosition": 500,
|
||||
"notePosition": 510,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1218,7 +1244,7 @@
|
||||
"wyurrlcDl416"
|
||||
],
|
||||
"title": "Release Template",
|
||||
"notePosition": 510,
|
||||
"notePosition": 520,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
|
||||
22
docs/Release Notes/Release Notes/v0.102.1.md
vendored
Normal file
22
docs/Release Notes/Release Notes/v0.102.1.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# v0.102.1
|
||||
> [!NOTE]
|
||||
> If you enjoyed this release, consider showing a token of appreciation by:
|
||||
>
|
||||
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
|
||||
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
|
||||
> * If you are interested in an [official mobile application](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/7447) ([#7447](https://github.com/TriliumNext/Trilium/issues/7447)) or [multi-user support](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/4956) ([#4956](https://github.com/TriliumNext/Trilium/issues/4956)), consider offering financial support via IssueHunt (see links).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is a hotfix of v0.102.0, addressing some blocking issues. For more information about the previous major version, see [v0.102.0 changelog](https://github.com/TriliumNext/Trilium/releases/tag/v0.102.0).
|
||||
|
||||
## 🐞 Bugfixes
|
||||
|
||||
* [Mind Map feature breaks rendering in v0.102.0](https://github.com/TriliumNext/Trilium/issues/8879)
|
||||
* Fixes for the PDF viewer:
|
||||
* [PDF view is '403 Forbidden' on Nginx Proxy Manager](https://github.com/TriliumNext/Trilium/issues/8877)
|
||||
* [PDF: address some layout issues](https://github.com/TriliumNext/Trilium/commit/8712e7dd160564f9a923a88bf5871e63c79d40f0) by @adoriandoran
|
||||
* Cache not properly invalidated across versions.
|
||||
|
||||
## 🛠️ Technical updates
|
||||
|
||||
* [Rework Docker infrastructure to use crane](https://github.com/TriliumNext/Trilium/pull/8869) by @perfectra1n
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/source",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
@@ -50,7 +50,7 @@
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.11.0",
|
||||
"@types/node": "24.12.0",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
@@ -58,7 +58,7 @@
|
||||
"cross-env": "10.1.0",
|
||||
"dpdm": "4.0.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.9.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.37",
|
||||
"@smithy/middleware-retry": "4.4.40",
|
||||
"@types/jquery": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@codemirror/commands": "6.10.2",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-php": "6.0.2",
|
||||
@@ -16,7 +16,7 @@
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@codemirror/view": "6.39.16",
|
||||
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-abyss": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-android-studio": "6.2.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/commons",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./lib/dayjs.js";
|
||||
export * from "./lib/notes.js";
|
||||
export * from "./lib/week_utils.js";
|
||||
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
|
||||
export * from "./lib/spreadsheet/render_to_html.js";
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AttachmentRow {
|
||||
deleteId?: string;
|
||||
contentLength?: number;
|
||||
content?: Buffer | string;
|
||||
/** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */
|
||||
encoding?: "base64";
|
||||
}
|
||||
|
||||
export interface RevisionRow {
|
||||
|
||||
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderSpreadsheetToHtml } from "./render_to_html.js";
|
||||
|
||||
describe("renderSpreadsheetToHtml", () => {
|
||||
it("renders a basic spreadsheet with values and styles", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
id: "test",
|
||||
sheetOrder: ["sheet1"],
|
||||
name: "",
|
||||
appVersion: "0.16.1",
|
||||
locale: "zhCN",
|
||||
styles: {
|
||||
boldStyle: { bl: 1 }
|
||||
},
|
||||
sheets: {
|
||||
sheet1: {
|
||||
id: "sheet1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 1000,
|
||||
columnCount: 20,
|
||||
defaultColumnWidth: 88,
|
||||
defaultRowHeight: 24,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"1": {
|
||||
"1": { v: "lol", t: 1 }
|
||||
},
|
||||
"3": {
|
||||
"0": { v: "wut", t: 1 },
|
||||
"2": { s: "boldStyle", v: "Bold string", t: 1 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
showGridlines: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
|
||||
// Should contain a table.
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toContain("</table>");
|
||||
|
||||
// Should contain cell values.
|
||||
expect(html).toContain("lol");
|
||||
expect(html).toContain("wut");
|
||||
expect(html).toContain("Bold string");
|
||||
|
||||
// Bold cell should have font-weight:bold.
|
||||
expect(html).toContain("font-weight:bold");
|
||||
|
||||
// Should not render sheet header for single sheet.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("renders multiple visible sheets with headers", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Data",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "A1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Summary",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "B1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("<h3>Data</h3>");
|
||||
expect(html).toContain("<h3>Summary</h3>");
|
||||
expect(html).toContain("A1");
|
||||
expect(html).toContain("B1");
|
||||
});
|
||||
|
||||
it("skips hidden sheets", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Visible",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "shown" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Hidden",
|
||||
hidden: 1,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "secret" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("shown");
|
||||
expect(html).not.toContain("secret");
|
||||
// Single visible sheet, no header.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("handles merged cells", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [
|
||||
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }
|
||||
],
|
||||
cellData: {
|
||||
"0": { "0": { v: "merged" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain('rowspan="2"');
|
||||
expect(html).toContain('colspan="2"');
|
||||
expect(html).toContain("merged");
|
||||
});
|
||||
|
||||
it("escapes HTML in cell values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": { "0": { v: "<script>alert('xss')</script>" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("handles invalid JSON gracefully", () => {
|
||||
const html = renderSpreadsheetToHtml("not json");
|
||||
expect(html).toContain("Unable to parse");
|
||||
});
|
||||
|
||||
it("handles empty workbook", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("Empty sheet");
|
||||
});
|
||||
|
||||
it("renders boolean values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": { v: true, t: 3 },
|
||||
"1": { v: false, t: 3 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("TRUE");
|
||||
expect(html).toContain("FALSE");
|
||||
});
|
||||
|
||||
it("applies inline styles for colors, alignment, and borders", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "styled",
|
||||
s: {
|
||||
bg: { rgb: "#FF0000" },
|
||||
cl: { rgb: "#FFFFFF" },
|
||||
ht: 2,
|
||||
bd: {
|
||||
b: { s: 1, cl: { rgb: "#000000" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("background-color:#FF0000");
|
||||
expect(html).toContain("color:#FFFFFF");
|
||||
expect(html).toContain("text-align:center");
|
||||
expect(html).toContain("border-bottom:");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in color values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
bg: { rgb: "red;background:url(//evil.com/steal)" },
|
||||
cl: { rgb: "#FFF;color:expression(alert(1))" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("evil.com");
|
||||
expect(html).not.toContain("expression");
|
||||
expect(html).toContain("transparent");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in font-family", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
ff: "Arial;}</style><script>alert(1)</script>"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("</style>");
|
||||
expect(html).toContain("font-family:Arial");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in border colors", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
bd: {
|
||||
b: { s: 1, cl: { rgb: "#000;background:url(//evil.com)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("evil.com");
|
||||
expect(html).toContain("transparent");
|
||||
});
|
||||
});
|
||||
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Converts a UniversJS workbook JSON structure into a static HTML table representation.
|
||||
* This is used for rendering spreadsheets in shared notes and exports.
|
||||
*
|
||||
* Only the subset of UniversJS types needed for rendering is defined here,
|
||||
* to avoid depending on @univerjs/core.
|
||||
*/
|
||||
|
||||
// #region UniversJS type subset
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: IWorkbookData;
|
||||
}
|
||||
|
||||
interface IWorkbookData {
|
||||
sheetOrder: string[];
|
||||
styles?: Record<string, IStyleData | null>;
|
||||
sheets: Record<string, IWorksheetData>;
|
||||
}
|
||||
|
||||
interface IWorksheetData {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: number;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
defaultColumnWidth?: number;
|
||||
defaultRowHeight?: number;
|
||||
mergeData?: IRange[];
|
||||
cellData: CellMatrix;
|
||||
rowData?: Record<number, IRowData>;
|
||||
columnData?: Record<number, IColumnData>;
|
||||
showGridlines?: number;
|
||||
}
|
||||
|
||||
type CellMatrix = Record<number, Record<number, ICellData>>;
|
||||
|
||||
interface ICellData {
|
||||
v?: string | number | boolean | null;
|
||||
t?: number | null;
|
||||
s?: IStyleData | string | null;
|
||||
}
|
||||
|
||||
interface IStyleData {
|
||||
bl?: number;
|
||||
it?: number;
|
||||
ul?: ITextDecoration;
|
||||
st?: ITextDecoration;
|
||||
fs?: number;
|
||||
ff?: string | null;
|
||||
bg?: IColorStyle | null;
|
||||
cl?: IColorStyle | null;
|
||||
ht?: number | null;
|
||||
vt?: number | null;
|
||||
bd?: IBorderData | null;
|
||||
}
|
||||
|
||||
interface ITextDecoration {
|
||||
s?: number;
|
||||
}
|
||||
|
||||
interface IColorStyle {
|
||||
rgb?: string | null;
|
||||
}
|
||||
|
||||
interface IBorderData {
|
||||
t?: IBorderStyleData | null;
|
||||
r?: IBorderStyleData | null;
|
||||
b?: IBorderStyleData | null;
|
||||
l?: IBorderStyleData | null;
|
||||
}
|
||||
|
||||
interface IBorderStyleData {
|
||||
s?: number;
|
||||
cl?: IColorStyle;
|
||||
}
|
||||
|
||||
interface IRange {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
}
|
||||
|
||||
interface IRowData {
|
||||
h?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
interface IColumnData {
|
||||
w?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
// Alignment enums (from UniversJS)
|
||||
const enum HorizontalAlign {
|
||||
LEFT = 1,
|
||||
CENTER = 2,
|
||||
RIGHT = 3
|
||||
}
|
||||
|
||||
const enum VerticalAlign {
|
||||
TOP = 1,
|
||||
MIDDLE = 2,
|
||||
BOTTOM = 3
|
||||
}
|
||||
|
||||
// Border style enum
|
||||
const enum BorderStyle {
|
||||
THIN = 1,
|
||||
MEDIUM = 6,
|
||||
THICK = 9,
|
||||
DASHED = 3,
|
||||
DOTTED = 4
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Parses the raw JSON content of a spreadsheet note and renders it as HTML.
|
||||
* Returns an HTML string containing one `<table>` per visible sheet.
|
||||
*/
|
||||
export function renderSpreadsheetToHtml(jsonContent: string): string {
|
||||
let data: PersistedData;
|
||||
try {
|
||||
data = JSON.parse(jsonContent);
|
||||
} catch {
|
||||
return "<p>Unable to parse spreadsheet data.</p>";
|
||||
}
|
||||
|
||||
if (!data?.workbook?.sheets) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const { workbook } = data;
|
||||
const sheetIds = workbook.sheetOrder ?? Object.keys(workbook.sheets);
|
||||
const visibleSheets = sheetIds
|
||||
.map((id) => workbook.sheets[id])
|
||||
.filter((s) => s && !s.hidden);
|
||||
|
||||
if (visibleSheets.length === 0) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const sheet of visibleSheets) {
|
||||
if (visibleSheets.length > 1) {
|
||||
parts.push(`<h3>${escapeHtml(sheet.name)}</h3>`);
|
||||
}
|
||||
parts.push(renderSheet(sheet, workbook.styles ?? {}));
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function renderSheet(sheet: IWorksheetData, styles: Record<string, IStyleData | null>): string {
|
||||
const { cellData, mergeData = [], columnData = {}, rowData = {} } = sheet;
|
||||
|
||||
// Determine the actual bounds (only cells with data).
|
||||
const bounds = computeBounds(cellData, mergeData);
|
||||
if (!bounds) {
|
||||
return "<p>Empty sheet.</p>";
|
||||
}
|
||||
|
||||
const { minRow, maxRow, minCol, maxCol } = bounds;
|
||||
|
||||
// Build a set of cells that are hidden by merges (non-origin cells).
|
||||
const mergeMap = buildMergeMap(mergeData, minRow, maxRow, minCol, maxCol);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('<table class="spreadsheet-table">');
|
||||
|
||||
// Colgroup for column widths.
|
||||
const defaultWidth = sheet.defaultColumnWidth ?? 88;
|
||||
lines.push("<colgroup>");
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
const colMeta = columnData[col];
|
||||
if (colMeta?.hd) continue;
|
||||
const width = isFiniteNumber(colMeta?.w) ? colMeta.w : defaultWidth;
|
||||
lines.push(`<col style="width:${width}px">`);
|
||||
}
|
||||
lines.push("</colgroup>");
|
||||
|
||||
const defaultHeight = sheet.defaultRowHeight ?? 24;
|
||||
|
||||
for (let row = minRow; row <= maxRow; row++) {
|
||||
const rowMeta = rowData[row];
|
||||
if (rowMeta?.hd) continue;
|
||||
|
||||
const height = isFiniteNumber(rowMeta?.h) ? rowMeta.h : defaultHeight;
|
||||
lines.push(`<tr style="height:${height}px">`);
|
||||
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
if (columnData[col]?.hd) continue;
|
||||
|
||||
const mergeInfo = mergeMap.get(cellKey(row, col));
|
||||
if (mergeInfo === "hidden") continue;
|
||||
|
||||
const cell = cellData[row]?.[col];
|
||||
const cellStyle = resolveCellStyle(cell, styles);
|
||||
const cssText = buildCssText(cellStyle);
|
||||
const value = formatCellValue(cell);
|
||||
|
||||
const attrs: string[] = [];
|
||||
if (cssText) attrs.push(`style="${cssText}"`);
|
||||
if (mergeInfo) {
|
||||
if (mergeInfo.rowSpan > 1) attrs.push(`rowspan="${mergeInfo.rowSpan}"`);
|
||||
if (mergeInfo.colSpan > 1) attrs.push(`colspan="${mergeInfo.colSpan}"`);
|
||||
}
|
||||
|
||||
lines.push(`<td${attrs.length ? " " + attrs.join(" ") : ""}>${value}</td>`);
|
||||
}
|
||||
|
||||
lines.push("</tr>");
|
||||
}
|
||||
|
||||
lines.push("</table>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// #region Bounds computation
|
||||
|
||||
interface Bounds {
|
||||
minRow: number;
|
||||
maxRow: number;
|
||||
minCol: number;
|
||||
maxCol: number;
|
||||
}
|
||||
|
||||
function computeBounds(cellData: CellMatrix, mergeData: IRange[]): Bounds | null {
|
||||
let minRow = Infinity;
|
||||
let maxRow = -Infinity;
|
||||
let minCol = Infinity;
|
||||
let maxCol = -Infinity;
|
||||
|
||||
for (const rowStr of Object.keys(cellData)) {
|
||||
const row = Number(rowStr);
|
||||
const cols = cellData[row];
|
||||
for (const colStr of Object.keys(cols)) {
|
||||
const col = Number(colStr);
|
||||
if (minRow > row) minRow = row;
|
||||
if (maxRow < row) maxRow = row;
|
||||
if (minCol > col) minCol = col;
|
||||
if (maxCol < col) maxCol = col;
|
||||
}
|
||||
}
|
||||
|
||||
// Extend bounds to cover merged ranges.
|
||||
for (const range of mergeData) {
|
||||
if (minRow > range.startRow) minRow = range.startRow;
|
||||
if (maxRow < range.endRow) maxRow = range.endRow;
|
||||
if (minCol > range.startColumn) minCol = range.startColumn;
|
||||
if (maxCol < range.endColumn) maxCol = range.endColumn;
|
||||
}
|
||||
|
||||
if (minRow > maxRow) return null;
|
||||
return { minRow, maxRow, minCol, maxCol };
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Merge handling
|
||||
|
||||
interface MergeOrigin {
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type MergeInfo = MergeOrigin | "hidden";
|
||||
|
||||
function cellKey(row: number, col: number): string {
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
function buildMergeMap(mergeData: IRange[], minRow: number, maxRow: number, minCol: number, maxCol: number): Map<string, MergeInfo> {
|
||||
const map = new Map<string, MergeInfo>();
|
||||
|
||||
for (const range of mergeData) {
|
||||
const startRow = Math.max(range.startRow, minRow);
|
||||
const endRow = Math.min(range.endRow, maxRow);
|
||||
const startCol = Math.max(range.startColumn, minCol);
|
||||
const endCol = Math.min(range.endColumn, maxCol);
|
||||
|
||||
map.set(cellKey(range.startRow, range.startColumn), {
|
||||
rowSpan: endRow - startRow + 1,
|
||||
colSpan: endCol - startCol + 1
|
||||
});
|
||||
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
if (r === range.startRow && c === range.startColumn) continue;
|
||||
map.set(cellKey(r, c), "hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Style resolution
|
||||
|
||||
function resolveCellStyle(cell: ICellData | undefined, styles: Record<string, IStyleData | null>): IStyleData | null {
|
||||
if (!cell?.s) return null;
|
||||
|
||||
if (typeof cell.s === "string") {
|
||||
return styles[cell.s] ?? null;
|
||||
}
|
||||
|
||||
return cell.s;
|
||||
}
|
||||
|
||||
function buildCssText(style: IStyleData | null): string {
|
||||
if (!style) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (style.bl) parts.push("font-weight:bold");
|
||||
if (style.it) parts.push("font-style:italic");
|
||||
if (style.ul?.s) parts.push("text-decoration:underline");
|
||||
if (style.st?.s) {
|
||||
// Combine with underline if both are set.
|
||||
const existing = parts.findIndex((p) => p.startsWith("text-decoration:"));
|
||||
if (existing >= 0) {
|
||||
parts[existing] = "text-decoration:underline line-through";
|
||||
} else {
|
||||
parts.push("text-decoration:line-through");
|
||||
}
|
||||
}
|
||||
if (style.fs && isFiniteNumber(style.fs)) parts.push(`font-size:${style.fs}pt`);
|
||||
if (style.ff) parts.push(`font-family:${sanitizeCssValue(style.ff)}`);
|
||||
if (style.bg?.rgb) parts.push(`background-color:${sanitizeCssColor(style.bg.rgb)}`);
|
||||
if (style.cl?.rgb) parts.push(`color:${sanitizeCssColor(style.cl.rgb)}`);
|
||||
|
||||
if (style.ht != null) {
|
||||
const align = horizontalAlignToCss(style.ht);
|
||||
if (align) parts.push(`text-align:${align}`);
|
||||
}
|
||||
if (style.vt != null) {
|
||||
const valign = verticalAlignToCss(style.vt);
|
||||
if (valign) parts.push(`vertical-align:${valign}`);
|
||||
}
|
||||
|
||||
if (style.bd) {
|
||||
appendBorderCss(parts, "border-top", style.bd.t);
|
||||
appendBorderCss(parts, "border-right", style.bd.r);
|
||||
appendBorderCss(parts, "border-bottom", style.bd.b);
|
||||
appendBorderCss(parts, "border-left", style.bd.l);
|
||||
}
|
||||
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function horizontalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case HorizontalAlign.LEFT: return "left";
|
||||
case HorizontalAlign.CENTER: return "center";
|
||||
case HorizontalAlign.RIGHT: return "right";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verticalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case VerticalAlign.TOP: return "top";
|
||||
case VerticalAlign.MIDDLE: return "middle";
|
||||
case VerticalAlign.BOTTOM: return "bottom";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendBorderCss(parts: string[], property: string, border: IBorderStyleData | null | undefined): void {
|
||||
if (!border) return;
|
||||
const width = borderStyleToWidth(border.s);
|
||||
const color = sanitizeCssColor(border.cl?.rgb ?? "#000");
|
||||
const style = borderStyleToCss(border.s);
|
||||
parts.push(`${property}:${width} ${style} ${color}`);
|
||||
}
|
||||
|
||||
function borderStyleToWidth(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.MEDIUM: return "2px";
|
||||
case BorderStyle.THICK: return "3px";
|
||||
default: return "1px";
|
||||
}
|
||||
}
|
||||
|
||||
function borderStyleToCss(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.DASHED: return "dashed";
|
||||
case BorderStyle.DOTTED: return "dotted";
|
||||
default: return "solid";
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks that a value is a finite number (guards against stringified payloads from JSON). */
|
||||
function isFiniteNumber(v: unknown): v is number {
|
||||
return typeof v === "number" && Number.isFinite(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an arbitrary string for use as a CSS value by removing characters
|
||||
* that could break out of a property (semicolons, braces, angle brackets, etc.).
|
||||
*/
|
||||
function sanitizeCssValue(value: string): string {
|
||||
return value.replace(/[;<>{}\\/()'"]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a CSS color string. Accepts hex colors (#rgb, #rrggbb, #rrggbbaa),
|
||||
* named colors (letters only), and rgb()/rgba()/hsl()/hsla() functional notation
|
||||
* with safe characters. Returns "transparent" for anything that doesn't match.
|
||||
*/
|
||||
function sanitizeCssColor(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
// Hex colors
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
// Named colors (letters only, reasonable length)
|
||||
if (/^[a-zA-Z]{1,30}$/.test(trimmed)) return trimmed;
|
||||
// Functional notation: rgb(), rgba(), hsl(), hsla() — allow digits, commas, dots, spaces, %
|
||||
if (/^(?:rgb|hsl)a?\([0-9.,\s%]+\)$/.test(trimmed)) return trimmed;
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Value formatting
|
||||
|
||||
function formatCellValue(cell: ICellData | undefined): string {
|
||||
if (!cell || cell.v == null) return "";
|
||||
|
||||
if (typeof cell.v === "boolean") {
|
||||
return cell.v ? "TRUE" : "FALSE";
|
||||
}
|
||||
|
||||
return escapeHtml(String(cell.v));
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/pdfjs-viewer",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
|
||||
@@ -71,13 +71,17 @@ function patchCacheBuster(htmlFilePath: string) {
|
||||
const version = packageJson.version;
|
||||
console.log(`Versioned URLs: ${version}.`)
|
||||
let html = readFileSync(htmlFilePath, "utf-8");
|
||||
html = html.replace(
|
||||
`<link rel="stylesheet" href="custom.css" />`,
|
||||
`<link rel="stylesheet" href="custom.css?v=${version}" />`);
|
||||
html = html.replace(
|
||||
`<script src="custom.mjs" type="module"></script>`,
|
||||
`<script src="custom.mjs?v=${version}" type="module"></script>`
|
||||
);
|
||||
for (const file of [ "viewer.css", "custom.css" ]) {
|
||||
html = html.replace(
|
||||
`<link rel="stylesheet" href="${file}" />`,
|
||||
`<link rel="stylesheet" href="${file}?v=${version}" />`);
|
||||
}
|
||||
for (const file of [ "viewer.mjs", "custom.mjs" ]) {
|
||||
html = html.replace(
|
||||
`<script src="${file}" type="module"></script>`,
|
||||
`<script src="${file}?v=${version}" type="module"></script>`
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(htmlFilePath, html);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.33",
|
||||
"katex": "0.16.38",
|
||||
"mermaid": "11.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,7 +35,7 @@
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"dotenv": "17.3.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"highlight.js": "11.11.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
2178
pnpm-lock.yaml
generated
2178
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user