diff --git a/README.md b/README.md index 3e555c2..641878a 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,42 @@ pnpm build - 前端不直接访问 SQLite 或厂商模型协议;持久数据通过 FastAPI 服务读写。 - 接口或数据结构变化时,同一提交同步更新前后端类型、契约和开发说明。 - 当前行为以代码、测试和运行中的 `/openapi.json` 为准;规划能力必须在文档中明确标注。 + +## 主题包与仓库发布(临时规范) + +主题页支持本地文件及 HTTP(S) 文件直链导入。两种入口均先解析、校验并展示清单和 CSS,用户点击安装后才写入本地存储。安装不会自动启用主题。 + +### 单文件 + +使用 UTF-8 编码,扩展名 `.theme`、`.yaml` 或 `.yml`。内容为 YAML 清单、一行 `---`、完整 CSS。可参考 `frontend/src/assets/themes/paper-moments.theme`。 + +### ZIP + +一个 ZIP 只包含一个主题。清单命名为 `theme.yaml`、`theme.yml`、`manifest.yaml` 或 `manifest.yml`,可以放在顶层,也可以放在仓库压缩包的子目录中。 + +```text +my-theme/ + theme.yaml + styles/ + theme.css +``` + +```yaml +theme_id: my-theme +name: My Theme +version: 1.0.0 +author: your-name +min_app_version: 0.2.0 +is_dark: false +css_entry: styles/theme.css +``` + +`css_entry` 相对于清单目录解析,不允许绝对路径、反斜杠及 `..`。CSS 应以 `[data-theme="my-theme"]` 限定主题样式。也支持仅包含一个 `.theme` 文件的 ZIP。 + +目前安装持久化的是清单和 CSS,不会托管 ZIP 内的图片、字体等资源;需要这些资源时请将它们内嵌为 CSS data URL。禁止 `@import` 和脚本表达式。 + +### URL 与社区仓库 + +发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。 + +下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。 diff --git a/backend/app/knowledge/parser.py b/backend/app/knowledge/parser.py index e343d39..5c7f93e 100644 --- a/backend/app/knowledge/parser.py +++ b/backend/app/knowledge/parser.py @@ -20,7 +20,6 @@ from app.errors import ApiError from app.textutils import count_tokens _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$") -_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$") _FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$") @@ -261,16 +260,29 @@ def _embedding_policy(markdown: str) -> bool: return value.value.lower() in {"true", "yes", "on"} -def _extract_frontmatter(markdown: str) -> dict[str, str]: - """极简 frontmatter 解析,只提取 key: value 行。""" +def _extract_frontmatter(markdown: str) -> dict[str, str | list[str]]: + """Read YAML scalars and tag sequences without constructing arbitrary objects.""" header = _frontmatter(markdown) if header is None: return {} - meta: dict[str, str] = {} - for line in header[0].splitlines(): - m = _FRONTMATTER_KEY_RE.match(line) - if m: - meta[m.group(1).lower()] = m.group(2).strip() + try: + node = yaml.compose(header[0], Loader=yaml.SafeLoader) + except yaml.YAMLError as exc: + raise ApiError(422, "INVALID_EMBEDDING_POLICY", "Frontmatter YAML 无效,无法确认本地索引策略。") from exc + meta: dict[str, str | list[str]] = {} + if not isinstance(node, yaml.MappingNode): + return meta # The policy validation below handles unsupported documents. + for key, value in node.value: + if not isinstance(key, yaml.ScalarNode): + continue + name = key.value.lower() + if name not in {"title", "tags"}: + continue + if isinstance(value, yaml.ScalarNode): + # Keep lexical values: YAML 1.1 would otherwise turn tags like on/yes into booleans. + meta[name] = "" if value.tag == "tag:yaml.org,2002:null" else value.value + elif name == "tags" and isinstance(value, yaml.SequenceNode): + meta[name] = [item.value for item in value.value if isinstance(item, yaml.ScalarNode)] return meta @@ -282,10 +294,10 @@ def _first_heading(markdown: str) -> str | None: return None -def _parse_tags(raw: str | None) -> list[str]: +def _parse_tags(raw: str | list[str] | None) -> list[str]: + if isinstance(raw, list): + return raw if not raw: return [] raw = raw.strip() - if raw.startswith("[") and raw.endswith("]"): - raw = raw[1:-1] - return [t.strip().strip("'\"") for t in raw.split(",") if t.strip()] + return [t.strip() for t in raw.split(",") if t.strip()] diff --git a/backend/tests/test_note_metadata.py b/backend/tests/test_note_metadata.py new file mode 100644 index 0000000..7626f5a --- /dev/null +++ b/backend/tests/test_note_metadata.py @@ -0,0 +1,46 @@ +import asyncio +from datetime import datetime, timezone + +import pytest + +from app.contracts import IndexRebuildRequest +from app.knowledge.parser import parse_note +from app.services import index_service, note_service + + +@pytest.mark.parametrize(('header', 'expected'), [ + ('tags:\n- python\n- rust', ['python', 'rust']), + ('tags:\n - python\n - rust', ['python', 'rust']), + ('"tags": ["a,b", "quote\\\"tag", "path\\\\tag"] # comment', ['a,b', 'quote"tag', 'path\\tag']), + ('tags: [on, yes, "true", "001"]', ['on', 'yes', 'true', '001']), + ('tags: python, rust', ['python', 'rust']), + ('tags: []', []), + ('tags: null', []), +]) +def test_yaml_tags_are_parsed_as_complete_values(header, expected): + now = datetime.now(timezone.utc) + note = parse_note( + markdown=f'---\ntitle: "Demo: YAML"\n{header}\n---\n# Body', + file_path='demo.md', folder='', created_at=now, updated_at=now, + ) + assert note.tags == expected + assert note.title == 'Demo: YAML' + + +def test_saved_metadata_survives_full_index_rebuild(): + async def scenario(): + note = await note_service.create_note(title='Demo', markdown='# Body', folder=None, tags=['old']) + for tags, yaml_tags in [ + (['python', 'a,b', 'on'], '\n - python\n - a,b\n - on'), + ([], ' []'), + ]: + markdown = f'---\ntitle: "Demo: updated"\ntags:{yaml_tags}\n---\n# Body\n' + saved = await note_service.update_note(note.note_id, markdown=markdown, tags=tags) + assert saved.tags == tags + job = await index_service.rebuild(IndexRebuildRequest()) + assert job.status == 'completed' + restored = await note_service.get_note(note.note_id) + assert restored.tags == tags + assert restored.title == 'Demo: updated' + assert restored.markdown == markdown + asyncio.run(scenario()) diff --git a/docs/README.md b/docs/README.md index 056265c..e346a21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ - [后端接口契约](contracts/后端接口契约-开发版.md) - [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md) - [前端页面需求说明](contracts/前端页面需求说明-开发版.md) +- [Tauri / Rust 桌面客户端需求说明(第三阶段,计划)](contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md) 运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。 diff --git a/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md b/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md new file mode 100644 index 0000000..ce79308 --- /dev/null +++ b/docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md @@ -0,0 +1,69 @@ +# Tauri / Rust 桌面客户端需求说明(第三阶段) + +状态:需求预留,尚未实现桌面客户端。本文不表示已有可调用的 Tauri Command 或可发布安装包。 + +基线日期:2026-09-05。 + +## 1. 目标与边界 + +第三阶段在现有 Vue 编辑器和 FastAPI AI Core 上接入 Tauri 2 / Rust Host,提供原生窗口、菜单、多 Vault 文件管理、安全凭据存储和 Sidecar 生命周期管理。 + +- Vue 负责页面、编辑事务、主题和交互状态;通过既有 Service 边界调用能力,不在组件中散布平台判断。 +- Rust Host 负责系统能力、路径权限、原生菜单事件及受控进程生命周期。 +- FastAPI AI Core 保留笔记解析、索引、检索、模型和 Agent 业务职责;同一文件不得同时由 Host 和 AI Core 无协调地写入。 +- Web 模式保留可运行能力;桌面专有功能通过能力检测显隐,不用无响应按钮假装已实现。 + +架构依据:[技术栈说明](../architecture/AI笔记软件技术栈说明-团队版-v2.3.md)、[前端页面需求](前端页面需求说明-开发版.md)、[第二阶段接口契约](第二阶段接口契约-开发版.md)。 + +## 2. 顶部菜单与元数据格式一键导入 + +### 2.1 入口预留 + +桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。 + +建议稳定的前端命令标识为 `editor.import-note-properties`,仅为设计标识,尚未注册为 Tauri IPC。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。 + +### 2.2 输入与转换规则 + +1. 无选区时识别当前笔记开头的属性块;有选区时只处理完整的属性块。无活动笔记、加载中、只读或冲突状态下禁用操作,并提供原因。 +2. 支持标准 YAML frontmatter,以及历史编辑器产生的 `***` 开头、`title:` / `tags:` 字段、横线结尾的兼容形式。普通分隔线、代码块和包含冒号的正文不得被误判。 +3. 将识别成功的内容规范化到文件头唯一的 `---` frontmatter 中,正文中的旧属性块仅在转换成功后移除。 +4. 写作模式显示独立标题和可编辑标签;源码模式显示真实 `title` / `tags` 字段。标签必须进入现有保存和索引链路,能被标签筛选使用,不能只创建装饰性标签元素。 +5. 保留未知属性及其类型,特别是 `embedding_local_only` 等行为配置。复杂 YAML 不得用正则拆分后静默丢弃;无法无损处理时说明原因,并保留原文供源码编辑。 +6. 标签支持字符串、逗号分隔值和 YAML 列表,去重并保留顺序;中文、空格、转义字符须正确往返。空标签与删除标签有明确语义。 +7. 已存在 frontmatter 时合并到同一个属性块;字段值冲突时展示差异供用户选择,禁止静默覆盖。重复执行不重复添加标签或属性块。 + +### 2.3 编辑与保存行为 + +- 无歧义转换一次菜单操作完成,并构成一个可撤销的编辑事务;转换失败不得改变文档或保存状态。 +- 转换作用于当前内存文档,不先从磁盘读取旧内容覆盖未保存编辑。操作绑定文件标识和文档版本,异步处理期间切换文件或继续编辑时,应取消或重新校验。 +- 成功后进入现有脏状态和自动保存流程。磁盘保存失败显示可重试状态,撤销/重做同时恢复正文、属性及标签。 +- 属性块不进入正文大纲;标题跳转、引用定位仍使用完整原文件的正确偏移。写作/源码切换、保存后重开不得改变属性语义。 +- 当前分支的 `frontend/src/features/editor/noteMetadata.ts` 仅是简单属性块展示与标签编辑基础;桌面阶段需补齐完整解析、合并冲突、单事务撤销和原生菜单分发,不能直接视为本节已经验收。 + +## 3. 桌面基础需求 + +| 模块 | 第三阶段要求 | 验收要点 | +| --- | --- | --- | +| 窗口与菜单 | 原生窗口控制、顶部菜单、焦点分发、关闭前未保存处理 | 菜单操作针对活动编辑器;多窗口不串文档;取消关闭保留编辑 | +| Vault 与文件系统 | 原生目录选择、多 Vault、最近打开、文件监听、路径规范化 | 未授权目录不可访问;重命名同步树和打开文件;外部修改不静默覆盖 | +| 写入与恢复 | 原子写入、版本/内容摘要校验、失败重试和异常退出恢复 | 不产生半写文件;并发保存不覆盖新版本;恢复流程可验证 | +| AI Core Sidecar | 启停、健康检查、日志、崩溃恢复、退出清理 | 不残留进程;不可用时显示原因;本地通信有访问控制 | +| 凭据 | 按既有架构接入 Stronghold/平台安全存储,制定开发凭据迁移方案 | 前端只持有凭据引用;不回显密钥;失败可恢复且不丢凭据 | +| MCP 与插件 | 按已冻结的 Host 沙箱契约落实文件、网络和子进程授权 | 沿用审批边界,不因桌面集成默认放开权限 | +| 主题 | 复用主题包校验;原生文件选择和下载适配共用检查流程 | 导入不自动启用;安装失败可恢复;ZIP 路径和资源限制继续有效 | +| 外观与导航 | 继承主题、代码配色、相对纸页宽度、文件/大纲切换 | 窗口缩放、高 DPI、深浅主题下无截断;键盘导航完整 | +| 发布 | Windows、macOS、Linux 构建与安装验证;签名、升级及回滚方案 | 未准备好签名和回滚前不启用自动更新;平台差异有说明 | + +云同步服务、移动端和主题社区服务端不因本文自动纳入第三阶段必交范围;需要单独确认范围与接口。 + +## 4. 开发顺序与验收 + +1. 冻结 Host 能力与 Service 适配接口,明确每类数据的写入责任方及权限模型。 +2. 接入窗口、菜单与编辑命令路由,完成“段落 → 导入为笔记属性…”的编辑器事务。 +3. 接入 Vault、文件监听、冲突处理、Sidecar 和凭据迁移。 +4. 完成平台测试、安装包和升级恢复验收。 + +元数据导入专项测试至少覆盖:标准/历史格式、普通正文误判、代码围栏、未知字段、复杂 YAML、同名字段冲突、重复导入、中文标签、撤销重做、未保存文档、处理中切换文件、保存失败、重开后标签检索,以及写作/源码模式的大纲与引用偏移。 + +第三阶段实现 PR 必须补充实际 Command 名称、输入输出类型、错误码、平台差异和测试证据;在此之前本文所有 Host 能力均标为计划实现。 diff --git a/frontend/package.json b/frontend/package.json index 76a1adf..f6bfc1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,12 +35,14 @@ "@vueuse/core": "^14.0.0", "codemirror": "^6.0.0", "dompurify": "^3.4.14", + "fflate": "^0.8.3", "marked": "^15.0.0", "mermaid": "^11.17.2", "pinia": "^4.0.0", "shiki": "^4.4.3", "vue": "^3.5.0", - "vue-router": "^5.0.0" + "vue-router": "^5.0.0", + "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9e942b1..5a0cce2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: dompurify: specifier: ^3.4.14 version: 3.4.14 + fflate: + specifier: ^0.8.3 + version: 0.8.3 marked: specifier: ^15.0.0 version: 15.0.12 @@ -97,14 +100,17 @@ importers: version: 3.5.42(typescript@5.9.3) vue-router: specifier: ^5.0.0 - version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)) + version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.20.1 '@vitejs/plugin-vue': specifier: ^5.0.0 - version: 5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)) + version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@vue/test-utils': specifier: ^2.5.0 version: 2.5.0(@vue/compiler-dom@3.5.42)(@vue/server-renderer@3.5.42)(vue@3.5.42(typescript@5.9.3)) @@ -116,10 +122,10 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.3(@types/node@22.20.1) + version: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)) + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) vue-tsc: specifier: ^2.0.0 version: 2.2.12(typescript@5.9.3) @@ -954,11 +960,6 @@ packages: '@volar/typescript@2.4.15': resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true '@vue-macros/common@3.1.4': resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} @@ -1392,6 +1393,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2149,6 +2153,11 @@ packages: utf-8-validate: optional: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3258,9 +3267,9 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) vue: 3.5.42(typescript@5.9.3) '@vitest/expect@4.1.11': @@ -3272,13 +3281,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1))': + '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -3310,13 +3319,11 @@ snapshots: '@volar/source-map@2.4.15': {} - '@volar/typescript@2.4.15(typescript@5.9.3)': + '@volar/typescript@2.4.15': dependencies: '@volar/language-core': 2.4.15 path-browserify: 1.0.1 vscode-uri: 3.2.0 - optionalDependencies: - typescript: 5.9.3 '@vue-macros/common@3.1.4(vue@3.5.42(typescript@5.9.3))': dependencies: @@ -3805,6 +3812,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + fsevents@2.3.3: optional: true @@ -4679,7 +4688,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.7 - unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)): + unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 @@ -4687,7 +4696,7 @@ snapshots: optionalDependencies: esbuild: 0.25.12 rollup: 4.63.1 - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) uuid@14.0.2: {} @@ -4701,7 +4710,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.3(@types/node@22.20.1): + vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.7) @@ -4712,11 +4721,12 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + yaml: 2.9.0 - vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)): + vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1)) + '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4733,7 +4743,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -4745,7 +4755,7 @@ snapshots: vue-component-type-helpers@3.3.11: {} - vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)): + vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)): dependencies: '@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3)) '@vue/devtools-api': 8.2.1 @@ -4761,13 +4771,13 @@ snapshots: picomatch: 4.0.7 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) unplugin-utils: 0.3.2 vue: 3.5.42(typescript@5.9.3) optionalDependencies: '@vue/compiler-sfc': 3.5.42 pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -4780,7 +4790,7 @@ snapshots: vue-tsc@2.2.12(typescript@5.9.3): dependencies: - '@volar/typescript': 2.4.15(typescript@5.9.3) + '@volar/typescript': 2.4.15 '@vue/language-core': 2.2.12(typescript@5.9.3) typescript: 5.9.3 @@ -4807,4 +4817,6 @@ snapshots: ws@8.21.3: {} + yaml@2.9.0: {} + zwitch@2.0.4: {} diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme new file mode 100644 index 0000000..f92ee31 --- /dev/null +++ b/frontend/src/assets/themes/paper-moments.theme @@ -0,0 +1,268 @@ +theme_id: paper-moments +name: 纸间时光 · Paper Moments +version: 1.4.1 +author: NotesAgent +description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 +min_app_version: 0.2.0 +is_dark: false +css_entry: theme.css +license: MIT +--- +[data-theme="paper-moments"] { + color-scheme: light; + --color-background-primary: #faf7ee; + --color-background-secondary: #f3eee3; + --color-background-tertiary: #ece5d7; + --color-background-hover: #f1e5da; + --color-background-active: #ecdbd2; + --color-background-overlay: rgba(65, 55, 45, .35); + --color-surface-primary: #fffdf5; + --color-surface-secondary: #f7f1e5; + --color-surface-elevated: #fffdf7; + --color-text-primary: #493f35; + --color-text-secondary: #6e6053; + --color-text-tertiary: #7d6b5e; + --color-text-inverse: #fffdf5; + --color-text-link: #875343; + --color-text-disabled: #9c9081; + --color-accent-primary: #875343; + --color-accent-primary-hover: #704334; + --color-accent-primary-active: #5e382b; + --color-accent-secondary: #a77a67; + --color-accent-soft: #f3e1d8; + --color-accent-soft-hover: #ecd3c7; + --color-border-default: #b5a693; + --color-border-subtle: #ded5c5; + --color-border-focus: #875343; + --color-border-disabled: #e2dacc; + --color-success: #526849; + --color-success-soft: #e5ecd9; + --color-warning: #806323; + --color-warning-soft: #faf0cb; + --color-error: #a0423c; + --color-error-soft: #f8e2dc; + --color-info: #456671; + --color-info-soft: #e1eef0; + --color-markdown-grid: #ded5c5; + --color-markdown-marker: #a77a67; + --color-markdown-table-header: #eee7d7; + --shadow-sm: 2px 3px 0 #e5ded0; + --shadow-md: 3px 4px 0 #dae5df, 6px 7px 0 #f0d8cf; + --shadow-lg: 4px 5px 0 #dae5df, 8px 9px 0 #f0d8cf; + --shadow-xl: 5px 6px 0 #dae5df, 10px 11px 0 #f0d8cf, 0 18px 42px #493f3520; +} + +[data-theme="paper-moments"] body, +[data-theme="paper-moments"] .feature-page, +[data-theme="paper-moments"] .main-content { + background-color: var(--color-background-primary); + background-image: radial-gradient(#b5a69350 .8px, transparent .8px); + background-size: 20px 20px; +} + +[data-theme="paper-moments"] .feature-header { + flex-wrap: wrap; + position: relative; + padding: 24px; + margin-top: 12px; + border: 1px solid #685949; + outline: 1px dashed #b5a693; + outline-offset: -8px; + border-radius: 12px 5px 12px 5px; + background: #fffdf5; + box-shadow: var(--shadow-md); +} + +[data-theme="paper-moments"] .feature-header::before, +[data-theme="paper-moments"] .editor-preview::before { + content: ''; + position: absolute; + top: -10px; + left: 42%; + width: 86px; + height: 22px; + background: repeating-linear-gradient(45deg, #c5dfe0b0 0 8px, #daeceba0 8px 16px); + transform: rotate(-3deg); + pointer-events: none; +} + +[data-theme="paper-moments"] .feature-header h1, +[data-theme="paper-moments"] .panel-title, +[data-theme="paper-moments"] .preview-heading h3 { + color: #875343; + font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; + letter-spacing: .04em; +} + +[data-theme="paper-moments"] .panel, +[data-theme="paper-moments"] .item-card { + border-color: #b5a693; + border-radius: 8px; + box-shadow: var(--shadow-sm); +} + +[data-theme="paper-moments"] .theme-card:nth-child(3n + 1) { background: #f8e9e3; } +[data-theme="paper-moments"] .theme-card:nth-child(3n + 2) { background: #e8f0f0; } +[data-theme="paper-moments"] .theme-card:nth-child(3n) { background: #fbf3d8; } + +[data-theme="paper-moments"] .editor-preview { + position: relative; + border: 1px solid #685949; + border-radius: 4px 14px 4px 10px; + background-color: #fffef8; + background-image: linear-gradient(90deg, transparent 20px, #e9cfc780 20px 22px, transparent 22px), repeating-linear-gradient(transparent 0 31px, #b6c7bd55 31px 32px); + box-shadow: 4px 5px 0 #e3e9d7; +} + +[data-theme="paper-moments"] .editor-preview::before { + background: repeating-linear-gradient(45deg, #e7bcb3b0 0 8px, #f2d4cba0 8px 16px); +} + +[data-theme="paper-moments"] .modal { border-color: #685949; border-radius: 12px; } +[data-theme="paper-moments"] .upload-area { background: #fbf6e7; } +[data-theme="paper-moments"] .button-secondary { background: #fff9e5; } + +[data-theme="paper-moments"] .workspace-view, +[data-theme="paper-moments"] .visual-editor { + background: radial-gradient(#b5a69355 .8px, transparent .8px) 0 0 / 20px 20px #f3eee3; +} +[data-theme="paper-moments"] .secondary-sidebar { + background: #fff9e9; + border-right: 1px dashed #b5a693; +} +[data-theme="paper-moments"] .primary-sidebar { background: #f1e9dc; } +[data-theme="paper-moments"] .file-tree-panel { background: #fff9e9; } +[data-theme="paper-moments"] .workspace-tabs { background: #e5eeee; border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .workspace-tabs button[aria-selected="true"] { background: #f8e9e3; color: #875343; box-shadow: inset 0 -2px #a77a67; } +[data-theme="paper-moments"] .outline-filename { border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .file-tree-panel .toolbar, +[data-theme="paper-moments"] .sidebar-header { background: #e5eeee; border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .editor-header { background: #f8e9e3; border-bottom: 1px solid #b5a693; } +[data-theme="paper-moments"] .markdown-toolbar { background: #fff9e9; border-bottom: 1px dashed #b5a693; } +[data-theme="paper-moments"] .milkdown-host { padding: 30px 24px 40px; } +[data-theme="paper-moments"] .milkdown-host .milkdown { background: transparent; } +[data-theme="paper-moments"] .visual-editor .milkdown-host .ProseMirror { + width: 90%; + max-width: none; + position: relative; + min-height: calc(100vh - 220px); + padding: 44px 40px 60px 52px; + border: 1px solid #685949; + border-radius: 8px 16px 8px 8px; + outline: 1px dashed #c5b9a7; + outline-offset: -10px; + background: linear-gradient(90deg, transparent 32px, #e9cfc7 32px 34px, transparent 34px), #fffef8; + box-shadow: 6px 6px 0 #d8e6e2, 12px 12px 0 #f0d8cf; +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror::before { + content: ''; + position: absolute; + top: -11px; + left: calc(50% - 48px); + width: 96px; + height: 24px; + background: repeating-linear-gradient(45deg, #e7bcb3c0 0 8px, #f2d4cbc0 8px 16px); + transform: rotate(-3deg); + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror > p { + background-image: repeating-linear-gradient(transparent 0 calc(1lh - 1px), #b6c7bd55 calc(1lh - 1px) 1lh); +} +[data-theme="paper-moments"] .milkdown-host .ProseMirror > :is(h1, h2, h3) { color: #875343; } +[data-theme="paper-moments"] .editor-pane.source { margin: 20px; width: calc(100% - 40px); border: 1px solid #b5a693; border-radius: 8px; background: #fffef8; box-shadow: var(--shadow-md); } +@media (max-width: 720px) { + [data-theme="paper-moments"] .milkdown-host { padding: 20px 12px 28px; } + [data-theme="paper-moments"] .visual-editor .milkdown-host .ProseMirror { width: 100%; padding: 30px 18px 40px 38px; } +} + +/* Warm neutral surfaces preserve the contrast of the selected Shiki palette. */ +[data-theme="paper-moments"][data-code-theme="github-light"] { + --color-code-background: #f1ecdf; + --color-code-text: #302b25; + --color-code-muted: #6d6256; + --color-code-border: #b1a18b; +} +[data-theme="paper-moments"][data-code-theme="github-dark"] { + --color-code-background: #282723; + --color-code-text: #f1e9da; + --color-code-muted: #bdb19f; + --color-code-border: #786b59; +} +[data-theme="paper-moments"] .milkdown-host .milkdown-code-block { + position: relative; + padding-top: 34px; + padding-bottom: 30px; + border-color: var(--color-code-border); + box-shadow: 3px 4px 0 #d8cebd; +} +[data-theme="paper-moments"] .milkdown-code-block::before { + content: ''; + position: absolute; + top: 15px; + left: 18px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #c77768; + box-shadow: 18px 0 0 #c9a65d, 36px 0 0 #819b75; + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-code-block::after { + content: attr(data-language-label); + position: absolute; + right: 18px; + bottom: 9px; + max-width: calc(100% - 36px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-code-muted); + font: 600 12px/1.4 var(--font-ui-mono); + pointer-events: none; +} +[data-theme="paper-moments"] .milkdown-code-block .tools { margin-left: 72px; } +[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine, +[data-theme="paper-moments"] .milkdown-code-block .cm-activeLineGutter { background: color-mix(in srgb, var(--color-code-text) 7%, transparent); } + +[data-theme="paper-moments"] .note-metadata { + position: relative; + width: 90%; + margin: 8px auto 30px; + padding: 24px 30px; + border: 1px solid #887460; + border-radius: 8px 14px 8px 8px; + outline: 1px dashed #c5b9a7; + outline-offset: -8px; + background: linear-gradient(110deg, #fffdf5, #fbf5e4); + box-shadow: 4px 5px 0 #d8e6e2, 8px 9px 0 #f0d8cf; +} +[data-theme="paper-moments"] .note-metadata::before { + content: ''; + position: absolute; + top: -10px; + right: 36px; + width: 78px; + height: 22px; + background: repeating-linear-gradient(45deg, #c5dfe0c0 0 8px, #daecebb0 8px 16px); + transform: rotate(3deg); + pointer-events: none; +} +[data-theme="paper-moments"] .metadata-caption { color: #806b58; letter-spacing: .12em; } +[data-theme="paper-moments"] .note-metadata h1 { + margin: 12px 0 18px; + color: #875343; + font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; + font-size: clamp(20px, 2vw, 28px); + line-height: 1.4; +} +[data-theme="paper-moments"] .metadata-tags { padding-top: 14px; border-top: 1px dashed #c5b9a7; gap: 8px; } +[data-theme="paper-moments"] .metadata-tag { border: 1px solid #d6b5a8; border-radius: 5px; background: #f5e3da; color: #704b3d; } +[data-theme="paper-moments"] .metadata-tag:nth-of-type(2n + 1) { border-color: #b5cdcf; background: #e5eeee; color: #456671; } +[data-theme="paper-moments"] .metadata-tag button { border-radius: 3px; cursor: pointer; } +[data-theme="paper-moments"] .metadata-tag button:hover { background: #ffffff80; } +[data-theme="paper-moments"] .metadata-tags input { border-color: #b5a693; background: #fffdf580; } +[data-theme="paper-moments"] .metadata-tags form button { padding: 4px 10px; border: 1px solid #b5a693; border-radius: 5px; background: #f7edce; color: #704b3d; cursor: pointer; } +[data-theme="paper-moments"] .metadata-tags button:focus-visible { outline: 2px solid #875343; outline-offset: 2px; } +@media (max-width: 720px) { + [data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; } +} diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue index 5fc08ac..dd11226 100644 --- a/frontend/src/components/common/MarkdownContent.vue +++ b/frontend/src/components/common/MarkdownContent.vue @@ -11,11 +11,11 @@ let renderVersion = 0 const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light')) // 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。 -watch([() => props.source, diagramTheme], async ([source, theme]) => { +watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async ([source, theme]) => { const version = ++renderVersion const result = await renderMarkdown(source, { theme }) if (version === renderVersion) html.value = result -}, { immediate: true }) +}, { immediate: true, flush: 'post' }) @@ -273,6 +341,20 @@ defineExpose({ getEditor: () => crepe?.editor }) .toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); } .milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); } .milkdown-host.loading { visibility: hidden; } +.note-metadata { box-sizing: border-box; width: 90%; margin: 0 auto 20px; padding: 20px 24px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); } +.metadata-caption { color: var(--color-text-secondary); font-size: var(--font-size-xs); } +.note-metadata h1 { margin: 10px 0 16px; font-size: 24px; color: var(--color-text-primary); overflow-wrap: anywhere; } +.metadata-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.metadata-label { margin-right: 4px; color: var(--color-text-secondary); font-size: var(--font-size-sm); } +.metadata-tag { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border-radius: var(--radius-full); background: var(--color-accent-soft); color: var(--color-accent-primary); font-size: var(--font-size-sm); } +.metadata-tag > span { overflow-wrap: anywhere; min-width: 0; } +.metadata-tag button { color: inherit; padding: 0 3px; } +.metadata-tags form { display: flex; gap: 6px; } +.metadata-tags input { width: 110px; padding: 5px 8px; border: 1px dashed var(--color-border-default); border-radius: var(--radius-sm); background: transparent; color: var(--color-text-primary); } +.metadata-tags input:focus { outline: 2px solid var(--color-border-focus); } +.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); } +.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; } +.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; } .editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); } .milkdown-host :deep(.milkdown) { min-height: 100%; diff --git a/frontend/src/features/editor/codeBlockLabels.spec.ts b/frontend/src/features/editor/codeBlockLabels.spec.ts new file mode 100644 index 0000000..982c5c7 --- /dev/null +++ b/frontend/src/features/editor/codeBlockLabels.spec.ts @@ -0,0 +1,18 @@ +// @vitest-environment happy-dom +import { expect, it } from 'vitest' +import { installCodeBlockLabels } from './codeBlockLabels' + +it('keeps footer labels in sync when the language changes and stops after disposal', async () => { + const root = document.createElement('div') + root.innerHTML = '
' + const dispose = installCodeBlockLabels(root) + const block = root.firstElementChild as HTMLElement + expect(block.dataset.languageLabel).toBe('Python') + block.querySelector('button')!.textContent = 'TypeScript' + await new Promise(resolve => setTimeout(resolve, 0)) + expect(block.dataset.languageLabel).toBe('TypeScript') + dispose() + block.querySelector('button')!.textContent = 'Rust' + await new Promise(resolve => setTimeout(resolve, 0)) + expect(block.dataset.languageLabel).toBe('TypeScript') +}) diff --git a/frontend/src/features/editor/codeBlockLabels.ts b/frontend/src/features/editor/codeBlockLabels.ts new file mode 100644 index 0000000..a1f10b5 --- /dev/null +++ b/frontend/src/features/editor/codeBlockLabels.ts @@ -0,0 +1,11 @@ +/** Mirror the live picker label for theme decorations without changing Markdown. */ +export function installCodeBlockLabels(root: HTMLElement): () => void { + const sync = () => root.querySelectorAll('.milkdown-code-block').forEach(block => { + const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text' + if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label + }) + const observer = new MutationObserver(sync) + observer.observe(root, { subtree: true, childList: true, characterData: true }) + sync() + return () => observer.disconnect() +} diff --git a/frontend/src/features/editor/mermaidPreview.spec.ts b/frontend/src/features/editor/mermaidPreview.spec.ts new file mode 100644 index 0000000..951c739 --- /dev/null +++ b/frontend/src/features/editor/mermaidPreview.spec.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { renderMermaid } from '@/services/mermaidService' +import { createMermaidPreview } from './mermaidPreview' + +vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn() })) + +it('renders SVG with the requested theme and keeps async revisions isolated', async () => { + let finish!: (value: any) => void + vi.mocked(renderMermaid).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: 'new', warnings: [], width: 10, height: 10 }) + const oldPublish = vi.fn() + const latestPublish = vi.fn() + const old = createMermaidPreview('graph TD; A-->B', false, oldPublish) + const latest = createMermaidPreview('graph TD; A-->C', true, latestPublish) + document.body.append(latest.cloneNode(true)) + await flushPromises() + finish({ svg: 'old', warnings: [] }) + await flushPromises() + expect(latest.querySelector('svg')?.textContent).toBe('new') + expect(old.querySelector('svg')?.textContent).toBe('old') + expect(oldPublish).not.toHaveBeenCalled() + expect(latestPublish).toHaveBeenCalledWith(latest) + expect(latestPublish.mock.calls[0]![0]).not.toBe(latest) + document.getElementById(latest.id)?.remove() + expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' }) +}) + +it('shows syntax errors as text without executing markup', async () => { + vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: [''], width: 0, height: 0 }) + const preview = createMermaidPreview('invalid', false, vi.fn()) + await flushPromises() + expect(preview.classList.contains('has-error')).toBe(true) + expect(preview.querySelector('img')).toBeNull() + expect(preview.textContent).toContain('点击编辑') +}) diff --git a/frontend/src/features/editor/mermaidPreview.ts b/frontend/src/features/editor/mermaidPreview.ts new file mode 100644 index 0000000..64c6e36 --- /dev/null +++ b/frontend/src/features/editor/mermaidPreview.ts @@ -0,0 +1,39 @@ +import { nextTick } from 'vue' +import { renderMermaid } from '@/services/mermaidService' +import { t } from '@/i18n' + +let previewId = 0 +export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement { + // Each revision owns its element, so a slow render cannot replace newer content. + const container = document.createElement('div') + container.className = 'editor-mermaid-preview' + container.id = `editor-mermaid-preview-${++previewId}` + container.setAttribute('aria-live', 'polite') + container.textContent = t('正在渲染图表…', 'Rendering diagram…') + const publish = async () => { + await nextTick() + // Milkdown sanitizes and copies this element. Publish only if its revision + // still exists; edits, language changes and unmounts remove the old marker. + const visible = document.getElementById(container.id) + if (visible) { + // PreviewPanel copies HTML instead of retaining the supplied element. + // Update the current copy through Milkdown's reactive callback. + applyPreview(container.cloneNode(true) as HTMLElement) + } + } + void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => { + if (result.warnings.length) { + container.classList.add('has-error') + container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}` + void publish() + return + } + // Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion. + container.innerHTML = result.svg + void publish() + }).catch(() => { + container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.') + void publish() + }) + return container +} diff --git a/frontend/src/features/editor/noteMetadata.spec.ts b/frontend/src/features/editor/noteMetadata.spec.ts new file mode 100644 index 0000000..a48473e --- /dev/null +++ b/frontend/src/features/editor/noteMetadata.spec.ts @@ -0,0 +1,54 @@ +import { expect, it } from 'vitest' +import { parseDocument } from 'yaml' +import { splitNoteMetadata, updateMetadataTags } from './noteMetadata' + +it('renders legacy properties and saves real frontmatter without losing other fields', () => { + const note = '***\n\ntitle: Python\ntags: python, 编程\nembedding_local_only: true\n----------------\n\n# 正文\n' + const metadata = splitNoteMetadata(note)! + expect(metadata.tags).toEqual(['python', '编程']) + expect(metadata.body).toBe('\n# 正文\n') + const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习']) + expect(prefix).toContain('embedding_local_only: true') + expect(prefix.startsWith('---\n')).toBe(true) + expect(splitNoteMetadata(prefix + metadata.body)!.tags).toEqual(['编程', '学习']) +}) + +it('does not mistake ordinary Markdown for metadata', () => { + expect(splitNoteMetadata('---\nA paragraph\n---\n')).toBeNull() +}) + +it.each(['- python\n- rust', ' - python\n - rust', '[python, rust]'])('replaces the complete YAML tag list: %s', (list) => { + const metadata = splitNoteMetadata(`---\ntitle: Demo\ntags:\n${list.startsWith('[') ? ' ' : ''}${list}\nextra:\n enabled: true # keep this\n---\n# Body\n`)! + expect(metadata.tags).toEqual(['python', 'rust']) + const prefix = updateMetadataTags(metadata, [...metadata.tags, 'new']) + const updated = splitNoteMetadata(prefix + metadata.body)! + expect(updated.tags).toEqual(['python', 'rust', 'new']) + expect(updated.body).toBe('# Body\n') + const document = parseDocument(updated.yaml) + expect(document.errors).toEqual([]) + expect(document.toJS().extra).toEqual({ enabled: true }) + expect(prefix).toContain('# keep this') + expect(splitNoteMetadata(updateMetadataTags(updated, []))!.tags).toEqual([]) +}) + +it('preserves quoted commas, escapes, multiline titles and nested properties', () => { + const tags = ['a,b', 'quote"tag', 'path\\tag', 'true'] + const metadata = splitNoteMetadata(`---\ntitle: |\n A multiline\n title\ntags: ${JSON.stringify(tags)}\nextra: {count: 2, enabled: false}\n---\n正文`)! + expect(metadata.tags).toEqual(tags) + const updated = splitNoteMetadata(updateMetadataTags(metadata, tags) + metadata.body)! + expect(updated.tags).toEqual(tags) + expect(updated.title).toBe(metadata.title) + expect(parseDocument(updated.yaml).toJS().extra).toEqual({ count: 2, enabled: false }) +}) + +it('preserves document encoding markers and tag anchors', () => { + const metadata = splitNoteMetadata('\uFEFF---\r\ntitle: Demo\r\ntags: &labels [python]\r\nrelated: *labels\r\n---\r\nBody')! + const prefix = updateMetadataTags(metadata, ['rust']) + expect(prefix.startsWith('\uFEFF---\r\n')).toBe(true) + expect(prefix.replace(/\r\n/g, '')).not.toContain('\n') + expect(parseDocument(splitNoteMetadata(prefix)!.yaml).toJS().related).toEqual(['rust']) +}) + +it.each(['tags: [broken', 'tags: [one]\ntags: [two]', 'tags: {nested: value}', 'tags: [1, true]', 'tags: [&label python]\nother: *label'])('leaves invalid or unsupported tag data in source mode: %s', (yaml) => { + expect(splitNoteMetadata(`---\ntitle: Demo\n${yaml}\n---\nBody`)).toBeNull() +}) diff --git a/frontend/src/features/editor/noteMetadata.ts b/frontend/src/features/editor/noteMetadata.ts new file mode 100644 index 0000000..a443081 --- /dev/null +++ b/frontend/src/features/editor/noteMetadata.ts @@ -0,0 +1,2 @@ +export { splitNoteMetadata, updateMetadataTags } from '@/utils/noteMetadata' +export type { NoteMetadata } from '@/utils/noteMetadata' diff --git a/frontend/src/features/themes/CommunityThemePreview.vue b/frontend/src/features/themes/CommunityThemePreview.vue index b1e5ecb..f643ecf 100644 --- a/frontend/src/features/themes/CommunityThemePreview.vue +++ b/frontend/src/features/themes/CommunityThemePreview.vue @@ -16,10 +16,15 @@ const previewDocument = computed(() => { style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }` doc.head.append(style) const article = doc.createElement('article') + article.className = 'panel' + const header = doc.createElement('header'); header.className = 'feature-header' const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId + header.append(heading) + const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;' const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button') - article.append(heading, text, button); doc.body.append(article) + journal.append(text) + article.append(header, journal, button); doc.body.append(article) return '' + doc.documentElement.outerHTML }) diff --git a/frontend/src/features/themes/ThemesView.spec.ts b/frontend/src/features/themes/ThemesView.spec.ts index e913295..9ad8e1b 100644 --- a/frontend/src/features/themes/ThemesView.spec.ts +++ b/frontend/src/features/themes/ThemesView.spec.ts @@ -5,10 +5,62 @@ import { createPinia, setActivePinia } from 'pinia' import ThemesView from './ThemesView.vue' import { useThemeStore } from '@/stores/theme' import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService' +import paperPackage from '@/assets/themes/paper-moments.theme?raw' let wrapper: VueWrapper beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) }) -afterEach(() => { wrapper?.unmount(); vi.useRealTimers() }) +afterEach(() => { useThemeStore().applyTheme('light'); wrapper?.unmount(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers() }) + +it('downloads a URL for inspection without automatically installing it', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(paperPackage))) + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme') + await wrapper.get('.url-import').trigger('submit') + await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true)) + expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false) + expect(wrapper.get('.inspection-result').text()).toContain('纸间时光') +}) + +it('ignores a URL response after the dialog is cancelled', async () => { + let respond!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { respond = resolve }))) + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme') + await wrapper.get('.url-import').trigger('submit') + await wrapper.get('.import-modal .inline-actions button').trigger('click') + respond(new Response(paperPackage)) + await flushPromises() + expect(useThemeStore().pendingInspection).toBeNull() + expect(wrapper.find('.import-modal').exists()).toBe(false) +}) + +it('opens the file picker from the styled button and imports the actual paper theme', async () => { + const store = useThemeStore() + wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click') + const input = wrapper.get('input[type="file"]') + const click = vi.spyOn(input.element, 'click').mockImplementation(() => {}) + await wrapper.get('.upload-area .button-primary').trigger('click') + expect(click).toHaveBeenCalledOnce() + Object.defineProperty(input.element, 'files', { value: [new File([paperPackage], 'paper-moments.theme', { type: 'text/plain' })] }) + await input.trigger('change') + await vi.waitFor(() => expect(store.pendingInspection?.compatible).toBe(true)) + expect(store.pendingInspection!.warnings).toEqual([]) + await wrapper.get('.import-modal .inline-actions .button-primary').trigger('click') + await flushPromises() + expect(store.isThemeInstalled('paper-moments')).toBe(true) + expect(localStorage.getItem('installed-themes-css-paper-moments')).toBe(getCommunityThemePreviewCss('paper-moments')) + expect(document.getElementById('theme-style-paper-moments')).toBeNull() + store.applyTheme('paper-moments') + expect(document.getElementById('theme-style-paper-moments')!.textContent).toBe(getCommunityThemePreviewCss('paper-moments')) + store.applyTheme('light') + expect(document.getElementById('theme-style-paper-moments')).toBeNull() +}) it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => { const store = useThemeStore() diff --git a/frontend/src/features/themes/ThemesView.vue b/frontend/src/features/themes/ThemesView.vue index f1b4c72..5f60437 100644 --- a/frontend/src/features/themes/ThemesView.vue +++ b/frontend/src/features/themes/ThemesView.vue @@ -1,19 +1,58 @@