+
diff --git a/src/renderer/src/components/notebook/note/appendExcerptCommand.ts b/src/renderer/src/components/notebook/note/appendExcerptCommand.ts
new file mode 100644
index 0000000..c0c99ce
--- /dev/null
+++ b/src/renderer/src/components/notebook/note/appendExcerptCommand.ts
@@ -0,0 +1,71 @@
+/**
+ * 「把一段摘录追加进当前笔记」的命令通道(#73)。
+ *
+ * 摘录来自左栏的阅读器,但**笔记正文只有笔记编辑器拥有**:`NoteEditorPanel` 把正文存在
+ * 自己的局部 state 里,`NoteEditor` 持有真正的那份 ProseMirror 文档。如果阅读器直接去改
+ * `itemStore.updateNote()`,就会出现两个写入源,编辑器下一次 `onUpdate` 会拿它手上的旧
+ * 正文把刚追加的摘录覆盖掉。
+ *
+ * 所以这里只传一个请求,不传状态:阅读器说「请把这段摘录追加进这篇笔记」,谁来追加、基于
+ * 什么基线追加,由当前挂载的那个编辑器自己决定。真正拥有编辑状态的人负责修改编辑状态。
+ *
+ * 命令必须带目标 `noteId`。只靠「当前挂载的编辑器」来匹配是错的:编辑器 A 正在卸载、用户
+ * 已经切到笔记 B 的时候,这条请求会被 B 的编辑器消费,摘录就落到错误的笔记里。这在这里
+ * 不会发生(`NotePanel` 一次只挂载一个编辑器),但不该由「通常只有一个」来承担,匹配目标
+ * 是这一层的正确性前提,而不是可以依赖的巧合。
+ *
+ * 之所以是一条命令而不是 store 里的一个字段:命令只投递给**当时在场的**订阅者,不会被
+ * 重放。放进 store 当状态的话,React 严格模式下 effect 的 mount → cleanup → mount 会让
+ * 同一个值被消费两次,摘录就重复了。
+ */
+
+interface AppendExcerptCommand {
+ /** 这段摘录属于哪篇笔记。 */
+ noteId: string
+ markdown: string
+}
+
+type AppendExcerptHandler = (excerptMarkdown: string) => void
+
+/**
+ * 还没有匹配的订阅者时留住最后一条请求。
+ *
+ * 「先发命令、目标编辑器还没挂上」这个窗口期真的存在(新建笔记后编辑器才挂载),静默丢掉
+ * 一条用户刚点的摘录是不能接受的。只留一条就够了:目标笔记的编辑器挂载时会把它取走。
+ */
+let pending: AppendExcerptCommand | null = null
+
+/** 同时只应该有一个消费者:`NotePanel` 一次只渲染一个编辑器,一篇文章只有一个写入者。 */
+let listener: { noteId: string; handler: AppendExcerptHandler } | null = null
+
+/** 请求把一段摘录追加进 `noteId` 这篇笔记。 */
+export function requestAppendExcerpt(noteId: string, excerptMarkdown: string): void {
+ if (listener && listener.noteId === noteId) {
+ listener.handler(excerptMarkdown)
+ return
+ }
+
+ pending = { noteId, markdown: excerptMarkdown }
+}
+
+/**
+ * 以 `noteId` 这篇笔记的身份订阅追加请求,返回取消订阅的函数。
+ *
+ * 有一条不属于自己的待处理请求时**不消费**,把它留给真正的目标 —— 这正是带 `noteId` 的
+ * 意义所在。属于自己且还没人消费的那条会在订阅瞬间补发一次并清掉,因此补发只可能发生
+ * 一次(不会有第二次 mount 又把它消费一遍)。
+ */
+export function subscribeAppendExcerpt(noteId: string, handler: AppendExcerptHandler): () => void {
+ const entry = { noteId, handler }
+ listener = entry
+
+ if (pending !== null && pending.noteId === noteId) {
+ const queued = pending
+ pending = null
+ handler(queued.markdown)
+ }
+
+ return () => {
+ if (listener === entry) listener = null
+ }
+}
diff --git a/src/renderer/src/components/notebook/note/noteEditor.css b/src/renderer/src/components/notebook/note/noteEditor.css
index 4b9d03d..d620858 100644
--- a/src/renderer/src/components/notebook/note/noteEditor.css
+++ b/src/renderer/src/components/notebook/note/noteEditor.css
@@ -110,6 +110,19 @@
opacity: 0.8;
}
+/* 摘录锚点(#73):正文里指回原文的一条来源引用。
+ 来源被删除后它不再可跳转,于是按禁用态呈现 —— 但引文和这条引用本身都留在正文里,
+ 因为「笔记当初来自这里」和「现在拿不到这份证据」是两件事。 */
+.ProseMirror a[data-source-missing] {
+ color: var(--muted-foreground);
+ text-decoration-line: line-through;
+ cursor: default;
+}
+
+.ProseMirror a[data-source-missing]:hover {
+ opacity: 1;
+}
+
.ProseMirror blockquote {
border-left: 2px solid var(--border);
padding-left: 1rem;
diff --git a/src/renderer/src/locales/en-US/notebook.json b/src/renderer/src/locales/en-US/notebook.json
index 07690be..3495afa 100644
--- a/src/renderer/src/locales/en-US/notebook.json
+++ b/src/renderer/src/locales/en-US/notebook.json
@@ -18,6 +18,7 @@
"backToList": "Back to List",
"save": "Save",
"noteSaved": "Saved",
+ "excerptSourceMissing": "This excerpt's source is no longer available.",
"noNotesYet": "No notes yet",
"noNotesYetDesc": "Click the \"+\" button in the top right to create a note",
"unsavedChangesWarning": "You have unsaved changes. Are you sure you want to leave?",
diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json
index e0f9cd9..9a41438 100644
--- a/src/renderer/src/locales/en-US/ui.json
+++ b/src/renderer/src/locales/en-US/ui.json
@@ -62,6 +62,11 @@
"readerZoomIn": "Zoom in",
"readerZoomOut": "Zoom out",
"readerPdfUnavailable": "This PDF could not be opened. The original file may be missing.",
+ "saveExcerpt": "Save excerpt",
+ "excerptSourceLabel": "{{title}} · p.{{page}}",
+ "excerptAppended": "Excerpt appended to {{title}}",
+ "excerptNoteTitle": "Excerpt — {{title}}",
+ "excerptSaveFailed": "Could not save the excerpt.",
"openUrl": "Open URL",
"cannotPreview": "Cannot preview this file type",
"onboardingTagline": "Research your documents. Trust every answer.",
diff --git a/src/renderer/src/locales/zh-CN/notebook.json b/src/renderer/src/locales/zh-CN/notebook.json
index a4de036..2fe4407 100644
--- a/src/renderer/src/locales/zh-CN/notebook.json
+++ b/src/renderer/src/locales/zh-CN/notebook.json
@@ -13,6 +13,7 @@
"backToList": "返回列表",
"save": "保存",
"noteSaved": "已保存",
+ "excerptSourceMissing": "这段摘录的来源已不可用。",
"delete": "删除",
"confirmDeleteNote": "确定要删除这个笔记吗?",
"noNotesYet": "暂无笔记",
diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json
index fbe32c1..34e6a53 100644
--- a/src/renderer/src/locales/zh-CN/ui.json
+++ b/src/renderer/src/locales/zh-CN/ui.json
@@ -61,6 +61,11 @@
"readerZoomIn": "放大",
"readerZoomOut": "缩小",
"readerPdfUnavailable": "无法打开此 PDF,原始文件可能已丢失。",
+ "saveExcerpt": "保存摘录",
+ "excerptSourceLabel": "{{title}} · 第 {{page}} 页",
+ "excerptAppended": "已追加到《{{title}}》",
+ "excerptNoteTitle": "摘录 — {{title}}",
+ "excerptSaveFailed": "保存摘录失败。",
"openUrl": "打开网页",
"openFile": "打开文件",
"cannotPreview": "无法预览此文件类型",
diff --git a/src/shared/utils/citations.ts b/src/shared/utils/citations.ts
index a7e1f9d..a6ea406 100644
--- a/src/shared/utils/citations.ts
+++ b/src/shared/utils/citations.ts
@@ -73,15 +73,20 @@ export const parseCitations = (metadata: unknown): Citation[] => {
}
/**
- * 一条 citation 的来源现在还能不能打开(#72)。
+ * 一条来源引用现在还能不能打开。
+ *
+ * 「来源」包括答案里的 citation(#72)和笔记里的摘录锚点(#73)—— 两者都只是把
+ * `documentId` 记在别处,都需要回答同一个问题,所以共用这一条规则。
*
* `documentsLoaded` 是让空的列表变得无歧义的那个字段:
*
* - 还没读过列表 —— 空数组只意味着「不知道」,保持可点击,由阅读器侧优雅回退;
- * - 成功读过列表 —— 空数组是真实答案(最后一个来源也被删了),chip 应禁用;
+ * - 成功读过列表 —— 空数组是真实答案(最后一个来源也被删了),引用应禁用;
* - 读取失败 —— 同样视为「不知道」,不能把失败当成「来源已删除」的证据。
+ *
+ * 禁用不等于删除:引用本身留在原地。源不在和「当初来自这里」是两件事。
*/
-export function citationDocumentExists(
+export function sourceDocumentExists(
documents: readonly { id: string }[],
documentsLoaded: boolean,
documentId: string
diff --git a/src/shared/utils/excerpt.ts b/src/shared/utils/excerpt.ts
new file mode 100644
index 0000000..9acd772
--- /dev/null
+++ b/src/shared/utils/excerpt.ts
@@ -0,0 +1,193 @@
+import type { SourceAnchor } from '../types/source'
+import { sourceAnchorFromSearchParams, sourceAnchorToSearchParams } from './sourceAnchor'
+
+/**
+ * 摘录到笔记的序列化(#73)。
+ *
+ * 一段摘录就是「引文 + 指回原文的定位」。两者都必须活在**笔记正文本身**里,理由是这个
+ * 仓库的笔记正文不是 Tiptap JSON:`NoteEditor` 每次改动都用 `tiptap-markdown` 把文档
+ * 序列化成 markdown 落库,读回时再交回 markdown 解析。任何只存在于 Tiptap 节点属性里的
+ * 定位,都会在下一次往返时无声消失。
+ *
+ * 所以定位写成一条普通的 markdown 链接,指向一个自定义 fragment:
+ *
+ * > 引文
+ * >
+ * > 第二行
+ *
+ * [Attention Is All You Need · p.5](#know-note-source?doc=doc_1&page=5&start=1832&end=1947)
+ *
+ * 这样做的三个后果都是想要的:
+ *
+ * - **往返安全** —— 引用块和链接是核心 markdown,任何解析器都能原样读回;不需要自定义
+ * 节点、不需要在 `addStorage().markdown` 里补序列化,也就不存在「序列化漏了一半」的
+ * 那种 bug;
+ * - **不会坏** —— 删掉这段代码、换一个编辑器、或者定位字段坏掉,它退化成一段引文加一条
+ * 链接,而不是笔记打不开;
+ * - **可测** —— 输入输出都是字符串,纯函数,不需要 DOM。
+ *
+ * fragment 里的定位直接复用 `SourceAnchor` 的 query 编码(`sourceAnchor.ts`),因此笔记里
+ * 的这条链接和聊天里点击引用走的是同一套解析与同一套优先级。
+ */
+
+/** 摘录链接的 fragment 标记。`#` 开头保证它不会和外部链接混淆。 */
+const EXCERPT_FRAGMENT = '#know-note-source?'
+
+/** 摘录链接的 href:`SourceAnchor` 的 query 编码,只加一个 fragment 标记。 */
+export function excerptSourceHref(anchor: SourceAnchor): string {
+ return EXCERPT_FRAGMENT + sourceAnchorToSearchParams(anchor).toString()
+}
+
+/**
+ * 这个 href 是不是一条摘录链接。
+ *
+ * 和 `excerptAnchorFromHref(...) !== null` 不是一回事:定位字段坏掉的摘录链接仍然是摘录
+ * 链接(`#know-note-source?` 后面没有 `doc`)。调用方需要能区分三种情况 —— 摘录链接、外部
+ * 链接、以及什么都不是 —— 否则一条解析不出来的摘录链接会被当成外部链接交给浏览器打开。
+ */
+export function isExcerptSourceHref(href: string | null | undefined): boolean {
+ return typeof href === 'string' && href.includes(EXCERPT_FRAGMENT)
+}
+
+/**
+ * href → 定位。不是摘录链接时返回 null。
+ *
+ * 用 `indexOf` 而不是 `startsWith`:同一个 href 可能是 markdown 里的原样值,也可能是
+ * 浏览器解析过 base 之后的绝对 URL(`file:///…` / `http://localhost:…/#know-note-source?…`)。
+ * `URLSearchParams` 会把 `#` 编码成 `%23`,所以这个标记不会出现在定位字段自己身上。
+ *
+ * 防御式解析沿用 `sourceAnchorFromSearchParams`:单个坏字段只丢弃它自己。解析不出
+ * `doc` 就等于「这条链接没有定位」,调用方据此禁用跳转而不是跳到别处。
+ */
+export function excerptAnchorFromHref(href: string | null | undefined): SourceAnchor | null {
+ if (typeof href !== 'string') return null
+
+ const marker = href.indexOf(EXCERPT_FRAGMENT)
+ if (marker < 0) return null
+
+ return sourceAnchorFromSearchParams(
+ new URLSearchParams(href.slice(marker + EXCERPT_FRAGMENT.length))
+ )
+}
+
+/**
+ * 把原文转成「看起来一模一样、但不会被 Markdown 重新解释」的文本。
+ *
+ * 摘录的原文是**用户选中的字**,不是用户写的 Markdown。一段从 PDF 或网页上选下来的文字
+ * 完全可能包含 `# 标题`、`- 列表`、`[foo](bar)`、`` `code` ``,甚至 `
` —— 它们必须原样显示,
+ * 而不是变成真标题、真列表、真链接、真粗体。
+ *
+ * 转义表参考 prosemirror-markdown 的 `esc()`(它已经被用在无数编辑器上),并额外补了
+ * 两类它不需要、而我们需要的情况:
+ *
+ * - `<` 与 `&` —— tiptap-markdown 默认 `html: true`,所以原本的 HTML 与实体是活的:
+ * `x` 会真的加粗,`&` 会显示成 `&`;
+ * - `|` —— 两行凑在一起会变成表格。
+ *
+ * 反斜杠必须先处理,它自己就是转义符。行首额外的标记(标题、引用、列表、分隔线、setext
+ * 下划线、有序列表)在行内转义之后再补,否则 `***` 会被转义两次。
+ *
+ * 行首缩进单独钳一下(见 `clampIndent`):那是唯一一种反斜杠解决不了的情况。
+ */
+export function escapeExcerptMarkdown(text: string): string {
+ return text
+ .split('\n')
+ .map((line) =>
+ clampIndent(line)
+ .replace(INLINE_ESCAPE, '\\$&')
+ .replace(LINE_START_ORDERED, '$1$2\\$3$4')
+ .replace(LINE_START_MARKER, '$1\\$2')
+ )
+ .join('\n')
+}
+
+/**
+ * 把行首缩进钳到至多 3 个空格。
+ *
+ * 行首四个空格(或一个制表符)在 Markdown 里是代码块:一段从 PDF 里选下来、恰好带着
+ * 首行缩进的文字,会静默变成一个代码块。这一种用反斜杠解决不了 —— 触发它的是空白
+ * 本身,而反斜杠只能转义标点字符。
+ *
+ * 也不用 HTML 实体(` `)绕:那在一次「解析 → 重新序列化」之后会退回普通空格,
+ * 于是下次加载又变成代码块 —— 会骰掉的修复比不修更糟。
+ *
+ * 钳到 3 个空格没有可观的代价:HTML 本来就会折叠行首空白,渲染出来一模一样;
+ * 保留前三个空格只是让 Markdown 源码里仍然看得出「原本有缩进」。
+ */
+function clampIndent(line: string): string {
+ return line.replace(/^[ \t]+/, (run) => (run.length >= 4 || run.includes('\t') ? ' ' : run))
+}
+
+/** 在一行里任何位置都有含义的字符。 */
+const INLINE_ESCAPE = /[\\`*_[\]~<&|]/g
+
+/** 只在行首才有含义的标记:标题、引用、列表、分隔线、setext 下划线。 */
+const LINE_START_MARKER = /^(\s*)([#>=\-+*])/
+
+/** 有序列表的 `1.` / `1)`。 */
+const LINE_START_ORDERED = /^(\s*)(\d+)([.)])(\s|$)/
+
+/**
+ * 一行 markdown 引用。空行也写成 `>`,否则引文里的空行会把引用块拆成两段。
+ *
+ * 先转义再引用:顺序反了会把引用块自己的 `>` 也转义掉。
+ *
+ * 同时去掉每行末尾的空白:markdown 里「行尾两个空格」是硬换行,摘录的换行方式不应该
+ * 由原文的尾随空格决定。
+ */
+export function quoteMarkdown(text: string): string {
+ return escapeExcerptMarkdown(text.replace(/\r\n?/g, '\n'))
+ .split('\n')
+ .map((line) => line.replace(/[ \t]+$/, ''))
+ .map((line) => (line.length === 0 ? '>' : `> ${line}`))
+ .join('\n')
+}
+
+export interface Excerpt {
+ /** 选中的原文。 */
+ text: string
+ /** 指回原文的定位。 */
+ anchor: SourceAnchor
+ /** 链接的可见文字,例如「Attention Is All You Need · p.5」。由调用方决定文案与语言。 */
+ label: string
+}
+
+/**
+ * 一段摘录 → 可以追加进笔记正文的 markdown。
+ *
+ * 返回 null 表示这段摘录没有内容(全空白)——由调用方决定怎么提示,而不是往笔记里塞一段
+ * 空的引用块。
+ */
+export function buildExcerptMarkdown({ text, anchor, label }: Excerpt): string | null {
+ const body = text.replace(/\r\n?/g, '\n').trim()
+ if (body.length === 0) return null
+
+ return `${quoteMarkdown(body)}\n\n[${escapeLinkText(label)}](${excerptSourceHref(anchor)})`
+}
+
+/**
+ * 把一段摘录接到笔记正文末尾。
+ *
+ * 只在纯文本层面做一件事:保证接缝处正好是一个空行。笔记正文可能是空的(新建笔记)、
+ * 可能没有以换行结尾、也可能已经带好几个尾随空白 —— 这些都不该由组件去手拼 `'\n\n'`。
+ *
+ * 两个退化情况都原样返回、不做多余的编辑:摘录是空的就不动正文(不留下一个孤立的空行),
+ * 正文是空的就只留摘录(不在开头塞一个空行)。
+ */
+export function appendExcerptMarkdown(currentMarkdown: string, excerptMarkdown: string): string {
+ const excerpt = excerptMarkdown.trim()
+ if (excerpt.length === 0) return currentMarkdown
+
+ const current = currentMarkdown.replace(/\s+$/, '')
+ if (current.length === 0) return excerpt
+
+ return `${current}\n\n${excerpt}`
+}
+
+/**
+ * 链接文字里的 `[` `]` `\` 必须转义,否则一个带方括号的来源标题会把整条链接解析坏,
+ * 连带让它后面的正文一起变形。
+ */
+function escapeLinkText(text: string): string {
+ return text.replace(/([\\[\]])/g, '\\$1')
+}
diff --git a/src/shared/utils/sourceAnchor.ts b/src/shared/utils/sourceAnchor.ts
index ff728cf..0b85f54 100644
--- a/src/shared/utils/sourceAnchor.ts
+++ b/src/shared/utils/sourceAnchor.ts
@@ -1,5 +1,5 @@
import type { Citation } from '../types/citation'
-import type { ReaderAnchor, SourceAnchor } from '../types/source'
+import type { ReaderAnchor, ReaderSelection, SourceAnchor } from '../types/source'
/**
* `SourceAnchor` 的构造与序列化(#72)。
@@ -38,6 +38,33 @@ export function citationToSourceAnchor(citation: Citation): SourceAnchor {
return { documentId: citation.documentId, location }
}
+/**
+ * 阅读器选区 → 定位(#73)。
+ *
+ * 与 `citationToSourceAnchor` 是同一个构造点的两个入口:citation 来自答案,selection
+ * 来自阅读器。两者都只搬运已经记录下来的字段,不补、不猜。
+ *
+ * 这里也是外层 `documentId` 与 `location.documentId` 唯一被同时写入的地方。`ReaderSelection`
+ * 继承 `ReaderAnchor`,因此它自己的 `documentId` 就定义了两个字段必须取同一个值,而不是
+ * 由调用方各自拼装出一份可能互相矛盾的对象。
+ */
+export function selectionToSourceAnchor(selection: ReaderSelection): SourceAnchor {
+ const location: ReaderAnchor = { documentId: selection.documentId }
+
+ if (typeof selection.page === 'number' && Number.isFinite(selection.page)) {
+ location.page = selection.page
+ }
+ if (selection.blockId) location.blockId = selection.blockId
+ if (typeof selection.startOffset === 'number' && Number.isFinite(selection.startOffset)) {
+ location.startOffset = selection.startOffset
+ }
+ if (typeof selection.endOffset === 'number' && Number.isFinite(selection.endOffset)) {
+ location.endOffset = selection.endOffset
+ }
+
+ return { documentId: selection.documentId, location }
+}
+
/** 只写入确实存在的字段:缺失的位置不应该以空串或 `"null"` 的形式出现在 URL 里。 */
export function sourceAnchorToSearchParams(anchor: SourceAnchor): URLSearchParams {
const params = new URLSearchParams()
diff --git a/test/appendExcerptCommand.test.ts b/test/appendExcerptCommand.test.ts
new file mode 100644
index 0000000..5aae90f
--- /dev/null
+++ b/test/appendExcerptCommand.test.ts
@@ -0,0 +1,113 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ requestAppendExcerpt,
+ subscribeAppendExcerpt
+} from '../src/renderer/src/components/notebook/note/appendExcerptCommand.ts'
+
+/**
+ * 追加命令是模块级单例状态,所以每个用例用**独立的 noteId**,避免上一条没人消费的请求
+ * 漏进下一个用例。这里钉的是它存在的理由:命令只投给目标笔记,不能投给「当时恰好在场的
+ * 那个编辑器」。
+ */
+
+const collected = (): { markdown: string[] } => ({ markdown: [] })
+
+test('a request reaches the subscriber for the same note', () => {
+ const seen = collected()
+ const off = subscribeAppendExcerpt('note_a1', (markdown) => seen.markdown.push(markdown))
+
+ requestAppendExcerpt('note_a1', '> excerpt')
+
+ assert.deepEqual(seen.markdown, ['> excerpt'])
+ off()
+})
+
+test('a request is held for its target when nothing is subscribed yet', () => {
+ requestAppendExcerpt('note_a2', '> queued')
+
+ const seen = collected()
+ const off = subscribeAppendExcerpt('note_a2', (markdown) => seen.markdown.push(markdown))
+
+ assert.deepEqual(seen.markdown, ['> queued'])
+ off()
+})
+
+test('a request is delivered exactly once, not again for a later subscriber', () => {
+ requestAppendExcerpt('note_a3', '> once')
+
+ const first = collected()
+ const offFirst = subscribeAppendExcerpt('note_a3', (m) => first.markdown.push(m))
+ offFirst()
+
+ const second = collected()
+ const offSecond = subscribeAppendExcerpt('note_a3', (m) => second.markdown.push(m))
+ offSecond()
+
+ assert.deepEqual(first.markdown, ['> once'])
+ assert.deepEqual(second.markdown, [])
+})
+
+/**
+ * 这个用例就是带 `noteId` 的全部理由:A 的摘录绝不能被 B 的编辑器消费。
+ * 没有目标匹配的话,这里会以「B 拿到了本该属于 A 的摘录」失败。
+ */
+test('a request for one note is never delivered to the editor of another', () => {
+ const seenByB = collected()
+ const offB = subscribeAppendExcerpt('note_b4', (markdown) => seenByB.markdown.push(markdown))
+
+ requestAppendExcerpt('note_a4', '> belongs to A')
+
+ assert.deepEqual(seenByB.markdown, [], 'B must not receive the excerpt that belongs to A')
+
+ // 目标笔记的编辑器随后挂载,它才是应该拿到这条请求的人。
+ const seenByA = collected()
+ const offA = subscribeAppendExcerpt('note_a4', (markdown) => seenByA.markdown.push(markdown))
+
+ assert.deepEqual(seenByA.markdown, ['> belongs to A'])
+ offA()
+ offB()
+})
+
+test('a request sent while another note is mounted waits for its own target', () => {
+ const seenByB = collected()
+ const offB = subscribeAppendExcerpt('note_b5', (markdown) => seenByB.markdown.push(markdown))
+
+ // B 在场,但请求是发给 A 的。
+ requestAppendExcerpt('note_a5', '> for A')
+ assert.deepEqual(seenByB.markdown, [])
+
+ // B 卸载,A 挂载。
+ offB()
+ const seenByA = collected()
+ const offA = subscribeAppendExcerpt('note_a5', (markdown) => seenByA.markdown.push(markdown))
+
+ assert.deepEqual(seenByA.markdown, ['> for A'])
+ offA()
+})
+
+test('unsubscribing stops delivery to that subscriber', () => {
+ const seen = collected()
+ const off = subscribeAppendExcerpt('note_a6', (markdown) => seen.markdown.push(markdown))
+
+ off()
+ requestAppendExcerpt('note_a6', '> after unmount')
+
+ assert.deepEqual(seen.markdown, [])
+})
+
+test('an unmounting editor does not clear the subscription that replaced it', () => {
+ const first = collected()
+ const offFirst = subscribeAppendExcerpt('note_a7', (m) => first.markdown.push(m))
+
+ const second = collected()
+ const offSecond = subscribeAppendExcerpt('note_a7', (m) => second.markdown.push(m))
+
+ // 旧订阅者的清理函数晚于新订阅者执行时,不能把新订阅者一起注销掉。
+ offFirst()
+ requestAppendExcerpt('note_a7', '> still delivered')
+
+ assert.deepEqual(second.markdown, ['> still delivered'])
+ assert.deepEqual(first.markdown, [])
+ offSecond()
+})
diff --git a/test/citations.test.ts b/test/citations.test.ts
index 73a3c10..44acbab 100644
--- a/test/citations.test.ts
+++ b/test/citations.test.ts
@@ -5,7 +5,7 @@ import {
buildRAGContext,
citationFromSearchResult
} from '../src/main/services/citations.ts'
-import { citationDocumentExists, parseCitations } from '../src/shared/utils/citations.ts'
+import { sourceDocumentExists, parseCitations } from '../src/shared/utils/citations.ts'
import type { SearchResult } from '../src/main/services/KnowledgeService.ts'
/**
@@ -263,20 +263,20 @@ test('metadata that is absent, null or the wrong shape yields no citations', ()
* 把前者当成删除会把冷启动时所有历史 citation 都误判成失效引用。
*/
test('a citation source is presumed present until the list has been read', () => {
- assert.equal(citationDocumentExists([], false, 'doc_1'), true)
- assert.equal(citationDocumentExists([{ id: 'other' }], false, 'doc_1'), true)
+ assert.equal(sourceDocumentExists([], false, 'doc_1'), true)
+ assert.equal(sourceDocumentExists([{ id: 'other' }], false, 'doc_1'), true)
})
test('after a successful load, only a listed document exists', () => {
- assert.equal(citationDocumentExists([{ id: 'doc_1' }], true, 'doc_1'), true)
- assert.equal(citationDocumentExists([{ id: 'other' }], true, 'doc_1'), false)
+ assert.equal(sourceDocumentExists([{ id: 'doc_1' }], true, 'doc_1'), true)
+ assert.equal(sourceDocumentExists([{ id: 'other' }], true, 'doc_1'), false)
})
test('a loaded empty list means every citation source is gone', () => {
- assert.equal(citationDocumentExists([], true, 'doc_1'), false)
+ assert.equal(sourceDocumentExists([], true, 'doc_1'), false)
})
test('a failed load is not evidence that the source was deleted', () => {
// loadDocuments() 失败时保持 documentsLoaded:false,而不是把失败当成空列表。
- assert.equal(citationDocumentExists([], false, 'doc_1'), true)
+ assert.equal(sourceDocumentExists([], false, 'doc_1'), true)
})
diff --git a/test/excerpt.test.ts b/test/excerpt.test.ts
new file mode 100644
index 0000000..203273d
--- /dev/null
+++ b/test/excerpt.test.ts
@@ -0,0 +1,352 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ appendExcerptMarkdown,
+ buildExcerptMarkdown,
+ escapeExcerptMarkdown,
+ excerptAnchorFromHref,
+ excerptSourceHref,
+ isExcerptSourceHref,
+ quoteMarkdown
+} from '../src/shared/utils/excerpt.ts'
+import { selectionToSourceAnchor } from '../src/shared/utils/sourceAnchor.ts'
+import type { ReaderSelection, SourceAnchor } from '../src/shared/types/source.ts'
+
+/**
+ * #73 的摘录必须活在笔记正文(markdown)里,而不是 Tiptap 节点属性里 —— `NoteEditor`
+ * 每次改动都会把文档序列化成 markdown 落库。这里钉住的就是那条往返:写进笔记的字符串
+ * 能不能把定位一字不差地读回来,以及读不回来的时候是不是安静地退化成普通文本。
+ */
+
+const anchor = (location: SourceAnchor['location']): SourceAnchor => ({
+ documentId: location.documentId,
+ location
+})
+
+const selection = (over: Partial = {}): ReaderSelection => ({
+ documentId: 'doc_1',
+ page: 5,
+ blockId: 'block_7',
+ startOffset: 1832,
+ endOffset: 1947,
+ text: 'the retrieved passage',
+ ...over
+})
+
+/** 从生成的摘录里取回被写进 markdown 的那个 href —— 也就是读者点击时手上真正有的东西。 */
+const hrefOf = (markdown: string): string => {
+ const match = /\]\(([^)]*)\)\s*$/.exec(markdown)
+ assert.ok(match, `no link destination at the end of: ${markdown}`)
+ return match[1]
+}
+
+test('an excerpt round-trips its anchor through the markdown it writes', () => {
+ const original = selectionToSourceAnchor(selection({ page: 5 }))
+ const markdown = buildExcerptMarkdown({
+ text: 'the retrieved passage',
+ anchor: original,
+ label: 'Attention Is All You Need · p.5'
+ })
+
+ assert.ok(markdown)
+ assert.deepEqual(excerptAnchorFromHref(hrefOf(markdown)), original)
+})
+
+test('a document-only anchor round-trips, page and offsets omitted', () => {
+ const original = anchor({ documentId: 'doc_1' })
+ const markdown = buildExcerptMarkdown({ text: 'quoted', anchor: original, label: 'A source' })
+
+ assert.ok(markdown)
+ assert.deepEqual(excerptAnchorFromHref(hrefOf(markdown)), original)
+})
+
+test('an excerpt with no body is refused rather than written as an empty quote', () => {
+ const anchorIn = anchor({ documentId: 'doc_1', page: 2 })
+
+ assert.equal(buildExcerptMarkdown({ text: '', anchor: anchorIn, label: 'x' }), null)
+ assert.equal(buildExcerptMarkdown({ text: ' \n\t\n ', anchor: anchorIn, label: 'x' }), null)
+})
+
+test('the quote keeps blank lines inside one blockquote, not two', () => {
+ const quoted = quoteMarkdown('first paragraph\n\nsecond paragraph')
+ const lines = quoted.split('\n')
+
+ assert.deepEqual(lines, ['> first paragraph', '>', '> second paragraph'])
+ // 每一行都带 `>`:任何一行漏掉都会把引用块提前结束。
+ for (const line of lines) assert.ok(line.startsWith('>'), line)
+})
+
+test('trailing whitespace is dropped so quoted text never gains a markdown hard break', () => {
+ assert.equal(
+ quoteMarkdown('a line with trailing spaces \nnext'),
+ '> a line with trailing spaces\n> next'
+ )
+})
+
+test('windows line endings are normalised before quoting', () => {
+ assert.equal(quoteMarkdown('one\r\ntwo\rthree'), '> one\n> two\n> three')
+})
+
+test('brackets in the label are escaped so the link cannot be broken by a title', () => {
+ const markdown = buildExcerptMarkdown({
+ text: 'quoted',
+ anchor: anchor({ documentId: 'doc_1', page: 1 }),
+ label: 'A [draft] \\ study'
+ })
+
+ assert.ok(markdown)
+ assert.ok(markdown.includes('[A \\[draft\\] \\\\ study]('), markdown)
+})
+
+test('an href survives identifiers that would otherwise break a markdown link', () => {
+ const nasty = anchor({
+ documentId: 'doc (1) [x] a b',
+ blockId: 'block/a(b) c',
+ page: 3
+ })
+ const href = excerptSourceHref(nasty)
+
+ // markdown 链接目标在 `(` `)` 处结束,空白也会截断它。
+ assert.equal(/[()\s<>]/.test(href), false, href)
+ assert.deepEqual(excerptAnchorFromHref(href), nasty)
+})
+
+test('the fragment survives being resolved against a document base', () => {
+ const original = anchor({ documentId: 'doc_1', page: 5, startOffset: 10, endOffset: 20 })
+ const resolved = `file:///app/index.html${excerptSourceHref(original)}`
+
+ assert.deepEqual(excerptAnchorFromHref(resolved), original)
+})
+
+test('a href that is not an excerpt anchor is not mistaken for one', () => {
+ const cases = [
+ null,
+ undefined,
+ '',
+ '#',
+ 'https://example.com/#know-note-source',
+ '#know-note-source',
+ '#know-note-source?',
+ '#other?doc=doc_1&page=5',
+ 'doc=doc_1&page=5'
+ ]
+
+ for (const href of cases) {
+ assert.equal(excerptAnchorFromHref(href), null, String(href))
+ }
+})
+
+test('a malformed position inside an excerpt link degrades field by field', () => {
+ const href = '#know-note-source?doc=doc_1&page=abc&start=100&end=200'
+
+ assert.deepEqual(
+ excerptAnchorFromHref(href),
+ anchor({ documentId: 'doc_1', startOffset: 100, endOffset: 200 })
+ )
+})
+
+/**
+ * 点击派发靠这个谓词把三种情况分开:摘录链接、外部链接、其它。它不能等同于
+ * 「`excerptAnchorFromHref` 有结果」—— 一条 `#know-note-source?` 后面没有 `doc` 的链接仍然是
+ * 摘录链接(应该就地拒绝跳转),而不是一条可以被交给浏览器打开的外部链接。
+ */
+
+test('an excerpt link is recognised even when its position does not parse', () => {
+ // 有标记、但没有可用的定位。
+ assert.equal(isExcerptSourceHref('#know-note-source?page=5'), true)
+ assert.equal(excerptAnchorFromHref('#know-note-source?page=5'), null)
+})
+
+test('a link that is not an excerpt link is never mistaken for one', () => {
+ const cases = [
+ null,
+ undefined,
+ '',
+ '#',
+ '#know-note-source',
+ '#other?doc=doc_1',
+ 'https://example.com/',
+ 'https://example.com/#know-note-source',
+ 'https://example.com/?doc=doc_1&page=5'
+ ]
+
+ for (const href of cases) assert.equal(isExcerptSourceHref(href), false, String(href))
+})
+
+test('whenever an anchor parses, its href is also recognised as an excerpt link', () => {
+ const hrefs = [
+ excerptSourceHref(anchor({ documentId: 'doc_1' })),
+ excerptSourceHref(anchor({ documentId: 'doc_1', page: 5, blockId: 'b1' })),
+ excerptSourceHref(anchor({ documentId: 'doc (1)', blockId: 'b/a b' })),
+ '#know-note-source?doc=doc_1',
+ '#know-note-source?doc=doc_1&page=abc'
+ ]
+
+ for (const href of hrefs) {
+ if (excerptAnchorFromHref(href) !== null) {
+ assert.equal(isExcerptSourceHref(href), true, href)
+ }
+ }
+})
+
+/**
+ * #73:摘录接进正文末尾。接缝必须正好是一个空行,且两个退化情况不能制造多余的空白。
+ * 组件里不该出现手拼的 `'\n\n'`。
+ */
+
+test('an excerpt is appended after a blank line', () => {
+ const current = '原来的笔记内容。'
+ const excerpt = buildExcerptMarkdown({
+ text: 'Attention mechanisms allow the model to weigh tokens.',
+ anchor: anchor({ documentId: 'doc_1', page: 5, startOffset: 10, endOffset: 58 }),
+ label: 'Attention Is All You Need · p.5'
+ })
+ assert.ok(excerpt)
+
+ assert.equal(
+ appendExcerptMarkdown(current, excerpt),
+ '原来的笔记内容。\n\n' +
+ '> Attention mechanisms allow the model to weigh tokens.\n\n' +
+ '[Attention Is All You Need · p.5](#know-note-source?doc=doc_1&page=5&start=10&end=58)'
+ )
+})
+
+test('appending uses exactly one blank line, whatever whitespace the note ended with', () => {
+ for (const current of ['body', 'body\n', 'body\n\n', 'body\n\n\n', 'body \n']) {
+ assert.equal(appendExcerptMarkdown(current, '> q'), 'body\n\n> q', JSON.stringify(current))
+ }
+})
+
+test('an empty note receives the excerpt without a leading blank line', () => {
+ for (const current of ['', ' ', '\n', '\n\n']) {
+ assert.equal(appendExcerptMarkdown(current, '> q'), '> q', JSON.stringify(current))
+ }
+})
+
+test('an empty excerpt leaves the note byte-for-byte unchanged', () => {
+ for (const excerpt of ['', ' ', '\n']) {
+ assert.equal(appendExcerptMarkdown('body\n', excerpt), 'body\n', JSON.stringify(excerpt))
+ }
+})
+
+test('appending is idempotent in structure: two appends give two separated blocks', () => {
+ const once = appendExcerptMarkdown('body', '> q1')
+ const twice = appendExcerptMarkdown(once, '> q2')
+
+ assert.equal(twice, 'body\n\n> q1\n\n> q2')
+})
+
+/**
+ * 摘录的原文是**用户选中的字**,不是用户写的 Markdown。一段从 PDF 或网页上选下来的文字
+ * 完全可能长得像 Markdown(`# 标题`、`- 列表`、`[foo](bar)`、``),它必须原样显示。
+ *
+ * 这些用例有一个共同点:不断言「序列化形式好看」,只断言**它不会被重新解释**。真实的
+ * Tiptap 往返(含多行组合、表格、缩进、原始 HTML)已在提交前单独用一次性 harness 验过
+ * 42 条,全部保持可见文本与结构;这里钉住的是那条规则本身,让它不会在以后被改坏。
+ */
+
+test('line-start markers are escaped so quoted text cannot become structure', () => {
+ assert.equal(escapeExcerptMarkdown('# not a heading'), '\\# not a heading')
+ assert.equal(escapeExcerptMarkdown('> not a quote'), '\\> not a quote')
+ assert.equal(escapeExcerptMarkdown('- not a list'), '\\- not a list')
+ assert.equal(escapeExcerptMarkdown('+ not a list'), '\\+ not a list')
+ assert.equal(escapeExcerptMarkdown('* not a list'), '\\* not a list')
+ assert.equal(escapeExcerptMarkdown('1. not ordered'), '1\\. not ordered')
+ assert.equal(escapeExcerptMarkdown('1) not ordered'), '1\\) not ordered')
+ assert.equal(escapeExcerptMarkdown('---'), '\\---')
+ assert.equal(escapeExcerptMarkdown('==='), '\\===')
+})
+
+test('an escaped line-start marker keeps its leading indentation', () => {
+ assert.equal(escapeExcerptMarkdown(' # deeper'), ' \\# deeper')
+})
+
+/** 去掉转义用的反斜杠,得到「渲染出来看到的字」。转义必须是可逆的。 */
+const visibleTextOf = (escaped: string): string => escaped.replace(/\\([\s\S])/g, '$1')
+
+/** 把转义对连字符一起丢掉:剩下的就是会被 Markdown 当成语法的地方。 */
+const bareSyntaxOf = (escaped: string): string => escaped.replace(/\\[\s\S]/g, '')
+
+test('inline markdown syntax is escaped so it cannot become a mark', () => {
+ const source = '*b* __c__ ~~d~~ `e` [f](g)  j &k; |'
+ const escaped = escapeExcerptMarkdown(source)
+
+ // 转义结果里不该剩下任何「裸的」有含义字符 —— 剩下的每一个都会变成结构或实体。
+ const bare = bareSyntaxOf(escaped)
+ for (const char of ['*', '_', '~', '`', '[', ']', '<', '&', '|']) {
+ assert.equal(bare.includes(char), false, `${char} left unescaped in ${bare}`)
+ }
+
+ // 反过来的那一半同样重要:转义不能改变可见文本。
+ assert.equal(visibleTextOf(escaped), source)
+})
+
+test('a backslash in the source survives as a visible backslash', () => {
+ const source = 'a \\* b'
+
+ assert.equal(visibleTextOf(escapeExcerptMarkdown(source)), source)
+})
+
+test('escaping never changes the visible text it is given', () => {
+ const sources = [
+ '# Attention',
+ '- bullet',
+ 'plain sentence',
+ '[ref]: https://example.com',
+ 'a | b',
+ '_underscores_ and ~tildes~',
+ '1. numbered',
+ 'trailing # hash',
+ '100% *sure*'
+ ]
+
+ for (const source of sources) {
+ assert.equal(visibleTextOf(escapeExcerptMarkdown(source)), source, source)
+ }
+})
+
+test('leading indentation is clamped so a line cannot become an indented code block', () => {
+ // 四个空格(或一个制表符)在 Markdown 里是代码块,而它是唯一一种反斜杠解决不了的情况。
+ assert.equal(escapeExcerptMarkdown(' four spaces'), ' four spaces')
+ assert.equal(escapeExcerptMarkdown(' eight spaces'), ' eight spaces')
+ assert.equal(escapeExcerptMarkdown('\tone tab'), ' one tab')
+ assert.equal(escapeExcerptMarkdown(' \tmixed'), ' mixed')
+})
+
+test('indentation up to three spaces is left alone', () => {
+ assert.equal(escapeExcerptMarkdown(' three spaces'), ' three spaces')
+ assert.equal(escapeExcerptMarkdown(' two spaces'), ' two spaces')
+})
+
+test('escaping is applied per line, not to the whole block', () => {
+ assert.equal(escapeExcerptMarkdown('# one\n- two\nplain three'), '\\# one\n\\- two\nplain three')
+})
+
+test('quoteMarkdown escapes before prefixing, so its own marker is never escaped', () => {
+ assert.equal(quoteMarkdown('> nested'), '> \\> nested')
+ assert.equal(quoteMarkdown('# Attention'), '> \\# Attention')
+ // 每一行都带 `>`,包括被转义的那一行 —— 引用块不会被转义内容拆开。
+ for (const line of quoteMarkdown('# one\n\n- two').split('\n')) {
+ assert.ok(line.startsWith('>'), line)
+ }
+})
+
+test('a markdown-looking selection is not rewritten into structure in the excerpt', () => {
+ const markdown = buildExcerptMarkdown({
+ text: '# Attention\n*not emphasis*\n[not a link](https://example.com)',
+ anchor: anchor({ documentId: 'doc_1', page: 2 }),
+ label: 'Src · p.2'
+ })
+
+ assert.ok(markdown)
+ assert.ok(markdown.includes('> \\# Attention'), markdown)
+ assert.ok(markdown.includes('> \\*not emphasis\\*'), markdown)
+ assert.ok(markdown.includes('> \\[not a link\\](https://example.com)'), markdown)
+})
+
+test('a plain-text selection is left untouched by escaping', () => {
+ const body = 'Just a normal sentence.\nAnd a second line.'
+
+ assert.equal(escapeExcerptMarkdown(body), body)
+ assert.equal(quoteMarkdown(body), '> Just a normal sentence.\n> And a second line.')
+})
diff --git a/test/sourceAnchor.test.ts b/test/sourceAnchor.test.ts
index 4089022..37bfec7 100644
--- a/test/sourceAnchor.test.ts
+++ b/test/sourceAnchor.test.ts
@@ -2,13 +2,14 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
citationToSourceAnchor,
+ selectionToSourceAnchor,
sourceAnchorFromSearchParams,
sourceAnchorToSearchParams,
sourceAnchorsEqual,
withSourceAnchor
} from '../src/shared/utils/sourceAnchor.ts'
import type { Citation } from '../src/shared/types/citation.ts'
-import type { SourceAnchor } from '../src/shared/types/source.ts'
+import type { ReaderSelection, SourceAnchor } from '../src/shared/types/source.ts'
/**
* #72 的引用跳转完全建立在 `SourceAnchor` 上:它既由 citation 快照构造,也由 URL
@@ -142,3 +143,54 @@ test('sourceAnchorsEqual compares the whole location, not just the document', ()
assert.equal(sourceAnchorsEqual(base, null), false)
assert.equal(sourceAnchorsEqual(null, null), true)
})
+
+/**
+ * #73:阅读器的选区也是 `SourceAnchor` 的一个入口,和 citation 并列。两件容易写错的事
+ * 在这里钉住 —— 非分页来源的 `page: null` 不能被写成 `page: null` 出现在 URL 里,以及
+ * 外层与 `location` 的 `documentId` 必须来自同一个来源身份,而不是各自拼装。
+ */
+
+const selection = (over: Partial = {}): ReaderSelection => ({
+ documentId: 'doc_1',
+ page: 5,
+ blockId: 'block_7',
+ startOffset: 1832,
+ endOffset: 1947,
+ text: 'the selected passage',
+ ...over
+})
+
+test('selectionToSourceAnchor carries every location field the reader recorded', () => {
+ assert.deepEqual(selectionToSourceAnchor(selection()), {
+ documentId: 'doc_1',
+ location: {
+ documentId: 'doc_1',
+ page: 5,
+ blockId: 'block_7',
+ startOffset: 1832,
+ endOffset: 1947
+ }
+ })
+})
+
+test('selectionToSourceAnchor omits an unrecorded position instead of persisting a null', () => {
+ const mapped = selectionToSourceAnchor(
+ selection({ page: null, blockId: null, startOffset: null, endOffset: null })
+ )
+
+ assert.deepEqual(mapped.location, { documentId: 'doc_1' })
+ assert.equal('page' in mapped.location, false)
+ assert.equal('blockId' in mapped.location, false)
+})
+
+test('selectionToSourceAnchor keeps the outer and inner document id the same', () => {
+ const mapped = selectionToSourceAnchor(selection({ documentId: 'doc_9' }))
+
+ assert.equal(mapped.documentId, mapped.location.documentId)
+})
+
+test('a selection-derived anchor round-trips through the URL like any other', () => {
+ const original = selectionToSourceAnchor(selection({ page: null }))
+
+ assert.deepEqual(sourceAnchorFromSearchParams(sourceAnchorToSearchParams(original)), original)
+})