From 312104e8c07b5e21135e8fabda1c5b037340208f Mon Sep 17 00:00:00 2001 From: mrsibe Date: Fri, 25 Sep 2026 18:59:08 +0800 Subject: [PATCH] feat(notes): save a reader excerpt into the note, anchored to its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting text in the reader had nowhere to go: notes are independent Tiptap documents with no link back to their source, so the reading loop stopped exactly where the provenance chain would start paying off. - An excerpt is a blockquote plus a `SourceAnchor` written as a plain markdown link (`#know-note-source?doc=…&page=…&start=…&end=…`). The anchor has to live in the note body itself: the body is not Tiptap JSON — `NoteEditor` serialises the document to markdown on every change and parses markdown back — so an anchor kept only in a node attribute would disappear on the next round trip. A quote plus an ordinary link also degrades for free: an unknown or malformed anchor loads as text, never as a note that cannot be opened. - `escapeExcerptMarkdown` keeps the selected text literal. An excerpt is what the user *selected*, not markdown they wrote, so `# heading`, `- list`, `[x](y)` and `` must not become a heading, a list, a link or bold. The table follows prosemirror-markdown's `esc()`, plus `<`, `&` and `|` because tiptap-markdown runs with `html: true` and raw HTML, entities and tables are all live. Leading indentation is clamped to three spaces — the one case escaping cannot express, since what triggers an indented code block is whitespace rather than punctuation. - `selectionToSourceAnchor` joins `citationToSourceAnchor` as the second entry at the single place that constructs a `SourceAnchor`, so the duplicated `documentId` has exactly one writer. - Saving appends to the open note and only creates one when none is open, so excerpts accumulate into the note being worked on instead of becoming a note each. The append travels as a targeted command (`noteId` + markdown) consumed by the editor that owns the document: writing through `updateNote` from the reader would be a second writer, and the editor's next `onUpdate` would overwrite the excerpt with its older body. - Note links now have one dispatch point. `openOnClick` is off because Tiptap's Link calls `window.open(href)` inside an editable editor, which is wrong both for these anchors and for ordinary links; `#know-note-source?…` goes to `openSourceAnchor` and everything else to `openExternalUrl`. A deleted source keeps the quote and the reference and only disables navigation (`data-source-missing` + `aria-disabled`) — the markdown is never rewritten, because "the evidence is unreachable now" and "this note came from there" are different facts. - `citationDocumentExists` becomes `sourceDocumentExists`, now that the excerpt anchor is a second consumer of the same rule. Verification: `npm run typecheck`, `npm test` (237 pass; 18 new — escaping and its reversibility, the append seam, anchor round-trip, and the targeted command's cross-note delivery), `npm run build`, `npm run check:design`, `npx prettier --check src test`. The Markdown → Tiptap round trip was checked against the real Tiptap in a throwaway jsdom harness (deliberately not committed — jsdom is only a transitive dependency, so it must not become a test dependency): 42 markdown-sensitive selections — headings, all three bullet markers, ordered lists, nested quotes, thematic breaks, inline links, images, inline/block HTML, entities, setext pairs, tables, 4/8-space and tab indentation, and multi-line combinations — all keep both their visible text and their structure. Not verified: nothing was exercised in a real Electron window. Click dispatch, the disabled styling, the append into a dirty editor and the save path are covered by unit tests over the pure parts only, and are listed as manual QA in the PR. Closes #73 --- .../src/components/notebook/NotePanel.tsx | 2 +- .../src/components/notebook/SourcePanel.tsx | 96 ++++- .../components/notebook/chat/MessageItem.tsx | 4 +- .../components/notebook/note/NoteEditor.tsx | 125 ++++++- .../notebook/note/appendExcerptCommand.ts | 71 ++++ .../components/notebook/note/noteEditor.css | 13 + src/renderer/src/locales/en-US/notebook.json | 1 + src/renderer/src/locales/en-US/ui.json | 5 + src/renderer/src/locales/zh-CN/notebook.json | 1 + src/renderer/src/locales/zh-CN/ui.json | 5 + src/shared/utils/citations.ts | 11 +- src/shared/utils/excerpt.ts | 193 ++++++++++ src/shared/utils/sourceAnchor.ts | 29 +- test/appendExcerptCommand.test.ts | 113 ++++++ test/citations.test.ts | 14 +- test/excerpt.test.ts | 352 ++++++++++++++++++ test/sourceAnchor.test.ts | 54 ++- 17 files changed, 1060 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/notebook/note/appendExcerptCommand.ts create mode 100644 src/shared/utils/excerpt.ts create mode 100644 test/appendExcerptCommand.test.ts create mode 100644 test/excerpt.test.ts diff --git a/src/renderer/src/components/notebook/NotePanel.tsx b/src/renderer/src/components/notebook/NotePanel.tsx index 2f2a130..6d45427 100644 --- a/src/renderer/src/components/notebook/NotePanel.tsx +++ b/src/renderer/src/components/notebook/NotePanel.tsx @@ -132,7 +132,7 @@ function NoteEditorPanel({ {/* 编辑器内容 */}
- +
) diff --git a/src/renderer/src/components/notebook/SourcePanel.tsx b/src/renderer/src/components/notebook/SourcePanel.tsx index b894855..b4158a5 100644 --- a/src/renderer/src/components/notebook/SourcePanel.tsx +++ b/src/renderer/src/components/notebook/SourcePanel.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, ReactElement } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Plus, FileText, Globe, FileUp, Loader2, StickyNote, ArrowLeft } from 'lucide-react' +import { Plus, FileText, Globe, FileUp, Loader2, StickyNote, ArrowLeft, Quote } from 'lucide-react' +import { toast } from 'sonner' import { useKnowledgeStore, setupKnowledgeListeners } from '../../store/knowledgeStore' import { useItemStore } from '../../store/itemStore' import { useUIStore } from '../../store/uiStore' @@ -16,12 +17,15 @@ import { PanelHeader } from '../ui/panel-header' import DocumentList from './source/DocumentList' import SourceReader from './source/reader/SourceReader' import type { KnowledgeDocument } from '../../../../shared/types/knowledge' -import type { ReaderAnchor } from '../../../../shared/types/source' +import type { ReaderAnchor, ReaderSelection } from '../../../../shared/types/source' import { + selectionToSourceAnchor, sourceAnchorFromSearchParams, sourceAnchorsEqual } from '../../../../shared/utils/sourceAnchor' +import { buildExcerptMarkdown } from '../../../../shared/utils/excerpt' import { useSourceAnchorNavigation } from '../../hooks/useSourceAnchorNavigation' +import { requestAppendExcerpt } from './note/appendExcerptCommand' // 添加来源类型 type AddSourceType = 'file' | 'url' | 'text' | 'note' @@ -172,11 +176,42 @@ interface DocumentViewerPanelProps { document: KnowledgeDocument anchor?: ReaderAnchor | null onBack: () => void + onSaveExcerpt: (excerptMarkdown: string, sourceTitle: string) => void } -function DocumentViewerPanel({ document, anchor, onBack }: DocumentViewerPanelProps) { +function DocumentViewerPanel({ + document, + anchor, + onBack, + onSaveExcerpt +}: DocumentViewerPanelProps) { const { t } = useTranslation('ui') + // 阅读器里当前选中的那段文字。它与用来跳转的 `anchor` 无关,所以单独一份 state。 + // 面板按 document.id 重挂载,切来源时选区自动清空。 + const [selection, setSelection] = useState(null) + + const handleSelectionChange = useCallback((next: ReaderSelection | null): void => { + setSelection(next) + }, []) + + const handleSaveExcerpt = useCallback((): void => { + if (!selection) return + + const markdown = buildExcerptMarkdown({ + text: selection.text, + anchor: selectionToSourceAnchor(selection), + label: + typeof selection.page === 'number' + ? t('excerptSourceLabel', { title: document.title, page: selection.page }) + : document.title + }) + if (!markdown) return + + onSaveExcerpt(markdown, document.title) + setSelection(null) + }, [selection, document.title, onSaveExcerpt, t]) + // Esc 是返回按钮的键盘等价物。它不是「关闭笔记本」快捷键(那个已经刻意不再绑到裸 // Escape),并且会先把 Escape 让给已经打开的对话框。 useEffect(() => { @@ -206,12 +241,31 @@ function DocumentViewerPanel({ document, anchor, onBack }: DocumentViewerPanelPr } center={{document.title}} + right={ + selection && ( + + ) + } /> {/* 来源阅读器(#71):PDF 渲染原页,其余格式走文本回退,两者同一契约。 - `anchor`(#72)把引用定位传进来,reader 自己决定页/块/偏移的优先级。 */} + `anchor`(#72)把引用定位传进来,reader 自己决定页/块/偏移的优先级。 + `onSelectionChange`(#73)把「选中的一段文字」交上来,用于摘录到笔记。 */}
- +
) @@ -240,7 +294,7 @@ export default function SourcePanel(): ReactElement { selectFiles } = useKnowledgeStore() - const { notes, loadNotes } = useItemStore() + const { notes, loadNotes, currentNote, createNote } = useItemStore() const { openSettings } = useUIStore() @@ -332,6 +386,35 @@ export default function SourcePanel(): ReactElement { ? focusedSource.location : null + /** + * 「保存摘录」(#73)。 + * + * 当前有打开的笔记就追加进去,没有才新建一条,并把新笔记打开(`createNote` 会把它设为 + * 当前笔记并进入编辑态)。摘录是往正在工作的笔记里沉淀,不是每次产出一张独立卡片。 + * + * 追加走命令而不是 `updateNote`:正文的写入者是笔记编辑器,外面直接改库会被编辑器下一次 + * `onUpdate` 用旧正文覆盖(见 `appendExcerptCommand`)。 + */ + const handleSaveExcerpt = useCallback( + async (excerptMarkdown: string, sourceTitle: string): Promise => { + if (!notebookId) return + + if (currentNote) { + requestAppendExcerpt(currentNote.id, excerptMarkdown) + toast.success(t('excerptAppended', { title: currentNote.title })) + return + } + + try { + await createNote(notebookId, excerptMarkdown, t('excerptNoteTitle', { title: sourceTitle })) + } catch (error) { + console.error('[SourcePanel] Failed to save the excerpt as a note:', error) + toast.error(t('excerptSaveFailed')) + } + }, + [notebookId, currentNote, createNote, t] + ) + // 处理文件上传 const handleFileUpload = useCallback(async () => { if (!notebookId) return @@ -486,6 +569,7 @@ export default function SourcePanel(): ReactElement { document={openDocument} anchor={readerAnchor} onBack={handleBack} + onSaveExcerpt={handleSaveExcerpt} /> ) : ( // 文档列表页面 diff --git a/src/renderer/src/components/notebook/chat/MessageItem.tsx b/src/renderer/src/components/notebook/chat/MessageItem.tsx index 48c4da2..a3f9f90 100644 --- a/src/renderer/src/components/notebook/chat/MessageItem.tsx +++ b/src/renderer/src/components/notebook/chat/MessageItem.tsx @@ -16,7 +16,7 @@ import { useKnowledgeStore } from '../../../store/knowledgeStore' import { useNotebookStore } from '../../../store/notebookStore' import { useSourceAnchorNavigation } from '../../../hooks/useSourceAnchorNavigation' import { parseRetrievalStatus, sourcesForDisplay } from '../../../../../shared/utils/answerSources' -import { parseCitations, citationDocumentExists } from '../../../../../shared/utils/citations' +import { parseCitations, sourceDocumentExists } from '../../../../../shared/utils/citations' import { citationToSourceAnchor } from '../../../../../shared/utils/sourceAnchor' import { Button } from '../../ui/button' import { ScrollArea, ScrollBar } from '../../ui/scroll-area' @@ -55,7 +55,7 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement // provably absent. Before that (or after a failed load) the list is "unknown", // not "deleted", so the chip stays clickable and the reader falls back. const documentExists = (documentId: string): boolean => - citationDocumentExists(documents, documentsLoaded, documentId) + sourceDocumentExists(documents, documentsLoaded, documentId) const handleOpenCitation = (citation: (typeof citations)[number]): void => { openSourceAnchor(citationToSourceAnchor(citation)) diff --git a/src/renderer/src/components/notebook/note/NoteEditor.tsx b/src/renderer/src/components/notebook/note/NoteEditor.tsx index e386cd3..95b5d4c 100644 --- a/src/renderer/src/components/notebook/note/NoteEditor.tsx +++ b/src/renderer/src/components/notebook/note/NoteEditor.tsx @@ -1,24 +1,59 @@ -import { ReactElement, useEffect } from 'react' +import { ReactElement, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useEditor, EditorContent } from '@tiptap/react' +import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { Markdown } from 'tiptap-markdown' import { Placeholder } from '@tiptap/extensions' import { ScrollArea } from '../../ui/scroll-area' import { Toaster } from '../../ui/sonner' +import { toast } from 'sonner' +import { sourceDocumentExists } from '../../../../../shared/utils/citations' +import { + appendExcerptMarkdown, + excerptAnchorFromHref, + isExcerptSourceHref +} from '../../../../../shared/utils/excerpt' +import { useSourceAnchorNavigation } from '../../../hooks/useSourceAnchorNavigation' +import { useKnowledgeStore } from '../../../store/knowledgeStore' +import { subscribeAppendExcerpt } from './appendExcerptCommand' import './noteEditor.css' interface NoteEditorProps { + /** 正在编辑的笔记 id。摘录追加命令按它匹配目标,不再靠「当前挂载的是谁」。 */ + noteId: string content: string onChange: (content: string) => void } -export default function NoteEditor({ content, onChange }: NoteEditorProps): ReactElement { +/** 正文的当前 markdown。`onUpdate`、外部同步和摘录追加都走这一个序列化入口。 */ +const markdownOf = (editor: Editor): string => + // SAFETY: `tiptap-markdown` 把 serializer 挂在 `editor.storage.markdown` 上,但它的 + // 类型增强没有覆盖到 @tiptap/core v3 的 storage 映射,所以 TS 看不到 `getMarkdown`。 + // 该扩展是下面 `useEditor` 的固定成员,挂载后这个字段必然存在(原本这里写的是 `as any`)。 + (editor.storage as unknown as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown() + +export default function NoteEditor({ noteId, content, onChange }: NoteEditorProps): ReactElement { const { t } = useTranslation('notebook') + const documents = useKnowledgeStore((state) => state.documents) + const documentsLoaded = useKnowledgeStore((state) => state.documentsLoaded) + const { openSourceAnchor } = useSourceAnchorNavigation() + + const containerRef = useRef(null) + + // `useEditor` 只创建一次编辑器,`editorProps` 里的闭包不会随重渲染更新,所以点击派发 + // 需要的东西放在 ref 里读最新值,而不是捕获进创建时的闭包。 + const clickContext = useRef({ documents, documentsLoaded, openSourceAnchor }) + useEffect(() => { + clickContext.current = { documents, documentsLoaded, openSourceAnchor } + }, [documents, documentsLoaded, openSourceAnchor]) + const editor = useEditor({ extensions: [ - StarterKit, + // 摘录锚点是普通 markdown 链接(#73),交给下面自己的派发处理。Tiptap 的 Link 默认 + // `openOnClick: true`,会在可编辑状态下直接 `window.open(href)` —— 对笔记里的锚点 + // 是错的,对普通外链也绕过了应用的 openExternalUrl。 + StarterKit.configure({ link: { openOnClick: false } }), Markdown, Placeholder.configure({ placeholder: t('startEditing', '输入笔记内容...') @@ -31,8 +66,7 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac } }, onUpdate: ({ editor }) => { - const markdown = (editor.storage as any).markdown.getMarkdown() - onChange(markdown) + onChange(markdownOf(editor)) } }) @@ -42,13 +76,88 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac // 当外部 content 变化时同步到编辑器 useEffect(() => { - if (editor && content !== (editor.storage as any).markdown.getMarkdown()) { + if (editor && content !== markdownOf(editor)) { editor.commands.setContent(content) } }, [content, editor]) // 快捷键保存由 NoteEditorPanel 统一处理,因为它同时管理标题输入框。 + // 摘录追加(#73):命令按 `noteId` 匹配,只被这篇笔记的编辑器消费;基线是它自己手上的 + // 正文,因此不存在「外部写入被编辑器用旧正文覆盖」的双写问题。 + // `setContent` 默认 emitUpdate,改动会沿既有 onUpdate → onChange → 保存路径流出去。 + useEffect(() => { + if (!editor) return + + return subscribeAppendExcerpt(noteId, (excerptMarkdown) => { + const current = markdownOf(editor) + const next = appendExcerptMarkdown(current, excerptMarkdown) + if (next === current) return + editor.commands.setContent(next) + }) + }, [editor, noteId]) + + // 来源被删除时,正文里的锚点**原样保留**,只是不再可跳转 —— 「现在拿不到这份证据」和 + // 「这段笔记当初来自这里」是两件事。标记只写在 DOM 上,markdown 一个字节都不改。 + useEffect(() => { + const root = containerRef.current + if (!root) return + + for (const element of root.querySelectorAll('a[href]')) { + const anchor = excerptAnchorFromHref(element.getAttribute('href')) + const missing = + anchor !== null && !sourceDocumentExists(documents, documentsLoaded, anchor.documentId) + + element.toggleAttribute('data-source-missing', missing) + if (missing) element.setAttribute('aria-disabled', 'true') + else element.removeAttribute('aria-disabled') + } + }, [documents, documentsLoaded, content, editor]) + + /** + * 笔记正文里的链接点击派发。 + * + * 用 React 的 capture 而不是 `editorProps.handleClick`:这里的闭包每次渲染都是新的, + * 不会读到过期的 documents;也不依赖 ProseMirror 插件与 view props 的处理顺序。 + * + * #know-note-source?... → 应用内回到原文(复用 #72 的 openSourceAnchor) + * 其它 → 交给系统浏览器 + */ + const handleClickCapture = useCallback( + (event: React.MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) return + + const link = target.closest('a') + if (!link) return + + const href = link.getAttribute('href') + + if (isExcerptSourceHref(href)) { + event.preventDefault() + const anchor = excerptAnchorFromHref(href) + if (!anchor) return + + const context = clickContext.current + // 来源已删除:不导航,也不把锚点从正文里删掉。但要说明为什么没反应, + // 否则「已禁用」和「坏了」在用户看来是一样的。 + if (!sourceDocumentExists(context.documents, context.documentsLoaded, anchor.documentId)) { + toast.error(t('excerptSourceMissing')) + return + } + context.openSourceAnchor(anchor) + return + } + + // 交给系统浏览器。空 href 和纯 fragment 不是外部地址;协议白名单不在这里重造 —— + // Link 的 renderHTML 已经把不被允许的协议写成 href=""。 + if (!href || href.startsWith('#')) return + event.preventDefault() + void window.api.openExternalUrl(href) + }, + [t] + ) + if (!editor) { return (
@@ -62,7 +171,7 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps): Reac {/* 编辑器内容 */} -
+
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) ![h](i) 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) +})