Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c5f81d49 | ||
|
|
124024a547 | ||
|
|
b87f94551b | ||
|
|
50d7fb4c7d | ||
|
|
04f36524b1 | ||
|
|
054f704c8b | ||
|
|
02dd585a4e | ||
|
|
8692910508 | ||
|
|
a63f6c57e0 | ||
|
|
d5b1050a86 | ||
|
|
311ea4a8ac | ||
|
|
f49d1245a1 | ||
|
|
64af1f5165 | ||
|
|
7eae7fba00 | ||
|
|
5c2441464d |
@@ -18,6 +18,8 @@ backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
|
||||
backend/data/exports/
|
||||
# 阶段验收笔记(验收用,不提交)
|
||||
backend/data/vault/验收/
|
||||
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
|
||||
|
||||
@@ -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` 稳定。
|
||||
|
||||
@@ -25,6 +25,7 @@ class Settings:
|
||||
vault_path: Path
|
||||
attachments_path: Path
|
||||
benchmark_datasets_path: Path
|
||||
exports_path: Path
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -45,4 +46,5 @@ def get_settings() -> Settings:
|
||||
benchmark_datasets_path=Path(
|
||||
os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks"))
|
||||
),
|
||||
exports_path=Path(os.getenv("APP_EXPORTS_PATH", str(data_dir / "exports"))),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,14 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SecretStr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from app.request_overrides import RequestOverride
|
||||
|
||||
|
||||
@@ -1273,3 +1280,88 @@ class BenchmarkReport(Contract):
|
||||
cases: list[RAGCaseResult] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
# Export(多格式文档导出)
|
||||
class ExportStatus(str, Enum):
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class ExportFormat(str, Enum):
|
||||
html = "html"
|
||||
pdf = "pdf"
|
||||
docx = "docx"
|
||||
|
||||
|
||||
class ExportSourceType(str, Enum):
|
||||
note = "note"
|
||||
markdown = "markdown"
|
||||
|
||||
|
||||
class ExportSource(Contract):
|
||||
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
|
||||
|
||||
type: ExportSourceType
|
||||
note_id: str | None = None
|
||||
markdown: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> "ExportSource":
|
||||
if self.type == ExportSourceType.note and not self.note_id:
|
||||
raise ValueError("note source requires note_id")
|
||||
if self.type == ExportSourceType.markdown and not self.markdown:
|
||||
raise ValueError("markdown source requires markdown")
|
||||
return self
|
||||
|
||||
|
||||
class ExportOptions(Contract):
|
||||
theme_id: str = "light"
|
||||
include_title: bool = True
|
||||
include_metadata: bool = False
|
||||
page_size: str = "A4"
|
||||
code_theme: str = "github-light"
|
||||
|
||||
|
||||
class ExportRequest(Contract):
|
||||
source: ExportSource
|
||||
format: ExportFormat
|
||||
options: ExportOptions = Field(default_factory=ExportOptions)
|
||||
|
||||
|
||||
class ExportProgress(Contract):
|
||||
phase: str
|
||||
current: int
|
||||
total: int
|
||||
percent: float | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ExportFile(Contract):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
size: int
|
||||
sha256: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ExportJob(Contract):
|
||||
job_id: str
|
||||
status: ExportStatus
|
||||
format: ExportFormat
|
||||
progress: ExportProgress | None = None
|
||||
file: ExportFile | None = None
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
created_at: datetime
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class ExportJobListResponse(Contract):
|
||||
items: list[ExportJob] = Field(default_factory=list)
|
||||
page: PageMeta = Field(default_factory=PageMeta)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Export Service:多格式文档导出(首批 HTML)。
|
||||
|
||||
模块划分:
|
||||
- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
- markdown.py mistune → Document AST 解析
|
||||
- exporters/html.py HtmlExporter(Document AST → HTML5)
|
||||
- service.py 导出任务注册表、后台执行、取消与文件生命周期
|
||||
"""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Document AST:导出器的内部中间表示(Internal Protocol,不放入 contracts.py)。
|
||||
|
||||
契约 §10.3 规定节点用稳定判别字段 node_id / type / attributes / children / text,
|
||||
类型专有信息统一放 attributes(如 heading 的 level、link 的 href、image 的 src)。
|
||||
导出器据此递归渲染,对无法表示的节点记 warning,不静默丢弃。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
|
||||
|
||||
class DocumentNode(BaseModel):
|
||||
"""递归文档节点;type 取契约 §10.3 首批 node type 之一。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str
|
||||
node_id: str
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
children: list["DocumentNode"] = Field(default_factory=list)
|
||||
text: str = ""
|
||||
|
||||
|
||||
class Document(DocumentNode):
|
||||
"""根节点,type 固定为 document。"""
|
||||
|
||||
type: str = "document"
|
||||
|
||||
|
||||
class DocumentExporter(Protocol):
|
||||
"""导出器协议(契约 §10.3):把 Document AST 渲染为指定格式的产物。"""
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> "ExportResult": ...
|
||||
|
||||
|
||||
class ExportResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: bytes
|
||||
mime_type: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1 @@
|
||||
"""Export 渲染器:Document AST → 具体格式产物。"""
|
||||
@@ -0,0 +1,296 @@
|
||||
"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。
|
||||
|
||||
mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot
|
||||
解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以
|
||||
EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.plot.parser import parse_source
|
||||
from app.plot.render import render_svg
|
||||
|
||||
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
|
||||
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
||||
|
||||
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
|
||||
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
|
||||
|
||||
# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
|
||||
_MAX_FUNCTION_PLOTS = 16
|
||||
# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
|
||||
# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
|
||||
_MAX_TOTAL_PLOT_NODES = 8000
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str | None:
|
||||
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return None
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme and scheme not in _ALLOWED_URL_SCHEMES:
|
||||
return None
|
||||
return url
|
||||
|
||||
_BASE_CSS = """
|
||||
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
|
||||
article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; }
|
||||
article.theme-dark { background: #0d1117; color: #c9d1d9; }
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
|
||||
h1.title { margin-top: 0; }
|
||||
p { margin: 0.6em 0; }
|
||||
a { color: #0969da; }
|
||||
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; }
|
||||
pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
|
||||
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
|
||||
pre code { background: none; padding: 0; }
|
||||
pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
|
||||
figure.function-plot { margin: 1em 0; text-align: center; }
|
||||
figure.function-plot svg { max-width: 100%; height: auto; }
|
||||
blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
|
||||
img { max-width: 100%; }
|
||||
table { border-collapse: collapse; margin: 0.8em 0; }
|
||||
th, td { border: 1px solid #d0d7de; padding: 6px 12px; }
|
||||
th { background: #f6f8fa; }
|
||||
dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; }
|
||||
dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; }
|
||||
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
|
||||
.math, .math-block { overflow-x: auto; padding: 0.4em 0; }
|
||||
.task-list-item { list-style: none; }
|
||||
.task-list-item input { margin-right: 0.4em; }
|
||||
hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
|
||||
""".strip()
|
||||
|
||||
|
||||
class HtmlExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
self._options = options
|
||||
self._plot_count = 0
|
||||
self._plot_nodes = 0
|
||||
warnings: list[str] = []
|
||||
body = self._render_children(document.children, warnings)
|
||||
content = self._assemble(document, options, body, warnings)
|
||||
return ExportResult(
|
||||
content=content.encode("utf-8"), mime_type="text/html", warnings=warnings
|
||||
)
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
|
||||
return self.render(document, options)
|
||||
|
||||
def _assemble(
|
||||
self, document: Document, options: ExportOptions, body: str, warnings: list[str]
|
||||
) -> str:
|
||||
title = str(document.attributes.get("title") or "")
|
||||
parts = [
|
||||
"<!doctype html>",
|
||||
'<html lang="zh-CN">',
|
||||
"<head>",
|
||||
'<meta charset="utf-8">',
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
]
|
||||
if title:
|
||||
parts.append(f"<title>{html.escape(title)}</title>")
|
||||
parts.append(f"<style>{_BASE_CSS}</style>")
|
||||
parts.append("</head>")
|
||||
parts.append("<body>")
|
||||
parts.append(f'<article class="theme-{html.escape(options.theme_id)}">')
|
||||
if options.include_title and title:
|
||||
parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
|
||||
if options.include_metadata:
|
||||
metadata = document.attributes.get("metadata")
|
||||
if metadata:
|
||||
parts.append(self._render_metadata(metadata))
|
||||
parts.append(body)
|
||||
parts.append("</article>")
|
||||
parts.append("</body>")
|
||||
parts.append("</html>")
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
def _render_metadata(self, metadata: dict) -> str:
|
||||
entries = ["<dl", ' class="metadata">']
|
||||
for key, value in metadata.items():
|
||||
entries.append(f"<dt>{html.escape(str(key))}</dt>")
|
||||
entries.append(f"<dd>{html.escape(self._fmt_meta_value(value))}</dd>")
|
||||
entries.append("</dl>")
|
||||
return "".join(entries)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_meta_value(value: object) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> str:
|
||||
return "".join(self._render_node(child, warnings) for child in children)
|
||||
|
||||
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
handler = getattr(self, f"_render_{node.type}", None)
|
||||
if handler is not None:
|
||||
return handler(node, warnings)
|
||||
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
|
||||
return ""
|
||||
|
||||
# --- 块级 ---
|
||||
def _render_heading(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
level = max(1, min(6, int(node.attributes.get("level", 1))))
|
||||
return f"<h{level}>{self._render_children(node.children, warnings)}</h{level}>"
|
||||
|
||||
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<p>{self._render_children(node.children, warnings)}</p>"
|
||||
|
||||
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
|
||||
|
||||
def _render_list(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "ol" if node.attributes.get("ordered") else "ul"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
inner = self._render_children(node.children, warnings)
|
||||
if node.attributes.get("task"):
|
||||
checked = " checked" if node.attributes.get("checked") else ""
|
||||
return (
|
||||
'<li class="task-list-item">'
|
||||
f'<input type="checkbox" disabled{checked}>{inner}</li>'
|
||||
)
|
||||
return f"<li>{inner}</li>"
|
||||
|
||||
def _render_table(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
rows = node.children
|
||||
head_rows = [r for r in rows if r.attributes.get("head")]
|
||||
body_rows = [r for r in rows if not r.attributes.get("head")]
|
||||
parts = ["<table>"]
|
||||
if head_rows:
|
||||
parts.append("<thead>")
|
||||
parts.extend(self._render_node(r, warnings) for r in head_rows)
|
||||
parts.append("</thead>")
|
||||
if body_rows:
|
||||
parts.append("<tbody>")
|
||||
parts.extend(self._render_node(r, warnings) for r in body_rows)
|
||||
parts.append("</tbody>")
|
||||
parts.append("</table>")
|
||||
return "".join(parts)
|
||||
|
||||
def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<tr>{self._render_children(node.children, warnings)}</tr>"
|
||||
|
||||
def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "th" if node.attributes.get("head") else "td"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
lang = str(node.attributes.get("language") or "")
|
||||
code = html.escape(node.text)
|
||||
lang_cls = f' class="language-{html.escape(lang)}"' if lang else ""
|
||||
theme = html.escape(self._options.code_theme)
|
||||
return f'<pre class="code-theme-{theme}"><code{lang_cls}>{code}</code></pre>'
|
||||
|
||||
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return "<hr>"
|
||||
|
||||
def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
warnings.append(_MERMAID_WARNING)
|
||||
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
||||
|
||||
@staticmethod
|
||||
def _format_plot_diagnostic(diag) -> str:
|
||||
loc = f"(第 {diag.line} 行)" if diag.line else ""
|
||||
return f"函数图像:{diag.message}{loc}"
|
||||
|
||||
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
|
||||
self._plot_count += 1
|
||||
if self._plot_count > _MAX_FUNCTION_PLOTS:
|
||||
warnings.append(
|
||||
f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位"
|
||||
)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
|
||||
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
|
||||
try:
|
||||
parsed = parse_source(node.text)
|
||||
for diag in parsed.diagnostics:
|
||||
warnings.append(self._format_plot_diagnostic(diag))
|
||||
if parsed.plot is None:
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
|
||||
if self._plot_nodes + parsed.plot.node_count > _MAX_TOTAL_PLOT_NODES:
|
||||
warnings.append(
|
||||
f"函数图像:文档内函数图像累计复杂度超过上限 {_MAX_TOTAL_PLOT_NODES} 节点,已回退为源码占位"
|
||||
)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
self._plot_nodes += parsed.plot.node_count
|
||||
rendered = render_svg(parsed.plot)
|
||||
except Exception as exc:
|
||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
warnings.extend(rendered.warnings)
|
||||
return f'<figure class="function-plot">{rendered.content}</figure>'
|
||||
|
||||
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
|
||||
|
||||
def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
# 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险
|
||||
warnings.append(_RAW_HTML_WARNING)
|
||||
return f'<div class="raw-html">{html.escape(node.text)}</div>'
|
||||
|
||||
# --- 行内 ---
|
||||
def _render_text(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return html.escape(node.text)
|
||||
|
||||
def _render_emphasis(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<em>{self._render_children(node.children, warnings)}</em>"
|
||||
|
||||
def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<strong>{self._render_children(node.children, warnings)}</strong>"
|
||||
|
||||
def _render_link(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
inner = self._render_children(node.children, warnings)
|
||||
href = str(node.attributes.get("href") or "")
|
||||
safe_href = _safe_url(href)
|
||||
if safe_href is None:
|
||||
# 危险协议(如 javascript:)降级为纯文本,不输出可点击链接
|
||||
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
||||
return inner
|
||||
title = str(node.attributes.get("title") or "")
|
||||
attrs = [f'href="{html.escape(safe_href)}"']
|
||||
if title:
|
||||
attrs.append(f'title="{html.escape(title)}"')
|
||||
return f"<a {' '.join(attrs)}>{inner}</a>"
|
||||
|
||||
def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<code>{html.escape(node.text)}</code>"
|
||||
|
||||
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
src = str(node.attributes.get("src") or "")
|
||||
alt = str(node.attributes.get("alt") or "")
|
||||
safe_src = _safe_url(src)
|
||||
if safe_src is None:
|
||||
# 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本
|
||||
warnings.append(f"图片地址不安全,已跳过:{src!r}")
|
||||
return html.escape(alt) if alt else ""
|
||||
title = str(node.attributes.get("title") or "")
|
||||
attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"']
|
||||
if title:
|
||||
attrs.append(f'title="{html.escape(title)}"')
|
||||
return f"<img {' '.join(attrs)}>"
|
||||
|
||||
def _render_math_inline(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"\\({html.escape(node.text)}\\)"
|
||||
|
||||
def _render_linebreak(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return "<br>"
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Markdown → Document AST:用 mistune 的 ast renderer 产出通用 token,再映射为内部节点。
|
||||
|
||||
选用 mistune 内置 'ast' renderer 而非自写 BaseRenderer,是因为 mistune 的行内渲染按
|
||||
字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token
|
||||
树,映射层只做 token → DocumentNode 的搬运,不掺入任何 HTML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mistune
|
||||
|
||||
from app.export.document import Document, DocumentNode
|
||||
|
||||
_PLUGINS = ["table", "math", "url", "task_lists"]
|
||||
|
||||
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块
|
||||
_MERMAID_LANG = "mermaid"
|
||||
_FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"}
|
||||
|
||||
|
||||
def parse_document(markdown: str) -> Document:
|
||||
"""把 Markdown 文本解析为 Document AST 根节点。"""
|
||||
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
|
||||
tokens = renderer(markdown)
|
||||
mapper = _AstMapper()
|
||||
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
|
||||
|
||||
|
||||
class _AstMapper:
|
||||
"""token 树 → DocumentNode 树的映射器;node_id 按遍历顺序递增,无需跨请求稳定。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._seq = 0
|
||||
|
||||
def next_id(self) -> str:
|
||||
self._seq += 1
|
||||
return f"node_{self._seq:03d}"
|
||||
|
||||
def map_blocks(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_block(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_block(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "heading":
|
||||
return DocumentNode(
|
||||
type="heading",
|
||||
node_id=self.next_id(),
|
||||
attributes={"level": token["attrs"]["level"]},
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind in ("paragraph", "block_text"):
|
||||
# block_text 是列表项内的段落块,仍按 paragraph 表达,由 list_item 包裹
|
||||
return DocumentNode(
|
||||
type="paragraph",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "list":
|
||||
return DocumentNode(
|
||||
type="list",
|
||||
node_id=self.next_id(),
|
||||
attributes={"ordered": bool(token.get("attrs", {}).get("ordered"))},
|
||||
children=[self.map_list_item(child) for child in token.get("children", [])],
|
||||
)
|
||||
if kind == "block_code":
|
||||
return self._map_code(token)
|
||||
if kind == "block_quote":
|
||||
return DocumentNode(
|
||||
type="blockquote",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_blocks(token.get("children", [])),
|
||||
)
|
||||
if kind == "table":
|
||||
return self._map_table(token)
|
||||
if kind == "block_math":
|
||||
return DocumentNode(
|
||||
type="math_block", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "thematic_break":
|
||||
return DocumentNode(type="thematic_break", node_id=self.next_id())
|
||||
if kind == "blank_line":
|
||||
return None
|
||||
if kind == "block_html":
|
||||
# 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文
|
||||
return DocumentNode(
|
||||
type="html_block", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
# 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(
|
||||
type="paragraph",
|
||||
node_id=self.next_id(),
|
||||
children=[DocumentNode(type="text", node_id=self.next_id(), text=raw)],
|
||||
)
|
||||
return None
|
||||
|
||||
def map_list_item(self, token: dict) -> DocumentNode:
|
||||
"""列表项:block_text 展平为行内子节点,嵌套 list 保留为子节点。"""
|
||||
attributes: dict = {}
|
||||
if token["type"] == "task_list_item":
|
||||
attributes = {"task": True, "checked": bool(token.get("attrs", {}).get("checked"))}
|
||||
children: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "block_text":
|
||||
children.extend(self.map_inline(child.get("children", [])))
|
||||
elif child["type"] == "list":
|
||||
children.append(self.map_block(child))
|
||||
else:
|
||||
node = self.map_block(child)
|
||||
if node is not None:
|
||||
children.append(node)
|
||||
return DocumentNode(
|
||||
type="list_item", node_id=self.next_id(), attributes=attributes, children=children
|
||||
)
|
||||
|
||||
def map_inline(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_inline_token(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_inline_token(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "text":
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "strong":
|
||||
return DocumentNode(
|
||||
type="strong", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "emphasis":
|
||||
return DocumentNode(
|
||||
type="emphasis", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "link":
|
||||
attrs = token.get("attrs", {})
|
||||
attributes = {"href": attrs.get("url", "")}
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(
|
||||
type="link", node_id=self.next_id(), attributes=attributes,
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "codespan":
|
||||
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "image":
|
||||
# mistune 图片 token:src 在 attrs.url,alt 来自 children 的文本,title 在 attrs.title
|
||||
attrs = token.get("attrs", {})
|
||||
alt = "".join(
|
||||
child.get("raw", "")
|
||||
for child in token.get("children", [])
|
||||
if child.get("type") == "text"
|
||||
)
|
||||
attributes = {"src": attrs.get("url", "")}
|
||||
if alt:
|
||||
attributes["alt"] = alt
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes)
|
||||
if kind == "inline_math":
|
||||
return DocumentNode(
|
||||
type="math_inline", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "softbreak":
|
||||
# HTML 中换行会折叠为空白,软换行按空格表达
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=" ")
|
||||
if kind == "linebreak":
|
||||
return DocumentNode(type="linebreak", node_id=self.next_id())
|
||||
# 未知行内 token 保守保留原文
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=raw)
|
||||
return None
|
||||
|
||||
def _map_code(self, token: dict) -> DocumentNode:
|
||||
info = (token.get("attrs", {}).get("info") or "").strip()
|
||||
lang = info.split()[0].lower() if info else ""
|
||||
code = token.get("raw", "").rstrip("\n")
|
||||
if lang == _MERMAID_LANG:
|
||||
return DocumentNode(type="mermaid", node_id=self.next_id(), text=code)
|
||||
if lang in _FUNCTION_PLOT_LANGS:
|
||||
return DocumentNode(type="function_plot", node_id=self.next_id(), text=code)
|
||||
attributes = {"language": lang} if lang else {}
|
||||
return DocumentNode(
|
||||
type="code_block", node_id=self.next_id(), attributes=attributes, text=code
|
||||
)
|
||||
|
||||
def _map_table(self, token: dict) -> DocumentNode:
|
||||
rows: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "table_head":
|
||||
rows.append(self._map_table_row(child, head=True))
|
||||
elif child["type"] == "table_body":
|
||||
for row in child.get("children", []):
|
||||
if row["type"] == "table_row":
|
||||
rows.append(self._map_table_row(row, head=False))
|
||||
elif child["type"] == "table_row":
|
||||
rows.append(self._map_table_row(child, head=False))
|
||||
return DocumentNode(type="table", node_id=self.next_id(), children=rows)
|
||||
|
||||
def _map_table_row(self, token: dict, *, head: bool) -> DocumentNode:
|
||||
cells: list[DocumentNode] = []
|
||||
for cell in token.get("children", []):
|
||||
if cell["type"] != "table_cell":
|
||||
continue
|
||||
attrs = cell.get("attrs", {})
|
||||
cell_attributes = {"head": bool(attrs.get("head", head))}
|
||||
if attrs.get("align"):
|
||||
cell_attributes["align"] = attrs["align"]
|
||||
cells.append(
|
||||
DocumentNode(
|
||||
type="table_cell",
|
||||
node_id=self.next_id(),
|
||||
attributes=cell_attributes,
|
||||
children=self.map_inline(cell.get("children", [])),
|
||||
)
|
||||
)
|
||||
return DocumentNode(
|
||||
type="table_row", node_id=self.next_id(), attributes={"head": head}, children=cells
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Export 服务:任务注册表、后台渲染、取消与产物生命周期。
|
||||
|
||||
与 Benchmark 一致采用「创建即返回 queued、后台 Task 异步执行」的内存模型:任务与产物
|
||||
暂存内存与 exports 目录,不持久化到 SQLite。导出是单阶段渲染,无 SSE 事件流,取消主要
|
||||
在渲染前/后让出执行权的边界生效;产物带 24h 过期时间,过期后不可下载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
ExportFile,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportOptions,
|
||||
ExportProgress,
|
||||
ExportRequest,
|
||||
ExportSource,
|
||||
ExportSourceType,
|
||||
ExportStatus,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.export.document import Document, ExportResult
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.services import note_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_jobs: dict[str, ExportJob] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_cancel_flags: dict[str, asyncio.Event] = {}
|
||||
MAX_JOBS = 100
|
||||
# 输入源(note / markdown)统一大小上限,防止未保存预览或超长笔记塞爆内存/产物
|
||||
MAX_MARKDOWN_CHARS = 200_000
|
||||
# 最终导出产物大小上限,防止超大 HTML 耗尽内存/磁盘
|
||||
MAX_EXPORT_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
# 并发渲染上限:解析/渲染是 CPU 密集的同步工作,限制同时执行的任务数,
|
||||
# 防止大量任务同时占满工作线程与内存
|
||||
MAX_CONCURRENT_RENDERS = 2
|
||||
_render_slots = asyncio.Semaphore(MAX_CONCURRENT_RENDERS)
|
||||
# 产物有效期
|
||||
FILE_TTL = timedelta(hours=24)
|
||||
|
||||
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
class ExportCancelled(Exception):
|
||||
"""导出在渲染前被取消时抛出,用于标记 cancelled。"""
|
||||
|
||||
|
||||
class ExportTooLarge(Exception):
|
||||
"""导出产物超过大小上限时抛出,用于标记 failed 并携带专用错误码。"""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _safe_download_name(title: str) -> str:
|
||||
"""清洗标题得到安全的下载文件名;空标题回退到 export。"""
|
||||
name = _INVALID_FILE_CHARS.sub("_", title).strip() or "export"
|
||||
return name[:80]
|
||||
|
||||
|
||||
def _export_path(job_id: str) -> Path:
|
||||
return get_settings().exports_path / f"{job_id}.html"
|
||||
|
||||
|
||||
def _delete_file(job_id: str) -> None:
|
||||
"""删除导出产物文件;文件不存在时忽略。"""
|
||||
try:
|
||||
_export_path(job_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to delete export file: %s", job_id)
|
||||
|
||||
|
||||
def cleanup_orphan_files() -> int:
|
||||
"""清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。"""
|
||||
exports_dir = get_settings().exports_path
|
||||
if not exports_dir.is_dir():
|
||||
return 0
|
||||
removed = 0
|
||||
for path in exports_dir.glob("*.html"):
|
||||
if path.stem not in _jobs:
|
||||
try:
|
||||
path.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
logger.warning("Failed to delete orphan export file: %s", path)
|
||||
return removed
|
||||
|
||||
|
||||
def _render_document(document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染辅助,供 asyncio.to_thread 调用;每次新建实例避免跨线程复用。"""
|
||||
return HtmlExporter().render(document, options)
|
||||
|
||||
|
||||
def _forget(job_id: str) -> None:
|
||||
_jobs.pop(job_id, None)
|
||||
_tasks.pop(job_id, None)
|
||||
_cancel_flags.pop(job_id, None)
|
||||
_delete_file(job_id)
|
||||
|
||||
|
||||
def _evict_terminal() -> bool:
|
||||
"""超过容量时淘汰最旧的终态任务;全为活动任务无法淘汰时返回 False。"""
|
||||
terminal = (ExportStatus.completed, ExportStatus.failed, ExportStatus.cancelled)
|
||||
while len(_jobs) >= MAX_JOBS:
|
||||
victim = next((jid for jid, job in _jobs.items() if job.status in terminal), None)
|
||||
if victim is None:
|
||||
return False
|
||||
_forget(victim)
|
||||
return True
|
||||
|
||||
|
||||
async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
||||
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
|
||||
if source.type == ExportSourceType.note:
|
||||
note = await note_service.get_note(source.note_id)
|
||||
if note is None:
|
||||
raise ApiError(
|
||||
404,
|
||||
"EXPORT_SOURCE_NOT_FOUND",
|
||||
"note not found",
|
||||
{"note_id": source.note_id},
|
||||
)
|
||||
if len(note.markdown) > MAX_MARKDOWN_CHARS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_OPTIONS_INVALID",
|
||||
f"note source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(note.markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
metadata = {
|
||||
"file_path": note.file_path,
|
||||
"tags": note.tags,
|
||||
"created_at": note.created_at,
|
||||
"updated_at": note.updated_at,
|
||||
}
|
||||
return note.markdown, note.title, metadata
|
||||
|
||||
markdown = source.markdown or ""
|
||||
if not markdown.strip():
|
||||
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
|
||||
if len(markdown) > MAX_MARKDOWN_CHARS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_OPTIONS_INVALID",
|
||||
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
return markdown, "", None
|
||||
|
||||
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||
if request.format != ExportFormat.html:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_FORMAT_UNSUPPORTED",
|
||||
"PDF/DOCX 暂未实现,当前仅支持 HTML",
|
||||
{"format": request.format.value},
|
||||
)
|
||||
markdown, title, metadata = await _resolve_source(request.source)
|
||||
|
||||
if not _evict_terminal():
|
||||
raise ApiError(
|
||||
429,
|
||||
"EXPORT_CAPACITY_EXCEEDED",
|
||||
"Export capacity exceeded; wait for active jobs to finish.",
|
||||
{},
|
||||
)
|
||||
|
||||
job_id = "export_" + uuid4().hex[:12]
|
||||
job = ExportJob(
|
||||
job_id=job_id,
|
||||
status=ExportStatus.queued,
|
||||
format=request.format,
|
||||
created_at=_now(),
|
||||
)
|
||||
_jobs[job_id] = job
|
||||
_cancel_flags[job_id] = asyncio.Event()
|
||||
_tasks[job_id] = asyncio.create_task(
|
||||
_execute(job_id, markdown, title, metadata, request.options)
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
async def _execute(
|
||||
job_id: str,
|
||||
markdown: str,
|
||||
title: str,
|
||||
metadata: dict | None,
|
||||
options: ExportOptions,
|
||||
) -> None:
|
||||
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
||||
cancel_event = _cancel_flags[job_id]
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.running,
|
||||
"started_at": _now(),
|
||||
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
|
||||
}
|
||||
)
|
||||
try:
|
||||
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数,
|
||||
# 超出限额的任务在此排队等待,避免大量任务同时占满工作线程与内存
|
||||
async with _render_slots:
|
||||
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
|
||||
await asyncio.sleep(0)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
|
||||
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
|
||||
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
|
||||
document = await asyncio.to_thread(parse_document, markdown)
|
||||
document.attributes["title"] = title
|
||||
if metadata:
|
||||
document.attributes["metadata"] = metadata
|
||||
|
||||
result = await asyncio.to_thread(_render_document, document, options)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
if len(result.content) > MAX_EXPORT_BYTES:
|
||||
raise ExportTooLarge()
|
||||
|
||||
out_dir = get_settings().exports_path
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = _export_path(job_id)
|
||||
path.write_bytes(result.content)
|
||||
|
||||
completed_at = _now()
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.completed,
|
||||
"progress": ExportProgress(
|
||||
phase="completed", current=1, total=1, percent=1.0
|
||||
),
|
||||
"file": ExportFile(
|
||||
file_name=f"{_safe_download_name(title)}.html",
|
||||
mime_type=result.mime_type,
|
||||
size=len(result.content),
|
||||
sha256=hashlib.sha256(result.content).hexdigest(),
|
||||
expires_at=completed_at + FILE_TTL,
|
||||
),
|
||||
"warnings": result.warnings,
|
||||
"completed_at": completed_at,
|
||||
}
|
||||
)
|
||||
except ExportCancelled:
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.cancelled,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except ExportTooLarge:
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.failed,
|
||||
"error": "Export output exceeds size limit.",
|
||||
"error_code": "EXPORT_OUTPUT_TOO_LARGE",
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码
|
||||
logger.exception("Export failed: job_id=%s", job_id)
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.failed,
|
||||
"error": "Export render failed.",
|
||||
"error_code": "EXPORT_RENDER_FAILED",
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_cancel_flags.pop(job_id, None)
|
||||
|
||||
|
||||
def list_exports(
|
||||
status: ExportStatus | None = None,
|
||||
format: ExportFormat | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[ExportJob], int]:
|
||||
jobs = list(_jobs.values())
|
||||
if status is not None:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
if format is not None:
|
||||
jobs = [j for j in jobs if j.format == format]
|
||||
jobs.sort(key=lambda j: j.created_at, reverse=True)
|
||||
total = len(jobs)
|
||||
return jobs[offset : offset + limit], total
|
||||
|
||||
|
||||
def get_export(job_id: str) -> ExportJob | None:
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def cancel_export(job_id: str) -> ExportJob | None:
|
||||
"""取消导出:仅 queued/running 可取消,后台 Task 在让出边界标记 cancelled。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in (ExportStatus.queued, ExportStatus.running):
|
||||
_cancel_flags[job_id].set()
|
||||
return job
|
||||
|
||||
|
||||
def get_export_file(job_id: str) -> Path:
|
||||
"""返回可下载产物的存储路径;未完成返回 404、过期返回 410。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
raise ApiError(404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id})
|
||||
if job.status != ExportStatus.completed or job.file is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
|
||||
)
|
||||
if job.file.expires_at <= _now():
|
||||
_forget(job_id) # 过期即清理内存记录与产物文件
|
||||
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
|
||||
return _export_path(job_id)
|
||||
|
||||
|
||||
async def wait_for_export(job_id: str) -> ExportJob | None:
|
||||
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
|
||||
task = _tasks.get(job_id)
|
||||
if task is not None:
|
||||
await task
|
||||
return _jobs.get(job_id)
|
||||
@@ -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()]
|
||||
|
||||
@@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException
|
||||
from app.config import get_settings
|
||||
from app.container import container
|
||||
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
|
||||
from app.export import service as export_service
|
||||
from app.routes import router as api_router
|
||||
from app.media_routes import router as media_router
|
||||
from app.local_model_routes import router as local_model_router
|
||||
@@ -20,6 +21,8 @@ settings = get_settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
# 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。
|
||||
export_service.cleanup_orphan_files()
|
||||
from app.services import transcription_service
|
||||
transcription_service.recover_interrupted()
|
||||
try:
|
||||
@@ -31,6 +34,7 @@ async def lifespan(_: FastAPI):
|
||||
from app.local_models import manager
|
||||
for _, key in list(manager._downloads):
|
||||
await manager.cancel_download(key)
|
||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Function Plot:函数图像的白名单表达式解析与静态 SVG 渲染。
|
||||
|
||||
模块划分:
|
||||
- model.py FunctionPlot 等内部数据模型(不进 contracts.py,同 Document AST)
|
||||
- parser.py function-plot 源码与表达式解析(ast 白名单,绝不 eval/exec)
|
||||
- render.py 把 FunctionPlot 渲染为内嵌 SVG(纯几何 + <text>,无脚本)
|
||||
"""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Function Plot 内部数据模型。
|
||||
|
||||
契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
|
||||
流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FunctionPlotExpression(BaseModel):
|
||||
"""单条函数表达式;expression 为数学表达式文本(不含 ``y =`` 前缀)。"""
|
||||
|
||||
expression: str
|
||||
label: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class PlotAxes(BaseModel):
|
||||
xlabel: str | None = None
|
||||
ylabel: str | None = None
|
||||
grid: bool = True
|
||||
|
||||
|
||||
class FunctionPlot(BaseModel):
|
||||
version: int = 1
|
||||
expressions: list[FunctionPlotExpression]
|
||||
domain: tuple[float, float] = (-10.0, 10.0)
|
||||
range: tuple[float, float] | None = None
|
||||
axes: PlotAxes = Field(default_factory=PlotAxes)
|
||||
# 该块所有表达式 AST 节点数之和,供导出器做文档级累计复杂度预算
|
||||
node_count: int = 0
|
||||
|
||||
|
||||
class PlotDiagnostic(BaseModel):
|
||||
severity: Literal["warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
line: int | None = None
|
||||
|
||||
|
||||
class FunctionPlotParseResult(BaseModel):
|
||||
"""解析结果:任一表达式 error 时 plot 为 None(整块回退占位),仅 warning 时 plot 有效。"""
|
||||
|
||||
plot: FunctionPlot | None = None
|
||||
diagnostics: list[PlotDiagnostic] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StaticRenderResult(BaseModel):
|
||||
content: str
|
||||
mime_type: str = "image/svg+xml"
|
||||
width: int
|
||||
height: int
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Function Plot 表达式解析:白名单数学语法,绝不执行 eval / 函数构造器 / 属性访问。
|
||||
|
||||
安全模型:先用 ``ast.parse(mode='eval')`` 把表达式变成纯 AST(这一步不执行任何代码),
|
||||
再逐节点白名单校验(只允许数字、变量 ``x``、常量 ``pi/e``、白名单函数调用与四则/幂
|
||||
运算),最后用递归解释器直接计算数值——全程不 ``compile``/``exec`` 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
import re
|
||||
from typing import NoReturn
|
||||
|
||||
from app.plot.model import (
|
||||
FunctionPlot,
|
||||
FunctionPlotExpression,
|
||||
FunctionPlotParseResult,
|
||||
PlotAxes,
|
||||
PlotDiagnostic,
|
||||
)
|
||||
|
||||
# 白名单函数(ln 是 log 的别名);abs 用内置函数,其余映射到 math
|
||||
_FUNCTION_IMPL: dict[str, object] = {
|
||||
"sin": math.sin,
|
||||
"cos": math.cos,
|
||||
"tan": math.tan,
|
||||
"asin": math.asin,
|
||||
"acos": math.acos,
|
||||
"atan": math.atan,
|
||||
"sinh": math.sinh,
|
||||
"cosh": math.cosh,
|
||||
"tanh": math.tanh,
|
||||
"exp": math.exp,
|
||||
"log": math.log,
|
||||
"ln": math.log,
|
||||
"log10": math.log10,
|
||||
"log2": math.log2,
|
||||
"sqrt": math.sqrt,
|
||||
"abs": abs,
|
||||
}
|
||||
_FUNCTIONS = frozenset(_FUNCTION_IMPL)
|
||||
_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e}
|
||||
|
||||
_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow)
|
||||
_ALLOWED_UNARY = (ast.UAdd, ast.USub)
|
||||
_DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"})
|
||||
_NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
|
||||
|
||||
# 表达式复杂度上限:深层嵌套或海量节点在递归校验/求值时会触发 RecursionError,
|
||||
# 用白名单校验提前拦截,保证失败走正常诊断路径而不是异常逃逸出导出链路。
|
||||
_MAX_AST_DEPTH = 200
|
||||
_MAX_AST_NODES = 1000
|
||||
# 单块 function-plot 允许的表达式数量上限,防止海量表达式导致超大 SVG 与海量采样求值
|
||||
_MAX_EXPRESSIONS = 16
|
||||
|
||||
|
||||
class PlotParseError(Exception):
|
||||
"""表达式解析/校验失败,携带可定位诊断。"""
|
||||
|
||||
def __init__(self, diagnostic: PlotDiagnostic) -> None:
|
||||
super().__init__(diagnostic.message)
|
||||
self.diagnostic = diagnostic
|
||||
|
||||
|
||||
def _unsafe(message: str) -> NoReturn:
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(severity="error", code="FUNCTION_PLOT_EXPRESSION_UNSAFE", message=message)
|
||||
)
|
||||
|
||||
|
||||
def _is_number(tok: str) -> bool:
|
||||
return bool(_NUMBER_RE.match(tok))
|
||||
|
||||
|
||||
def _tokenize(s: str) -> list[str]:
|
||||
"""把预处理后的表达式切成数字/标识符/运算符/括号 token。"""
|
||||
tokens: list[str] = []
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
ch = s[i]
|
||||
if ch.isspace():
|
||||
i += 1
|
||||
continue
|
||||
if ch.isdigit() or ch == ".":
|
||||
j = i
|
||||
while j < n and (s[j].isdigit() or s[j] == "."):
|
||||
j += 1
|
||||
# 科学计数法:数字后紧跟 e/E[+-]数字 视为同一数字
|
||||
if j < n and s[j] in "eE":
|
||||
k = j + 1
|
||||
if k < n and s[k] in "+-":
|
||||
k += 1
|
||||
if k < n and s[k].isdigit():
|
||||
while k < n and s[k].isdigit():
|
||||
k += 1
|
||||
j = k
|
||||
tokens.append(s[i:j])
|
||||
i = j
|
||||
continue
|
||||
if ch.isalpha() or ch == "_":
|
||||
j = i
|
||||
while j < n and (s[j].isalnum() or s[j] == "_"):
|
||||
j += 1
|
||||
tokens.append(s[i:j])
|
||||
i = j
|
||||
continue
|
||||
if ch == "*" and i + 1 < n and s[i + 1] == "*":
|
||||
tokens.append("**")
|
||||
i += 2
|
||||
continue
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_value_end(tok: str) -> bool:
|
||||
"""该 token 之后允许补乘号(数字/右括号/变量 x/常量)。"""
|
||||
return tok == ")" or _is_number(tok) or tok == "x" or tok in _CONSTANTS
|
||||
|
||||
|
||||
def _is_value_start(tok: str) -> bool:
|
||||
"""该 token 可作为乘号右侧起点(左括号/数字/任意标识符,含函数名)。"""
|
||||
return tok == "(" or _is_number(tok) or (tok and (tok[0].isalpha() or tok[0] == "_"))
|
||||
|
||||
|
||||
def _insert_implicit_multiplication(s: str) -> str:
|
||||
"""补隐式乘法:2x、2(x+1)、(x+1)(x-1)、x sin(x) 等;函数名后的 ``(`` 是调用不补。"""
|
||||
tokens = _tokenize(s)
|
||||
out: list[str] = []
|
||||
prev: str | None = None
|
||||
for tok in tokens:
|
||||
if prev is not None and _is_value_end(prev) and _is_value_start(tok):
|
||||
out.append("*")
|
||||
out.append(tok)
|
||||
prev = tok
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _preprocess(expr: str) -> str:
|
||||
"""``^`` 视为幂,补隐式乘法后再交给 ast.parse。"""
|
||||
return _insert_implicit_multiplication(expr.replace("^", "**"))
|
||||
|
||||
|
||||
def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None:
|
||||
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
|
||||
|
||||
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
|
||||
RecursionError 而绕过解析失败路径。
|
||||
"""
|
||||
if counter is None:
|
||||
counter = [0]
|
||||
if depth > _MAX_AST_DEPTH:
|
||||
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
|
||||
counter[0] += 1
|
||||
if counter[0] > _MAX_AST_NODES:
|
||||
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})")
|
||||
if isinstance(node, ast.Constant):
|
||||
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||
_unsafe(f"不支持的常量 {node.value!r}")
|
||||
return
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id == "x" or node.id in _CONSTANTS:
|
||||
return
|
||||
_unsafe(f"未知标识符 {node.id!r}")
|
||||
if isinstance(node, ast.BinOp):
|
||||
if not isinstance(node.op, _ALLOWED_BINOPS):
|
||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||
_check_node(node.left, depth + 1, counter)
|
||||
_check_node(node.right, depth + 1, counter)
|
||||
return
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
if not isinstance(node.op, _ALLOWED_UNARY):
|
||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||
_check_node(node.operand, depth + 1, counter)
|
||||
return
|
||||
if isinstance(node, ast.Call):
|
||||
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
|
||||
_unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}")
|
||||
if node.keywords:
|
||||
_unsafe("函数调用不支持关键字参数")
|
||||
# 白名单内所有函数均恰取 1 个参数,提前校验避免求值期 TypeError
|
||||
if len(node.args) != 1:
|
||||
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个")
|
||||
for arg in node.args:
|
||||
_check_node(arg, depth + 1, counter)
|
||||
return
|
||||
_unsafe(f"不支持的语法 {type(node).__name__}")
|
||||
|
||||
|
||||
def parse_expression(expr: str) -> ast.Expression:
|
||||
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
|
||||
preprocessed = _preprocess(expr)
|
||||
try:
|
||||
tree = ast.parse(preprocessed, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"表达式语法错误:{exc.msg}",
|
||||
)
|
||||
) from exc
|
||||
except RecursionError as exc:
|
||||
# 极深嵌套可能在 ast.parse 阶段就触发 RecursionError,转为可定位诊断
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式嵌套过深,无法解析",
|
||||
)
|
||||
) from exc
|
||||
_check_node(tree.body)
|
||||
return tree
|
||||
|
||||
|
||||
def _count_nodes(node: ast.AST) -> int:
|
||||
"""统计已通过校验的表达式 AST 节点数,供文档级累计复杂度预算使用。"""
|
||||
counter = [0]
|
||||
_check_node(node, counter=counter)
|
||||
return counter[0]
|
||||
|
||||
|
||||
def evaluate(expr_ast: ast.Expression, x: float) -> float:
|
||||
"""递归解释已校验 AST 得到数值,全程不编译/执行代码。"""
|
||||
return _eval_node(expr_ast.body, x)
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST, x: float) -> float:
|
||||
if isinstance(node, ast.Constant):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.Name):
|
||||
return x if node.id == "x" else _CONSTANTS[node.id]
|
||||
if isinstance(node, ast.BinOp):
|
||||
left = _eval_node(node.left, x)
|
||||
right = _eval_node(node.right, x)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
if isinstance(node.op, ast.Div):
|
||||
return left / right
|
||||
# 负数底 + 非整数指数会得到复数,数学绘图不支持,抛 ValueError 让采样点作为断点处理
|
||||
if left < 0 and not right.is_integer():
|
||||
raise ValueError("negative base with fractional exponent")
|
||||
return left**right
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
value = _eval_node(node.operand, x)
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
if isinstance(node, ast.Call):
|
||||
args = [_eval_node(arg, x) for arg in node.args]
|
||||
return _FUNCTION_IMPL[node.func.id](*args) # type: ignore[operator]
|
||||
raise ValueError("unreachable node")
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
return line.split("#", 1)[0].strip()
|
||||
|
||||
|
||||
def _parse_pair(value: str) -> tuple[float, float]:
|
||||
"""解析 ``min, max`` / ``min max`` 数值对。"""
|
||||
parts = [p for p in re.split(r"[,,\s]+", value.strip()) if p]
|
||||
if len(parts) != 2:
|
||||
raise ValueError("需要两个数值")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def _parse_directive(line: str) -> tuple[str, str] | None:
|
||||
"""指令行形如 ``key: value``(表达式不含冒号,冒号是可靠判别)。"""
|
||||
if ":" not in line or "=" in line:
|
||||
return None
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip().lower()
|
||||
if not key or " " in key:
|
||||
return None
|
||||
return key, value.strip()
|
||||
|
||||
|
||||
def parse_source(source: str) -> FunctionPlotParseResult:
|
||||
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
|
||||
diagnostics: list[PlotDiagnostic] = []
|
||||
expressions: list[FunctionPlotExpression] = []
|
||||
domain: tuple[float, float] = (-10.0, 10.0)
|
||||
range_: tuple[float, float] | None = None
|
||||
xlabel: str | None = None
|
||||
ylabel: str | None = None
|
||||
grid: bool = True
|
||||
has_error = False
|
||||
total_nodes = 0
|
||||
|
||||
for lineno, raw_line in enumerate(source.splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
directive = _parse_directive(line)
|
||||
if directive is not None:
|
||||
key, value = directive
|
||||
if key == "domain":
|
||||
try:
|
||||
domain = _parse_pair(value)
|
||||
except ValueError:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"domain 需要两个数值,已忽略:{value!r}",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
elif key == "range":
|
||||
try:
|
||||
range_ = _parse_pair(value)
|
||||
except ValueError:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"range 需要两个数值,已忽略:{value!r}",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
elif key == "xlabel":
|
||||
xlabel = value or None
|
||||
elif key == "ylabel":
|
||||
ylabel = value or None
|
||||
elif key == "grid":
|
||||
grid = value.lower() in ("true", "1", "yes", "on")
|
||||
else:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"未知指令 {key!r} 已忽略",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# 表达式行:y = <expr> 或裸 <expr>
|
||||
expr_text = _strip_comment(line)
|
||||
if not expr_text:
|
||||
continue
|
||||
if "=" in expr_text:
|
||||
lhs, _, rhs = expr_text.partition("=")
|
||||
if lhs.strip().lower() not in ("y", ""):
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式应形如 'y = <expr>'",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
has_error = True
|
||||
continue
|
||||
expr_text = rhs.strip()
|
||||
if not expr_text:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式为空",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
try:
|
||||
tree = parse_expression(expr_text)
|
||||
except PlotParseError as exc:
|
||||
exc.diagnostic.line = lineno
|
||||
diagnostics.append(exc.diagnostic)
|
||||
has_error = True
|
||||
continue
|
||||
total_nodes += _count_nodes(tree.body)
|
||||
expressions.append(FunctionPlotExpression(expression=expr_text))
|
||||
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
|
||||
if len(expressions) > _MAX_EXPRESSIONS:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_TOO_MANY_EXPRESSIONS",
|
||||
message=f"表达式数量超过上限 {_MAX_EXPRESSIONS},已回退为源码占位",
|
||||
)
|
||||
)
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
|
||||
if has_error:
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
if not expressions:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="没有找到任何函数表达式",
|
||||
)
|
||||
)
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
|
||||
plot = FunctionPlot(
|
||||
expressions=expressions,
|
||||
domain=domain,
|
||||
range=range_,
|
||||
axes=PlotAxes(xlabel=xlabel, ylabel=ylabel, grid=grid),
|
||||
node_count=total_nodes,
|
||||
)
|
||||
return FunctionPlotParseResult(plot=plot, diagnostics=diagnostics)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Function Plot → 静态 SVG 渲染。
|
||||
|
||||
只输出纯几何与 <text> 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。
|
||||
所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from app.plot.model import FunctionPlot, StaticRenderResult
|
||||
from app.plot.parser import PlotParseError, evaluate, parse_expression
|
||||
|
||||
_WIDTH = 640
|
||||
_HEIGHT = 480
|
||||
_MARGIN = 52 # 四周留白,放轴刻度与标签
|
||||
_SAMPLES = 400
|
||||
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
|
||||
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
||||
|
||||
|
||||
def _safe_color(color: str | None, fallback: str) -> str:
|
||||
return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback
|
||||
|
||||
|
||||
def _valid_span(lo: float, hi: float) -> bool:
|
||||
"""范围跨度有效:端点有限、跨度有限且大于零。
|
||||
|
||||
端点相减可能溢出为 ``inf``(如 ``-1e308`` 到 ``1e308``),需单独校验跨度,
|
||||
否则后续坐标换算会生成含 ``nan`` 的 SVG。
|
||||
"""
|
||||
span = hi - lo
|
||||
return math.isfinite(lo) and math.isfinite(hi) and math.isfinite(span) and span > 0
|
||||
|
||||
|
||||
def _fmt_num(v: float) -> str:
|
||||
if v == 0:
|
||||
return "0"
|
||||
if abs(v) >= 1e6 or abs(v) < 1e-6:
|
||||
return f"{v:.2e}"
|
||||
return f"{v:.6g}"
|
||||
|
||||
|
||||
def _nice_step(span: float, target_ticks: int = 6) -> float:
|
||||
raw = abs(span) / target_ticks
|
||||
if not math.isfinite(raw) or raw <= 0:
|
||||
return 1.0 # 兜底步长,避免 span 为 0/inf 时产生非法刻度
|
||||
mag = 10 ** math.floor(math.log10(raw))
|
||||
for m in (1, 2, 5, 10):
|
||||
if raw <= m * mag:
|
||||
return m * mag
|
||||
return 10 * mag
|
||||
|
||||
|
||||
def _ticks(lo: float, hi: float, step: float) -> list[float]:
|
||||
# 防御:非法步长直接返回空,避免除零
|
||||
if not math.isfinite(step) or step <= 0:
|
||||
return []
|
||||
first = math.ceil(lo / step) * step
|
||||
values: list[float] = []
|
||||
v = first
|
||||
# 有上限的整数索引推进 + 步长推进校验,防止浮点精度导致 v+step==v 的死循环
|
||||
for _ in range(1000):
|
||||
if v > hi + step * 1e-9:
|
||||
break
|
||||
values.append(v)
|
||||
nxt = v + step
|
||||
if nxt <= v:
|
||||
break # 步长小于当前数值的浮点精度,已无法推进
|
||||
v = nxt
|
||||
return values
|
||||
|
||||
|
||||
def _compute_range(
|
||||
fns: list[tuple[object, object]],
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
) -> tuple[float, float]:
|
||||
"""采样确定 y 范围;取有限样本的 min/max 加 5% 余量。"""
|
||||
ys: list[float] = []
|
||||
for _expr, tree in fns:
|
||||
for i in range(_SAMPLES + 1):
|
||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||
try:
|
||||
y = evaluate(tree, x) # type: ignore[arg-type]
|
||||
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||
continue
|
||||
# 复数等非实数结果直接跳过,不参与范围统计
|
||||
if isinstance(y, (int, float)) and math.isfinite(y):
|
||||
ys.append(y)
|
||||
|
||||
if not ys:
|
||||
return -10.0, 10.0
|
||||
lo, hi = min(ys), max(ys)
|
||||
if lo == hi:
|
||||
lo -= 1.0
|
||||
hi += 1.0
|
||||
pad = (hi - lo) * 0.05
|
||||
return lo - pad, hi + pad
|
||||
|
||||
|
||||
def _polyline(
|
||||
tree: object,
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
color: str,
|
||||
) -> str:
|
||||
"""采样并把非有限点处断开成多段 polyline,避免画穿渐近线。"""
|
||||
segments: list[str] = []
|
||||
points: list[str] = []
|
||||
for i in range(_SAMPLES + 1):
|
||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||
try:
|
||||
y = evaluate(tree, x) # type: ignore[arg-type]
|
||||
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||
y = math.nan
|
||||
if not isinstance(y, (int, float)) or not math.isfinite(y):
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
points = []
|
||||
continue
|
||||
px = sx(x)
|
||||
py = sy(y)
|
||||
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
|
||||
if not (math.isfinite(px) and math.isfinite(py)):
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
points = []
|
||||
continue
|
||||
points.append(f"{px:.2f},{py:.2f}")
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
return "".join(segments)
|
||||
|
||||
|
||||
def _grid(
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||
parts.append(f'<line x1="{sx(x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(x):.2f}" y2="{sy(ymax):.2f}" stroke="#eaeef2"/>')
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(f'<line x1="{sx(xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(y):.2f}" stroke="#eaeef2"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _axes(
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
|
||||
x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
|
||||
y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
|
||||
parts.append(
|
||||
f'<line x1="{sx(xmin):.2f}" y1="{sy(x_axis_y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(x_axis_y):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(y_axis_x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(y_axis_x):.2f}" y2="{sy(ymax):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(x):.2f}" y="{sy(x_axis_y) + 14:.2f}" text-anchor="middle" font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||
)
|
||||
# y 轴刻度数字(画在轴左侧)
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str:
|
||||
parts: list[str] = []
|
||||
if plot.axes.xlabel:
|
||||
parts.append(
|
||||
f'<text x="{(_WIDTH / 2):.2f}" y="{_HEIGHT - 10:.2f}" text-anchor="middle" font-size="12" fill="#1f2328">{html.escape(plot.axes.xlabel)}</text>'
|
||||
)
|
||||
if plot.axes.ylabel:
|
||||
parts.append(
|
||||
f'<text x="16" y="{(_HEIGHT / 2):.2f}" text-anchor="middle" font-size="12" fill="#1f2328" transform="rotate(-90 16 {_HEIGHT / 2:.2f})">{html.escape(plot.axes.ylabel)}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
|
||||
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
|
||||
warnings: list[str] = []
|
||||
xmin, xmax = plot.domain
|
||||
if not _valid_span(xmin, xmax):
|
||||
warnings.append("domain 无效,回退到 [-10, 10]")
|
||||
xmin, xmax = -10.0, 10.0
|
||||
|
||||
# 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发)
|
||||
fns: list[tuple[object, object]] = []
|
||||
for expr in plot.expressions:
|
||||
try:
|
||||
tree = parse_expression(expr.expression)
|
||||
except PlotParseError as exc:
|
||||
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})")
|
||||
continue
|
||||
fns.append((expr, tree))
|
||||
|
||||
# 纵轴范围:显式 range 有效则用之;无效(退化/非有限/跨度溢出)丢弃并自动采样重算
|
||||
if plot.range is not None:
|
||||
lo, hi = float(plot.range[0]), float(plot.range[1])
|
||||
if _valid_span(lo, hi):
|
||||
ymin, ymax = lo, hi
|
||||
else:
|
||||
warnings.append("range 无效,改用自动范围")
|
||||
ymin, ymax = _compute_range(fns, xmin, xmax)
|
||||
else:
|
||||
ymin, ymax = _compute_range(fns, xmin, xmax)
|
||||
|
||||
# 最终防线:自动范围在极端样本下也可能溢出,坐标映射前必须保证跨度有限且大于零
|
||||
if not _valid_span(ymin, ymax):
|
||||
warnings.append("y 范围跨度无法表示,回退到 [-10, 10]")
|
||||
ymin, ymax = -10.0, 10.0
|
||||
|
||||
def sx(x: float) -> float:
|
||||
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
|
||||
|
||||
def sy(y: float) -> float:
|
||||
return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
|
||||
|
||||
parts: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img">'
|
||||
]
|
||||
if plot.axes.grid:
|
||||
parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy))
|
||||
parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy))
|
||||
for i, (expr, tree) in enumerate(fns):
|
||||
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
||||
parts.append(_polyline(tree, xmin, xmax, sx, sy, color))
|
||||
parts.append(_labels(plot, sx, sy))
|
||||
parts.append("</svg>")
|
||||
|
||||
return StaticRenderResult(
|
||||
content="".join(parts),
|
||||
width=_WIDTH,
|
||||
height=_HEIGHT,
|
||||
warnings=warnings,
|
||||
)
|
||||
+85
-1
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
@@ -53,6 +53,11 @@ from app.contracts import (
|
||||
ModelRoutingResponse,
|
||||
SpeakerMatchRequest,
|
||||
SpeakerMatchResult,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportJobListResponse,
|
||||
ExportRequest,
|
||||
ExportStatus,
|
||||
Note,
|
||||
NoteCreateRequest,
|
||||
NoteListResponse,
|
||||
@@ -101,8 +106,10 @@ from app.contracts import (
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.benchmarks import datasets as benchmark_datasets
|
||||
from app.benchmarks import service as benchmark_service
|
||||
from app.config import get_settings
|
||||
from app.container import container
|
||||
from app.errors import ApiError
|
||||
from app.export import service as export_service
|
||||
from app.extensions import ExtensionError
|
||||
from app.extensions.mcp_registry import McpRegistryError
|
||||
from app.providers.base import ProviderError
|
||||
@@ -1468,3 +1475,80 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport:
|
||||
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id}
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
@router.post(
|
||||
"/exports",
|
||||
response_model=ExportJob,
|
||||
status_code=202,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
return await export_service.create_export(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports",
|
||||
response_model=ExportJobListResponse,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def list_exports(
|
||||
status: ExportStatus | None = Query(default=None),
|
||||
format: ExportFormat | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ExportJobListResponse:
|
||||
items, total = export_service.list_exports(
|
||||
status=status, format=format, limit=limit, offset=offset
|
||||
)
|
||||
return ExportJobListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{job_id}",
|
||||
response_model=ExportJob,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def get_export(job_id: str) -> ExportJob:
|
||||
job = export_service.get_export(job_id)
|
||||
if job is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{job_id}/file",
|
||||
tags=["Export"],
|
||||
)
|
||||
async def get_export_file(job_id: str) -> FileResponse:
|
||||
path = export_service.get_export_file(job_id) # 未完成/过期分别抛 404/410
|
||||
job = export_service.get_export(job_id)
|
||||
if job is None or job.file is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
|
||||
)
|
||||
return FileResponse(
|
||||
path=path,
|
||||
media_type=job.file.mime_type,
|
||||
filename=job.file.file_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/exports/{job_id}/cancel",
|
||||
response_model=OperationResponse,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def cancel_export(job_id: str) -> OperationResponse:
|
||||
job = export_service.cancel_export(job_id)
|
||||
if job is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
|
||||
)
|
||||
return OperationResponse(
|
||||
status="accepted", resource_id=job_id, message="Export cancellation accepted."
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"fastapi>=0.116,<1.0",
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
"mistune>=3.0,<4.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"referencing>=0.36,<1.0",
|
||||
"sqlite-vec>=0.1.9",
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Export Service 的单元与端到端测试。
|
||||
|
||||
沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录,
|
||||
不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一
|
||||
事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportOptions,
|
||||
ExportRequest,
|
||||
ExportSource,
|
||||
ExportSourceType,
|
||||
ExportStatus,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.export import service as export_service
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
|
||||
MD = """# 进程调度
|
||||
|
||||
一些 **加粗** 和 *斜体*,[链接](https://a.b) 与 `code`。
|
||||
|
||||
- 项目一
|
||||
- 项目二
|
||||
|
||||
```python
|
||||
print(1)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
```
|
||||
|
||||
```function_plot
|
||||
y = x
|
||||
```
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
行内 $x^2$ 与块级
|
||||
$$
|
||||
y = mx + b
|
||||
$$
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_export_state():
|
||||
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。"""
|
||||
export_service._jobs.clear()
|
||||
export_service._tasks.clear()
|
||||
export_service._cancel_flags.clear()
|
||||
yield
|
||||
export_service._jobs.clear()
|
||||
export_service._tasks.clear()
|
||||
export_service._cancel_flags.clear()
|
||||
|
||||
|
||||
def _create_and_wait(request: ExportRequest) -> object:
|
||||
"""创建导出并在同一事件循环内等待后台任务结束,返回终态 ExportJob。"""
|
||||
|
||||
async def _execute():
|
||||
job = await export_service.create_export(request)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
return asyncio.run(_execute())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# markdown → Document AST
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _types(nodes) -> list[str]:
|
||||
return [n.type for n in nodes]
|
||||
|
||||
|
||||
def test_parse_document_heading_and_inline() -> None:
|
||||
doc = parse_document("# 标题\n\n一段 **加粗** 和 [链接](https://a.b)。")
|
||||
|
||||
assert doc.type == "document"
|
||||
heading = doc.children[0]
|
||||
assert heading.type == "heading"
|
||||
assert heading.attributes["level"] == 1
|
||||
|
||||
para = doc.children[1]
|
||||
assert para.type == "paragraph"
|
||||
kinds = _types(para.children)
|
||||
assert "text" in kinds
|
||||
assert "strong" in kinds
|
||||
assert "link" in kinds
|
||||
|
||||
link = next(c for c in para.children if c.type == "link")
|
||||
assert link.attributes["href"] == "https://a.b"
|
||||
|
||||
|
||||
def test_parse_document_list_and_code_fencing() -> None:
|
||||
doc = parse_document("- a\n- b\n\n```mermaid\ngraph LR\n```\n\n```function_plot\ny=x\n```\n\n```python\nx\n```")
|
||||
|
||||
kinds = [c.type for c in doc.children]
|
||||
assert kinds[0] == "list"
|
||||
assert kinds[1] == "mermaid"
|
||||
assert kinds[2] == "function_plot"
|
||||
assert kinds[3] == "code_block"
|
||||
|
||||
code = doc.children[3]
|
||||
assert code.attributes["language"] == "python"
|
||||
assert code.text == "x"
|
||||
|
||||
|
||||
def test_parse_document_table_and_math() -> None:
|
||||
doc = parse_document("| a | b |\n|---|---|\n| 1 | 2 |\n\n$x^2$\n\n$$\ny=mx\n$$")
|
||||
|
||||
table = doc.children[0]
|
||||
assert table.type == "table"
|
||||
assert table.children[0].type == "table_row"
|
||||
assert table.children[0].children[0].attributes["head"] is True
|
||||
|
||||
# 表格后是「行内数学所在段落」与「块级数学」
|
||||
kinds = [c.type for c in doc.children[1:]]
|
||||
assert "paragraph" in kinds
|
||||
assert "math_block" in kinds
|
||||
|
||||
|
||||
def test_parse_document_image_maps_src_alt_title() -> None:
|
||||
doc = parse_document('')
|
||||
img = doc.children[0].children[0]
|
||||
assert img.type == "image"
|
||||
assert img.attributes["src"] == "https://a.b/img.png"
|
||||
assert img.attributes["alt"] == "替代文本"
|
||||
assert img.attributes["title"] == "标题"
|
||||
|
||||
|
||||
def test_parse_document_function_plot_dash_alias() -> None:
|
||||
doc = parse_document("```function-plot\ny = x^2\n```")
|
||||
assert doc.children[0].type == "function_plot"
|
||||
assert doc.children[0].text == "y = x^2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HtmlExporter
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def _render(markdown: str, *, title: str = "") -> str:
|
||||
doc = parse_document(markdown)
|
||||
doc.attributes["title"] = title
|
||||
result = await HtmlExporter().export(doc, ExportOptions())
|
||||
return result.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_html_exporter_renders_basic_nodes_and_escapes() -> None:
|
||||
html = asyncio.run(_render("# 标题\n\n**加粗** [链接](https://a.b) 与 <b>原始</b>。"))
|
||||
|
||||
assert "<h1>标题</h1>" in html
|
||||
assert "<strong>加粗</strong>" in html
|
||||
assert '<a href="https://a.b">链接</a>' in html
|
||||
# 原始 HTML 必须被转义,不能注入文档
|
||||
assert "<b>原始</b>" in html
|
||||
assert "<b>原始</b>" not in html
|
||||
|
||||
|
||||
def test_html_exporter_marks_mermaid_and_function_plot() -> None:
|
||||
result = asyncio.run(HtmlExporter().export(parse_document("```mermaid\ngraph LR\n```"), ExportOptions()))
|
||||
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="mermaid">graph LR</pre>' in html
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_rejects_unsafe_link_protocol() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "javascript:" not in html
|
||||
assert "点我" in html
|
||||
assert any("不安全" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_rejects_unsafe_image_protocol() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document(""), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "data:" not in html
|
||||
assert "<img" not in html
|
||||
assert "alt" in html
|
||||
assert any("不安全" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_preserves_raw_html_block() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document("<div>重要正文</div>"), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "重要正文" in html
|
||||
assert "<div>" not in html
|
||||
assert "<div>重要正文</div>" in html
|
||||
assert any("原始 HTML" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_include_title_and_metadata() -> None:
|
||||
doc = parse_document("正文")
|
||||
doc.attributes["title"] = "操作系统复习"
|
||||
doc.attributes["metadata"] = {"tags": ["os", "复习"]}
|
||||
|
||||
opts = ExportOptions(include_title=True, include_metadata=True)
|
||||
result = asyncio.run(HtmlExporter().export(doc, opts))
|
||||
html = result.content.decode("utf-8")
|
||||
|
||||
assert '<h1 class="title">操作系统复习</h1>' in html
|
||||
assert "os, 复习" in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ExportService
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _markdown_request(markdown: str, *, format: ExportFormat = ExportFormat.html) -> ExportRequest:
|
||||
return ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.markdown, markdown=markdown),
|
||||
format=format,
|
||||
)
|
||||
|
||||
|
||||
def test_export_markdown_source_completes_and_writes_file() -> None:
|
||||
finished = _create_and_wait(_markdown_request(MD))
|
||||
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.mime_type == "text/html"
|
||||
assert finished.file.size > 0
|
||||
assert len(finished.file.sha256) == 64
|
||||
|
||||
path = get_settings().exports_path / f"{finished.job_id}.html"
|
||||
assert path.exists()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "进程调度" in content
|
||||
|
||||
|
||||
def test_export_note_source_resolves_title_and_metadata() -> None:
|
||||
from app.services import note_service
|
||||
|
||||
async def _go():
|
||||
note = await note_service.create_note(
|
||||
title="操作系统复习", markdown="# 进程调度\n\n内容。", folder="导出", tags=["os"]
|
||||
)
|
||||
request = ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
|
||||
format=ExportFormat.html,
|
||||
options=ExportOptions(include_metadata=True),
|
||||
)
|
||||
job = await export_service.create_export(request)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.file_name == "操作系统复习.html"
|
||||
content = (get_settings().exports_path / f"{finished.job_id}.html").read_text(encoding="utf-8")
|
||||
assert "操作系统复习" in content
|
||||
assert "进程调度" in content
|
||||
|
||||
|
||||
def test_export_pdf_unsupported() -> None:
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(
|
||||
export_service.create_export(_markdown_request("# x", format=ExportFormat.pdf))
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "EXPORT_FORMAT_UNSUPPORTED"
|
||||
|
||||
|
||||
def test_export_unknown_note_404() -> None:
|
||||
request = ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
|
||||
format=ExportFormat.html,
|
||||
)
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(export_service.create_export(request))
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.code == "EXPORT_SOURCE_NOT_FOUND"
|
||||
|
||||
|
||||
def test_export_empty_markdown_invalid() -> None:
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(export_service.create_export(_markdown_request(" ")))
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
|
||||
|
||||
|
||||
def test_export_cancel_queued_job() -> None:
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# x"))
|
||||
cancelled = export_service.cancel_export(job.job_id)
|
||||
assert cancelled is not None
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.cancelled
|
||||
assert finished.file is None
|
||||
|
||||
|
||||
def test_export_file_expired_410() -> None:
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# x"))
|
||||
finished = await export_service.wait_for_export(job.job_id)
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
export_service._jobs[job.job_id] = finished.model_copy(
|
||||
update={"file": finished.file.model_copy(update={"expires_at": past})}
|
||||
)
|
||||
return job.job_id
|
||||
|
||||
job_id = asyncio.run(_go())
|
||||
path = get_settings().exports_path / f"{job_id}.html"
|
||||
with pytest.raises(ApiError) as exc:
|
||||
export_service.get_export_file(job_id)
|
||||
assert exc.value.status_code == 410
|
||||
assert exc.value.code == "EXPORT_FILE_EXPIRED"
|
||||
assert not path.exists() # 过期即清理产物文件
|
||||
assert export_service.get_export(job_id) is None # 内存记录一并清理
|
||||
|
||||
|
||||
def test_export_eviction_deletes_file() -> None:
|
||||
finished = _create_and_wait(_markdown_request("# 淘汰"))
|
||||
victim_path = get_settings().exports_path / f"{finished.job_id}.html"
|
||||
assert victim_path.exists()
|
||||
|
||||
# 塞满 MAX_JOBS 个终态任务,下一次 create 会淘汰最旧的终态(finished 最先插入)
|
||||
for i in range(export_service.MAX_JOBS):
|
||||
export_service._jobs[f"export_fake_{i}"] = ExportJob(
|
||||
job_id=f"export_fake_{i}",
|
||||
status=ExportStatus.completed,
|
||||
format=ExportFormat.html,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
_create_and_wait(_markdown_request("# 触发淘汰"))
|
||||
assert not victim_path.exists()
|
||||
|
||||
|
||||
def test_cleanup_orphan_files() -> None:
|
||||
exports_dir = get_settings().exports_path
|
||||
exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
orphan = exports_dir / "export_orphan.html"
|
||||
orphan.write_text("stale", encoding="utf-8")
|
||||
|
||||
finished = _create_and_wait(_markdown_request("# 保留"))
|
||||
keep_path = exports_dir / f"{finished.job_id}.html"
|
||||
assert keep_path.exists()
|
||||
|
||||
removed = export_service.cleanup_orphan_files()
|
||||
assert removed >= 1
|
||||
assert not orphan.exists()
|
||||
assert keep_path.exists() # 仍在注册表中的任务文件保留
|
||||
|
||||
|
||||
def test_export_cancel_during_running(monkeypatch) -> None:
|
||||
import threading
|
||||
import time
|
||||
|
||||
real_parse = parse_document
|
||||
started = threading.Event()
|
||||
|
||||
def slow_parse(markdown: str):
|
||||
started.set()
|
||||
time.sleep(0.1)
|
||||
return real_parse(markdown)
|
||||
|
||||
monkeypatch.setattr(export_service, "parse_document", slow_parse)
|
||||
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# 运行中取消"))
|
||||
while not started.is_set():
|
||||
await asyncio.sleep(0)
|
||||
export_service.cancel_export(job.job_id)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.cancelled
|
||||
assert finished.file is None
|
||||
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
|
||||
|
||||
|
||||
def test_export_list_and_get() -> None:
|
||||
finished = _create_and_wait(_markdown_request("# 列表测试"))
|
||||
|
||||
items, total = export_service.list_exports(limit=50, offset=0)
|
||||
assert total == 1
|
||||
assert items[0].job_id == finished.job_id
|
||||
|
||||
got = export_service.get_export(finished.job_id)
|
||||
assert got is not None and got.status == ExportStatus.completed
|
||||
|
||||
assert export_service.get_export("export_missing") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 契约校验
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_export_source_requires_matching_field() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExportSource(type=ExportSourceType.note, note_id=None)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportSource(type=ExportSourceType.markdown, markdown=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:资源上限
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_export_note_source_size_limit(monkeypatch) -> None:
|
||||
# P1:note 源超出 MAX_MARKDOWN_CHARS 应在创建期拒绝,不进入后台渲染
|
||||
from app.services import note_service
|
||||
|
||||
monkeypatch.setattr(export_service, "MAX_MARKDOWN_CHARS", 10)
|
||||
|
||||
async def _go():
|
||||
note = await note_service.create_note(
|
||||
title="超长笔记", markdown="a" * 20, folder="导出", tags=[]
|
||||
)
|
||||
return await export_service.create_export(
|
||||
ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
|
||||
format=ExportFormat.html,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(_go())
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
|
||||
|
||||
|
||||
def test_export_output_too_large(monkeypatch) -> None:
|
||||
# P1:产物超出 MAX_EXPORT_BYTES 应标记 failed 且不落盘
|
||||
monkeypatch.setattr(export_service, "MAX_EXPORT_BYTES", 10)
|
||||
|
||||
finished = _create_and_wait(_markdown_request("# 产物超限"))
|
||||
assert finished.status == ExportStatus.failed
|
||||
assert finished.error_code == "EXPORT_OUTPUT_TOO_LARGE"
|
||||
assert finished.file is None
|
||||
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
|
||||
|
||||
|
||||
def test_export_limits_concurrent_rendering(monkeypatch) -> None:
|
||||
# P1:并发渲染受 MAX_CONCURRENT_RENDERS 限制,大量任务不会同时占满工作线程
|
||||
import threading
|
||||
import time
|
||||
|
||||
real_render = export_service._render_document
|
||||
active = 0
|
||||
peak = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def slow_render(document, options):
|
||||
nonlocal active, peak
|
||||
with lock:
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
time.sleep(0.05)
|
||||
with lock:
|
||||
active -= 1
|
||||
return real_render(document, options)
|
||||
|
||||
monkeypatch.setattr(export_service, "_render_document", slow_render)
|
||||
|
||||
async def _go():
|
||||
jobs = [
|
||||
await export_service.create_export(_markdown_request(f"# t{i}"))
|
||||
for i in range(6)
|
||||
]
|
||||
return [await export_service.wait_for_export(j.job_id) for j in jobs]
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert all(j.status == ExportStatus.completed for j in finished)
|
||||
assert peak <= export_service.MAX_CONCURRENT_RENDERS
|
||||
@@ -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())
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Function Plot 的解析与静态 SVG 渲染测试。
|
||||
|
||||
覆盖 parser 的白名单表达式(幂/隐式乘法/函数/常量)、拒绝项(属性访问、任意调用等)、
|
||||
parse_source 指令与回退,以及 render 的 SVG 输出与 HTML 导出链路集成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.plot.parser import PlotParseError, evaluate, parse_expression, parse_source
|
||||
from app.plot.render import render_svg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 表达式解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_expression_power_and_implicit_multiplication() -> None:
|
||||
assert evaluate(parse_expression("x^2"), 3) == 9.0
|
||||
assert evaluate(parse_expression("2^3"), 0) == 8.0
|
||||
assert evaluate(parse_expression("2x+1"), 3) == 7.0
|
||||
assert evaluate(parse_expression("2(x+1)"), 3) == 8.0
|
||||
assert evaluate(parse_expression("(x+1)(x-1)"), 3) == 8.0
|
||||
|
||||
|
||||
def test_parse_expression_functions_and_constants() -> None:
|
||||
assert evaluate(parse_expression("sin(0)"), 0) == 0.0
|
||||
assert math.isclose(evaluate(parse_expression("sin(pi/2)"), 0), 1.0)
|
||||
assert math.isclose(evaluate(parse_expression("ln(e)"), 0), 1.0)
|
||||
assert evaluate(parse_expression("abs(-3)"), 0) == 3.0
|
||||
|
||||
|
||||
def test_parse_expression_rejects_unsafe() -> None:
|
||||
unsafe = [
|
||||
"os.system('x')",
|
||||
"__import__('os')",
|
||||
"foo(x)",
|
||||
"eval('x')",
|
||||
"x[0]",
|
||||
"x.attr",
|
||||
"lambda: 1",
|
||||
]
|
||||
for expr in unsafe:
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE", expr
|
||||
|
||||
|
||||
def test_parse_expression_syntax_error() -> None:
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression("x +")
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_PARSE_FAILED"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fenced 源码解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_source_directives() -> None:
|
||||
result = parse_source("domain: 0, 10\nrange: -1, 1\nxlabel: x\ngrid: false\ny = x^2")
|
||||
assert result.plot is not None
|
||||
assert result.plot.domain == (0.0, 10.0)
|
||||
assert result.plot.range == (-1.0, 1.0)
|
||||
assert result.plot.axes.xlabel == "x"
|
||||
assert result.plot.axes.grid is False
|
||||
assert len(result.plot.expressions) == 1
|
||||
assert result.plot.expressions[0].expression == "x^2"
|
||||
|
||||
|
||||
def test_parse_source_bare_and_multi_expression() -> None:
|
||||
result = parse_source("x^2\nsin(x)")
|
||||
assert result.plot is not None
|
||||
assert [e.expression for e in result.plot.expressions] == ["x^2", "sin(x)"]
|
||||
|
||||
|
||||
def test_parse_source_unknown_directive_warns() -> None:
|
||||
result = parse_source("foo: bar\ny = x")
|
||||
assert result.plot is not None # 未知指令仅 warning,不阻断
|
||||
assert any(d.severity == "warning" for d in result.diagnostics)
|
||||
|
||||
|
||||
def test_parse_source_error_returns_no_plot() -> None:
|
||||
result = parse_source("y = os.system('x')")
|
||||
assert result.plot is None
|
||||
assert any(d.severity == "error" for d in result.diagnostics)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SVG 渲染
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_render_svg_contains_polyline_and_axes() -> None:
|
||||
plot = parse_source("y = x^2").plot
|
||||
rendered = render_svg(plot)
|
||||
svg = rendered.content
|
||||
assert "<svg" in svg
|
||||
assert "<polyline" in svg
|
||||
assert "<line" in svg # 坐标轴/网格
|
||||
assert "<script" not in svg
|
||||
assert rendered.width == 640
|
||||
assert rendered.height == 480
|
||||
|
||||
|
||||
def test_render_svg_multiple_functions() -> None:
|
||||
plot = parse_source("y = x^2\ny = sin(x)").plot
|
||||
rendered = render_svg(plot)
|
||||
assert rendered.content.count("<polyline") >= 2
|
||||
|
||||
|
||||
def test_render_svg_labels() -> None:
|
||||
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "时间" in rendered.content
|
||||
assert "数值" in rendered.content
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTML 导出链路集成
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_html_exporter_embeds_function_plot_svg() -> None:
|
||||
md = "```function-plot\ny = x^2\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<figure class="function-plot">' in html
|
||||
assert "<svg" in html
|
||||
assert "<polyline" in html
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_fallback_on_error() -> None:
|
||||
md = "```function-plot\ny = os.system('x')\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert "<svg" not in html
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:浮点刻度 / 求值异常 / 无效范围
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_render_svg_huge_domain_ticks_bounded() -> None:
|
||||
# P1:巨大 domain 下步长受浮点精度限制无法推进,刻度应有限而非死循环
|
||||
plot = parse_source("domain: 10000000000000000, 10000000000000002\nrange: -1, 1\ny = 0").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
|
||||
|
||||
def test_parse_expression_rejects_wrong_arg_count() -> None:
|
||||
# P2:sin() / sin(1, 2) 应在解析期拒绝,而非求值期 TypeError
|
||||
with pytest.raises(PlotParseError):
|
||||
parse_expression("sin()")
|
||||
with pytest.raises(PlotParseError):
|
||||
parse_expression("sin(1, 2)")
|
||||
|
||||
|
||||
def test_render_svg_nonreal_samples_are_break_points() -> None:
|
||||
# P2:x^0.5 在负数域产生复数,应作为断点处理,正半轴仍可绘制
|
||||
plot = parse_source("domain: -4, 4\ny = x^0.5").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
|
||||
|
||||
def test_render_svg_invalid_range_falls_back() -> None:
|
||||
# P2:退化 range(1, 1)应丢弃并自动采样,而非 ZeroDivisionError
|
||||
plot = parse_source("range: 1, 1\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
assert any("range" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
def test_render_svg_nonfinite_range_falls_back() -> None:
|
||||
# P2:非有限 range 端点应丢弃并自动采样
|
||||
plot = parse_source("range: nan, 1\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_render_error_falls_back(monkeypatch) -> None:
|
||||
# P2:渲染异常不阻断整篇导出,回退占位并记 warning
|
||||
import app.export.exporters.html as html_mod
|
||||
|
||||
def boom(plot):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(html_mod, "render_svg", boom)
|
||||
md = "```function-plot\ny = x\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert any("渲染失败" in w for w in result.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:复杂表达式 / 极端数值范围
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_expression_rejects_excessive_depth() -> None:
|
||||
# P2:超长加法链的 AST 深度超限,应拒绝为 PlotParseError 而非触发 RecursionError
|
||||
expr = "+".join(["1"] * 300)
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
|
||||
|
||||
|
||||
def test_parse_expression_rejects_excessive_nodes() -> None:
|
||||
# P2:浅层但节点超限的表达式(满二叉树)应被节点数上限拦截
|
||||
def balanced(depth: int) -> str:
|
||||
if depth == 0:
|
||||
return "x"
|
||||
return f"({balanced(depth - 1)}+{balanced(depth - 1)})"
|
||||
|
||||
expr = balanced(10) # ~2047 个节点,深度仅 ~10
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_deep_expression_falls_back() -> None:
|
||||
# P2:复杂表达式解析失败应回退占位,不阻断整篇导出
|
||||
expr = "+".join(["1"] * 300)
|
||||
md = f"```function-plot\ny = {expr}\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert "<svg" not in html
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_render_svg_extreme_domain_no_nan() -> None:
|
||||
# P2:有限但跨度溢出的 domain 应回退安全范围,SVG 不得含 nan/inf
|
||||
plot = parse_source("domain: -1e308, 1e308\nrange: -1, 1\ny = 0").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
assert "nan" not in rendered.content
|
||||
assert "inf" not in rendered.content
|
||||
assert any("domain" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
def test_render_svg_extreme_range_no_nan() -> None:
|
||||
# P2:有限但跨度溢出的 range 应回退自动范围,SVG 不得含 nan/inf
|
||||
plot = parse_source("domain: -1, 1\nrange: -1e308, 1e308\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
assert "nan" not in rendered.content
|
||||
assert "inf" not in rendered.content
|
||||
assert any("range" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:函数/图像数量上限
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_source_rejects_too_many_expressions() -> None:
|
||||
# P1:单块表达式数量超限应整块回退,避免海量采样求值
|
||||
source = "\n".join(f"y = x + {i}" for i in range(50))
|
||||
result = parse_source(source)
|
||||
assert result.plot is None
|
||||
assert any(d.code == "FUNCTION_PLOT_TOO_MANY_EXPRESSIONS" for d in result.diagnostics)
|
||||
|
||||
|
||||
def test_html_exporter_limits_function_plot_count() -> None:
|
||||
# P1:文档内函数图像数量超限,超出部分回退占位,不耗尽资源
|
||||
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(blocks), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
# 上限 16:前 16 个渲染为 SVG,其余 4 个回退占位
|
||||
assert html.count('<figure class="function-plot">') == 16
|
||||
assert html.count('<pre class="function-plot">') == 4
|
||||
assert any("函数图像数量超过上限" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_limits_total_plot_nodes(monkeypatch) -> None:
|
||||
# P1:文档级累计 AST 节点预算超限后,后续图像回退占位,防止组合复杂度耗尽 CPU
|
||||
import app.export.exporters.html as html_mod
|
||||
|
||||
monkeypatch.setattr(html_mod, "_MAX_TOTAL_PLOT_NODES", 5)
|
||||
# 第一个图块 y=x(1 节点)在预算内;第二个图块 y=x+x+x+x(7 节点)累计超限
|
||||
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert html.count('<figure class="function-plot">') == 1
|
||||
assert html.count('<pre class="function-plot">') == 1
|
||||
assert any("累计复杂度" in w for w in result.warnings)
|
||||
Generated
+11
@@ -364,6 +364,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notes-agent-backend"
|
||||
version = "0.1.0"
|
||||
@@ -373,6 +382,7 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mistune" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "sqlite-vec" },
|
||||
@@ -390,6 +400,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
{ name = "mistune", specifier = ">=3.0,<4.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "referencing", specifier = ">=0.36,<1.0" },
|
||||
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- [后端接口契约](contracts/后端接口契约-开发版.md)
|
||||
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
|
||||
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
|
||||
- [Tauri / Rust 桌面客户端需求说明(第三阶段,计划)](contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md)
|
||||
|
||||
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [Benchmark 开发说明](development/Benchmark开发说明.md)
|
||||
- [Export 开发说明](development/Export开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
||||
- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md)
|
||||
|
||||
@@ -2365,7 +2365,7 @@ Quality
|
||||
└── Retrieval 参数调优
|
||||
|
||||
Content Output
|
||||
├── Markdown → HTML / PDF / DOCX
|
||||
├── Markdown → HTML(已实现)/ PDF / DOCX(暂缓)
|
||||
├── Mermaid 编辑、预览与静态导出
|
||||
└── Function Plot 解析、预览与静态导出
|
||||
|
||||
@@ -2376,7 +2376,7 @@ Frontend Extension
|
||||
└── Plugin Settings UI
|
||||
```
|
||||
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark 和 Provider 增强已经实现;Agent Benchmark、内容导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark、Provider 增强与 Markdown → HTML 导出已经实现;Agent Benchmark、PDF/DOCX 导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
|
||||
第三阶段处理:
|
||||
|
||||
|
||||
@@ -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 能力均标为计划实现。
|
||||
@@ -68,11 +68,11 @@
|
||||
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
|
||||
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
|
||||
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
|
||||
| Export | POST | `/api/exports` | 计划新增 | 创建 HTML/PDF/DOCX 导出任务 |
|
||||
| Export | GET | `/api/exports` | 计划新增 | 分页获取导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}` | 计划新增 | 查询导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}/file` | 计划新增 | 下载已完成产物 |
|
||||
| Export | POST | `/api/exports/{job_id}/cancel` | 计划新增 | 取消导出任务 |
|
||||
| Export | POST | `/api/exports` | 已实现(HTML) | 创建导出任务;`pdf`/`docx` 暂缓,返回 `EXPORT_FORMAT_UNSUPPORTED` |
|
||||
| Export | GET | `/api/exports` | 已实现(HTML) | 分页获取导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}` | 已实现(HTML) | 查询导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}/file` | 已实现(HTML) | 下载已完成产物 |
|
||||
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现(HTML) | 取消导出任务 |
|
||||
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
||||
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
|
||||
|
||||
@@ -1080,6 +1080,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
|
||||
## 10. Export Service
|
||||
|
||||
> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。
|
||||
|
||||
### 10.1 创建导出任务
|
||||
|
||||
`POST /api/exports`,返回 `202 ExportJob`。
|
||||
@@ -1090,7 +1092,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
"type": "note",
|
||||
"note_id": "note_123"
|
||||
},
|
||||
"format": "pdf",
|
||||
"format": "html",
|
||||
"options": {
|
||||
"theme_id": "light",
|
||||
"include_title": true,
|
||||
@@ -1101,7 +1103,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
}
|
||||
```
|
||||
|
||||
`source.type` 首批支持 `note` 和 `markdown`。`markdown` 来源用于尚未保存的预览,字段大小受限且不持久化到 Trace。`format` 固定为 `html`、`pdf`、`docx`。
|
||||
`source.type` 首批支持 `note` 和 `markdown`。`note` 来源通过 `source.note_id` 引用已建索引笔记;`markdown` 来源用于尚未保存的预览,内容放在 `source.markdown` 字段,大小限制为 200 000 字符、不持久化到 Trace。`format` 可取 `html`、`pdf`、`docx`,但当前仅 `html` 已实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`。
|
||||
|
||||
响应:
|
||||
|
||||
@@ -1109,11 +1111,12 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
{
|
||||
"job_id": "export_123",
|
||||
"status": "queued",
|
||||
"format": "pdf",
|
||||
"format": "html",
|
||||
"progress": null,
|
||||
"file": null,
|
||||
"warnings": [],
|
||||
"error": null,
|
||||
"error_code": null,
|
||||
"created_at": "2026-08-31T10:30:00Z",
|
||||
"started_at": null,
|
||||
"completed_at": null
|
||||
@@ -1129,14 +1132,14 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
| POST | `/api/exports/{job_id}/cancel` | `OperationResponse` |
|
||||
| GET | `/api/exports/{job_id}/file` | 文件流 |
|
||||
|
||||
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期 Job 不返回空文件。
|
||||
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期的 Job 不返回空文件:未完成/失败返回 `EXPORT_JOB_NOT_FOUND`(404),产物过期(超过 `expires_at`)返回 `EXPORT_FILE_EXPIRED`(410)。
|
||||
|
||||
完成 Job 的 file:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_name": "操作系统复习.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"file_name": "操作系统复习.html",
|
||||
"mime_type": "text/html",
|
||||
"size": 1048576,
|
||||
"sha256": "...",
|
||||
"expires_at": "2026-09-01T10:30:00Z"
|
||||
@@ -1225,6 +1228,7 @@ EXPORT_RENDER_FAILED
|
||||
EXPORT_UNSUPPORTED_CONTENT
|
||||
EXPORT_JOB_NOT_FOUND
|
||||
EXPORT_FILE_EXPIRED
|
||||
EXPORT_OUTPUT_TOO_LARGE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Export 开发说明
|
||||
|
||||
> 所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML 的完整生命周期与 function-plot 静态 SVG 渲染;PDF/DOCX 在后续 PR 补齐。契约对应 [第二阶段接口契约 §10](../contracts/第二阶段接口契约-开发版.md)。
|
||||
|
||||
## 定位
|
||||
|
||||
Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTML 文件。采用与 Benchmark 一致的「创建即返回 queued、后台 asyncio.Task 执行」的内存模型,产物带 24h 过期时间,过期后不可下载。导出是轮询式(无 SSE 事件流),客户端通过 `GET /api/exports/{job_id}` 轮询状态,完成后走 `GET /api/exports/{job_id}/file` 下载。
|
||||
|
||||
## 模块布局
|
||||
|
||||
```text
|
||||
backend/app/export/
|
||||
├── __init__.py 包说明
|
||||
├── document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
├── markdown.py mistune 'ast' renderer → Document AST
|
||||
├── exporters/
|
||||
│ ├── __init__.py
|
||||
│ └── html.py HtmlExporter(Document AST → 完整 HTML5)
|
||||
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
|
||||
```
|
||||
|
||||
HTTP DTO(`ExportStatus` / `ExportFormat` / `ExportSource` / `ExportOptions` / `ExportJob` 等)放在 [app/contracts.py](../../backend/app/contracts.py),与 Benchmark DTO 同层;`DocumentNode` / `ExportResult` 属导出器内部协议,放在 `export/document.py`,不进入 HTTP 契约。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/exports` | 创建导出任务(202) |
|
||||
| GET | `/api/exports?status=&format=&limit=&offset=` | 分页获取任务 |
|
||||
| GET | `/api/exports/{job_id}` | 查询任务状态 |
|
||||
| GET | `/api/exports/{job_id}/file` | 下载已完成产物 |
|
||||
| POST | `/api/exports/{job_id}/cancel` | 取消任务 |
|
||||
|
||||
`source.type` 支持 `note`(引用已建索引笔记)与 `markdown`(未保存预览,字段为 `source.markdown`,上限 200 000 字符)。当前仅 `format=html` 实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`。
|
||||
|
||||
## Markdown → Document AST
|
||||
|
||||
解析用 [mistune](https://github.com/lepture/mistune) 的内置 `renderer="ast"`(非自写 `BaseRenderer`),因为 mistune 的行内渲染按字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 `children`/`attrs`/`raw` 的 token 树,`_AstMapper` 只做 token → `DocumentNode` 的搬运,不掺入任何 HTML。插件启用 `table`、`math`、`url`、`task_lists`。
|
||||
|
||||
fenced code 按语言分流:`mermaid` → `mermaid` 节点、`function_plot`/`functionplot` → `function_plot` 节点,其余 → `code_block`(`attributes.language`)。`node_id` 按遍历顺序 `node_{seq:03d}` 生成,仅渲染内部使用,无需跨请求稳定。
|
||||
|
||||
## HtmlExporter
|
||||
|
||||
递归渲染 Document AST 为完整 HTML5 文档(`<!doctype html>` + `<head>` 内嵌基础 CSS + `<body>`),标题/正文/元信息文本一律 `html.escape`。`function_plot` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `<pre class="function-plot">` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `<pre class="mermaid">` 并记 warning,均不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
|
||||
|
||||
## 运行生命周期
|
||||
|
||||
`queued → running → completed | failed | cancelled`。
|
||||
|
||||
- 创建时校验:`format` 非 html → `EXPORT_FORMAT_UNSUPPORTED`;`note` 源不存在 → `EXPORT_SOURCE_NOT_FOUND`(404);`markdown` 源为空或超上限 → `EXPORT_OPTIONS_INVALID`。
|
||||
- 内存注册表上限 `MAX_JOBS=100`,超限只淘汰终态任务;满容量且全为活动任务时返回 `EXPORT_CAPACITY_EXCEEDED`(429)。
|
||||
- 后台渲染在解析前后各让出一次执行权,使「创建后立即取消」的 queued 任务能及时进入 cancelled。
|
||||
- 失败只向公开响应暴露项目错误码与安全消息,详细异常进入日志。
|
||||
|
||||
## 产物生命周期
|
||||
|
||||
产物写入 `settings.exports_path`(默认 `backend/data/exports/`,可通过 `APP_EXPORTS_PATH` 覆盖,已加入 `.gitignore`),文件名为 `{job_id}.html`,下载 `Content-Disposition` 用 `_safe_download_name` 清洗标题得到。`ExportFile` 记录 `sha256`、`size` 与 `expires_at`(`completed_at + 24h`),过期返回 `EXPORT_FILE_EXPIRED`(410)。
|
||||
|
||||
## 资源上限
|
||||
|
||||
为防止超大输入或海量函数图像耗尽内存/线程,导出链路内置以下上限:
|
||||
|
||||
- 输入源(`note` 与 `markdown`)统一限制 `MAX_MARKDOWN_CHARS = 200_000` 字符,超限返回 `EXPORT_OPTIONS_INVALID`。
|
||||
- 单个 `function-plot` 图块最多 16 条表达式,超限整块回退占位并记结构化诊断 `FUNCTION_PLOT_TOO_MANY_EXPRESSIONS`。
|
||||
- 单篇文档最多 16 个函数图像,超出部分回退占位并记 warning。
|
||||
- 单篇文档累计函数图像 AST 节点预算 `_MAX_TOTAL_PLOT_NODES = 8000`,超出部分回退占位并记 warning,防止多图块 × 多表达式 × 深表达式组合在采样求值时长时间占满 CPU。
|
||||
- 并发渲染上限 `MAX_CONCURRENT_RENDERS = 2`,解析/渲染是 CPU 密集工作,超出限额的任务在内存中排队等待渲染槽位,避免大量任务同时占满工作线程与内存。
|
||||
- 最终产物大小上限 `MAX_EXPORT_BYTES = 20 MB`,超限任务标记 failed 并返回 `EXPORT_OUTPUT_TOO_LARGE`。
|
||||
|
||||
## 错误码
|
||||
|
||||
错误分两类:**同步错误**在创建/查询请求的 HTTP 响应里直接返回对应状态码;**异步任务错误**在创建时已返回 `202`,后续轮询 `GET /api/exports/{job_id}` 仍返回 `200`,错误通过任务状态与 `error_code` 字段暴露,**不映射 HTTP 状态码**。
|
||||
|
||||
同步错误:
|
||||
|
||||
```text
|
||||
EXPORT_SOURCE_NOT_FOUND 404
|
||||
EXPORT_FORMAT_UNSUPPORTED 400
|
||||
EXPORT_OPTIONS_INVALID 400
|
||||
EXPORT_UNSUPPORTED_CONTENT 422(预留)
|
||||
EXPORT_JOB_NOT_FOUND 404
|
||||
EXPORT_FILE_EXPIRED 410
|
||||
EXPORT_CAPACITY_EXCEEDED 429
|
||||
```
|
||||
|
||||
异步任务错误(轮询返回 `200`,字段形如 `{"status": "failed", "error_code": "..."}`):
|
||||
|
||||
```text
|
||||
EXPORT_RENDER_FAILED
|
||||
EXPORT_OUTPUT_TOO_LARGE
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、pdf 拒绝、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。
|
||||
|
||||
## 范围外(后续 PR)
|
||||
|
||||
- PDF / DOCX 导出(`python-docx` 等底层库在 PoC 后冻结,封装在 Exporter Adapter 内)。
|
||||
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
||||
- 代码语法高亮(当前仅 CSS class 占位)。
|
||||
@@ -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",
|
||||
|
||||
Generated
+38
-26
@@ -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: {}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -13,7 +13,7 @@ const emit = defineEmits<{
|
||||
(e: 'rendered', info: { width: number; height: number }): void
|
||||
}>()
|
||||
|
||||
const { mermaidTheme } = useMermaidTheme()
|
||||
const { mermaidTheme, themeId } = useMermaidTheme()
|
||||
const svgHtml = ref('')
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
@@ -52,7 +52,7 @@ async function doRender() {
|
||||
|
||||
onMounted(doRender)
|
||||
|
||||
watch(() => [props.source, mermaidTheme.value], () => { scale.value = 1; doRender() })
|
||||
watch(() => [props.source, mermaidTheme.value, themeId.value], () => { scale.value = 1; doRender() }, { flush: 'post' })
|
||||
|
||||
function zoomIn() { scale.value = Math.min(scale.value * 1.2, 5) }
|
||||
function zoomOut() { scale.value = Math.max(scale.value / 1.2, 0.2) }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import SecondarySidebar from './SecondarySidebar.vue'
|
||||
|
||||
it('resizes by keyboard, clamps bounds and restores the saved width', async () => {
|
||||
localStorage.removeItem('workspace-sidebar-width')
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
||||
await router.push('/')
|
||||
const options = { props: { component: 'file-tree' }, global: { plugins: [router], stubs: { FileTreePanel: true } } }
|
||||
let wrapper = mount(SecondarySidebar, options)
|
||||
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(localStorage.getItem('workspace-sidebar-width')).toBe('288')
|
||||
wrapper.unmount()
|
||||
wrapper = mount(SecondarySidebar, options)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('aside').attributes('style')).toContain('288px')
|
||||
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'Home' })
|
||||
expect(wrapper.get('aside').attributes('style')).toContain('200px')
|
||||
wrapper.unmount()
|
||||
localStorage.removeItem('workspace-sidebar-width')
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
|
||||
import ConversationListPanel from '@/features/chat/ConversationListPanel.vue'
|
||||
import RunListPanel from '@/features/agent/RunListPanel.vue'
|
||||
@@ -15,6 +15,40 @@ const props = defineProps<{
|
||||
|
||||
const route = useRoute()
|
||||
const routeName = computed(() => route.name as string)
|
||||
const sidebar = ref<HTMLElement | null>(null)
|
||||
const width = ref(272)
|
||||
const maxWidth = ref(520)
|
||||
let dragging = false
|
||||
function saveWidth() { try { localStorage.setItem('workspace-sidebar-width', String(width.value)) } catch { /* Keep resizing available when storage is unavailable. */ } }
|
||||
function clampWidth(value: number) { return Math.max(200, Math.min(maxWidth.value, value)) }
|
||||
function updateBounds() {
|
||||
maxWidth.value = Math.max(200, Math.min(520, window.innerWidth - (sidebar.value?.getBoundingClientRect().left ?? 0) - 320))
|
||||
width.value = clampWidth(width.value)
|
||||
}
|
||||
function beginResize(event: PointerEvent) {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
updateBounds()
|
||||
dragging = true
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
function resize(event: PointerEvent) {
|
||||
if (dragging) width.value = clampWidth(event.clientX - (sidebar.value?.getBoundingClientRect().left ?? 0))
|
||||
}
|
||||
function endResize() { if (dragging) { dragging = false; saveWidth() } }
|
||||
function resizeWithKeyboard(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
updateBounds()
|
||||
width.value = event.key === 'Home' ? 200 : event.key === 'End' ? maxWidth.value : clampWidth(width.value + (event.key === 'ArrowLeft' ? -16 : 16))
|
||||
saveWidth()
|
||||
}
|
||||
onMounted(() => {
|
||||
try { const saved = Number(localStorage.getItem('workspace-sidebar-width')); if (saved >= 200 && Number.isFinite(saved)) width.value = saved } catch { /* Use default width. */ }
|
||||
updateBounds()
|
||||
window.addEventListener('resize', updateBounds)
|
||||
})
|
||||
onBeforeUnmount(() => { endResize(); window.removeEventListener('resize', updateBounds) })
|
||||
|
||||
const sidebarTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
@@ -32,15 +66,15 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="secondary-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<aside ref="sidebar" class="secondary-sidebar" :style="component === 'file-tree' ? { width: `${width}px` } : undefined">
|
||||
<div v-if="component !== 'file-tree'" class="sidebar-header">
|
||||
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
|
||||
<div v-if="showSkillToggle" class="sidebar-tabs">
|
||||
<router-link to="/extensions/skills" class="tab" :class="{ active: routeName === 'skills' }">Skill</router-link>
|
||||
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" :class="{ 'file-sidebar-content': component === 'file-tree' }">
|
||||
<FileTreePanel v-if="component === 'file-tree'" />
|
||||
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
||||
<RunListPanel v-else-if="component === 'run-list'" />
|
||||
@@ -48,11 +82,13 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
||||
<TaskFiltersPanel v-else-if="component === 'task-filters'" />
|
||||
<ExtensionListPanel v-else-if="component === 'extension-list'" />
|
||||
</div>
|
||||
<div v-if="component === 'file-tree'" class="sidebar-resizer" role="separator" aria-orientation="vertical" :aria-label="t('调整文件侧栏宽度', 'Resize file sidebar')" :aria-valuenow="width" :aria-valuemin="200" :aria-valuemax="maxWidth" tabindex="0" @pointerdown="beginResize" @pointermove="resize" @pointerup="endResize" @pointercancel="endResize" @lostpointercapture="endResize" @keydown="resizeWithKeyboard" @dblclick="width = clampWidth(272); saveWidth()" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secondary-sidebar {
|
||||
position: relative;
|
||||
width: var(--sidebar-secondary-width);
|
||||
background: var(--color-surface-secondary);
|
||||
border-right: 1px solid var(--color-border-default);
|
||||
@@ -111,5 +147,8 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.sidebar-resizer { position: absolute; top: 0; bottom: 0; right: -3px; width: 6px; z-index: 20; cursor: col-resize; touch-action: none; }
|
||||
.sidebar-resizer:hover, .sidebar-resizer:focus-visible { background: var(--color-accent-secondary); outline: none; }
|
||||
.file-sidebar-content { min-height: 0; overflow: hidden; scrollbar-gutter: auto; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -7,6 +8,15 @@ import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
watch(() => editorStore.headingRequest, request => {
|
||||
const input = sourceEditor.value
|
||||
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
||||
input.focus()
|
||||
input.setSelectionRange(request.offset, request.offset)
|
||||
const lines = input.value.slice(0, request.offset).split('\n').length - 1
|
||||
input.scrollTop = lines * (parseFloat(getComputedStyle(input).lineHeight) || 24)
|
||||
})
|
||||
function updateContent(event: Event) {
|
||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
@@ -16,7 +26,7 @@ function updateContent(event: Event) {
|
||||
<template>
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import { indentWithTab } from '@codemirror/commands'
|
||||
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
|
||||
import './language-icons.css'
|
||||
import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||
import { installCodeBlockLabels } from './codeBlockLabels'
|
||||
import { createMermaidPreview } from './mermaidPreview'
|
||||
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import {
|
||||
createCodeBlockCommand,
|
||||
toggleEmphasisCommand,
|
||||
@@ -33,6 +37,22 @@ import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const metadata = ref(splitNoteMetadata(props.initialContent))
|
||||
const tagDraft = ref('')
|
||||
function setTags(tags: string[]) {
|
||||
if (!metadata.value || !crepe) return
|
||||
const prefix = updateMetadataTags(metadata.value, tags)
|
||||
const body = crepe.editor.action(getMarkdown())
|
||||
metadata.value = splitNoteMetadata(prefix + body)
|
||||
editorStore.updateContent(prefix + body)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
}
|
||||
function addTags() {
|
||||
const tags = tagDraft.value.split(/[,,]/).map(tag => tag.trim()).filter(tag => tag && !/[\r\n"\\]/.test(tag))
|
||||
if (!tags.length || !metadata.value) return
|
||||
setTags([...metadata.value.tags, ...tags])
|
||||
tagDraft.value = ''
|
||||
}
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
@@ -41,6 +61,23 @@ const loading = ref(true)
|
||||
const fontSizeInput = ref(16)
|
||||
let crepe: Crepe | null = null
|
||||
let disposeLanguagePicker: (() => void) | undefined
|
||||
let disposeCodeLabels: (() => void) | undefined
|
||||
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
|
||||
for (const [id, entry] of diagramPreviews) {
|
||||
if (entry.apply === apply) diagramPreviews.delete(id)
|
||||
}
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply)
|
||||
diagramPreviews.set(element.id, { source, apply })
|
||||
return element
|
||||
}
|
||||
watch(() => themeStore.currentThemeId, () => {
|
||||
const current = [...diagramPreviews.entries()]
|
||||
diagramPreviews.clear()
|
||||
for (const [id, entry] of current) {
|
||||
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply))
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
function applyProofingPreferences() {
|
||||
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
|
||||
@@ -120,12 +157,14 @@ function applyFontSizeValue() {
|
||||
onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.initialContent,
|
||||
defaultValue: metadata.value?.body ?? props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false },
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
previewOnlyByDefault: false,
|
||||
previewOnlyByDefault: true,
|
||||
previewToggleText: previewOnly => previewOnly ? t('编辑', 'Edit') : t('预览', 'Preview'),
|
||||
previewLabel: t('图表预览', 'Preview'),
|
||||
searchPlaceholder: t('搜索语言', 'Search languages'),
|
||||
noResultText: t('没有匹配的语言', 'No matching language'),
|
||||
copyText: t('复制', 'Copy'),
|
||||
@@ -182,26 +221,44 @@ onMounted(async () => {
|
||||
...config,
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
renderLanguage: renderCodeLanguage,
|
||||
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
|
||||
? renderDiagram(content, applyPreview)
|
||||
: config.renderPreview(language, content, applyPreview),
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
||||
})))
|
||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
||||
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
||||
editorStore.updateContent(markdown)
|
||||
const fullMarkdown = (metadata.value?.prefix ?? '') + markdown
|
||||
if (markdown === previousMarkdown || fullMarkdown === editorStore.content) return
|
||||
editorStore.updateContent(fullMarkdown)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
})
|
||||
})
|
||||
await crepe.create()
|
||||
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
|
||||
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
|
||||
applyProofingPreferences()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
|
||||
watch(() => editorStore.headingRequest, request => {
|
||||
if (!request || request.path !== editorStore.currentFilePath || !crepe) return
|
||||
crepe.editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
let index = 0
|
||||
view.state.doc.forEach((node, offset) => {
|
||||
if (node.type.name !== 'heading') return
|
||||
if (index++ !== request.index) return
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1)).scrollIntoView())
|
||||
view.focus()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
@@ -244,7 +301,18 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
|
||||
</div>
|
||||
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器…', 'Loading editor…') }}</div>
|
||||
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
|
||||
<div class="milkdown-host" :class="{ loading }">
|
||||
<section v-if="metadata" class="note-metadata" :aria-label="t('笔记属性', 'Note properties')">
|
||||
<span class="metadata-caption">{{ t('笔记属性', 'Note properties') }}</span>
|
||||
<h1 v-if="metadata.title">{{ metadata.title }}</h1>
|
||||
<div class="metadata-tags">
|
||||
<span class="metadata-label">{{ t('标签', 'Tags') }}</span>
|
||||
<span v-for="tag in metadata.tags" :key="tag" class="metadata-tag"><span>{{ tag }}</span><button type="button" :aria-label="`${t('移除标签', 'Remove tag')} ${tag}`" @click="setTags(metadata.tags.filter(item => item !== tag))">×</button></span>
|
||||
<form @submit.prevent="addTags"><input v-model="tagDraft" :aria-label="t('添加标签', 'Add tag')" :placeholder="t('+ 添加标签', '+ Add tag')" /><button v-if="tagDraft.trim()" type="submit">{{ t('添加', 'Add') }}</button></form>
|
||||
</div>
|
||||
</section>
|
||||
<div ref="editorRoot" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 = '<div class="milkdown-code-block"><button class="language-button">Python</button></div>'
|
||||
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')
|
||||
})
|
||||
@@ -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<HTMLElement>('.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()
|
||||
}
|
||||
@@ -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: '<svg><text>new</text></svg>', 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: '<svg><text>old</text></svg>', 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: ['<img src=x onerror=alert(1)>'], 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('点击编辑')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { splitNoteMetadata, updateMetadataTags } from '@/utils/noteMetadata'
|
||||
export type { NoteMetadata } from '@/utils/noteMetadata'
|
||||
@@ -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 '<!doctype html>' + doc.documentElement.outerHTML
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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<HTMLInputElement>('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()
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { mockCommunityThemes } from '@/services/themePackageService'
|
||||
import { mockCommunityThemes, decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from '@/services/themePackageService'
|
||||
import type { ThemePackageInspection } from '@/contracts'
|
||||
import { t } from '@/i18n'
|
||||
import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const activeTab = ref<'installed' | 'community'>('installed')
|
||||
const showImportDialog = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const previewThemeId = ref<string | null>(null)
|
||||
const communityPreviewId = ref<string | null>(null)
|
||||
const actionError = ref('')
|
||||
const importUrl = ref('')
|
||||
const importing = ref(false)
|
||||
let importGeneration = 0
|
||||
let downloadController: AbortController | undefined
|
||||
|
||||
function resetImport() {
|
||||
importGeneration++
|
||||
downloadController?.abort()
|
||||
importing.value = false
|
||||
themeStore.pendingInspection = null
|
||||
themeStore.importError = null
|
||||
actionError.value = ''
|
||||
}
|
||||
function closeImport() { resetImport(); showImportDialog.value = false }
|
||||
function openImport() { resetImport(); showImportDialog.value = true }
|
||||
onBeforeUnmount(resetImport)
|
||||
|
||||
async function importPackage(load: () => Promise<string>) {
|
||||
resetImport()
|
||||
const generation = importGeneration
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await inspectThemePackage(await load())
|
||||
if (generation !== importGeneration) return
|
||||
themeStore.pendingInspection = result
|
||||
if (!result.compatible) actionError.value = result.warnings[0] ?? '主题包无法解析'
|
||||
} catch (error) {
|
||||
if (generation === importGeneration) actionError.value = error instanceof Error ? error.message : '导入失败'
|
||||
} finally { if (generation === importGeneration) importing.value = false }
|
||||
}
|
||||
|
||||
function importFromUrl() {
|
||||
void importPackage(() => {
|
||||
downloadController = new AbortController()
|
||||
return fetchThemePackage(importUrl.value, downloadController.signal)
|
||||
})
|
||||
}
|
||||
|
||||
const shikiPreview = `\`\`\`typescript
|
||||
const notes = await search('本地优先')
|
||||
@@ -30,23 +69,16 @@ function handleFileImport(event: Event) {
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
actionError.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const result = await themeStore.inspectThemePackage(String(reader.result ?? ''))
|
||||
if (result.compatible) {
|
||||
previewThemeId.value = result.manifest.theme_id
|
||||
} else {
|
||||
actionError.value = result.warnings[0] ?? '主题包无法解析'
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = error instanceof Error ? error.message : '导入失败'
|
||||
}
|
||||
}
|
||||
reader.onerror = () => { actionError.value = '文件读取失败' }
|
||||
// 主题包是文本格式(YAML 清单 + --- + CSS),二进制包在解析阶段会被拒绝。
|
||||
reader.readAsText(file)
|
||||
void importPackage(async () => {
|
||||
if (file.size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(new Error('文件读取失败'))
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
return decodeThemePackage(new Uint8Array(bytes))
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmInstall(inspection: ThemePackageInspection) {
|
||||
@@ -90,7 +122,7 @@ onMounted(() => {
|
||||
<p>浏览、导入和管理主题,打造你的知识工作流。</p>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
|
||||
<button class="button-secondary" @click="openImport">导入主题</button>
|
||||
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -124,7 +156,7 @@ onMounted(() => {
|
||||
:class="{ selected: themeStore.currentThemeId === theme.theme_id }"
|
||||
@click="themeStore.applyTheme(theme.theme_id)"
|
||||
>
|
||||
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
|
||||
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
|
||||
<span></span><span></span><span></span><div></div>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
@@ -150,7 +182,7 @@ onMounted(() => {
|
||||
:key="theme.theme_id"
|
||||
class="item-card theme-card"
|
||||
>
|
||||
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : 'preview-light'">
|
||||
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : 'preview-light'">
|
||||
<span></span><span></span><span></span><div></div>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
@@ -165,14 +197,15 @@ onMounted(() => {
|
||||
<span v-for="tag in theme.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="theme-actions">
|
||||
<a v-if="theme.theme_id === 'paper-moments'" class="button-secondary small" :href="paperMomentsUrl" download="paper-moments.theme">下载主题包</a>
|
||||
<button
|
||||
v-if="themeStore.isThemeInstalled(theme.theme_id)"
|
||||
v-if="themeStore.allThemes.some(installed => installed.theme_id === theme.theme_id && installed.version === theme.version)"
|
||||
class="button-secondary small"
|
||||
@click="themeStore.applyTheme(theme.theme_id)"
|
||||
>启用</button>
|
||||
<template v-else>
|
||||
<button class="button-secondary small" @click="previewCommunity(theme.theme_id)">预览</button>
|
||||
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">安装</button>
|
||||
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">{{ themeStore.isThemeInstalled(theme.theme_id) ? '更新' : '安装' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
@@ -193,11 +226,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showImportDialog" class="modal-backdrop" @click.self="showImportDialog = false">
|
||||
<div v-if="showImportDialog" class="modal-backdrop" @click.self="closeImport">
|
||||
<div class="modal import-modal">
|
||||
<span class="badge info">主题导入</span>
|
||||
<h2>导入主题包</h2>
|
||||
<p class="subtle">单文件主题包:YAML 清单 + 一行 <code>---</code> + 主题 CSS。安装前会校验清单与 CSS 安全性。</p>
|
||||
<p class="subtle">选择本地文件或粘贴主题包直链。支持单文件主题与 ZIP,安装前会校验清单和 CSS。</p>
|
||||
<p v-if="actionError" class="error-banner" role="alert">{{ actionError }}</p>
|
||||
|
||||
<div v-if="themeStore.pendingInspection?.compatible" class="inspection-result">
|
||||
<div class="inspect-head">
|
||||
@@ -222,13 +256,21 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-area">
|
||||
<input type="file" accept=".yaml,.yml,.theme" @change="handleFileImport" />
|
||||
<p>点击选择主题包文件</p>
|
||||
<p class="subtle">支持 .yaml / .yml / .theme;ZIP 需要 Host 端解压,暂不支持。</p>
|
||||
<input ref="fileInput" class="theme-file-input" type="file" accept=".yaml,.yml,.theme,.zip" tabindex="-1" aria-label="主题包文件" @change="handleFileImport" />
|
||||
<button type="button" class="button-primary" :disabled="importing" @click="fileInput?.click()">选择主题包文件</button>
|
||||
<p>从本地导入你喜欢的主题</p>
|
||||
<p class="subtle">支持 .yaml / .yml / .theme / .zip,最大 5 MB。</p>
|
||||
<form class="url-import" @submit.prevent="importFromUrl">
|
||||
<label for="theme-package-url">从 URL 导入</label>
|
||||
<input id="theme-package-url" v-model="importUrl" class="input" type="url" required placeholder="https://example.com/theme.zip" :disabled="importing" />
|
||||
<button class="button-secondary" type="submit" :disabled="importing">{{ importing ? '正在读取…' : '下载并校验' }}</button>
|
||||
<p class="subtle">请使用文件直链;远程服务器需允许跨域访问。</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="inline-actions">
|
||||
<button class="button-secondary" @click="showImportDialog = false">取消</button>
|
||||
<button v-if="themeStore.pendingInspection?.compatible" class="button-secondary" @click="resetImport">重新选择</button>
|
||||
<button class="button-secondary" @click="closeImport">取消</button>
|
||||
<button
|
||||
v-if="themeStore.pendingInspection?.compatible"
|
||||
class="button-primary"
|
||||
@@ -262,9 +304,16 @@ onMounted(() => {
|
||||
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }
|
||||
.preview-sepia span { background: #d8c69c; }
|
||||
.preview-sepia div { background: #f4e8ca; }
|
||||
.preview-paper { background: #fffdf5; border: 1px dashed #8b7865; box-shadow: 3px 3px 0 #d8e6e2, 6px 6px 0 #f0d8cf; }
|
||||
.preview-paper span { background: #efd8d0; }
|
||||
.preview-paper span:nth-child(2) { background: #d8e7e8; }
|
||||
.preview-paper span:nth-child(3) { background: #f6e9b8; }
|
||||
.preview-paper div { border: 1px solid #b5a693; background: repeating-linear-gradient(#fffef8 0 14px, #dce4db 14px 15px); }
|
||||
.theme-actions a { text-decoration: none; }
|
||||
|
||||
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
|
||||
.theme-info strong { display: block; margin-bottom: 2px; }
|
||||
.theme-info > .badge { flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.theme-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
@@ -347,10 +396,10 @@ onMounted(() => {
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.upload-area:hover { border-color: var(--color-accent-secondary); }
|
||||
.upload-area input {
|
||||
display: block;
|
||||
margin: 0 auto var(--space-md);
|
||||
}
|
||||
.url-import { display: grid; gap: 10px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--color-border-default); text-align: left; }
|
||||
.url-import .input { width: 100%; min-width: 0; }
|
||||
.upload-area .theme-file-input { display: none; }
|
||||
.upload-area > button { margin-bottom: var(--space-md); }
|
||||
.upload-area p { color: var(--color-text-secondary); }
|
||||
|
||||
.inspection-result {
|
||||
|
||||
@@ -48,6 +48,77 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('FileTreePanel file switching', () => {
|
||||
it('expands every nested folder from the toolbar', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
|
||||
await router.push('/workspace')
|
||||
const store = useWorkspaceStore()
|
||||
store.fileTree = [{ id: 'a', name: 'A', path: '/a', type: 'folder', is_open: false, children: [{ id: 'b', name: 'B', path: '/a/b', type: 'folder', is_open: false }] }]
|
||||
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
|
||||
await wrapper.get('[aria-label="全部展开文件夹"]').trigger('click')
|
||||
expect(store.fileTree[0]!.is_open).toBe(true)
|
||||
expect(store.fileTree[0]!.children![0]!.is_open).toBe(true)
|
||||
})
|
||||
it('switches full-height panels using tabs and preserves the file search', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
|
||||
await router.push('/workspace')
|
||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
|
||||
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(false)
|
||||
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
|
||||
await wrapper.get('.file-search input').setValue('笔记')
|
||||
await wrapper.get('#workspace-outline-tab').trigger('click')
|
||||
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(false)
|
||||
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(true)
|
||||
expect(wrapper.get('#workspace-outline-tab').attributes('aria-selected')).toBe('true')
|
||||
await wrapper.get('#workspace-outline-tab').trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
|
||||
expect((wrapper.get('.file-search input').element as HTMLInputElement).value).toBe('笔记')
|
||||
})
|
||||
it('reveals search on upward wheel and filters without changing folder state', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
|
||||
await router.push('/workspace')
|
||||
const store = useWorkspaceStore()
|
||||
await store.openVault('C:/vault')
|
||||
store.toggleFolder('/数据结构')
|
||||
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
|
||||
expect(wrapper.find('.file-search').exists()).toBe(false)
|
||||
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
|
||||
await wrapper.get('.file-search input').setValue('红黑')
|
||||
expect(wrapper.findAll('.tree-node').map(node => node.text())).toEqual(['数据结构', '红黑树.md'])
|
||||
expect(store.fileTree[0]!.is_open).toBe(false)
|
||||
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: 50 })
|
||||
expect(wrapper.find('.file-search').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('creates a folder through the file context menu in its containing directory', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
|
||||
await router.push('/workspace')
|
||||
await useWorkspaceStore().openVault('C:/vault')
|
||||
const create = vi.spyOn(workspaceService, 'createFolder').mockResolvedValue({ id: 'new', name: '子目录', path: '/数据结构/子目录', type: 'folder' })
|
||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await wrapper.findAll('.tree-node').find(node => node.text().includes('红黑树'))!.trigger('contextmenu')
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>('.context-menu button')].find(item => item.textContent === '新建文件夹')!
|
||||
button.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.get('.new-item input').setValue('子目录')
|
||||
await wrapper.get('.new-item').trigger('submit')
|
||||
expect(create).toHaveBeenCalledWith('/数据结构', '子目录')
|
||||
})
|
||||
|
||||
it('collapses nested headings and requests navigation to a duplicate heading', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
|
||||
await router.push('/workspace')
|
||||
const store = useEditorStore()
|
||||
store.currentFilePath = '/note.md'
|
||||
store.content = '# 标题\n\n## 子标题\n\n# 标题\n'
|
||||
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
|
||||
await wrapper.get('#workspace-outline-tab').trigger('click')
|
||||
expect(wrapper.findAll('.outline-title')).toHaveLength(3)
|
||||
await wrapper.get('.outline-row button[aria-expanded]').trigger('click')
|
||||
expect(wrapper.findAll('.outline-title')).toHaveLength(2)
|
||||
await wrapper.findAll('.outline-title')[1]!.trigger('click')
|
||||
expect(store.headingRequest).toEqual({ index: 2, offset: store.content.lastIndexOf('# 标题'), path: '/note.md' })
|
||||
})
|
||||
it('switches both workspace selection and editor content on consecutive clicks', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { noteOutline } from './outline'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import FileTreeNode from './FileTreeNode.vue'
|
||||
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
|
||||
import { Document, DocumentAdd, FolderAdd, ArrowRight } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@@ -22,6 +23,70 @@ const selectedFolderPath = ref(
|
||||
)
|
||||
const contextTarget = ref<FileNode | null>(null)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
const searchVisible = ref(false)
|
||||
const activeTab = ref<'files' | 'outline'>('files')
|
||||
function switchTab(tab: 'files' | 'outline') { activeTab.value = tab; closeContextMenu() }
|
||||
function navigateTabs(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
switchTab(event.key === 'Home' ? 'files' : event.key === 'End' ? 'outline' : activeTab.value === 'files' ? 'outline' : 'files')
|
||||
const parent = (event.target as HTMLElement).parentElement
|
||||
void nextTick(() => parent?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus())
|
||||
}
|
||||
const searchQuery = ref('')
|
||||
const searchFocused = ref(false)
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const createError = ref('')
|
||||
const creating = ref(false)
|
||||
const outline = computed(() => noteOutline(editorStore.content))
|
||||
const collapsedHeadings = ref(new Set<number>())
|
||||
const visibleHeadings = computed(() => {
|
||||
let hiddenBelow = 7
|
||||
return outline.value.filter(heading => {
|
||||
if (heading.level > hiddenBelow) return false
|
||||
hiddenBelow = collapsedHeadings.value.has(heading.index) ? heading.level : 7
|
||||
return true
|
||||
})
|
||||
})
|
||||
const hasChildren = (index: number) => {
|
||||
const position = outline.value.findIndex(heading => heading.index === index)
|
||||
return (outline.value[position + 1]?.level ?? 0) > (outline.value[position]?.level ?? 6)
|
||||
}
|
||||
function toggleHeading(index: number) {
|
||||
const next = new Set(collapsedHeadings.value)
|
||||
if (next.has(index)) next.delete(index); else next.add(index)
|
||||
collapsedHeadings.value = next
|
||||
}
|
||||
watch(() => editorStore.content, () => { collapsedHeadings.value = new Set() })
|
||||
const filteredTree = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
if (!query) return workspaceStore.fileTree
|
||||
const filter = (nodes: FileNode[]): FileNode[] => nodes.flatMap(node => {
|
||||
if (node.name.toLocaleLowerCase().includes(query)) return [{ ...node, is_open: true }]
|
||||
const children = filter(node.children ?? [])
|
||||
return children.length ? [{ ...node, children, is_open: true }] : []
|
||||
})
|
||||
return filter(workspaceStore.fileTree)
|
||||
})
|
||||
function expandAllFiles() {
|
||||
const expand = (nodes: FileNode[]) => nodes.forEach(node => {
|
||||
if (node.type === 'folder') { node.is_open = true; expand(node.children ?? []) }
|
||||
})
|
||||
expand(workspaceStore.fileTree)
|
||||
}
|
||||
let lastScrollTop = 0
|
||||
function revealSearch(event: WheelEvent) {
|
||||
if (activeTab.value !== 'files') return
|
||||
if (event.deltaY < 0) searchVisible.value = true
|
||||
else if (event.deltaY > 0 && !searchFocused.value && !searchQuery.value) searchVisible.value = false
|
||||
}
|
||||
function onTreeScroll(event: Event) {
|
||||
const top = (event.target as HTMLElement).scrollTop
|
||||
if (top < lastScrollTop) searchVisible.value = true
|
||||
else if (top > lastScrollTop && !searchFocused.value && !searchQuery.value) searchVisible.value = false
|
||||
lastScrollTop = top
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
watch(() => workspaceStore.activeFilePath, (path) => {
|
||||
if (!path) return
|
||||
@@ -30,14 +95,23 @@ watch(() => workspaceStore.activeFilePath, (path) => {
|
||||
})
|
||||
|
||||
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
||||
if (creating.value) return
|
||||
closeContextMenu()
|
||||
createError.value = ''
|
||||
newItemType.value = type
|
||||
newItemName.value = ''
|
||||
parentPath.value = parent
|
||||
void nextTick(() => createInput.value?.focus())
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
const rawName = newItemName.value.trim()
|
||||
if (!rawName || !newItemType.value) return
|
||||
if (creating.value) return
|
||||
if (/[\\/]/.test(rawName) || ['.', '..'].includes(rawName)) { createError.value = t('请输入有效名称,不要包含路径分隔符', 'Enter a name without path separators'); return }
|
||||
creating.value = true
|
||||
createError.value = ''
|
||||
try {
|
||||
if (newItemType.value === 'file') {
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
||||
@@ -55,6 +129,8 @@ async function createItem() {
|
||||
}
|
||||
newItemType.value = null
|
||||
newItemName.value = ''
|
||||
} catch (error) { createError.value = error instanceof Error ? error.message : t('创建失败', 'Creation failed') }
|
||||
finally { creating.value = false }
|
||||
}
|
||||
|
||||
async function openNode(node: FileNode) {
|
||||
@@ -84,7 +160,7 @@ function openContextMenu(event: MouseEvent, node: FileNode) {
|
||||
selectedTreePath.value = node.path
|
||||
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
|
||||
contextTarget.value = node
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
contextMenuPosition.value = { x: Math.max(8, Math.min(event.clientX, window.innerWidth - 170)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 170)) }
|
||||
}
|
||||
|
||||
function closeContextMenu() { contextTarget.value = null }
|
||||
@@ -136,38 +212,106 @@ function containingFolder(path: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="file-tree-panel" @click="closeContextMenu">
|
||||
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
|
||||
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
|
||||
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
|
||||
<button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'files'" id="workspace-files-panel" class="files-panel" role="tabpanel" aria-labelledby="workspace-files-tab">
|
||||
<div class="toolbar">
|
||||
<button type="button" :title="t('新建笔记', 'New note')" :aria-label="t('新建笔记', 'New note')" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
|
||||
<button type="button" :title="t('新建文件夹', 'New folder')" :aria-label="t('新建文件夹', 'New folder')" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
|
||||
<button type="button" :aria-label="t('搜索文件', 'Search files')" :aria-expanded="searchVisible" @click="searchVisible = !searchVisible">{{ t('搜索', 'Search') }}</button>
|
||||
<button type="button" :aria-label="t('全部展开文件夹', 'Expand all folders')" @click="expandAllFiles">{{ t('全部展开', 'Expand all') }}</button>
|
||||
</div>
|
||||
<div v-if="searchVisible || searchQuery || searchFocused" class="file-search">
|
||||
<input v-model="searchQuery" type="search" :placeholder="t('搜索文件或文件夹…', 'Search files or folders…')" :aria-label="t('搜索文件或文件夹', 'Search files or folders')" @focus="searchFocused = true" @blur="searchFocused = false" />
|
||||
</div>
|
||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
|
||||
<button type="submit">{{ t('创建', 'Create') }}</button>
|
||||
<button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
|
||||
<input ref="createInput" v-model="newItemName" :disabled="creating" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" />
|
||||
<button type="submit" :disabled="creating">{{ t('创建', 'Create') }}</button>
|
||||
<button type="button" :disabled="creating" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
|
||||
</form>
|
||||
<div class="tree">
|
||||
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
|
||||
<p v-if="createError" class="create-error" role="alert">{{ createError }}</p>
|
||||
<div class="tree" @scroll.passive="onTreeScroll" @contextmenu.self="openContextMenu($event, { id: 'root', name: '/', path: '/', type: 'folder' })">
|
||||
<FileTreeNode v-for="node in filteredTree" :key="node.id" :node="node"
|
||||
:active-path="selectedTreePath" @open="openNode" @context-menu="openContextMenu" />
|
||||
<p v-if="searchQuery && !filteredTree.length" class="subtle">{{ t('没有匹配的文件', 'No matching files') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="activeTab === 'outline'" id="workspace-outline-panel" class="outline-panel" role="tabpanel" aria-labelledby="workspace-outline-tab">
|
||||
<div class="outline-document">
|
||||
<span class="outline-document-icon"><AppIcon :icon="Document" :size="18" /></span>
|
||||
<div class="outline-document-info">
|
||||
<p class="outline-filename" :title="editorStore.currentFilePath ?? ''">{{ editorStore.currentFilePath?.split('/').pop() ?? t('未打开笔记', 'No note open') }}</p>
|
||||
<span class="outline-meta">{{ t('文档目录', 'Contents') }} · {{ outline.length }} {{ t('个标题', 'headings') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="outline.length" class="outline-controls">
|
||||
<span>{{ t('目录', 'Contents') }}</span>
|
||||
<button :title="t('展开全部标题', 'Expand all headings')" @click="collapsedHeadings = new Set()">{{ t('全部展开', 'Expand all') }}</button>
|
||||
</div>
|
||||
<nav class="outline-list" :aria-label="t('当前笔记大纲', 'Current note outline')">
|
||||
<div v-for="heading in visibleHeadings" :key="heading.index" class="outline-row" :class="{ 'is-selected': editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index, 'is-nested': heading.level > 1 }" :style="{ marginLeft: `${(heading.level - 1) * 10}px` }">
|
||||
<button v-if="hasChildren(heading.index)" class="outline-toggle" :aria-label="t('折叠或展开标题', 'Toggle heading')" :aria-expanded="!collapsedHeadings.has(heading.index)" @click="toggleHeading(heading.index)"><AppIcon :icon="ArrowRight" :size="10" /></button>
|
||||
<span v-else class="outline-spacer" />
|
||||
<button class="outline-title" :title="heading.title" :aria-current="editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index ? 'location' : undefined" @click="editorStore.jumpToHeading(heading.index, heading.offset)"><span class="outline-text">{{ heading.title }}</span><span class="outline-level" aria-hidden="true">H{{ heading.level }}</span></button>
|
||||
</div>
|
||||
<div v-if="!outline.length" class="outline-empty"><AppIcon :icon="Document" :size="28" /><strong>{{ t('还没有目录', 'No outline yet') }}</strong><p>{{ t('在笔记中添加标题,即可在这里浏览和跳转。', 'Add headings to your note to navigate here.') }}</p></div>
|
||||
</nav>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="contextTarget" class="context-menu"
|
||||
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
|
||||
<button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
|
||||
<button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
|
||||
<button @click="beginCreate('file', selectedFolderPath)">{{ t('新建文件', 'New file') }}</button>
|
||||
<button @click="beginCreate('folder', selectedFolderPath)">{{ t('新建文件夹', 'New folder') }}</button>
|
||||
<button v-if="contextTarget.path !== '/'" @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
|
||||
<button v-if="contextTarget.path !== '/'" class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel { height: 100%; }
|
||||
.file-tree-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--color-surface-secondary); color: var(--color-text-primary); }
|
||||
.workspace-tabs { display: flex; flex-shrink: 0; gap: 4px; padding: 8px; border-bottom: 1px solid var(--color-border-default); background: var(--color-background-secondary); }
|
||||
.workspace-tabs button { flex: 1; min-height: 34px; font-weight: 600; color: var(--color-text-secondary); }
|
||||
.workspace-tabs button[aria-selected="true"] { background: var(--color-accent-soft); color: var(--color-accent-primary); box-shadow: inset 0 -2px var(--color-accent-primary); }
|
||||
.files-panel { display: flex; flex: 1; min-height: 0; flex-direction: column; }
|
||||
.file-tree-panel button:focus-visible, .context-menu button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: -2px; }
|
||||
.file-search { padding: 8px; }
|
||||
.file-search input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
|
||||
.create-error { padding: 8px; color: var(--color-error); }
|
||||
.outline-panel { flex: 1; min-height: 0; overflow: auto; }
|
||||
.outline-document { display: flex; align-items: center; gap: 10px; margin: 12px 10px; padding: 12px 10px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-sm); }
|
||||
.outline-document-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 36px; border-radius: var(--radius-sm); background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||
.outline-document-info { min-width: 0; }
|
||||
.outline-filename { margin: 0 0 4px; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-primary); font-size: var(--font-size-sm); font-weight: 600; border: 0; }
|
||||
.outline-meta { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||||
.outline-controls { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px 8px; color: var(--color-text-secondary); font-size: var(--font-size-xs); }
|
||||
.outline-controls button { color: var(--color-accent-primary); font-size: inherit; }
|
||||
.outline-list { padding: 0 10px 16px; }
|
||||
.outline-row { position: relative; display: flex; align-items: center; min-height: 34px; margin-bottom: 2px; padding: 0 6px 0 2px; border: 1px solid transparent; border-radius: var(--radius-sm); transition: background-color var(--motion-fast); }
|
||||
.outline-row:hover { background: var(--color-background-hover); }
|
||||
.outline-row.is-selected { background: var(--color-accent-soft); box-shadow: inset 2px 0 var(--color-accent-primary); }
|
||||
.outline-spacer, .outline-toggle { width: 18px; flex-shrink: 0; }
|
||||
.outline-row .outline-toggle { display: grid; place-items: center; padding: 4px 0; color: var(--color-text-secondary); }
|
||||
.outline-toggle[aria-expanded="true"] :deep(svg) { transform: rotate(90deg); }
|
||||
.outline-row .outline-title { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 6px 2px; text-align: left; background: transparent; }
|
||||
.outline-level { flex-shrink: 0; color: var(--color-text-tertiary); font: 400 10px/18px var(--font-ui-mono); }
|
||||
.outline-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-sm); line-height: 20px; }
|
||||
.is-selected .outline-text { color: var(--color-accent-primary); font-weight: 600; }
|
||||
.is-selected .outline-level { color: var(--color-accent-primary); }
|
||||
.outline-empty { display: grid; justify-items: center; gap: 10px; padding: 32px 16px; text-align: center; color: var(--color-text-secondary); }
|
||||
.outline-empty strong { color: var(--color-text-primary); font-size: var(--font-size-sm); }
|
||||
.outline-empty p { margin: 0; font-size: var(--font-size-xs); line-height: 1.7; }
|
||||
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
|
||||
button:hover { background: var(--color-background-secondary); }
|
||||
button:hover { background: var(--color-background-hover); }
|
||||
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
|
||||
.new-item input { min-width: 0; flex: 1; }
|
||||
.tree { padding: var(--space-xs); }
|
||||
.new-item input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
|
||||
.file-search input:focus, .new-item input:focus { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
|
||||
.tree { padding: var(--space-xs); flex: 1; min-height: 80px; overflow: auto; }
|
||||
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
|
||||
.context-menu button { text-align: left; }
|
||||
.context-menu .danger { color: var(--color-error); }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { noteOutline } from './outline'
|
||||
|
||||
it('hides legacy metadata while preserving editor heading positions', () => {
|
||||
const source = '***\n\ntitle: Python\ntags: python\n---\n\n# Variables\n'
|
||||
expect(noteOutline(source)).toEqual([{ index: 0, level: 1, title: 'Variables', offset: source.indexOf('# Variables') }])
|
||||
})
|
||||
|
||||
it('keeps duplicate headings distinct and skips code fences', () => {
|
||||
const source = '# Same\n\n```md\n# Not a heading\n```\n\n## Same\n\nSetext\n---\n'
|
||||
expect(noteOutline(source).map(h => [h.index, h.level, h.title, source.slice(h.offset, h.offset + 2)])).toEqual([
|
||||
[0, 1, 'Same', '# '], [1, 2, 'Same', '##'], [2, 2, 'Setext', 'Se'],
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { marked } from 'marked'
|
||||
import { splitNoteMetadata } from '../editor/noteMetadata'
|
||||
|
||||
export interface OutlineHeading { index: number; level: number; title: string; offset: number }
|
||||
|
||||
export function noteOutline(source: string): OutlineHeading[] {
|
||||
const headings: OutlineHeading[] = []
|
||||
const metadata = splitNoteMetadata(source)
|
||||
let offset = metadata?.prefix.length ?? 0
|
||||
let headingIndex = 0
|
||||
for (const token of marked.lexer(metadata?.body ?? source)) {
|
||||
const start = source.indexOf(token.raw, offset)
|
||||
if (token.type === 'heading') {
|
||||
const index = headingIndex++
|
||||
headings.push({ index, level: token.depth, title: token.text.replace(/[*_`]/g, ''), offset: Math.max(0, start) })
|
||||
}
|
||||
if (start >= 0) offset = start + token.raw.length
|
||||
}
|
||||
return headings
|
||||
}
|
||||
@@ -1,31 +1,45 @@
|
||||
import mermaid from 'mermaid'
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
let initialized = false
|
||||
let initTheme: 'light' | 'dark' = 'light'
|
||||
export function mermaidThemeVariables(dark: boolean) {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement)
|
||||
const color = (name: string, fallback: string) => style?.getPropertyValue(`--color-${name}`).trim() || fallback
|
||||
const text = color('text-primary', dark ? '#e6edf3' : '#1f2328')
|
||||
const border = color('border-default', dark ? '#484f58' : '#d0d7de')
|
||||
const surface = color('surface-primary', dark ? '#161b22' : '#ffffff')
|
||||
const primary = color('accent-soft', dark ? '#30363d' : '#eef0ff')
|
||||
const line = color('text-secondary', dark ? '#b1bac4' : '#656d76')
|
||||
return {
|
||||
darkMode: dark, background: surface, primaryColor: primary, primaryTextColor: text, primaryBorderColor: border,
|
||||
secondaryColor: color('info-soft', primary), secondaryTextColor: text, secondaryBorderColor: border,
|
||||
tertiaryColor: color('success-soft', primary), tertiaryTextColor: text, tertiaryBorderColor: border,
|
||||
textColor: text, lineColor: line, mainBkg: primary, nodeBorder: border,
|
||||
clusterBkg: surface, clusterBorder: border, edgeLabelBackground: surface,
|
||||
actorBkg: primary, actorBorder: border, actorTextColor: text, actorLineColor: line,
|
||||
signalColor: line, signalTextColor: text, labelBoxBkgColor: surface, labelBoxBorderColor: border, labelTextColor: text,
|
||||
noteBkgColor: color('warning-soft', primary), noteTextColor: text, noteBorderColor: border,
|
||||
activationBkgColor: primary, activationBorderColor: border,
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInitialized(theme: 'light' | 'dark') {
|
||||
if (!initialized) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
theme: 'base',
|
||||
themeVariables: mermaidThemeVariables(theme === 'dark'),
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'var(--font-ui-sans)',
|
||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
||||
sequence: { useMaxWidth: true },
|
||||
gantt: { useMaxWidth: true },
|
||||
})
|
||||
initialized = true
|
||||
initTheme = theme
|
||||
return
|
||||
}
|
||||
if (initTheme !== theme) {
|
||||
mermaid.initialize({
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
})
|
||||
initTheme = theme
|
||||
}
|
||||
}
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
function serialized<T>(work: () => Promise<T>): Promise<T> {
|
||||
const result = queue.then(work)
|
||||
queue = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
export interface MermaidRenderResult {
|
||||
@@ -43,7 +57,11 @@ export interface MermaidParseError {
|
||||
|
||||
let renderCounter = 0
|
||||
|
||||
export async function renderMermaid(
|
||||
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}): Promise<MermaidRenderResult> {
|
||||
return serialized(() => renderMermaidNow(source, options))
|
||||
}
|
||||
|
||||
async function renderMermaidNow(
|
||||
source: string,
|
||||
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
|
||||
): Promise<MermaidRenderResult> {
|
||||
@@ -106,18 +124,14 @@ function escapeXml(str: string): string {
|
||||
|
||||
export function useMermaidTheme() {
|
||||
const themeStore = useThemeStore()
|
||||
const mermaidTheme = ref<'light' | 'dark'>(themeStore.isDark ? 'dark' : 'light')
|
||||
watch(() => themeStore.isDark, (isDark) => {
|
||||
mermaidTheme.value = isDark ? 'dark' : 'light'
|
||||
ensureInitialized(mermaidTheme.value)
|
||||
})
|
||||
return { mermaidTheme }
|
||||
const mermaidTheme = computed<'light' | 'dark'>(() => themeStore.isDark ? 'dark' : 'light')
|
||||
const themeId = computed(() => themeStore.currentThemeId)
|
||||
return { mermaidTheme, themeId }
|
||||
}
|
||||
|
||||
export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> {
|
||||
try {
|
||||
ensureInitialized('light')
|
||||
await mermaid.parse(source)
|
||||
await serialized(async () => { ensureInitialized('light'); await mermaid.parse(source) })
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import mermaid from 'mermaid'
|
||||
import { mermaidThemeVariables, renderMermaid } from './mermaidService'
|
||||
|
||||
vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), render: vi.fn().mockResolvedValue({ svg: '<svg viewBox="0 0 10 10"></svg>' }) } }))
|
||||
afterEach(() => { document.documentElement.removeAttribute('style'); vi.clearAllMocks() })
|
||||
it('uses the current theme tokens for nodes, actors, text and lines', () => {
|
||||
document.documentElement.style.setProperty('--color-accent-soft', '#f3e1d8')
|
||||
document.documentElement.style.setProperty('--color-text-primary', '#493f35')
|
||||
const theme = mermaidThemeVariables(false)
|
||||
expect(theme.primaryColor).toBe('#f3e1d8')
|
||||
expect(theme.actorBkg).toBe('#f3e1d8')
|
||||
expect(theme.primaryTextColor).toBe('#493f35')
|
||||
expect(theme.actorTextColor).toBe('#493f35')
|
||||
})
|
||||
it('keeps explicit diagram styling and initializes base palette on each render', async () => {
|
||||
const source = 'graph TD; A-->B; style A fill:#f9f'
|
||||
await renderMermaid(source)
|
||||
expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'base', securityLevel: 'strict', themeVariables: expect.any(Object) }))
|
||||
expect(mermaid.render).toHaveBeenCalledWith(expect.any(String), source)
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from './themePackageService'
|
||||
import paper from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it('reads ZIP manifests under repository folders and validates the bundled CSS', async () => {
|
||||
const [yaml, css] = paper.split('\n---\n')
|
||||
const zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
|
||||
const result = await inspectThemePackage(await decodeThemePackage(zip))
|
||||
expect(result.compatible).toBe(true)
|
||||
expect(result.css).toBe(css!.trim())
|
||||
})
|
||||
it('accepts a zipped single-file theme', async () => {
|
||||
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
|
||||
})
|
||||
it('rejects unsafe paths, ambiguous manifests and oversized input', async () => {
|
||||
await expect(decodeThemePackage(zipSync({ '../paper.theme': strToU8(paper) }))).rejects.toThrow('非法')
|
||||
await expect(decodeThemePackage(zipSync({ 'theme.yaml': strToU8(paper), 'manifest.yml': strToU8(paper) }))).rejects.toThrow('多个')
|
||||
await expect(decodeThemePackage(new Uint8Array(MAX_THEME_BYTES + 1))).rejects.toThrow('5 MB')
|
||||
})
|
||||
it('uses the same ZIP parser for URL downloads without sending credentials', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(zipSync({ 'paper.theme': strToU8(paper) })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
expect(await fetchThemePackage('https://example.com/theme.zip')).toBe(paper)
|
||||
expect(fetcher).toHaveBeenCalledWith('https://example.com/theme.zip', expect.objectContaining({ credentials: 'omit' }))
|
||||
await expect(fetchThemePackage('file:///theme.zip')).rejects.toThrow('HTTP(S)')
|
||||
})
|
||||
it('reports HTTP and streaming size failures', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('', { status: 404 })).mockResolvedValueOnce(new Response(new Uint8Array(MAX_THEME_BYTES + 1))))
|
||||
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('404')
|
||||
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('5 MB')
|
||||
})
|
||||
@@ -1,7 +1,83 @@
|
||||
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
|
||||
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
export const MAX_THEME_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Normalize all transports to the existing single-file inspection format. */
|
||||
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
|
||||
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
|
||||
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return decode(bytes)
|
||||
const { unzipSync } = await import('fflate')
|
||||
let total = 0
|
||||
let count = 0
|
||||
const names = new Set<string>()
|
||||
const safePath = (path: string) => path.length > 0 && !path.startsWith('/') && !path.includes('\\') && !path.includes(':') && !path.split('/').some(part => part === '..' || part === '.')
|
||||
const files = unzipSync(bytes, { filter: file => {
|
||||
if (!safePath(file.name) || names.has(file.name)) throw new Error('ZIP 包含非法或重复路径')
|
||||
names.add(file.name)
|
||||
total += file.originalSize
|
||||
if (++count > 100 || total > 10 * 1024 * 1024) throw new Error('ZIP 解压内容不能超过 10 MB 或 100 个文件')
|
||||
return !file.name.endsWith('/')
|
||||
} })
|
||||
const entries = Object.keys(files)
|
||||
const manifests = entries.filter(name => /(^|\/)(theme|manifest)\.ya?ml$/i.test(name))
|
||||
if (!manifests.length) {
|
||||
const single = entries.filter(name => name.endsWith('.theme'))
|
||||
if (single.length !== 1) throw new Error('ZIP 需要唯一的 theme.yaml / manifest.yaml,或一个 .theme 文件')
|
||||
return decode(files[single[0]!]!)
|
||||
}
|
||||
if (manifests.length !== 1) throw new Error('ZIP 中存在多个主题清单,请每包只放一个主题')
|
||||
const manifestPath = manifests[0]!
|
||||
const yaml = decode(files[manifestPath]!)
|
||||
const manifest = inspectYamlContent(yaml)
|
||||
if (!safePath(manifest.css_entry)) throw new Error('css_entry 必须是包内相对路径')
|
||||
const base = manifestPath.slice(0, manifestPath.lastIndexOf('/') + 1)
|
||||
const css = files[base + manifest.css_entry]
|
||||
if (!css) throw new Error(`ZIP 中找不到 CSS 文件:${manifest.css_entry}`)
|
||||
return `${yaml}\n---\n${decode(css)}`
|
||||
}
|
||||
|
||||
export async function fetchThemePackage(urlText: string, signal?: AbortSignal): Promise<string> {
|
||||
const url = new URL(urlText.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('请输入不含账号密码的 HTTP(S) 主题包直链')
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
if (signal?.aborted) abort()
|
||||
const timeout = setTimeout(abort, 30000)
|
||||
try {
|
||||
const response = await fetch(url.href, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
||||
if (!response.ok) throw new Error(`下载失败:HTTP ${response.status}`)
|
||||
if (Number(response.headers.get('content-length')) > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
if (!response.body) throw new Error('下载内容为空')
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
size += value.length
|
||||
if (size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
chunks.push(value)
|
||||
}
|
||||
} finally { await reader.cancel() }
|
||||
const bytes = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
|
||||
return await decodeThemePackage(bytes)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error('下载已取消或超时,请重试')
|
||||
if (error instanceof TypeError) throw new Error('无法下载,请检查直链及服务器是否允许跨域访问(CORS)')
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredThemes(): InstalledTheme[] {
|
||||
try {
|
||||
@@ -113,8 +189,7 @@ function inspectYamlContent(yamlText: string): ThemeManifest {
|
||||
* ---
|
||||
* [data-theme="my-theme"] { --color-... }
|
||||
*
|
||||
* 浏览器端没有解压能力,所以不支持 ZIP —— 与其把二进制当文本解析出
|
||||
* 一堆乱码再报「清单无效」,不如直接告诉用户格式不支持。
|
||||
* ZIP 必须先通过 decodeThemePackage 解码;此函数只处理规范化后的文本。
|
||||
*/
|
||||
export function parseThemePackage(packageData: string): { manifestText: string; css: string } {
|
||||
if (looksLikeZip(packageData)) {
|
||||
@@ -146,19 +221,19 @@ function looksLikeZip(data: string): boolean {
|
||||
}
|
||||
|
||||
export async function selectThemePackage(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
// 只接受能在浏览器里解析的单文件主题;ZIP 需要 Host 端解压,暂不支持。
|
||||
input.accept = '.yaml,.yml,.theme'
|
||||
input.accept = '.yaml,.yml,.theme,.zip'
|
||||
input.multiple = false
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
if (file.size > MAX_THEME_BYTES) { reject(new Error('主题包不能超过 5 MB')); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onload = () => { void decodeThemePackage(new Uint8Array(reader.result as ArrayBuffer)).then(resolve, reject) }
|
||||
reader.onerror = () => resolve(null)
|
||||
reader.readAsText(file)
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
@@ -280,7 +355,10 @@ export function setActiveCustomTheme(themeId: string | null) {
|
||||
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
const paperMoments = parseThemePackage(paperMomentsPackage)
|
||||
|
||||
export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{ ...inspectYamlContent(paperMoments.manifestText), tags: ['浅色', '手帐', '纸张'] },
|
||||
{
|
||||
theme_id: 'ocean-blue',
|
||||
name: 'Ocean Blue',
|
||||
@@ -293,18 +371,6 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
tags: ['浅色', '蓝色', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'forest-green',
|
||||
name: 'Forest Green',
|
||||
version: '1.0.1',
|
||||
author: 'nature-collection',
|
||||
description: '森林绿色护眼主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '绿色', '护眼'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'midnight-purple',
|
||||
name: 'Midnight Purple',
|
||||
@@ -317,39 +383,12 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
tags: ['深色', '紫色', '极客'],
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
{
|
||||
theme_id: 'solarized-light',
|
||||
name: 'Solarized Light',
|
||||
version: '1.1.0',
|
||||
author: 'solarized',
|
||||
description: '经典 Solarized 浅色主题',
|
||||
min_app_version: '0.1.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '经典', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'dracula',
|
||||
name: 'Dracula',
|
||||
version: '3.0.0',
|
||||
author: 'dracula-theme',
|
||||
description: '流行的 Dracula 暗色主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: true,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['深色', '紫色', '高对比'],
|
||||
license: 'MIT',
|
||||
},
|
||||
]
|
||||
|
||||
function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string): string {
|
||||
function buildCommunityThemeCss(themeId: string, isDark: boolean): string {
|
||||
const palettes: Record<string, { primary: string; soft: string; hover: string }> = {
|
||||
'ocean-blue': { primary: '#0077b6', soft: '#e0f0fa', hover: '#005f92' },
|
||||
'forest-green': { primary: '#2d6a4f', soft: '#e8f5ec', hover: '#1b4332' },
|
||||
'midnight-purple': { primary: '#9d4edd', soft: '#2b1a3e', hover: '#7b2cbf' },
|
||||
'solarized-light': { primary: '#b58900', soft: '#fdf6e3', hover: '#8a6d0b' },
|
||||
'dracula': { primary: '#bd93f9', soft: '#2d2a3e', hover: '#a77bf5' },
|
||||
}
|
||||
const p = palettes[themeId] ?? palettes['ocean-blue']
|
||||
if (isDark) {
|
||||
@@ -415,12 +454,13 @@ function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string
|
||||
export async function installCommunityTheme(themeId: string): Promise<InstalledTheme> {
|
||||
const themeManifest = mockCommunityThemes.find((t) => t.theme_id === themeId)
|
||||
if (!themeManifest) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
const css = buildCommunityThemeCss(themeId, themeManifest.is_dark, themeManifest.theme_id)
|
||||
const css = getCommunityThemePreviewCss(themeId)
|
||||
return installTheme(themeManifest, css)
|
||||
}
|
||||
|
||||
export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
if (themeId === 'paper-moments') return paperMoments.css
|
||||
const t = mockCommunityThemes.find((m) => m.theme_id === themeId)
|
||||
if (!t) return ''
|
||||
return buildCommunityThemeCss(themeId, t.is_dark, themeId)
|
||||
return buildCommunityThemeCss(themeId, t.is_dark)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,25 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('workspaceService backend adapter', () => {
|
||||
it.each([
|
||||
['tags:\n- python\n- rust', { tags: ['python', 'rust'] }],
|
||||
['tags: []', { tags: [] }],
|
||||
['tags:', { tags: [] }],
|
||||
['tags: ["a,b", rust]', { tags: ['a,b', 'rust'] }],
|
||||
['title: Demo', {}],
|
||||
['tags: [broken', {}],
|
||||
])('saves explicit metadata tags with the same Markdown snapshot: %s', async (yaml, tagPayload) => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockImplementation(async (input) => String(input) === '/api/workspace/open'
|
||||
? jsonResponse(workspaceSnapshot) : jsonResponse({}))
|
||||
await workspaceService.openVault('C:\\data\\vault')
|
||||
const markdown = `---\n${yaml}\n---\n# Body\n`
|
||||
await workspaceService.saveFileContent('/课程/操作系统.md', markdown)
|
||||
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
|
||||
expect(String(patchCall?.[0])).toBe('/api/notes/note-os')
|
||||
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown, ...tagPayload })
|
||||
})
|
||||
|
||||
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import apiClient from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
import * as noteService from './noteService'
|
||||
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
@@ -122,7 +123,12 @@ export async function getNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
|
||||
const metadata = splitNoteMetadata(content)
|
||||
await noteService.updateNote(await requireNoteId(filePath), {
|
||||
markdown: content,
|
||||
// Explicit [] clears the index; absent tags retain API-managed tags.
|
||||
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFile(
|
||||
|
||||
@@ -13,6 +13,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const currentFilePath = ref<string | null>(null)
|
||||
const highlightBlockId = ref<string | null>(null)
|
||||
const cursorPosition = ref({ line: 0, column: 0 })
|
||||
const headingRequest = ref<{ index: number; offset: number; path: string | null } | null>(null)
|
||||
function jumpToHeading(index: number, offset: number) {
|
||||
headingRequest.value = { index, offset, path: currentFilePath.value }
|
||||
}
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
||||
@@ -144,6 +148,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
headingRequest,
|
||||
jumpToHeading,
|
||||
mode,
|
||||
content,
|
||||
saveStatus,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isMap, isScalar, isSeq, parseDocument } from 'yaml'
|
||||
|
||||
export interface NoteMetadata {
|
||||
prefix: string
|
||||
yaml: string
|
||||
body: string
|
||||
title: string
|
||||
tags: string[]
|
||||
hasTags: boolean
|
||||
}
|
||||
|
||||
function parseProperties(yaml: string) {
|
||||
const document = parseDocument(yaml)
|
||||
// Unsupported YAML stays available in source mode without partial rewriting.
|
||||
if (document.errors.length || document.warnings.length || !isMap(document.contents)) return null
|
||||
return document
|
||||
}
|
||||
|
||||
export function splitNoteMetadata(source: string): NoteMetadata | null {
|
||||
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:-{3,}|\.\.\.)[ \t]*(?:\r?\n|$)/)
|
||||
if (!match) return null
|
||||
const yaml = match[2]!
|
||||
const document = parseProperties(yaml)
|
||||
if (!document || (!document.has('title') && !document.has('tags'))) return null
|
||||
const title = document.get('title') ?? ''
|
||||
if (typeof title !== 'string') return null
|
||||
const tagNode = document.get('tags', true)
|
||||
let tags: string[] = []
|
||||
if (isSeq(tagNode)) {
|
||||
// Do not remove anchored list items that other properties may reference.
|
||||
if (!tagNode.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) return null
|
||||
tags = tagNode.items.map(item => (item as { value: string }).value)
|
||||
} else if (isScalar(tagNode)) {
|
||||
if (typeof tagNode.value === 'string') tags = tagNode.value.split(',').map(tag => tag.trim()).filter(Boolean)
|
||||
else if (tagNode.value !== null) return null
|
||||
} else if (tagNode !== undefined) return null
|
||||
return { prefix: match[0], yaml, body: source.slice(match[0].length), title, tags, hasTags: document.has('tags') }
|
||||
}
|
||||
|
||||
export function updateMetadataTags(metadata: NoteMetadata, tags: string[]): string {
|
||||
const document = parseProperties(metadata.yaml)
|
||||
if (!document) throw new Error('Invalid note metadata')
|
||||
const previous = document.get('tags', true)
|
||||
const replacement = document.createNode([...new Set(tags)])
|
||||
if (isScalar(previous) || isSeq(previous)) {
|
||||
replacement.anchor = previous.anchor
|
||||
replacement.comment = previous.comment
|
||||
replacement.commentBefore = previous.commentBefore
|
||||
}
|
||||
document.set('tags', replacement)
|
||||
const newline = metadata.prefix.includes('\r\n') ? '\r\n' : '\n'
|
||||
const prefix = `---\n${document.toString()}---\n`.replace(/\n/g, newline)
|
||||
return (metadata.prefix.startsWith('\uFEFF') ? '\uFEFF' : '') + prefix
|
||||
}
|
||||
Reference in New Issue
Block a user