From 5a45c236f3ea45ef1798ef7f12f14a032de60327 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 00:33:00 +0800 Subject: [PATCH 001/409] docs: add knowledge base notes redesign research docs --- docs/knowledge-base-notes/01-current-state.md | 121 +++++ .../02-target-architecture.md | 219 ++++++++ .../03-editor-cards-obsidian.md | 481 ++++++++++++++++++ .../04-implementation-roadmap.md | 214 ++++++++ docs/knowledge-base-notes/README.md | 92 ++++ 5 files changed, 1127 insertions(+) create mode 100644 docs/knowledge-base-notes/01-current-state.md create mode 100644 docs/knowledge-base-notes/02-target-architecture.md create mode 100644 docs/knowledge-base-notes/03-editor-cards-obsidian.md create mode 100644 docs/knowledge-base-notes/04-implementation-roadmap.md create mode 100644 docs/knowledge-base-notes/README.md diff --git a/docs/knowledge-base-notes/01-current-state.md b/docs/knowledge-base-notes/01-current-state.md new file mode 100644 index 00000000..0ca94538 --- /dev/null +++ b/docs/knowledge-base-notes/01-current-state.md @@ -0,0 +1,121 @@ +# Current State + +## Data Model + +The current annotation system has three main concepts: + +- `Highlight` + - Stored in the `highlights` table. + - Fields include `book_id`, `cfi`, selected `text`, `color`, optional inline + `note`, `chapter_title`, timestamps, and sync metadata after migration. +- `Note` + - Stored in the `notes` table. + - Fields include `book_id`, optional `highlight_id`, optional `cfi`, `title`, + Markdown `content`, `chapter_title`, `tags`, timestamps, and sync metadata. +- `Bookmark` + - Stored in `bookmarks`. + +Important mismatch: + +- The global Notes UI mostly uses `highlightsWithBooks` and treats + `highlight.note` as the user's note. +- The standalone `notes` table exists and is synced, but it is not the main + product workflow. +- Export currently passes `[] as Note[]` in key places, so standalone notes are + often ignored by the user-facing note export flow. + +## Desktop Editing + +Desktop already has a Tiptap-based Markdown editor: + +- File: `packages/app/src/components/ui/markdown-editor.tsx` +- Uses `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/markdown`, and + placeholder support. +- Initializes with `contentType: "markdown"`. +- Persists `editor.getMarkdown()` through the existing note field. + +This is a good starting point, but it is not yet a reusable knowledge editor. +It is coupled to small note editing and stores Markdown only. + +## Mobile Editing + +Mobile currently uses a native React Native editor: + +- File: `packages/app-expo/src/components/ui/RichTextEditor.tsx` +- Uses a multiline `TextInput`. +- Has a Markdown toolbar and preview mode. +- Used by note cards, reader note modal, and selection popover. + +This cannot support Tiptap node views or ReadAny custom cards. The mobile +knowledge editor should move to a WebView editor bundle and a typed bridge. + +## Notes UI + +Desktop and mobile both present notes as book-grouped notebooks: + +- Desktop: `packages/app/src/components/notes/NotesPage.tsx` +- Mobile: `packages/app-expo/src/screens/NotesView.tsx` + +Current tabs are essentially: + +- Notes: highlights with `note` +- Highlights: highlights without `note` + +This is useful for annotation review, but not enough for a knowledge base. A +knowledge base needs document editing, source cards, backlinks, book home pages, +and cross-book search. + +## Export + +The existing exporter supports: + +- Markdown +- JSON +- Obsidian Markdown with frontmatter and callouts +- Notion-friendly clipboard text + +File: `packages/core/src/export/annotation-exporter.ts` + +Limitations: + +- It exports annotations, not a document graph. +- Obsidian support is one-shot Markdown output, not a linked vault workflow. +- Custom cards have no serialization policy. + +## Sync + +The current sync service is per-device JSON changesets: + +- File: `packages/core/src/sync/simple-sync.ts` +- `SYNC_TABLES` includes books, highlights, notes, bookmarks, threads, messages, + skills, tags, groups, and reading sessions. +- Deletions use `sync_tombstones`. +- Conflict behavior is mostly last-write-wins based on timestamp columns. + +New knowledge tables must be added to this system. If Markdown files, +attachments, or Obsidian vault outputs are synced as files, they must also be +represented in file sync manifests. + +## Testing Baseline + +Relevant existing tests: + +- `packages/core/src/db/__tests__/note-queries.test.ts` +- `packages/core/src/stores/annotation-store.test.ts` +- `packages/core/src/sync/__tests__/simple-sync.integration.test.ts` +- `packages/core/src/sync/__tests__/sync-files.test.ts` + +The next feature should add tests at the DB, store, conversion, and sync layers +before large UI work lands. + +## Current Risks + +- `highlights.note` and `notes.content` can diverge if both remain writable. +- Markdown-only storage cannot faithfully represent rich custom cards. +- JSON-only storage would weaken Obsidian/export unless a stable Markdown + projection exists. +- Mobile WebView editing needs a robust bridge, autosave, keyboard handling, and + error states. +- Obsidian "live sync" can create hard conflict cases if ReadAny and Obsidian + edit the same file independently. + diff --git a/docs/knowledge-base-notes/02-target-architecture.md b/docs/knowledge-base-notes/02-target-architecture.md new file mode 100644 index 00000000..c2871a83 --- /dev/null +++ b/docs/knowledge-base-notes/02-target-architecture.md @@ -0,0 +1,219 @@ +# Target Architecture + +## Core Concepts + +The knowledge base should be built around documents and links, not around only +annotations. + +Recommended concepts: + +- Book + - Existing library book entity. + - Owns a default knowledge document. +- Highlight + - Existing reader annotation tied to a CFI. + - Remains optimized for reader rendering. +- Knowledge document + - Editable Tiptap document. + - Can be a book home page, standalone note, highlight note, review, summary, + imported Markdown file, AI-generated note, or custom user document. +- Knowledge link + - Connects documents to books, highlights, CFIs, other documents, external + URLs, Obsidian paths, or AI messages. +- Knowledge attachment + - Images and other files used inside documents and cards. +- Knowledge card + - Structured Tiptap node with a type, version, attrs, optional content, and a + Markdown fallback renderer. + +## Proposed Tables + +Names are intentionally explicit and can be shortened during implementation. + +```sql +CREATE TABLE IF NOT EXISTS knowledge_documents ( + id TEXT PRIMARY KEY, + book_id TEXT, + parent_id TEXT, + type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + content_json TEXT NOT NULL DEFAULT '{}', + content_md TEXT NOT NULL DEFAULT '', + content_schema_version INTEGER NOT NULL DEFAULT 1, + excerpt TEXT, + tags TEXT DEFAULT '[]', + source_kind TEXT, + source_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS knowledge_links ( + id TEXT PRIMARY KEY, + from_document_id TEXT NOT NULL, + to_kind TEXT NOT NULL, + to_id TEXT NOT NULL, + relation TEXT NOT NULL, + label TEXT, + cfi TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (from_document_id) REFERENCES knowledge_documents(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS knowledge_attachments ( + id TEXT PRIMARY KEY, + document_id TEXT, + kind TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT, + local_path TEXT, + remote_path TEXT, + size INTEGER DEFAULT 0, + hash TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT +); + +CREATE TABLE IF NOT EXISTS knowledge_card_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + schema_json TEXT NOT NULL DEFAULT '{}', + built_in INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT +); +``` + +## Document Types + +Recommended initial values: + +- `book_home` + - One default document per book. + - Auto-created lazily when opening book details or notes. +- `standalone_note` + - User-created note under a book or global knowledge area. +- `highlight_note` + - A rich note linked to a highlight. + - Replaces inline `highlights.note` over time. +- `review` + - Long-form book review. +- `summary` + - User or AI generated summary. +- `imported_markdown` + - A document imported from Markdown/Obsidian. + +## Migration Strategy + +Use a compatibility migration, not a destructive rewrite. + +Phase 1: + +- Add new knowledge tables. +- For every book, create `book_home` lazily. +- For every existing `notes` row, create a matching `knowledge_document`. +- For every `highlight.note`, create a `highlight_note` document linked to the + highlight. +- Keep writing `highlights.note` for old UI compatibility while the new UI is + being built. + +Phase 2: + +- New UI reads from knowledge documents. +- Highlight note edits update the linked knowledge document first. +- A compatibility projection updates `highlights.note` from the Markdown + projection so old paths keep working. + +Phase 3: + +- Remove direct editing paths for `highlights.note`. +- Keep the column as a denormalized preview field or migrate it away after + enough versions. + +## Sync Strategy + +Add new tables to `SYNC_TABLES`: + +- `knowledge_documents` using `updated_at` +- `knowledge_links` using `updated_at` +- `knowledge_attachments` using `updated_at` +- `knowledge_card_templates` using `updated_at` + +Initial conflict model: + +- Last-write-wins per document row. +- Keep `content_schema_version` for future migrations. +- Store `content_md` as a derived projection, but sync it with the document row + so remote devices can search and export without rendering JSON immediately. + +Future conflict model: + +- Add per-document revision hashes. +- Consider block-level merge only after the basic document model is stable. +- If collaborative editing is introduced later, use a CRDT layer rather than + stretching the current table sync model. + +## File and Attachment Sync + +Attachments should not be stored only in SQLite. + +Recommended remote layout: + +```text +/readany/data/knowledge/ + attachments/ + {hash-or-id}.{ext} + exports/ + obsidian/ + manifest.json +``` + +The DB row stores metadata and the file sync layer moves bytes. This keeps S3, +WebDAV, and LAN behavior consistent. + +## AI Integration + +Knowledge documents should become AI-readable sources: + +- Search current book home document and linked highlight notes. +- Search global knowledge documents. +- Let tools create or update documents with explicit user confirmation. +- Let tools manage book grouping and document tags through typed operations. +- Keep citation links back to book CFI, highlight, or source document. + +Recommended tools later: + +- `searchKnowledge` +- `getBookKnowledge` +- `createKnowledgeDocument` +- `updateKnowledgeDocument` +- `linkKnowledgeSource` +- `tagKnowledgeDocument` + +## Search + +Start with deterministic local search: + +- `content_md` FTS for keyword search. +- Tags and title indexes. +- Book-scoped and global filters. + +Then integrate with existing vectorization: + +- Knowledge documents can be vectorized independently from books. +- Book text, highlights, and knowledge documents should be separate source types + in retrieval results. + diff --git a/docs/knowledge-base-notes/03-editor-cards-obsidian.md b/docs/knowledge-base-notes/03-editor-cards-obsidian.md new file mode 100644 index 00000000..87ade84f --- /dev/null +++ b/docs/knowledge-base-notes/03-editor-cards-obsidian.md @@ -0,0 +1,481 @@ +# Editor, Cards, and Obsidian + +## Editor Direction + +Use one shared Tiptap editor model across desktop and mobile. + +Desktop: + +- Replace the current note-only `MarkdownEditor` with a reusable + `KnowledgeEditor`. +- Keep React Tiptap and the existing visual language. +- Save canonical JSON plus Markdown projection. + +Mobile: + +- Replace the native `RichTextEditor` with a WebView-backed Tiptap editor. +- Reuse the same extension list and card registry. +- Use a typed RN bridge for editor lifecycle, content updates, toolbar state, + focus, blur, keyboard-aware sizing, and errors. + +Dependency note: + +- Desktop already depends on Tiptap packages. +- Mobile currently has `react-native-webview`, but does not yet ship a Tiptap + editor bundle. +- Static JSON-to-Markdown/HTML rendering should add `@tiptap/static-renderer` + or an equivalent shared renderer in the package that owns editor utilities. + +Recommended package boundary: + +```text +packages/core/ + knowledge data model, projections, export, search helpers + +packages/editor/ + shared Tiptap extensions, card registry, JSON/Markdown conversion + +packages/app/ + desktop React editor shell and card node views + +packages/app-expo/ + WebView editor host, native toolbar, keyboard/safe-area behavior +``` + +## Mobile WebView Bridge + +Recommended bridge messages: + +```ts +type EditorCommand = + | { type: "init"; documentId: string; contentJson: unknown; theme: EditorTheme; readOnly?: boolean } + | { type: "setContent"; contentJson: unknown } + | { type: "focus"; position?: "start" | "end" } + | { type: "blur" } + | { type: "runCommand"; command: string; attrs?: Record } + | { type: "setTheme"; theme: EditorTheme } + | { type: "requestContent"; requestId: string }; + +type EditorEvent = + | { type: "ready" } + | { type: "contentChanged"; contentJson: unknown; contentMd: string; plainText: string } + | { type: "selectionChanged"; marks: Record; blockType?: string } + | { type: "focusChanged"; focused: boolean } + | { type: "heightChanged"; height: number } + | { type: "error"; code: string; message: string; details?: unknown }; +``` + +Bridge requirements: + +- Debounce `contentChanged`. +- Always expose an explicit error state instead of silent loading. +- Support autosave and recovery. +- Keep toolbar state outside the WebView where possible so mobile UI stays native. +- Use the existing WebView patterns from reader, Mermaid, and mindmap views. + +## Canonical Content + +Save both: + +- `content_json`: canonical Tiptap/ProseMirror JSON. +- `content_md`: deterministic Markdown projection. + +The projection should be generated by shared editor utilities so desktop, mobile, +export, sync, and AI all see the same representation. + +## Rich Text Scope by Scenario + +The knowledge editor should not expose every rich-text feature everywhere. Each +editing surface has a different job, so the toolbar and supported blocks should +be tiered by scenario. + +### Quick Annotation Notes + +Used when the user selects text in the reader and quickly adds a note. + +Keep this surface lightweight: + +- Paragraphs +- Bold, italic, strikethrough +- Inline code +- Links +- Bullet list +- Ordered list +- Blockquote +- Undo and redo + +Avoid heavy blocks here: + +- Tables +- Image upload +- Mermaid and mindmap cards +- Multi-column layout +- Large embedded AI cards + +Reason: + +Quick annotation is an interruption inside reading. The editor should help the +user capture a thought in seconds and then get out of the way. + +### Highlight Note Documents + +Used when a highlight note is opened as a richer knowledge document. + +Support: + +- Everything from quick annotation notes +- Headings H2/H3 +- Task list +- Quote/source card linked to the highlight and CFI +- Callout card +- Related-note link card +- Tags + +Reason: + +This is still source-attached, but the user may want to expand a short annotation +into a reusable note. + +### Book Home Documents + +Used as the main knowledge page for each book. + +Support: + +- Headings H1-H3 +- Paragraphs +- Bold, italic, strikethrough, inline code +- Links and internal document links +- Bullet, ordered, and task lists +- Blockquote +- Horizontal rule +- Callout cards +- Quote cards +- Book metadata card +- Highlight collection card +- AI summary card +- Review card +- Mermaid and mindmap cards +- Image attachments +- Table blocks, if the table extension is stable on both desktop and mobile + +Reason: + +The book home document is the user's durable workspace. It should support +structured reading notes, outlines, summaries, reviews, and visual thinking. + +### Standalone Knowledge Documents + +Used for user-created notes that may or may not belong to a book. + +Support: + +- Same baseline as book home documents +- Backlinks +- Internal document links +- Tags +- Attachments +- AI-generated summary card + +Reason: + +These documents are closest to a normal knowledge-base note and should feel +powerful without being book-only. + +### Reviews and Long-Form Writing + +Used for book reviews, reading essays, and polished exports. + +Support: + +- Headings +- Rich paragraphs +- Links +- Lists +- Blockquotes +- Quote cards +- Footnote-style source references +- Images +- Export-friendly callouts + +Avoid by default: + +- Highly interactive cards that do not export cleanly +- Layout-only blocks that degrade poorly to Markdown + +Reason: + +Reviews must export cleanly to Markdown, Obsidian, and shareable text. + +### AI-Generated Knowledge Blocks + +Used for summaries, Q&A, concept extraction, timelines, and mindmaps. + +Support: + +- ReadAny AI summary card +- Q&A card +- Concept card +- Timeline card +- Mermaid and mindmap cards +- Source quote cards with citations + +Rules: + +- AI-generated blocks should be clearly marked. +- The user should be able to convert an AI block into normal editable content. +- Source citations should remain linked to book CFI, highlight, or document ID. + +Reason: + +AI output often starts structured, but users need ownership and editability after +insertion. + +### Mobile Toolbar Scope + +Mobile should expose a smaller toolbar first and move complex actions into an +insert menu. + +Primary toolbar: + +- Undo +- Redo +- Bold +- Italic +- Link +- Bullet list +- Ordered list +- Blockquote +- Insert menu + +Insert menu: + +- Heading +- Task list +- Quote card +- Callout card +- Image +- AI card +- Mermaid or mindmap +- Related note + +Reason: + +Mobile has limited horizontal space and keyboard pressure. The editing surface +should feel native and calm, not like a desktop toolbar squeezed into a phone. + +### Desktop Toolbar Scope + +Desktop can expose richer groups: + +- History +- Text style +- Headings +- Lists +- Insert +- Cards +- Export/preview + +Reason: + +Desktop has enough space for a more capable writing workspace, especially in the +book home and standalone knowledge document views. + +## Initial Extension Set + +Recommended v1 Tiptap extension set: + +- StarterKit +- Placeholder +- Link +- TaskList and TaskItem +- Image +- Table, TableRow, TableHeader, TableCell, only after mobile WebView behavior is + verified +- ReadAnyCard +- ReadAnyInternalLink +- ReadAnySourceReference + +Defer until later: + +- Collaboration/CRDT +- Comments +- Multi-column layout +- Drawing canvas +- Database/table-view blocks +- Arbitrary HTML embeds + +The deferred features are useful, but they increase sync, export, mobile, and +security complexity. They should not block the first knowledge-base milestone. + +## Custom Cards + +ReadAny cards should be Tiptap custom nodes. + +Recommended node shape: + +```json +{ + "type": "readanyCard", + "attrs": { + "cardType": "bookQuote", + "version": 1, + "sourceId": "highlight-123", + "theme": "quote", + "data": {} + }, + "content": [] +} +``` + +Card registry fields: + +- `cardType` +- `version` +- `insertLabel` +- `schema` +- `createDefaultAttrs` +- `renderEditorNodeView` +- `renderReadOnly` +- `renderMarkdownFallback` +- `upgradeAttrs` + +Initial built-in cards: + +- Quote card linked to a CFI/highlight. +- Book metadata card. +- AI summary card. +- Question/answer card. +- Review card. +- Mindmap or Mermaid card wrapper. +- Related notes/backlinks card. + +## Markdown Fallback + +Each custom card must degrade to readable Markdown. + +Recommended syntax: + +```markdown +:::readany-card type="bookQuote" id="highlight-123" version="1" +> The quoted text goes here. + +Source: Chapter 1 +::: +``` + +For Obsidian, the same data can also become callouts: + +```markdown +> [!quote] ReadAny quote +> The quoted text goes here. +> +> Source: [[Book Title#Chapter 1]] +``` + +If a card cannot be represented as pure Markdown, include a readable summary and +a hidden JSON block only in ReadAny-managed exports. + +## Obsidian Integration + +Obsidian supports Markdown, JSON Canvas, images, media files, and PDFs. It also +supports `obsidian://` URIs for opening vaults, creating notes, appending content, +and opening files/headings. + +Recommended stages: + +### Stage 1: Export Vault + +Create an export folder: + +```text +ReadAny/ + Books/ + {Book Title}.md + Highlights/ + {Book Title} - Highlights.md + Notes/ + {Book Title}/ + {Document Title}.md + Assets/ + ... + .readany/ + manifest.json +``` + +Use frontmatter: + +```yaml +--- +readany_id: doc_... +book_id: book_... +type: book_home +title: ... +author: ... +tags: + - readany +--- +``` + +### Stage 2: Desktop Linked Folder + +Desktop can let the user choose an Obsidian vault/folder and write Markdown +projections there. + +Rules: + +- ReadAny remains the source of truth. +- Export updates should be deterministic and id-based. +- `.readany/manifest.json` maps ReadAny IDs to Obsidian paths and hashes. +- If the Obsidian file changed externally, show a conflict screen instead of + overwriting silently. + +### Stage 3: Import and Reconcile + +Import Markdown edits back into ReadAny only after conflict handling exists. + +Rules: + +- Preserve `readany_id` frontmatter when possible. +- Unknown Markdown files import as `imported_markdown`. +- Unsupported ReadAny cards import as fallback Markdown blocks. + +### Stage 4: Optional Obsidian URI Actions + +Use `obsidian://open` or `obsidian://new` for convenience: + +- Open linked note in Obsidian. +- Create or append an exported note. +- Search inside the configured vault. + +This should be a convenience layer, not the sync backbone. + +## Rendering Modes + +The same document should render in different modes: + +- Editor mode: interactive Tiptap with node views. +- Read-only mode: static renderer or lightweight React renderer. +- Markdown mode: projection for export and AI. +- Obsidian mode: Markdown with frontmatter, callouts, and wikilinks. + +## UX Direction + +The knowledge base should feel like a reading workspace, not a generic note app. + +Desktop: + +- Left: library/knowledge navigation. +- Center: selected book or document. +- Right: context panel for sources, backlinks, cards, and AI. +- Reader side panel should show the current book knowledge document and linked + annotations without forcing the user into a modal. + +Mobile: + +- Book knowledge page should use native navigation and bottom sheets where + appropriate. +- Long-form editing uses the WebView editor in a focused screen. +- Quick annotation stays lightweight: select text, choose highlight/note, save. +- Custom cards should be readable in compact mode and editable in a focused + sheet/screen. diff --git a/docs/knowledge-base-notes/04-implementation-roadmap.md b/docs/knowledge-base-notes/04-implementation-roadmap.md new file mode 100644 index 00000000..f26dd9cf --- /dev/null +++ b/docs/knowledge-base-notes/04-implementation-roadmap.md @@ -0,0 +1,214 @@ +# Implementation Roadmap + +This feature is too large for one PR. It should land as a sequence of stable, +testable layers. + +## Phase 0: Research and Design + +Status: current branch. + +Deliverables: + +- Architecture research docs. +- Data model proposal. +- Editor and Obsidian plan. +- Implementation split. + +No runtime behavior should change in this phase. + +## Phase 1: Core Knowledge Model + +Goal: + +- Add knowledge tables and core queries without replacing the UI yet. + +Work: + +- Add DB migrations for `knowledge_documents`, `knowledge_links`, + `knowledge_attachments`, and `knowledge_card_templates`. +- Add core types. +- Add query modules and tests. +- Add conversion helpers: + - `createBookHomeDocument` + - `createHighlightNoteDocument` + - `projectKnowledgeDocumentToMarkdown` +- Add sync table entries. +- Add simple sync integration tests for document create/update/delete. + +Verification: + +- Existing annotation tests still pass. +- New document query tests pass. +- Sync applies knowledge documents and tombstones across devices. + +## Phase 2: Desktop Knowledge MVP + +Goal: + +- Introduce a desktop knowledge page for books while keeping old notes usable. + +Work: + +- Add `KnowledgeEditor` using Tiptap JSON canonical storage. +- Auto-create and open the book home document. +- Show linked highlights and notes as source cards. +- Allow standalone book notes. +- Add basic tags and backlinks display. +- Keep `highlights.note` compatibility projection. + +Verification: + +- Existing notes page still works. +- Book home document persists and syncs. +- Editing a highlight note updates the linked document and old note preview. +- Export still includes old notes and new knowledge documents. + +## Phase 3: Mobile WebView Tiptap Editor + +Goal: + +- Replace mobile Markdown TextInput editing with a WebView Tiptap editor. + +Work: + +- Build a local editor HTML bundle for Expo WebView. +- Add typed bridge messages. +- Add native toolbar state. +- Add autosave and explicit error states. +- Update `NoteCard`, `ReaderNoteViewModal`, and `SelectionPopover`. + +Verification: + +- iOS and Android can edit, save, focus, blur, and recover drafts. +- Keyboard does not cover the editor controls. +- WebView errors are visible and actionable. +- Existing reader selection flow remains fast. + +## Phase 4: Export and Obsidian v1 + +Goal: + +- Export the knowledge graph as a useful Markdown vault. + +Work: + +- Add `KnowledgeExporter`. +- Export book home, highlight notes, standalone notes, reviews, summaries, and + assets. +- Add frontmatter with stable IDs. +- Add Obsidian callout rendering for ReadAny cards. +- Add desktop linked-folder export with manifest and conflict detection. + +Verification: + +- Exported Markdown opens cleanly in Obsidian. +- Wikilinks and assets resolve. +- Re-export updates existing files by ID. +- External edits are detected before overwrite. + +## Phase 5: AI Knowledge Tools + +Goal: + +- Let AI read and manage the user's knowledge base safely. + +Work: + +- Add tools for search, get, create, update, tag, and link. +- Add tool permission UI where needed. +- Include knowledge documents in retrieval. +- Add compact summaries for long documents. + +Verification: + +- AI can answer from book text, annotations, and knowledge documents. +- AI never silently overwrites user documents. +- Tool failures display clear failure cards on desktop and mobile. + +## Phase 6: Custom Card Platform + +Goal: + +- Make ReadAny cards extensible and pleasant. + +Work: + +- Add card registry. +- Add built-in card nodes and node views. +- Add Markdown fallback renderer for every card. +- Add static read-only rendering. +- Add card template sync. + +Verification: + +- Cards edit on desktop and mobile. +- Cards export to readable Markdown. +- Unsupported card versions degrade safely. +- Card attrs migrate across schema versions. + +## Suggested PR Split + +1. `feat/kb-core-model` + - DB, types, queries, sync, tests. +2. `feat/kb-tiptap-core` + - Shared editor utilities, JSON/Markdown projection, card registry skeleton. +3. `feat/kb-desktop-mvp` + - Desktop book home document and knowledge editor. +4. `feat/kb-mobile-editor-webview` + - Mobile WebView editor and bridge. +5. `feat/kb-export-obsidian` + - Knowledge exporter and Obsidian vault export. +6. `feat/kb-ai-tools` + - AI tools and retrieval integration. +7. `feat/kb-custom-cards` + - Built-in rich cards and card UI polish. + +## Test Plan by Layer + +Core DB: + +- Insert/update/delete knowledge documents. +- Link documents to books, highlights, CFIs, and external URLs. +- Tombstones are inserted on delete. +- Derived Markdown updates when JSON changes. + +Sync: + +- New tables are included in collect/apply. +- Deletions propagate. +- Linked documents survive book sync. +- Attachments are represented in file manifests. + +Editor: + +- JSON to Markdown projection is deterministic. +- Markdown import creates valid Tiptap JSON. +- Custom card nodes preserve attrs. +- Unsupported cards render fallback. + +Mobile: + +- WebView editor ready/error states. +- Bridge command/event contract. +- Keyboard and safe-area behavior. +- Autosave and draft recovery. + +Export: + +- Obsidian vault structure. +- Frontmatter ID stability. +- Wikilinks and assets. +- Re-export conflict detection. + +## Open Product Questions + +- Should there be a global knowledge area outside books in v1, or only + book-scoped knowledge first? +- Should highlight notes appear inline in the reader, in the book home document, + or both? +- Should Obsidian linked-folder mode be read-only export first, or allow import in + the same milestone? +- Which custom cards are required for v1? +- Should AI-created documents require explicit confirmation every time? +- How much of the old Notes page should remain after the knowledge page ships? + diff --git a/docs/knowledge-base-notes/README.md b/docs/knowledge-base-notes/README.md new file mode 100644 index 00000000..354fe819 --- /dev/null +++ b/docs/knowledge-base-notes/README.md @@ -0,0 +1,92 @@ +# Knowledge Base Notes Redesign + +This document set researches and scopes the next-generation ReadAny note system: +turning highlights and notes into a book-centered knowledge base with Tiptap +editing, mobile WebView editing, Obsidian export/linking, richer export, and +extensible custom display cards. + +## Product Goal + +ReadAny should stop treating notes as only "text attached to a highlight". +The target system should support: + +- A dedicated editable knowledge document for every book. +- Highlights, quote notes, standalone notes, reviews, summaries, AI outputs, and + custom cards as first-class knowledge items. +- A unified Tiptap editor on desktop and mobile. +- Mobile editing through a WebView-based Tiptap editor instead of native + TextInput markdown editing. +- Obsidian-friendly export and, on desktop, optional linked vault/folder output. +- Export to Markdown, JSON, Obsidian-style vault folders, and later other targets. +- Backlinks, tags, book grouping, search, AI retrieval, and source citations. +- Extensible custom cards that can render rich interactive UI inside ReadAny and + degrade cleanly to Markdown outside ReadAny. + +## Key Recommendation + +Use Tiptap JSON as the canonical document source and maintain Markdown as a +projection/cache for export, Obsidian, full-text search, and interoperability. + +Why: + +- Tiptap officially recommends JSON persistence for flexibility and parseability. +- Custom ReadAny cards need structured node attributes and interactive node views. +- Markdown is still the right interchange format for Obsidian and export. +- A dual representation lets ReadAny be powerful without trapping user content. + +In short: + +```text +Tiptap JSON = source of truth inside ReadAny +Markdown = deterministic projection for export, Obsidian, search, and fallback +``` + +## Recommended Architecture + +- Keep `highlights` as reader annotations because they are tied to CFI and reader + rendering. +- Introduce knowledge documents as the new primary note layer. +- Create one `book_home` knowledge document per book. +- Convert highlight notes into knowledge documents linked to the highlight, while + keeping the existing `highlights.note` field during migration for compatibility. +- Add a registry-driven Tiptap extension layer for ReadAny cards. +- Add sync metadata to every new knowledge table and include them in + `SYNC_TABLES`. +- Treat attachments and exported vault files as file-sync concerns, not only DB + rows. + +## Research Inputs + +Code paths inspected: + +- `packages/core/src/types/annotation.ts` +- `packages/core/src/db/db-core.ts` +- `packages/core/src/db/note-queries.ts` +- `packages/core/src/db/highlight-queries.ts` +- `packages/core/src/stores/annotation-store.ts` +- `packages/core/src/stores/notebook-store.ts` +- `packages/core/src/sync/simple-sync.ts` +- `packages/core/src/export/annotation-exporter.ts` +- `packages/app/src/components/ui/markdown-editor.tsx` +- `packages/app/src/components/notes/NotesPage.tsx` +- `packages/app/src/components/reader/NotebookPanel.tsx` +- `packages/app-expo/src/components/ui/RichTextEditor.tsx` +- `packages/app-expo/src/screens/NotesView.tsx` +- `packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx` +- `packages/app-expo/src/components/reader/SelectionPopover.tsx` + +External docs checked: + +- Tiptap persistence: JSON is recommended for editor state persistence. +- Tiptap custom nodes and React node views. +- Tiptap static renderer for HTML, Markdown, and React output from JSON. +- Obsidian accepted formats: Markdown, Canvas, JSON Canvas, images, media, PDF. +- Obsidian URI: open, new, append, search, and vault/file addressing. + +## Document Map + +- [Current State](01-current-state.md) +- [Target Architecture](02-target-architecture.md) +- [Editor, Cards, and Obsidian](03-editor-cards-obsidian.md) +- [Roadmap](04-implementation-roadmap.md) + From f62684928ad6d71500a6be2ab17329dd04589002 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 01:15:56 +0800 Subject: [PATCH 002/409] feat(core): add knowledge base data model --- .../src/db/__tests__/book-queries.test.ts | 102 +++- .../db/__tests__/knowledge-queries.test.ts | 351 ++++++++++++ packages/core/src/db/book-queries.ts | 44 +- packages/core/src/db/database.ts | 23 + packages/core/src/db/db-core.ts | 91 +++ packages/core/src/db/index.ts | 18 + packages/core/src/db/knowledge-queries.ts | 519 ++++++++++++++++++ .../__tests__/simple-sync.integration.test.ts | 215 +++++++- packages/core/src/sync/simple-sync.ts | 4 + packages/core/src/types/index.ts | 1 + packages/core/src/types/knowledge.ts | 100 ++++ 11 files changed, 1440 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/db/__tests__/knowledge-queries.test.ts create mode 100644 packages/core/src/db/knowledge-queries.ts create mode 100644 packages/core/src/types/knowledge.ts diff --git a/packages/core/src/db/__tests__/book-queries.test.ts b/packages/core/src/db/__tests__/book-queries.test.ts index 2ad76c3e..bdc08ad0 100644 --- a/packages/core/src/db/__tests__/book-queries.test.ts +++ b/packages/core/src/db/__tests__/book-queries.test.ts @@ -1,5 +1,5 @@ -import type { Book } from "../../types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Book } from "../../types"; // --- Mock db-core --- const mockExecute = vi.fn(); @@ -16,7 +16,11 @@ const coreMocks = vi.hoisted(() => ({ insertTombstone: vi.fn(), parseJSON: vi.fn((str: string | null | undefined, fallback: unknown) => { if (!str) return fallback; - try { return JSON.parse(str); } catch { return fallback; } + try { + return JSON.parse(str); + } catch { + return fallback; + } }), })); @@ -26,17 +30,13 @@ const dependencyMocks = vi.hoisted(() => ({ })); vi.mock("../db-core", () => coreMocks); -vi.mock("../thread-queries", () => ({ deleteThreadsByBookId: dependencyMocks.deleteThreadsByBookId })); +vi.mock("../thread-queries", () => ({ + deleteThreadsByBookId: dependencyMocks.deleteThreadsByBookId, +})); vi.mock("../chunk-queries", () => ({ deleteChunks: dependencyMocks.deleteChunks })); -const { - getBooks, - getBook, - getDeletedBookByFileHash, - insertBook, - updateBook, - deleteBook, -} = await import("../book-queries"); +const { getBooks, getBook, getDeletedBookByFileHash, insertBook, updateBook, deleteBook } = + await import("../book-queries"); const sampleBook: Book = { id: "book-1", @@ -166,7 +166,7 @@ describe("book-queries", () => { const book = await getBook("book-1"); expect(book).not.toBeNull(); - expect(book!.id).toBe("book-1"); + expect(book?.id).toBe("book-1"); expect(mockSelect).toHaveBeenCalledWith( "SELECT * FROM books WHERE id = ? AND deleted_at IS NULL", ["book-1"], @@ -262,18 +262,36 @@ describe("book-queries", () => { await deleteBook("book-1", { preserveData: true }); - expect(mockSelect).not.toHaveBeenCalledWith("SELECT id FROM highlights WHERE book_id = ?", ["book-1"]); - expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM highlights WHERE book_id = ?", ["book-1"]); - expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM notes WHERE book_id = ?", ["book-1"]); - expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM bookmarks WHERE book_id = ?", ["book-1"]); - expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM reading_sessions WHERE book_id = ?", ["book-1"]); + expect(mockSelect).not.toHaveBeenCalledWith("SELECT id FROM highlights WHERE book_id = ?", [ + "book-1", + ]); + expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM highlights WHERE book_id = ?", [ + "book-1", + ]); + expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM notes WHERE book_id = ?", [ + "book-1", + ]); + expect(mockExecute).not.toHaveBeenCalledWith( + "DELETE FROM knowledge_documents WHERE book_id = ?", + ["book-1"], + ); + expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM bookmarks WHERE book_id = ?", [ + "book-1", + ]); + expect(mockExecute).not.toHaveBeenCalledWith( + "DELETE FROM reading_sessions WHERE book_id = ?", + ["book-1"], + ); expect(mockExecute).not.toHaveBeenCalledWith("DELETE FROM books WHERE id = ?", ["book-1"]); expect(dependencyMocks.deleteThreadsByBookId).toHaveBeenCalledWith("book-1"); expect(dependencyMocks.deleteChunks).toHaveBeenCalledWith("book-1"); - expect(mockExecute).toHaveBeenCalledWith( - expect.stringContaining("UPDATE books"), - [expect.any(Number), 3000, 1, "device-1", "book-1"], - ); + expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining("UPDATE books"), [ + expect.any(Number), + 3000, + 1, + "device-1", + "book-1", + ]); }); it("hard-deletes everything when preserveData is not requested", async () => { @@ -281,17 +299,53 @@ describe("book-queries", () => { mockSelect .mockResolvedValueOnce([{ id: "hl-1" }]) .mockResolvedValueOnce([{ id: "note-1" }]) + .mockResolvedValueOnce([{ id: "doc-1" }]) + .mockResolvedValueOnce([{ id: "knowledge-link-1" }]) + .mockResolvedValueOnce([{ id: "knowledge-attachment-1" }]) .mockResolvedValueOnce([{ id: "bm-1" }]); await deleteBook("book-1"); - expect(mockExecute).toHaveBeenCalledWith("DELETE FROM highlights WHERE book_id = ?", ["book-1"]); + expect(mockExecute).toHaveBeenCalledWith("DELETE FROM highlights WHERE book_id = ?", [ + "book-1", + ]); expect(mockExecute).toHaveBeenCalledWith("DELETE FROM notes WHERE book_id = ?", ["book-1"]); - expect(mockExecute).toHaveBeenCalledWith("DELETE FROM bookmarks WHERE book_id = ?", ["book-1"]); - expect(mockExecute).toHaveBeenCalledWith("DELETE FROM reading_sessions WHERE book_id = ?", ["book-1"]); + expect(mockExecute).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM knowledge_links"), + ["book-1"], + ); + expect(mockExecute).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM knowledge_attachments"), + ["book-1"], + ); + expect(mockExecute).toHaveBeenCalledWith( + "DELETE FROM knowledge_documents WHERE book_id = ?", + ["book-1"], + ); + expect(mockExecute).toHaveBeenCalledWith("DELETE FROM bookmarks WHERE book_id = ?", [ + "book-1", + ]); + expect(mockExecute).toHaveBeenCalledWith("DELETE FROM reading_sessions WHERE book_id = ?", [ + "book-1", + ]); expect(mockExecute).toHaveBeenCalledWith("DELETE FROM books WHERE id = ?", ["book-1"]); expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "hl-1", "highlights"); expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "note-1", "notes"); + expect(coreMocks.insertTombstone).toHaveBeenCalledWith( + mockDb, + "knowledge-link-1", + "knowledge_links", + ); + expect(coreMocks.insertTombstone).toHaveBeenCalledWith( + mockDb, + "knowledge-attachment-1", + "knowledge_attachments", + ); + expect(coreMocks.insertTombstone).toHaveBeenCalledWith( + mockDb, + "doc-1", + "knowledge_documents", + ); expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "bm-1", "bookmarks"); expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "book-1", "books"); }); diff --git a/packages/core/src/db/__tests__/knowledge-queries.test.ts b/packages/core/src/db/__tests__/knowledge-queries.test.ts new file mode 100644 index 00000000..1a6c8e9b --- /dev/null +++ b/packages/core/src/db/__tests__/knowledge-queries.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { KnowledgeAttachment, KnowledgeCardTemplate, KnowledgeLink } from "../../types"; + +const mockExecute = vi.fn(); +const mockSelect = vi.fn(); +const mockDb = { execute: mockExecute, select: mockSelect, close: vi.fn() }; + +const coreMocks = vi.hoisted(() => ({ + getDB: vi.fn(), + getDeviceId: vi.fn(), + nextSyncVersion: vi.fn(), + nextUpdatedAt: vi.fn(), + insertTombstone: vi.fn(), + parseJSON: vi.fn((str: string | null | undefined, fallback: unknown) => { + if (!str) return fallback; + try { + return JSON.parse(str); + } catch { + return fallback; + } + }), +})); + +const idMocks = vi.hoisted(() => ({ + generateId: vi.fn(() => "generated-id"), +})); + +vi.mock("../db-core", () => coreMocks); +vi.mock("../../utils/generate-id", () => idMocks); + +const { + createKnowledgeDocument, + deleteKnowledgeAttachment, + deleteKnowledgeDocument, + deleteKnowledgeLink, + ensureBookHomeDocument, + getKnowledgeAttachments, + getKnowledgeCardTemplates, + getKnowledgeDocument, + getKnowledgeDocuments, + getKnowledgeLinks, + insertKnowledgeAttachment, + insertKnowledgeDocument, + insertKnowledgeLink, + updateKnowledgeDocument, + upsertKnowledgeCardTemplate, +} = await import("../knowledge-queries"); + +const docRow = { + id: "doc-1", + book_id: "book-1", + parent_id: null, + type: "book_home", + title: "Book Home", + content_json: '{"type":"doc","content":[]}', + content_md: "# Book Home", + content_schema_version: 1, + excerpt: "Short", + tags: '["tag-a"]', + source_kind: "book", + source_id: "book-1", + created_at: 1000, + updated_at: 1000, + deleted_at: null, +}; + +describe("knowledge-queries", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Date, "now").mockReturnValue(1234); + coreMocks.getDB.mockResolvedValue(mockDb); + coreMocks.getDeviceId.mockResolvedValue("device-1"); + coreMocks.nextSyncVersion.mockResolvedValue(7); + coreMocks.nextUpdatedAt.mockResolvedValue(2345); + coreMocks.insertTombstone.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("maps a knowledge document row", async () => { + mockSelect.mockResolvedValue([docRow]); + + const doc = await getKnowledgeDocument("doc-1"); + + expect(doc).toMatchObject({ + id: "doc-1", + bookId: "book-1", + type: "book_home", + title: "Book Home", + contentJson: { type: "doc", content: [] }, + contentMd: "# Book Home", + tags: ["tag-a"], + sourceKind: "book", + sourceId: "book-1", + }); + expect(mockSelect).toHaveBeenCalledWith( + "SELECT * FROM knowledge_documents WHERE id = ? LIMIT 1", + ["doc-1"], + ); + }); + + it("filters knowledge documents by book, type, source, and limit", async () => { + mockSelect.mockResolvedValue([]); + + await getKnowledgeDocuments({ + bookId: "book-1", + parentId: null, + type: "book_home", + sourceKind: "book", + sourceId: "book-1", + limit: 5, + }); + + const [sql, params] = mockSelect.mock.calls[0]; + expect(sql).toContain("deleted_at IS NULL"); + expect(sql).toContain("book_id = ?"); + expect(sql).toContain("parent_id IS NULL"); + expect(sql).toContain("type = ?"); + expect(sql).toContain("source_kind = ?"); + expect(sql).toContain("source_id = ?"); + expect(params).toEqual(["book-1", "book_home", "book", "book-1", 5]); + }); + + it("creates a knowledge document with defaults and sync tracking", async () => { + mockExecute.mockResolvedValue(undefined); + + const doc = await createKnowledgeDocument({ + bookId: "book-1", + type: "book_home", + title: " Book Home ", + sourceKind: "book", + sourceId: "book-1", + }); + + expect(doc).toMatchObject({ + id: "generated-id", + title: "Book Home", + contentJson: { type: "doc", content: [] }, + contentMd: "", + contentSchemaVersion: 1, + tags: [], + createdAt: 1234, + updatedAt: 1234, + }); + + const [sql, params] = mockExecute.mock.calls[0]; + expect(sql).toContain("INSERT INTO knowledge_documents"); + expect(params[0]).toBe("generated-id"); + expect(params[1]).toBe("book-1"); + expect(params[5]).toBe('{"type":"doc","content":[]}'); + expect(params[15]).toBe(7); + expect(params[16]).toBe("device-1"); + }); + + it("ensures book home document by returning the existing document first", async () => { + mockSelect.mockResolvedValue([docRow]); + + const doc = await ensureBookHomeDocument("book-1", "Fallback"); + + expect(doc.id).toBe("doc-1"); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts a provided knowledge document", async () => { + mockExecute.mockResolvedValue(undefined); + + await insertKnowledgeDocument({ + id: "doc-2", + bookId: "book-1", + type: "standalone_note", + title: "Manual", + contentJson: { type: "doc" }, + contentMd: "Manual", + contentSchemaVersion: 1, + tags: ["x"], + createdAt: 1000, + updatedAt: 1000, + }); + + const [sql, params] = mockExecute.mock.calls[0]; + expect(sql).toContain("INSERT INTO knowledge_documents"); + expect(params[0]).toBe("doc-2"); + expect(params[9]).toBe('["x"]'); + }); + + it("updates content, nullable fields, and sync tracking", async () => { + mockExecute.mockResolvedValue(undefined); + + await updateKnowledgeDocument("doc-1", { + title: "Updated", + contentJson: { type: "doc", content: [{ type: "paragraph" }] }, + contentMd: "Updated", + excerpt: undefined, + tags: ["new"], + sourceKind: undefined, + }); + + const [sql, params] = mockExecute.mock.calls[0]; + expect(sql).toContain("title = ?"); + expect(sql).toContain("content_json = ?"); + expect(sql).toContain("content_md = ?"); + expect(sql).toContain("excerpt = ?"); + expect(sql).toContain("source_kind = ?"); + expect(sql).toContain("updated_at = ?"); + expect(sql).toContain("sync_version = ?"); + expect(params).toContain("Updated"); + expect(params).toContain('{"type":"doc","content":[{"type":"paragraph"}]}'); + expect(params).toContain('["new"]'); + expect(params).toContain(2345); + expect(params).toContain(7); + expect(params).toContain("device-1"); + }); + + it("deletes a knowledge document with a tombstone", async () => { + mockExecute.mockResolvedValue(undefined); + + await deleteKnowledgeDocument("doc-1"); + + expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "doc-1", "knowledge_documents"); + expect(mockExecute).toHaveBeenCalledWith("DELETE FROM knowledge_documents WHERE id = ?", [ + "doc-1", + ]); + }); + + it("maps and inserts knowledge links", async () => { + mockSelect.mockResolvedValue([ + { + id: "link-1", + from_document_id: "doc-1", + to_kind: "highlight", + to_id: "hl-1", + relation: "source", + label: "Quote", + cfi: "epubcfi(/6/2)", + created_at: 1000, + updated_at: 1000, + }, + ]); + + const links = await getKnowledgeLinks("doc-1"); + expect(links[0]).toMatchObject({ + id: "link-1", + fromDocumentId: "doc-1", + toKind: "highlight", + relation: "source", + cfi: "epubcfi(/6/2)", + }); + + const link: KnowledgeLink = { + id: "link-2", + fromDocumentId: "doc-1", + toKind: "document", + toId: "doc-2", + relation: "related", + createdAt: 1000, + updatedAt: 1000, + }; + await insertKnowledgeLink(link); + expect(mockExecute.mock.calls[0][0]).toContain("INSERT INTO knowledge_links"); + + await deleteKnowledgeLink("link-2"); + expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "link-2", "knowledge_links"); + }); + + it("maps and inserts knowledge attachments", async () => { + mockSelect.mockResolvedValue([ + { + id: "att-1", + document_id: "doc-1", + kind: "image", + file_name: "cover.png", + mime_type: "image/png", + local_path: "/tmp/cover.png", + remote_path: "/readany/data/knowledge/cover.png", + size: 12, + hash: "hash", + created_at: 1000, + updated_at: 1000, + }, + ]); + + const attachments = await getKnowledgeAttachments("doc-1"); + expect(attachments[0]).toMatchObject({ + id: "att-1", + documentId: "doc-1", + kind: "image", + fileName: "cover.png", + size: 12, + }); + + const attachment: KnowledgeAttachment = { + id: "att-2", + documentId: "doc-1", + kind: "file", + fileName: "note.bin", + size: 1, + createdAt: 1000, + updatedAt: 1000, + }; + await insertKnowledgeAttachment(attachment); + expect(mockExecute.mock.calls[0][0]).toContain("INSERT INTO knowledge_attachments"); + + await deleteKnowledgeAttachment("att-2"); + expect(coreMocks.insertTombstone).toHaveBeenCalledWith( + mockDb, + "att-2", + "knowledge_attachments", + ); + }); + + it("maps and upserts card templates", async () => { + mockSelect.mockResolvedValue([ + { + id: "card-quote", + name: "Quote", + version: 1, + schema_json: '{"type":"object"}', + built_in: 1, + enabled: 1, + created_at: 1000, + updated_at: 1000, + }, + ]); + + const templates = await getKnowledgeCardTemplates(); + expect(templates[0]).toMatchObject({ + id: "card-quote", + schemaJson: { type: "object" }, + builtIn: true, + enabled: true, + }); + + const template: KnowledgeCardTemplate = { + id: "card-review", + name: "Review", + version: 1, + schemaJson: { type: "object" }, + builtIn: false, + enabled: true, + createdAt: 1000, + updatedAt: 1000, + }; + await upsertKnowledgeCardTemplate(template); + const [sql, params] = mockExecute.mock.calls[0]; + expect(sql).toContain("INSERT INTO knowledge_card_templates"); + expect(sql).toContain("ON CONFLICT(id) DO UPDATE"); + expect(params[3]).toBe('{"type":"object"}'); + }); +}); diff --git a/packages/core/src/db/book-queries.ts b/packages/core/src/db/book-queries.ts index 9bcd9ab4..bb3e90bf 100644 --- a/packages/core/src/db/book-queries.ts +++ b/packages/core/src/db/book-queries.ts @@ -362,9 +362,31 @@ export async function deleteBook(id: string, options: DeleteBookOptions = {}): P return; } - const [highlightRows, noteRows, bookmarkRows] = await Promise.all([ + const [ + highlightRows, + noteRows, + knowledgeDocumentRows, + knowledgeLinkRows, + knowledgeAttachmentRows, + bookmarkRows, + ] = await Promise.all([ database.select<{ id: string }>("SELECT id FROM highlights WHERE book_id = ?", [id]), database.select<{ id: string }>("SELECT id FROM notes WHERE book_id = ?", [id]), + database.select<{ id: string }>("SELECT id FROM knowledge_documents WHERE book_id = ?", [id]), + database.select<{ id: string }>( + `SELECT kl.id + FROM knowledge_links kl + INNER JOIN knowledge_documents kd ON kl.from_document_id = kd.id + WHERE kd.book_id = ?`, + [id], + ), + database.select<{ id: string }>( + `SELECT ka.id + FROM knowledge_attachments ka + INNER JOIN knowledge_documents kd ON ka.document_id = kd.id + WHERE kd.book_id = ?`, + [id], + ), database.select<{ id: string }>("SELECT id FROM bookmarks WHERE book_id = ?", [id]), ]); @@ -374,12 +396,32 @@ export async function deleteBook(id: string, options: DeleteBookOptions = {}): P for (const row of noteRows) { await insertTombstone(database, row.id, "notes"); } + for (const row of knowledgeLinkRows) { + await insertTombstone(database, row.id, "knowledge_links"); + } + for (const row of knowledgeAttachmentRows) { + await insertTombstone(database, row.id, "knowledge_attachments"); + } + for (const row of knowledgeDocumentRows) { + await insertTombstone(database, row.id, "knowledge_documents"); + } for (const row of bookmarkRows) { await insertTombstone(database, row.id, "bookmarks"); } await database.execute("DELETE FROM highlights WHERE book_id = ?", [id]); await database.execute("DELETE FROM notes WHERE book_id = ?", [id]); + await database.execute( + `DELETE FROM knowledge_links + WHERE from_document_id IN (SELECT id FROM knowledge_documents WHERE book_id = ?)`, + [id], + ); + await database.execute( + `DELETE FROM knowledge_attachments + WHERE document_id IN (SELECT id FROM knowledge_documents WHERE book_id = ?)`, + [id], + ); + await database.execute("DELETE FROM knowledge_documents WHERE book_id = ?", [id]); await database.execute("DELETE FROM bookmarks WHERE book_id = ?", [id]); await database.execute("DELETE FROM reading_sessions WHERE book_id = ?", [id]); await deleteThreadsByBookId(id); diff --git a/packages/core/src/db/database.ts b/packages/core/src/db/database.ts index b4242886..2be16d59 100644 --- a/packages/core/src/db/database.ts +++ b/packages/core/src/db/database.ts @@ -70,6 +70,29 @@ export { deleteNote, } from "./note-queries"; +export { + getKnowledgeDocument, + getKnowledgeDocuments, + getBookHomeDocument, + createKnowledgeDocument, + ensureBookHomeDocument, + insertKnowledgeDocument, + updateKnowledgeDocument, + deleteKnowledgeDocument, + getKnowledgeLinks, + insertKnowledgeLink, + deleteKnowledgeLink, + getKnowledgeAttachments, + insertKnowledgeAttachment, + deleteKnowledgeAttachment, + getKnowledgeCardTemplates, + upsertKnowledgeCardTemplate, +} from "./knowledge-queries"; +export type { + CreateKnowledgeDocumentInput, + KnowledgeDocumentFilters, +} from "./knowledge-queries"; + export { getBookmarks, insertBookmark, diff --git a/packages/core/src/db/db-core.ts b/packages/core/src/db/db-core.ts index b83f1b28..baca6fbf 100644 --- a/packages/core/src/db/db-core.ts +++ b/packages/core/src/db/db-core.ts @@ -407,6 +407,81 @@ export async function initDatabase(): Promise { ) `); + await database.execute(` + CREATE TABLE IF NOT EXISTS knowledge_documents ( + id TEXT PRIMARY KEY, + book_id TEXT, + parent_id TEXT, + type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + content_json TEXT NOT NULL DEFAULT '{}', + content_md TEXT NOT NULL DEFAULT '', + content_schema_version INTEGER NOT NULL DEFAULT 1, + excerpt TEXT, + tags TEXT DEFAULT '[]', + source_kind TEXT, + source_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_documents(id) ON DELETE SET NULL + ) + `); + + await database.execute(` + CREATE TABLE IF NOT EXISTS knowledge_links ( + id TEXT PRIMARY KEY, + from_document_id TEXT NOT NULL, + to_kind TEXT NOT NULL, + to_id TEXT NOT NULL, + relation TEXT NOT NULL, + label TEXT, + cfi TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (from_document_id) REFERENCES knowledge_documents(id) ON DELETE CASCADE + ) + `); + + await database.execute(` + CREATE TABLE IF NOT EXISTS knowledge_attachments ( + id TEXT PRIMARY KEY, + document_id TEXT, + kind TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT, + local_path TEXT, + remote_path TEXT, + size INTEGER DEFAULT 0, + hash TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (document_id) REFERENCES knowledge_documents(id) ON DELETE SET NULL + ) + `); + + await database.execute(` + CREATE TABLE IF NOT EXISTS knowledge_card_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + schema_json TEXT NOT NULL DEFAULT '{}', + built_in INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT + ) + `); + await database.execute(` CREATE TABLE IF NOT EXISTS bookmarks ( id TEXT PRIMARY KEY, @@ -501,6 +576,18 @@ export async function initDatabase(): Promise { "CREATE INDEX IF NOT EXISTS idx_highlights_book ON highlights(book_id)", ); await database.execute("CREATE INDEX IF NOT EXISTS idx_notes_book ON notes(book_id)"); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_knowledge_documents_book ON knowledge_documents(book_id)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_knowledge_documents_source ON knowledge_documents(source_kind, source_id)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_knowledge_links_from ON knowledge_links(from_document_id)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_document ON knowledge_attachments(document_id)", + ); await database.execute("CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id)"); await database.execute( "CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id)", @@ -591,6 +678,10 @@ export async function initDatabase(): Promise { "tags", "book_tags", "book_groups", + "knowledge_documents", + "knowledge_links", + "knowledge_attachments", + "knowledge_card_templates", "reading_sessions", "threads", "messages", diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 19d0915b..94e66d0a 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -47,6 +47,23 @@ export { insertNote, updateNote, deleteNote, + // Knowledge queries + getKnowledgeDocument, + getKnowledgeDocuments, + getBookHomeDocument, + createKnowledgeDocument, + ensureBookHomeDocument, + insertKnowledgeDocument, + updateKnowledgeDocument, + deleteKnowledgeDocument, + getKnowledgeLinks, + insertKnowledgeLink, + deleteKnowledgeLink, + getKnowledgeAttachments, + insertKnowledgeAttachment, + deleteKnowledgeAttachment, + getKnowledgeCardTemplates, + upsertKnowledgeCardTemplate, // Bookmark queries getBookmarks, insertBookmark, @@ -82,3 +99,4 @@ export { } from "./database"; export type { HighlightWithBook } from "./database"; +export type { CreateKnowledgeDocumentInput, KnowledgeDocumentFilters } from "./database"; diff --git a/packages/core/src/db/knowledge-queries.ts b/packages/core/src/db/knowledge-queries.ts new file mode 100644 index 00000000..72b44272 --- /dev/null +++ b/packages/core/src/db/knowledge-queries.ts @@ -0,0 +1,519 @@ +import type { + JSONValue, + KnowledgeAttachment, + KnowledgeAttachmentKind, + KnowledgeCardTemplate, + KnowledgeDocument, + KnowledgeDocumentType, + KnowledgeLink, + KnowledgeLinkRelation, + KnowledgeLinkTargetKind, + KnowledgeSourceKind, +} from "../types"; +import { EMPTY_TIPTAP_DOCUMENT } from "../types"; +import { generateId } from "../utils/generate-id"; +import { + getDB, + getDeviceId, + insertTombstone, + nextSyncVersion, + nextUpdatedAt, + parseJSON, +} from "./db-core"; + +const KNOWLEDGE_SCHEMA_VERSION = 1; + +interface KnowledgeDocumentRow { + id: string; + book_id: string | null; + parent_id: string | null; + type: string; + title: string; + content_json: string; + content_md: string; + content_schema_version: number | null; + excerpt: string | null; + tags: string; + source_kind: string | null; + source_id: string | null; + created_at: number; + updated_at: number; + deleted_at: number | null; +} + +interface KnowledgeLinkRow { + id: string; + from_document_id: string; + to_kind: string; + to_id: string; + relation: string; + label: string | null; + cfi: string | null; + created_at: number; + updated_at: number; +} + +interface KnowledgeAttachmentRow { + id: string; + document_id: string | null; + kind: string; + file_name: string; + mime_type: string | null; + local_path: string | null; + remote_path: string | null; + size: number | null; + hash: string | null; + created_at: number; + updated_at: number; +} + +interface KnowledgeCardTemplateRow { + id: string; + name: string; + version: number | null; + schema_json: string; + built_in: number | null; + enabled: number | null; + created_at: number; + updated_at: number; +} + +export interface CreateKnowledgeDocumentInput { + id?: string; + bookId?: string; + parentId?: string; + type: KnowledgeDocumentType; + title?: string; + contentJson?: JSONValue; + contentMd?: string; + contentSchemaVersion?: number; + excerpt?: string; + tags?: string[]; + sourceKind?: KnowledgeSourceKind; + sourceId?: string; +} + +export interface KnowledgeDocumentFilters { + bookId?: string; + parentId?: string | null; + type?: KnowledgeDocumentType; + sourceKind?: KnowledgeSourceKind; + sourceId?: string; + limit?: number; +} + +function rowToKnowledgeDocument(row: KnowledgeDocumentRow): KnowledgeDocument { + return { + id: row.id, + bookId: row.book_id || undefined, + parentId: row.parent_id || undefined, + type: row.type as KnowledgeDocumentType, + title: row.title, + contentJson: parseJSON(row.content_json, EMPTY_TIPTAP_DOCUMENT) as JSONValue, + contentMd: row.content_md || "", + contentSchemaVersion: row.content_schema_version ?? KNOWLEDGE_SCHEMA_VERSION, + excerpt: row.excerpt || undefined, + tags: parseJSON(row.tags, []) as string[], + sourceKind: (row.source_kind as KnowledgeSourceKind | null) || undefined, + sourceId: row.source_id || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at || undefined, + }; +} + +function rowToKnowledgeLink(row: KnowledgeLinkRow): KnowledgeLink { + return { + id: row.id, + fromDocumentId: row.from_document_id, + toKind: row.to_kind as KnowledgeLinkTargetKind, + toId: row.to_id, + relation: row.relation as KnowledgeLinkRelation, + label: row.label || undefined, + cfi: row.cfi || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToKnowledgeAttachment(row: KnowledgeAttachmentRow): KnowledgeAttachment { + return { + id: row.id, + documentId: row.document_id || undefined, + kind: row.kind as KnowledgeAttachmentKind, + fileName: row.file_name, + mimeType: row.mime_type || undefined, + localPath: row.local_path || undefined, + remotePath: row.remote_path || undefined, + size: row.size ?? 0, + hash: row.hash || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToKnowledgeCardTemplate(row: KnowledgeCardTemplateRow): KnowledgeCardTemplate { + return { + id: row.id, + name: row.name, + version: row.version ?? 1, + schemaJson: parseJSON(row.schema_json, {}) as JSONValue, + builtIn: row.built_in === 1, + enabled: row.enabled !== 0, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function getKnowledgeDocument(id: string): Promise { + const database = await getDB(); + const rows = await database.select( + "SELECT * FROM knowledge_documents WHERE id = ? LIMIT 1", + [id], + ); + return rows[0] ? rowToKnowledgeDocument(rows[0]) : null; +} + +export async function getKnowledgeDocuments( + filters: KnowledgeDocumentFilters = {}, +): Promise { + const database = await getDB(); + const where: string[] = ["deleted_at IS NULL"]; + const params: unknown[] = []; + + if (filters.bookId !== undefined) { + where.push("book_id = ?"); + params.push(filters.bookId); + } + if (Object.prototype.hasOwnProperty.call(filters, "parentId")) { + if (filters.parentId === null) { + where.push("parent_id IS NULL"); + } else { + where.push("parent_id = ?"); + params.push(filters.parentId); + } + } + if (filters.type !== undefined) { + where.push("type = ?"); + params.push(filters.type); + } + if (filters.sourceKind !== undefined) { + where.push("source_kind = ?"); + params.push(filters.sourceKind); + } + if (filters.sourceId !== undefined) { + where.push("source_id = ?"); + params.push(filters.sourceId); + } + + params.push(filters.limit ?? 200); + const rows = await database.select( + `SELECT * FROM knowledge_documents + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC, created_at DESC + LIMIT ?`, + params, + ); + return rows.map(rowToKnowledgeDocument); +} + +export async function getBookHomeDocument(bookId: string): Promise { + const rows = await getKnowledgeDocuments({ bookId, type: "book_home", limit: 1 }); + return rows[0] ?? null; +} + +export async function createKnowledgeDocument( + input: CreateKnowledgeDocumentInput, +): Promise { + const now = Date.now(); + const document: KnowledgeDocument = { + id: input.id ?? generateId(), + bookId: input.bookId, + parentId: input.parentId, + type: input.type, + title: input.title?.trim() ?? "", + contentJson: input.contentJson ?? EMPTY_TIPTAP_DOCUMENT, + contentMd: input.contentMd ?? "", + contentSchemaVersion: input.contentSchemaVersion ?? KNOWLEDGE_SCHEMA_VERSION, + excerpt: input.excerpt, + tags: input.tags ?? [], + sourceKind: input.sourceKind, + sourceId: input.sourceId, + createdAt: now, + updatedAt: now, + }; + await insertKnowledgeDocument(document); + return document; +} + +export async function ensureBookHomeDocument( + bookId: string, + fallbackTitle = "", +): Promise { + const existing = await getBookHomeDocument(bookId); + if (existing) return existing; + return createKnowledgeDocument({ + bookId, + type: "book_home", + title: fallbackTitle, + sourceKind: "book", + sourceId: bookId, + }); +} + +export async function insertKnowledgeDocument(document: KnowledgeDocument): Promise { + const database = await getDB(); + const deviceId = await getDeviceId(); + const syncVersion = await nextSyncVersion(database, "knowledge_documents"); + + await database.execute( + `INSERT INTO knowledge_documents ( + id, book_id, parent_id, type, title, content_json, content_md, + content_schema_version, excerpt, tags, source_kind, source_id, + created_at, updated_at, deleted_at, sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + document.id, + document.bookId ?? null, + document.parentId ?? null, + document.type, + document.title, + JSON.stringify(document.contentJson), + document.contentMd, + document.contentSchemaVersion, + document.excerpt ?? null, + JSON.stringify(document.tags), + document.sourceKind ?? null, + document.sourceId ?? null, + document.createdAt, + document.updatedAt, + document.deletedAt ?? null, + syncVersion, + deviceId, + ], + ); +} + +export async function updateKnowledgeDocument( + id: string, + updates: Partial< + Pick< + KnowledgeDocument, + | "bookId" + | "parentId" + | "type" + | "title" + | "contentJson" + | "contentMd" + | "contentSchemaVersion" + | "excerpt" + | "tags" + | "sourceKind" + | "sourceId" + | "deletedAt" + > + >, +): Promise { + const database = await getDB(); + const sets: string[] = []; + const values: unknown[] = []; + + const setNullable = (column: string, value: unknown) => { + sets.push(`${column} = ?`); + values.push(value ?? null); + }; + + if (Object.prototype.hasOwnProperty.call(updates, "bookId")) { + setNullable("book_id", updates.bookId); + } + if (Object.prototype.hasOwnProperty.call(updates, "parentId")) { + setNullable("parent_id", updates.parentId); + } + if (updates.type !== undefined) { + sets.push("type = ?"); + values.push(updates.type); + } + if (updates.title !== undefined) { + sets.push("title = ?"); + values.push(updates.title.trim()); + } + if (updates.contentJson !== undefined) { + sets.push("content_json = ?"); + values.push(JSON.stringify(updates.contentJson)); + } + if (updates.contentMd !== undefined) { + sets.push("content_md = ?"); + values.push(updates.contentMd); + } + if (updates.contentSchemaVersion !== undefined) { + sets.push("content_schema_version = ?"); + values.push(updates.contentSchemaVersion); + } + if (Object.prototype.hasOwnProperty.call(updates, "excerpt")) { + setNullable("excerpt", updates.excerpt); + } + if (updates.tags !== undefined) { + sets.push("tags = ?"); + values.push(JSON.stringify(updates.tags)); + } + if (Object.prototype.hasOwnProperty.call(updates, "sourceKind")) { + setNullable("source_kind", updates.sourceKind); + } + if (Object.prototype.hasOwnProperty.call(updates, "sourceId")) { + setNullable("source_id", updates.sourceId); + } + if (Object.prototype.hasOwnProperty.call(updates, "deletedAt")) { + setNullable("deleted_at", updates.deletedAt); + } + + if (sets.length === 0) return; + + const deviceId = await getDeviceId(); + const updatedAt = await nextUpdatedAt(database, "knowledge_documents", id); + const syncVersion = await nextSyncVersion(database, "knowledge_documents"); + sets.push("updated_at = ?"); + values.push(updatedAt); + sets.push("sync_version = ?"); + values.push(syncVersion); + sets.push("last_modified_by = ?"); + values.push(deviceId); + values.push(id); + + await database.execute(`UPDATE knowledge_documents SET ${sets.join(", ")} WHERE id = ?`, values); +} + +export async function deleteKnowledgeDocument(id: string): Promise { + const database = await getDB(); + await insertTombstone(database, id, "knowledge_documents"); + await database.execute("DELETE FROM knowledge_documents WHERE id = ?", [id]); +} + +export async function getKnowledgeLinks(documentId: string): Promise { + const database = await getDB(); + const rows = await database.select( + `SELECT * FROM knowledge_links + WHERE from_document_id = ? + ORDER BY created_at ASC`, + [documentId], + ); + return rows.map(rowToKnowledgeLink); +} + +export async function insertKnowledgeLink(link: KnowledgeLink): Promise { + const database = await getDB(); + const deviceId = await getDeviceId(); + const syncVersion = await nextSyncVersion(database, "knowledge_links"); + + await database.execute( + `INSERT INTO knowledge_links ( + id, from_document_id, to_kind, to_id, relation, label, cfi, + created_at, updated_at, sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + link.id, + link.fromDocumentId, + link.toKind, + link.toId, + link.relation, + link.label ?? null, + link.cfi ?? null, + link.createdAt, + link.updatedAt, + syncVersion, + deviceId, + ], + ); +} + +export async function deleteKnowledgeLink(id: string): Promise { + const database = await getDB(); + await insertTombstone(database, id, "knowledge_links"); + await database.execute("DELETE FROM knowledge_links WHERE id = ?", [id]); +} + +export async function getKnowledgeAttachments(documentId: string): Promise { + const database = await getDB(); + const rows = await database.select( + `SELECT * FROM knowledge_attachments + WHERE document_id = ? + ORDER BY created_at ASC`, + [documentId], + ); + return rows.map(rowToKnowledgeAttachment); +} + +export async function insertKnowledgeAttachment(attachment: KnowledgeAttachment): Promise { + const database = await getDB(); + const deviceId = await getDeviceId(); + const syncVersion = await nextSyncVersion(database, "knowledge_attachments"); + + await database.execute( + `INSERT INTO knowledge_attachments ( + id, document_id, kind, file_name, mime_type, local_path, remote_path, + size, hash, created_at, updated_at, sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + attachment.id, + attachment.documentId ?? null, + attachment.kind, + attachment.fileName, + attachment.mimeType ?? null, + attachment.localPath ?? null, + attachment.remotePath ?? null, + attachment.size, + attachment.hash ?? null, + attachment.createdAt, + attachment.updatedAt, + syncVersion, + deviceId, + ], + ); +} + +export async function deleteKnowledgeAttachment(id: string): Promise { + const database = await getDB(); + await insertTombstone(database, id, "knowledge_attachments"); + await database.execute("DELETE FROM knowledge_attachments WHERE id = ?", [id]); +} + +export async function getKnowledgeCardTemplates(): Promise { + const database = await getDB(); + const rows = await database.select( + "SELECT * FROM knowledge_card_templates WHERE enabled = 1 ORDER BY built_in DESC, name ASC", + ); + return rows.map(rowToKnowledgeCardTemplate); +} + +export async function upsertKnowledgeCardTemplate(template: KnowledgeCardTemplate): Promise { + const database = await getDB(); + const deviceId = await getDeviceId(); + const syncVersion = await nextSyncVersion(database, "knowledge_card_templates"); + + await database.execute( + `INSERT INTO knowledge_card_templates ( + id, name, version, schema_json, built_in, enabled, created_at, updated_at, + sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + version = excluded.version, + schema_json = excluded.schema_json, + built_in = excluded.built_in, + enabled = excluded.enabled, + updated_at = excluded.updated_at, + sync_version = excluded.sync_version, + last_modified_by = excluded.last_modified_by`, + [ + template.id, + template.name, + template.version, + JSON.stringify(template.schemaJson), + template.builtIn ? 1 : 0, + template.enabled ? 1 : 0, + template.createdAt, + template.updatedAt, + syncVersion, + deviceId, + ], + ); +} diff --git a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts index d23b931e..634226bd 100644 --- a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts +++ b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts @@ -74,6 +74,57 @@ const TABLE_COLUMNS: Record = { "created_at", "updated_at", ], + knowledge_documents: [ + "id", + "book_id", + "parent_id", + "type", + "title", + "content_json", + "content_md", + "content_schema_version", + "excerpt", + "tags", + "source_kind", + "source_id", + "created_at", + "updated_at", + "deleted_at", + ], + knowledge_links: [ + "id", + "from_document_id", + "to_kind", + "to_id", + "relation", + "label", + "cfi", + "created_at", + "updated_at", + ], + knowledge_attachments: [ + "id", + "document_id", + "kind", + "file_name", + "mime_type", + "local_path", + "remote_path", + "size", + "hash", + "created_at", + "updated_at", + ], + knowledge_card_templates: [ + "id", + "name", + "version", + "schema_json", + "built_in", + "enabled", + "created_at", + "updated_at", + ], bookmarks: ["id", "book_id", "cfi", "label", "chapter_title", "created_at", "updated_at"], threads: [ "id", @@ -280,13 +331,44 @@ class FakeSyncDb { private assertForeignKeys(table: string, row: Row): void { if ( - ["highlights", "notes", "bookmarks", "book_tags", "reading_sessions"].includes(table) && + [ + "highlights", + "notes", + "knowledge_documents", + "bookmarks", + "book_tags", + "reading_sessions", + ].includes(table) && row.book_id && !this.tables.get("books")?.has(String(row.book_id)) ) { throw new Error("FOREIGN KEY constraint failed"); } + if ( + table === "knowledge_documents" && + row.parent_id && + !this.tables.get("knowledge_documents")?.has(String(row.parent_id)) + ) { + throw new Error("FOREIGN KEY constraint failed"); + } + + if ( + table === "knowledge_links" && + row.from_document_id && + !this.tables.get("knowledge_documents")?.has(String(row.from_document_id)) + ) { + throw new Error("FOREIGN KEY constraint failed"); + } + + if ( + table === "knowledge_attachments" && + row.document_id && + !this.tables.get("knowledge_documents")?.has(String(row.document_id)) + ) { + throw new Error("FOREIGN KEY constraint failed"); + } + if ( table === "messages" && row.thread_id && @@ -301,10 +383,33 @@ class FakeSyncDb { } private deleteBookDependents(bookId: string): void { - for (const table of ["highlights", "notes", "bookmarks", "book_tags", "reading_sessions"]) { + for (const table of [ + "highlights", + "notes", + "knowledge_documents", + "bookmarks", + "book_tags", + "reading_sessions", + ]) { const rows = this.tables.get(table); for (const [id, row] of rows ?? []) { - if (row.book_id === bookId) rows?.delete(id); + if (row.book_id === bookId) { + rows?.delete(id); + if (table === "knowledge_documents") { + this.deleteKnowledgeDocumentDependents(id); + } + } + } + } + } + + private deleteKnowledgeDocumentDependents(documentId: string): void { + for (const table of ["knowledge_links", "knowledge_attachments"]) { + const rows = this.tables.get(table); + for (const [id, row] of rows ?? []) { + if (row.from_document_id === documentId || row.document_id === documentId) { + rows?.delete(id); + } } } } @@ -429,6 +534,73 @@ function highlightRow(overrides: Row = {}): Row { }; } +function knowledgeDocumentRow(overrides: Row = {}): Row { + return { + id: "doc-1", + book_id: "book-1", + parent_id: null, + type: "book_home", + title: "Book home", + content_json: '{"type":"doc","content":[]}', + content_md: "# Book home", + content_schema_version: 1, + excerpt: "Book home", + tags: "[]", + source_kind: "book", + source_id: "book-1", + created_at: 1000, + updated_at: 1000, + deleted_at: null, + ...overrides, + }; +} + +function knowledgeLinkRow(overrides: Row = {}): Row { + return { + id: "knowledge-link-1", + from_document_id: "doc-1", + to_kind: "highlight", + to_id: "hl-1", + relation: "source", + label: "Source highlight", + cfi: "epubcfi(/6/2)", + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + +function knowledgeAttachmentRow(overrides: Row = {}): Row { + return { + id: "knowledge-attachment-1", + document_id: "doc-1", + kind: "image", + file_name: "quote.png", + mime_type: "image/png", + local_path: "knowledge/quote.png", + remote_path: "/readany/data/knowledge/quote.png", + size: 10, + hash: "hash-1", + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + +function knowledgeCardTemplateRow(overrides: Row = {}): Row { + return { + id: "card-quote", + name: "Quote card", + version: 1, + schema_json: '{"type":"object"}', + built_in: 1, + enabled: 1, + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + async function syncDevice( deviceId: string, db: FakeSyncDb, @@ -518,6 +690,43 @@ describe("simple sync convergence", () => { expect(target.get("highlights", "hl-1")).toBeTruthy(); }); + it("syncs knowledge documents, links, attachments, and card templates", async () => { + const backend = new MemoryBackend(); + const deviceA = new FakeSyncDb(); + const deviceB = new FakeSyncDb(); + + deviceA.insert("books", bookRow()); + deviceA.insert("highlights", highlightRow()); + deviceA.insert("knowledge_documents", knowledgeDocumentRow()); + deviceA.insert("knowledge_links", knowledgeLinkRow()); + deviceA.insert("knowledge_attachments", knowledgeAttachmentRow()); + deviceA.insert("knowledge_card_templates", knowledgeCardTemplateRow()); + + now = 1100; + await syncDevice("device-a", deviceA, backend); + + now = 1200; + const result = await syncDevice("device-b", deviceB, backend); + + expect(result.success).toBe(true); + expect(deviceB.get("knowledge_documents", "doc-1")).toMatchObject({ + title: "Book home", + source_kind: "book", + }); + expect(deviceB.get("knowledge_links", "knowledge-link-1")).toMatchObject({ + from_document_id: "doc-1", + to_kind: "highlight", + }); + expect(deviceB.get("knowledge_attachments", "knowledge-attachment-1")).toMatchObject({ + document_id: "doc-1", + file_name: "quote.png", + }); + expect(deviceB.get("knowledge_card_templates", "card-quote")).toMatchObject({ + name: "Quote card", + built_in: 1, + }); + }); + it("keeps a newer local record when an older remote tombstone arrives", async () => { const target = new FakeSyncDb(); target.insert("books", bookRow({ updated_at: 2500 })); diff --git a/packages/core/src/sync/simple-sync.ts b/packages/core/src/sync/simple-sync.ts index 42c2d97b..2932237b 100644 --- a/packages/core/src/sync/simple-sync.ts +++ b/packages/core/src/sync/simple-sync.ts @@ -52,6 +52,10 @@ const SYNC_TABLES: SyncTableConfig[] = [ }, { name: "highlights", pk: "id", timestampCol: "updated_at" }, { name: "notes", pk: "id", timestampCol: "updated_at" }, + { name: "knowledge_documents", pk: "id", timestampCol: "updated_at" }, + { name: "knowledge_links", pk: "id", timestampCol: "updated_at" }, + { name: "knowledge_attachments", pk: "id", timestampCol: "updated_at" }, + { name: "knowledge_card_templates", pk: "id", timestampCol: "updated_at" }, { name: "bookmarks", pk: "id", timestampCol: "updated_at" }, { name: "threads", pk: "id", timestampCol: "updated_at" }, { name: "messages", pk: "id", timestampCol: "created_at" }, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 2af70088..67d2a462 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -5,6 +5,7 @@ export * from "./annotation"; export * from "./book"; export * from "./chat"; export * from "./font"; +export * from "./knowledge"; // message.ts has StreamEvent/StreamEventType that conflict with chat.ts // The message.ts versions are the V2 ones — export them with explicit names export { diff --git a/packages/core/src/types/knowledge.ts b/packages/core/src/types/knowledge.ts new file mode 100644 index 00000000..b667f3c9 --- /dev/null +++ b/packages/core/src/types/knowledge.ts @@ -0,0 +1,100 @@ +/** Knowledge base types: documents, links, attachments, and card templates. */ + +export type JSONPrimitive = string | number | boolean | null; +export type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }; + +export type KnowledgeDocumentType = + | "book_home" + | "standalone_note" + | "highlight_note" + | "review" + | "summary" + | "imported_markdown"; + +export type KnowledgeSourceKind = + | "book" + | "highlight" + | "note" + | "cfi" + | "ai_message" + | "external" + | "obsidian"; + +export type KnowledgeLinkTargetKind = + | "book" + | "highlight" + | "document" + | "cfi" + | "url" + | "ai_message" + | "obsidian"; + +export type KnowledgeLinkRelation = + | "source" + | "references" + | "backlink" + | "related" + | "contains" + | "generated_from"; + +export type KnowledgeAttachmentKind = "image" | "audio" | "video" | "pdf" | "file"; + +export interface KnowledgeDocument { + id: string; + bookId?: string; + parentId?: string; + type: KnowledgeDocumentType; + title: string; + contentJson: JSONValue; + contentMd: string; + contentSchemaVersion: number; + excerpt?: string; + tags: string[]; + sourceKind?: KnowledgeSourceKind; + sourceId?: string; + createdAt: number; + updatedAt: number; + deletedAt?: number; +} + +export interface KnowledgeLink { + id: string; + fromDocumentId: string; + toKind: KnowledgeLinkTargetKind; + toId: string; + relation: KnowledgeLinkRelation; + label?: string; + cfi?: string; + createdAt: number; + updatedAt: number; +} + +export interface KnowledgeAttachment { + id: string; + documentId?: string; + kind: KnowledgeAttachmentKind; + fileName: string; + mimeType?: string; + localPath?: string; + remotePath?: string; + size: number; + hash?: string; + createdAt: number; + updatedAt: number; +} + +export interface KnowledgeCardTemplate { + id: string; + name: string; + version: number; + schemaJson: JSONValue; + builtIn: boolean; + enabled: boolean; + createdAt: number; + updatedAt: number; +} + +export const EMPTY_TIPTAP_DOCUMENT: JSONValue = { + type: "doc", + content: [], +}; From 785272a4a41b8847f69fbe8a8183e3c146c6a81f Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 01:22:52 +0800 Subject: [PATCH 003/409] feat(core): add knowledge editor projection --- packages/core/package.json | 2 + .../src/knowledge/editor-projection.test.ts | 166 ++++++++++ .../core/src/knowledge/editor-projection.ts | 293 ++++++++++++++++++ packages/core/src/knowledge/index.ts | 12 + 4 files changed, 473 insertions(+) create mode 100644 packages/core/src/knowledge/editor-projection.test.ts create mode 100644 packages/core/src/knowledge/editor-projection.ts create mode 100644 packages/core/src/knowledge/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index 3be16c33..c8a48655 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,6 +47,8 @@ "./hooks/*": "./src/hooks/*.ts", "./hooks/reader": "./src/hooks/reader/index.ts", "./hooks/reader/*": "./src/hooks/reader/*.ts", + "./knowledge": "./src/knowledge/index.ts", + "./knowledge/*": "./src/knowledge/*.ts", "./events/*": "./src/events/*.ts", "./feedback": "./src/feedback/index.ts", "./feedback/*": "./src/feedback/*.ts" diff --git a/packages/core/src/knowledge/editor-projection.test.ts b/packages/core/src/knowledge/editor-projection.test.ts new file mode 100644 index 00000000..7570500e --- /dev/null +++ b/packages/core/src/knowledge/editor-projection.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + markdownToBasicTiptap, + normalizeTiptapDocument, + renderKnowledgeJsonToMarkdown, +} from "./editor-projection"; + +describe("editor projection", () => { + it("renders common Tiptap nodes to Markdown", () => { + const markdown = renderKnowledgeJsonToMarkdown({ + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Chapter Notes" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Read " }, + { type: "text", text: "deeply", marks: [{ type: "bold" }] }, + { type: "text", text: " and " }, + { type: "text", text: "slowly", marks: [{ type: "italic" }] }, + { type: "text", text: "." }, + ], + }, + { + type: "blockquote", + content: [{ type: "paragraph", content: [{ type: "text", text: "A quote" }] }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "One" }] }], + }, + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "Two" }] }], + }, + ], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: true }, + content: [{ type: "paragraph", content: [{ type: "text", text: "Review" }] }], + }, + ], + }, + { type: "horizontalRule" }, + { type: "image", attrs: { src: "cover.png", alt: "Cover" } }, + ], + }); + + expect(markdown).toBe( + [ + "## Chapter Notes", + "Read **deeply** and *slowly*.", + "> A quote", + "- One\n- Two", + "- [x] Review", + "---", + "![Cover](cover.png)", + ].join("\n\n"), + ); + }); + + it("renders ReadAny cards as Obsidian-friendly callouts by default", () => { + const markdown = renderKnowledgeJsonToMarkdown({ + type: "doc", + content: [ + { + type: "readanyCard", + attrs: { + cardType: "bookQuote", + title: "Important Quote", + text: "Reading is thinking.", + sourceTitle: "Chapter 1", + }, + }, + ], + }); + + expect(markdown).toBe("> [!note] Important Quote\n> Reading is thinking.\n> Source: Chapter 1"); + }); + + it("can preserve ReadAny card metadata for round-tripping", () => { + const markdown = renderKnowledgeJsonToMarkdown( + { + type: "doc", + content: [ + { + type: "readanyCard", + attrs: { + cardType: "bookQuote", + id: "card-1", + version: 2, + sourceId: "hl-1", + markdown: "> Quote", + }, + }, + ], + }, + { includeReadAnyCardMetadata: true }, + ); + + expect(markdown).toBe( + ':::readany-card type="bookQuote" id="card-1" version="2" source="hl-1"\n> Quote\n:::', + ); + }); + + it("imports basic Markdown blocks into Tiptap JSON", () => { + const json = markdownToBasicTiptap( + ["# Title", "Paragraph", "> Quote", "- A\n- B", "1. One\n2. Two", "---"].join("\n\n"), + ); + + expect(json).toEqual({ + type: "doc", + content: [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Title" }] }, + { type: "paragraph", content: [{ type: "text", text: "Paragraph" }] }, + { + type: "blockquote", + content: [{ type: "paragraph", content: [{ type: "text", text: "Quote" }] }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "A" }] }], + }, + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "B" }] }], + }, + ], + }, + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "One" }] }], + }, + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text: "Two" }] }], + }, + ], + }, + { type: "horizontalRule" }, + ], + }); + }); + + it("normalizes invalid content to an empty Tiptap document", () => { + expect(normalizeTiptapDocument(null)).toEqual({ type: "doc", content: [] }); + expect(renderKnowledgeJsonToMarkdown(null)).toBe(""); + }); +}); diff --git a/packages/core/src/knowledge/editor-projection.ts b/packages/core/src/knowledge/editor-projection.ts new file mode 100644 index 00000000..230f5eae --- /dev/null +++ b/packages/core/src/knowledge/editor-projection.ts @@ -0,0 +1,293 @@ +import type { JSONValue } from "../types"; +import { EMPTY_TIPTAP_DOCUMENT } from "../types"; + +export interface TiptapMark { + type: string; + attrs?: Record; +} + +export interface TiptapNode { + type: string; + attrs?: Record; + text?: string; + marks?: TiptapMark[]; + content?: TiptapNode[]; +} + +export interface ReadAnyCardAttrs { + cardType?: string; + id?: string; + version?: number; + title?: string; + text?: string; + sourceTitle?: string; + sourceId?: string; + cfi?: string; + markdown?: string; + data?: unknown; +} + +export interface MarkdownProjectionOptions { + /** Preserve custom card metadata with fenced ReadAny blocks. */ + includeReadAnyCardMetadata?: boolean; +} + +function isObject(value: JSONValue | unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function isTiptapNode(value: JSONValue | unknown): value is TiptapNode { + return isObject(value) && typeof value.type === "string"; +} + +export function normalizeTiptapDocument(content: JSONValue | null | undefined): TiptapNode { + if (isTiptapNode(content)) return content; + return EMPTY_TIPTAP_DOCUMENT as TiptapNode; +} + +function escapeMarkdownText(text: string): string { + return text.replace(/\u00a0/g, " "); +} + +function applyMark(markdown: string, mark: TiptapMark): string { + if (!markdown) return markdown; + + switch (mark.type) { + case "bold": + case "strong": + return `**${markdown}**`; + case "italic": + case "em": + return `*${markdown}*`; + case "strike": + return `~~${markdown}~~`; + case "code": + return `\`${markdown.replace(/`/g, "\\`")}\``; + case "link": { + const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : ""; + return href ? `[${markdown}](${href})` : markdown; + } + default: + return markdown; + } +} + +function renderInline(node: TiptapNode): string { + if (node.type === "text") { + return (node.marks ?? []).reduce( + (markdown, mark) => applyMark(markdown, mark), + escapeMarkdownText(node.text ?? ""), + ); + } + + if (node.type === "hardBreak") return " \n"; + if (node.type === "readanyInternalLink") { + const label = String(node.attrs?.label ?? node.attrs?.title ?? node.attrs?.documentId ?? ""); + const id = String(node.attrs?.documentId ?? ""); + return id ? `[[${label || id}]]` : label; + } + if (node.type === "readanySourceReference") { + const label = String(node.attrs?.label ?? node.attrs?.sourceTitle ?? "Source"); + const cfi = String(node.attrs?.cfi ?? ""); + return cfi ? `[${label}](readany://cfi/${encodeURIComponent(cfi)})` : label; + } + + return (node.content ?? []).map(renderInline).join(""); +} + +function prefixLines(text: string, prefix: string): string { + return text + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); +} + +function renderReadAnyCard(node: TiptapNode, options: MarkdownProjectionOptions): string { + const attrs = (node.attrs ?? {}) as ReadAnyCardAttrs; + const cardType = attrs.cardType || "custom"; + const title = attrs.title || attrs.sourceTitle || cardType; + const body = + attrs.markdown || + attrs.text || + (node.content ?? []) + .map((child) => renderBlock(child, 0, options)) + .join("\n\n") + .trim(); + + if (!options.includeReadAnyCardMetadata) { + const lines = [`> [!note] ${title}`]; + if (body) lines.push(prefixLines(body, "> ")); + if (attrs.sourceTitle) lines.push(`> Source: ${attrs.sourceTitle}`); + return lines.join("\n"); + } + + const attrText = [ + `type="${cardType}"`, + attrs.id ? `id="${attrs.id}"` : "", + `version="${attrs.version ?? 1}"`, + attrs.sourceId ? `source="${attrs.sourceId}"` : "", + ] + .filter(Boolean) + .join(" "); + + return [`:::readany-card ${attrText}`, body, ":::"].filter(Boolean).join("\n"); +} + +function renderListItem( + node: TiptapNode, + index: number, + ordered: boolean, + options: MarkdownProjectionOptions, +): string { + const marker = ordered ? `${index + 1}. ` : "- "; + const rendered = (node.content ?? []) + .map((child) => renderBlock(child, 0, options)) + .filter(Boolean) + .join("\n"); + + if (!rendered) return marker.trimEnd(); + + const [firstLine, ...rest] = rendered.split("\n"); + return [`${marker}${firstLine}`, ...rest.map((line) => ` ${line}`)].join("\n"); +} + +function renderBlock( + node: TiptapNode, + listDepth: number, + options: MarkdownProjectionOptions, +): string { + switch (node.type) { + case "doc": + return (node.content ?? []) + .map((child) => renderBlock(child, listDepth, options)) + .filter(Boolean) + .join("\n\n"); + case "paragraph": + return (node.content ?? []).map(renderInline).join("").trim(); + case "heading": { + const level = Math.min(Math.max(Number(node.attrs?.level ?? 1), 1), 6); + const text = (node.content ?? []).map(renderInline).join("").trim(); + return `${"#".repeat(level)} ${text}`.trimEnd(); + } + case "blockquote": { + const text = (node.content ?? []) + .map((child) => renderBlock(child, listDepth, options)) + .filter(Boolean) + .join("\n\n"); + return prefixLines(text, "> "); + } + case "bulletList": + return (node.content ?? []) + .map((child, index) => renderListItem(child, index, false, options)) + .join("\n"); + case "orderedList": + return (node.content ?? []) + .map((child, index) => renderListItem(child, index, true, options)) + .join("\n"); + case "taskList": + return (node.content ?? []) + .map((child) => renderBlock(child, listDepth + 1, options)) + .join("\n"); + case "taskItem": { + const checked = node.attrs?.checked === true ? "x" : " "; + const text = (node.content ?? []) + .map((child) => renderBlock(child, listDepth + 1, options)) + .filter(Boolean) + .join("\n"); + const [firstLine, ...rest] = text.split("\n"); + return [`- [${checked}] ${firstLine ?? ""}`, ...rest.map((line) => ` ${line}`)].join("\n"); + } + case "listItem": + return (node.content ?? []) + .map((child) => renderBlock(child, listDepth + 1, options)) + .join("\n"); + case "codeBlock": { + const language = typeof node.attrs?.language === "string" ? node.attrs.language : ""; + const code = (node.content ?? []).map((child) => child.text ?? renderInline(child)).join(""); + return `\`\`\`${language}\n${code}\n\`\`\``; + } + case "horizontalRule": + return "---"; + case "image": { + const src = typeof node.attrs?.src === "string" ? node.attrs.src : ""; + const alt = typeof node.attrs?.alt === "string" ? node.attrs.alt : ""; + return src ? `![${alt}](${src})` : ""; + } + case "readanyCard": + return renderReadAnyCard(node, options); + default: + return (node.content ?? []) + .map((child) => renderBlock(child, listDepth, options)) + .filter(Boolean) + .join("\n\n"); + } +} + +export function renderKnowledgeJsonToMarkdown( + content: JSONValue | null | undefined, + options: MarkdownProjectionOptions = {}, +): string { + const document = normalizeTiptapDocument(content); + return renderBlock(document, 0, options).trim(); +} + +function textNode(text: string): TiptapNode { + return { type: "text", text }; +} + +function paragraphNode(text: string): TiptapNode { + return text ? { type: "paragraph", content: [textNode(text)] } : { type: "paragraph" }; +} + +export function markdownToBasicTiptap(markdown: string): TiptapNode { + const blocks = markdown + .replace(/\r\n/g, "\n") + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter(Boolean); + + const content = blocks.map((block) => { + const heading = block.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + return { + type: "heading", + attrs: { level: heading[1].length }, + content: [textNode(heading[2])], + }; + } + + if (block === "---") return { type: "horizontalRule" }; + + if (block.startsWith("> ")) { + return { + type: "blockquote", + content: [paragraphNode(block.replace(/^>\s?/gm, ""))], + }; + } + + const lines = block.split("\n"); + if (lines.every((line) => /^[-*]\s+/.test(line))) { + return { + type: "bulletList", + content: lines.map((line) => ({ + type: "listItem", + content: [paragraphNode(line.replace(/^[-*]\s+/, ""))], + })), + }; + } + + if (lines.every((line) => /^\d+\.\s+/.test(line))) { + return { + type: "orderedList", + content: lines.map((line) => ({ + type: "listItem", + content: [paragraphNode(line.replace(/^\d+\.\s+/, ""))], + })), + }; + } + + return paragraphNode(block); + }); + + return { type: "doc", content }; +} diff --git a/packages/core/src/knowledge/index.ts b/packages/core/src/knowledge/index.ts new file mode 100644 index 00000000..7d095e40 --- /dev/null +++ b/packages/core/src/knowledge/index.ts @@ -0,0 +1,12 @@ +export { + isTiptapNode, + markdownToBasicTiptap, + normalizeTiptapDocument, + renderKnowledgeJsonToMarkdown, +} from "./editor-projection"; +export type { + MarkdownProjectionOptions, + ReadAnyCardAttrs, + TiptapMark, + TiptapNode, +} from "./editor-projection"; From 4f0037024aa6d2cbd984ad3334d40b8ba538da36 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 01:53:00 +0800 Subject: [PATCH 004/409] feat(desktop): add book knowledge home workspace --- .../03-editor-cards-obsidian.md | 253 ++++++----- .../app/src/components/notes/NotesPage.tsx | 392 ++++++++++++++++-- .../app/src/components/ui/markdown-editor.tsx | 5 +- packages/app/src/lib/db/database.ts | 22 +- packages/core/src/i18n/locales/en/notes.json | 14 +- packages/core/src/i18n/locales/es/notes.json | 14 +- packages/core/src/i18n/locales/fr/notes.json | 14 +- packages/core/src/i18n/locales/ja/notes.json | 14 +- packages/core/src/i18n/locales/ko/notes.json | 14 +- .../core/src/i18n/locales/zh-TW/notes.json | 14 +- packages/core/src/i18n/locales/zh/notes.json | 14 +- .../core/src/knowledge/editor-projection.ts | 2 +- 12 files changed, 625 insertions(+), 147 deletions(-) diff --git a/docs/knowledge-base-notes/03-editor-cards-obsidian.md b/docs/knowledge-base-notes/03-editor-cards-obsidian.md index 87ade84f..a37810ce 100644 --- a/docs/knowledge-base-notes/03-editor-cards-obsidian.md +++ b/docs/knowledge-base-notes/03-editor-cards-obsidian.md @@ -86,96 +86,133 @@ export, sync, and AI all see the same representation. ## Rich Text Scope by Scenario The knowledge editor should not expose every rich-text feature everywhere. Each -editing surface has a different job, so the toolbar and supported blocks should -be tiered by scenario. +editing surface has a different job, so rich text must be tiered by scenario. +This avoids the worst outcome: every note surface becoming a bloated document +editor while still failing to support the durable knowledge-base workflows. + +### Rich Text Tiers + +Use three editor tiers and map every surface to one of them: + +| Tier | Purpose | Typical surfaces | +| --- | --- | --- | +| `inline_note` | Capture a thought while reading, with almost no interruption. | Reader selection note, quick highlight note, small annotation card. | +| `knowledge_doc` | Build a durable book-centered or standalone knowledge document. | Book home, standalone note, expanded highlight note. | +| `publishable_doc` | Write something that exports cleanly to Markdown, Obsidian, and shareable text. | Book review, reading essay, exported summary. | + +The tier is part of the editor configuration, not a different storage format. +All tiers still save canonical Tiptap JSON and derive Markdown from that JSON. + +### Baseline Feature Matrix + +| Feature | Quick annotation | Knowledge document | Review / export | +| --- | --- | --- | --- | +| Paragraphs | Required | Required | Required | +| Bold, italic, strike | Required | Required | Required | +| Inline code | Required | Required | Optional | +| Links | Required | Required | Required | +| Headings | No | H1-H3 | H1-H3 | +| Bullet / ordered lists | Required | Required | Required | +| Task lists | No | Required | Optional | +| Blockquote | Required | Required | Required | +| Horizontal rule | No | Required | Required | +| Code block | No | Optional | Optional | +| Images / attachments | No | Required | Required | +| Tables | No | Optional after mobile verification | Optional after mobile verification | +| Internal links / backlinks | No | Required | Required | +| Tags | Metadata chip UI, not rich text | Metadata chip UI, not rich text | Metadata chip UI, not rich text | +| Source citation | Auto-linked to highlight | Source reference card | Footnote-style source reference | +| ReadAny cards | No large cards | Required | Only export-friendly cards | +| Mermaid / mindmap | No | Required as cards | Optional as export-friendly cards | +| AI cards | No | Required | Convert to editable content before final export | ### Quick Annotation Notes Used when the user selects text in the reader and quickly adds a note. -Keep this surface lightweight: +Keep: -- Paragraphs -- Bold, italic, strikethrough -- Inline code -- Links -- Bullet list -- Ordered list -- Blockquote -- Undo and redo +- Paragraphs. +- Bold, italic, strikethrough. +- Inline code. +- Links. +- Bullet list and ordered list. +- Blockquote. +- Undo and redo. -Avoid heavy blocks here: +Avoid: -- Tables -- Image upload -- Mermaid and mindmap cards -- Multi-column layout -- Large embedded AI cards +- Headings. +- Tables. +- Image upload. +- Mermaid and mindmap cards. +- Large embedded AI cards. +- Multi-column or layout blocks. Reason: Quick annotation is an interruption inside reading. The editor should help the -user capture a thought in seconds and then get out of the way. +user capture a thought in seconds and then get out of the way. If a quick note +needs to grow up, it can be opened as a knowledge document. ### Highlight Note Documents Used when a highlight note is opened as a richer knowledge document. -Support: +Keep: -- Everything from quick annotation notes -- Headings H2/H3 -- Task list -- Quote/source card linked to the highlight and CFI -- Callout card -- Related-note link card -- Tags +- Everything from quick annotation notes. +- Headings H2/H3. +- Task list. +- Quote/source card linked to the highlight and CFI. +- Callout card. +- Related-note link card. +- Tags and backlinks outside the text canvas. Reason: This is still source-attached, but the user may want to expand a short annotation -into a reusable note. +into a reusable note without losing the original quote position. ### Book Home Documents Used as the main knowledge page for each book. -Support: - -- Headings H1-H3 -- Paragraphs -- Bold, italic, strikethrough, inline code -- Links and internal document links -- Bullet, ordered, and task lists -- Blockquote -- Horizontal rule -- Callout cards -- Quote cards -- Book metadata card -- Highlight collection card -- AI summary card -- Review card -- Mermaid and mindmap cards -- Image attachments -- Table blocks, if the table extension is stable on both desktop and mobile +Keep: + +- Headings H1-H3. +- Paragraphs and rich inline marks. +- Links and internal document links. +- Bullet, ordered, and task lists. +- Blockquote and horizontal rule. +- Callout cards. +- Quote cards. +- Book metadata card. +- Highlight collection card. +- AI summary card. +- Review card. +- Mermaid and mindmap cards. +- Image attachments. +- Table blocks only after the table extension behaves well in the mobile WebView. Reason: -The book home document is the user's durable workspace. It should support -structured reading notes, outlines, summaries, reviews, and visual thinking. +The book home document is the user's durable workspace. It needs to support +structured reading notes, outlines, source collections, summaries, reviews, and +visual thinking. This is the richest default editing surface. ### Standalone Knowledge Documents Used for user-created notes that may or may not belong to a book. -Support: +Keep: -- Same baseline as book home documents -- Backlinks -- Internal document links -- Tags -- Attachments -- AI-generated summary card +- Same baseline as book home documents. +- Backlinks. +- Internal document links. +- Tags. +- Attachments. +- AI-generated summary card. Reason: @@ -186,51 +223,70 @@ powerful without being book-only. Used for book reviews, reading essays, and polished exports. -Support: +Keep: -- Headings -- Rich paragraphs -- Links -- Lists -- Blockquotes -- Quote cards -- Footnote-style source references -- Images -- Export-friendly callouts +- Headings. +- Rich paragraphs. +- Links. +- Lists. +- Blockquotes. +- Quote cards. +- Footnote-style source references. +- Images. +- Export-friendly callouts. Avoid by default: -- Highly interactive cards that do not export cleanly -- Layout-only blocks that degrade poorly to Markdown +- Highly interactive cards that do not export cleanly. +- Layout-only blocks that degrade poorly to Markdown. +- App-only AI controls inside the final exported document. Reason: -Reviews must export cleanly to Markdown, Obsidian, and shareable text. +Reviews must export cleanly to Markdown, Obsidian, and shareable text. This +surface can be rich, but the richness must have a readable fallback. ### AI-Generated Knowledge Blocks Used for summaries, Q&A, concept extraction, timelines, and mindmaps. -Support: +Keep: -- ReadAny AI summary card -- Q&A card -- Concept card -- Timeline card -- Mermaid and mindmap cards -- Source quote cards with citations +- ReadAny AI summary card. +- Q&A card. +- Concept card. +- Timeline card. +- Mermaid and mindmap cards. +- Source quote cards with citations. Rules: - AI-generated blocks should be clearly marked. - The user should be able to convert an AI block into normal editable content. - Source citations should remain linked to book CFI, highlight, or document ID. +- Failed AI/tool blocks should remain visible as failure cards, not disappear or + spin forever. Reason: AI output often starts structured, but users need ownership and editability after insertion. +### Structured Fields Are Not Rich Text + +Some book and note fields should stay as structured controls, not editor blocks: + +- Book title, author, language, publisher, ISBN, publish date, rating. +- Tags and groups. +- Reading status and progress. +- Source file path, cloud file path, CFI, chapter id, and sync metadata. + +Reason: + +These fields need reliable sync, filtering, sorting, AI tools, export frontmatter, +and form validation. Putting them inside the rich editor would make them harder +to query and easier to corrupt. + ### Mobile Toolbar Scope Mobile should expose a smaller toolbar first and move complex actions into an @@ -238,26 +294,26 @@ insert menu. Primary toolbar: -- Undo -- Redo -- Bold -- Italic -- Link -- Bullet list -- Ordered list -- Blockquote -- Insert menu +- Undo. +- Redo. +- Bold. +- Italic. +- Link. +- Bullet list. +- Ordered list. +- Blockquote. +- Insert menu. Insert menu: -- Heading -- Task list -- Quote card -- Callout card -- Image -- AI card -- Mermaid or mindmap -- Related note +- Heading. +- Task list. +- Quote card. +- Callout card. +- Image. +- AI card. +- Mermaid or mindmap. +- Related note. Reason: @@ -268,13 +324,14 @@ should feel native and calm, not like a desktop toolbar squeezed into a phone. Desktop can expose richer groups: -- History -- Text style -- Headings -- Lists -- Insert -- Cards -- Export/preview +- History. +- Text style. +- Headings. +- Lists. +- Insert. +- Cards. +- Source/backlink panel. +- Export/preview. Reason: diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index 4fbd54b4..c235af08 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -4,14 +4,19 @@ import { Input } from "@/components/ui/input"; import { MarkdownEditor } from "@/components/ui/markdown-editor"; import { useResolvedSrc, useSyncVersion } from "@/hooks/use-resolved-src"; import type { HighlightWithBook } from "@/lib/db/database"; -import { getBook as getBookRecord } from "@/lib/db/database"; +import { + ensureBookHomeDocument, + getBook as getBookRecord, + updateKnowledgeDocument, +} from "@/lib/db/database"; import { openDesktopBook } from "@/lib/library/open-book"; import { useAnnotationStore } from "@/stores/annotation-store"; import { useAppStore } from "@/stores/app-store"; import { useLibraryStore } from "@/stores/library-store"; import { type ExportFormat, annotationExporter } from "@readany/core/export"; +import { markdownToBasicTiptap } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; -import type { Highlight, Note } from "@readany/core/types"; +import type { Highlight, KnowledgeDocument, Note } from "@readany/core/types"; import { HIGHLIGHT_COLOR_HEX } from "@readany/core/types"; import { cn } from "@readany/core/utils"; import { eventBus } from "@readany/core/utils/event-bus"; @@ -20,9 +25,12 @@ import { Check, ChevronLeft, Edit3, + FileText, Highlighter, NotebookPen, + Save, Search, + Sparkles, Trash2, X, } from "lucide-react"; @@ -31,14 +39,23 @@ import { * Layout: Left panel (book notebooks grid) + Right panel (selected book's notes & highlights) * Notes and highlights are displayed separately. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { ExportDropdown } from "./ExportDropdown"; -type DetailTab = "notes" | "highlights"; +type DetailTab = "knowledge" | "notes" | "highlights"; + +function createKnowledgeExcerpt(markdown: string): string | undefined { + const text = markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/[#>*_`~\-[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return text ? text.slice(0, 220) : undefined; +} // Helper component to resolve and display cover images interface CoverImageProps extends React.ImgHTMLAttributes { @@ -46,25 +63,23 @@ interface CoverImageProps extends React.ImgHTMLAttributes { fallback?: React.ReactNode; } -function CoverImage({ url, fallback, ...imgProps }: CoverImageProps) { +function CoverImage({ url, fallback, alt = "", ...imgProps }: CoverImageProps) { const resolvedSrc = useResolvedSrc(url ?? undefined); const syncVersion = useSyncVersion(); - const [hasError, setHasError] = useState(false); + const [failedKey, setFailedKey] = useState(null); + const imageKey = resolvedSrc ? `${resolvedSrc}-${syncVersion}` : null; - useEffect(() => { - setHasError(false); - }, [resolvedSrc, syncVersion]); - - if (!resolvedSrc || hasError) { + if (!resolvedSrc || imageKey === failedKey) { return <>{fallback}; } return ( setHasError(true)} + onError={() => setFailedKey(imageKey)} {...imgProps} + alt={alt} /> ); } @@ -85,11 +100,17 @@ export function NotesPage() { const [selectedBookId, setSelectedBookId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isLoading, setIsLoading] = useState(true); - const [detailTab, setDetailTab] = useState("notes"); + const [detailTab, setDetailTab] = useState("knowledge"); // Edit state const [editingId, setEditingId] = useState(null); const [editNote, setEditNote] = useState(""); + const [knowledgeHome, setKnowledgeHome] = useState(null); + const [knowledgeContent, setKnowledgeContent] = useState(""); + const [savedKnowledgeContent, setSavedKnowledgeContent] = useState(""); + const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); + const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); + const knowledgeSaveVersionRef = useRef(0); useEffect(() => { if (activeTabId !== "notes") return; @@ -107,7 +128,7 @@ export function NotesPage() { }); }, [activeTabId, loadAllHighlightsWithBooks, loadStats]); - // Group highlights by book + // Group highlights by book, but keep every library book available as a knowledge workspace. const bookNotebooks = useMemo(() => { const grouped = new Map< string, @@ -123,13 +144,27 @@ export function NotesPage() { } >(); + for (const book of books) { + if (book.deletedAt) continue; + grouped.set(book.id, { + bookId: book.id, + title: book.meta.title || t("notes.unknownBook"), + author: book.meta.author || t("notes.unknownAuthor"), + coverUrl: book.meta.coverUrl || null, + highlights: [], + notesCount: 0, + highlightsOnlyCount: 0, + latestAt: book.lastOpenedAt || book.updatedAt || book.addedAt, + }); + } + for (const h of highlightsWithBooks) { const existing = grouped.get(h.bookId); if (existing) { existing.highlights.push(h); if (h.note) existing.notesCount++; else existing.highlightsOnlyCount++; - if (h.createdAt > existing.latestAt) existing.latestAt = h.createdAt; + if (h.updatedAt > existing.latestAt) existing.latestAt = h.updatedAt; } else { grouped.set(h.bookId, { bookId: h.bookId, @@ -145,12 +180,24 @@ export function NotesPage() { } return Array.from(grouped.values()).sort((a, b) => b.latestAt - a.latestAt); - }, [highlightsWithBooks, t]); + }, [books, highlightsWithBooks, t]); const selectedBook = useMemo(() => { if (!selectedBookId) return null; return bookNotebooks.find((b) => b.bookId === selectedBookId) || null; }, [selectedBookId, bookNotebooks]); + const selectedKnowledgeBookId = selectedBook?.bookId ?? null; + const selectedKnowledgeBookTitle = selectedBook?.title ?? ""; + + useEffect(() => { + if (!selectedBookId) return; + if (bookNotebooks.some((book) => book.bookId === selectedBookId)) return; + + setSelectedBookId(null); + setDetailTab("knowledge"); + setSearchQuery(""); + setEditingId(null); + }, [bookNotebooks, selectedBookId]); // Split into notes (has note text) and highlights-only const { notes, highlightsOnly } = useMemo(() => { @@ -172,7 +219,8 @@ export function NotesPage() { }; }, [selectedBook, searchQuery]); - const currentList = detailTab === "notes" ? notes : highlightsOnly; + const currentList = + detailTab === "notes" ? notes : detailTab === "highlights" ? highlightsOnly : []; // Group by chapter const itemsByChapter = useMemo(() => { @@ -186,6 +234,78 @@ export function NotesPage() { return chapters; }, [currentList, t]); + useEffect(() => { + let cancelled = false; + + async function loadKnowledgeHome() { + knowledgeSaveVersionRef.current += 1; + + if (!selectedKnowledgeBookId) { + setKnowledgeHome(null); + setKnowledgeContent(""); + setSavedKnowledgeContent(""); + setIsKnowledgeSaving(false); + return; + } + + setIsKnowledgeLoading(true); + setIsKnowledgeSaving(false); + try { + const document = await ensureBookHomeDocument( + selectedKnowledgeBookId, + selectedKnowledgeBookTitle, + ); + if (cancelled) return; + setKnowledgeHome(document); + setKnowledgeContent(document.contentMd); + setSavedKnowledgeContent(document.contentMd); + } catch (error) { + console.error("[Notes] Failed to load knowledge home:", error); + toast.error(t("notes.knowledgeLoadFailed")); + } finally { + if (!cancelled) setIsKnowledgeLoading(false); + } + } + + void loadKnowledgeHome(); + + return () => { + cancelled = true; + }; + }, [selectedKnowledgeBookId, selectedKnowledgeBookTitle, t]); + + useEffect(() => { + if (!knowledgeHome || knowledgeContent === savedKnowledgeContent) return; + + const saveVersion = knowledgeSaveVersionRef.current + 1; + knowledgeSaveVersionRef.current = saveVersion; + + const timeout = window.setTimeout(async () => { + setIsKnowledgeSaving(true); + try { + await updateKnowledgeDocument(knowledgeHome.id, { + contentMd: knowledgeContent, + contentJson: markdownToBasicTiptap( + knowledgeContent, + ) as unknown as KnowledgeDocument["contentJson"], + excerpt: createKnowledgeExcerpt(knowledgeContent), + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return; + setSavedKnowledgeContent(knowledgeContent); + } catch (error) { + if (knowledgeSaveVersionRef.current !== saveVersion) return; + console.error("[Notes] Failed to save knowledge home:", error); + toast.error(t("notes.knowledgeSaveFailed")); + } finally { + if (knowledgeSaveVersionRef.current === saveVersion) { + setIsKnowledgeSaving(false); + } + } + }, 700); + + return () => window.clearTimeout(timeout); + }, [knowledgeHome, knowledgeContent, savedKnowledgeContent, t]); + const handleOpenBook = async (bookId: string, _title: string, cfi?: string) => { const book = books.find((item) => item.id === bookId) ?? @@ -390,7 +510,7 @@ export function NotesPage() { setSelectedBookId(book.bookId); setSearchQuery(""); setEditingId(null); - setDetailTab("notes"); + setDetailTab("knowledge"); }} /> ))} @@ -446,6 +566,19 @@ export function NotesPage() { {/* Tab switcher + search */}
+
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-8 text-sm" - /> -
+ {detailTab === "knowledge" ? ( +
+ + {selectedBook.highlights.length} {t("notes.highlightsCount")} + + + {selectedBook.notesCount} {t("notes.notesCount")} + + + + {isKnowledgeSaving + ? t("notes.knowledgeSaving") + : knowledgeContent === savedKnowledgeContent + ? t("notes.knowledgeSaved") + : t("notes.knowledgePending")} + +
+ ) : ( +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-8 text-sm" + /> +
+ )}
{/* Content */}
- {currentList.length === 0 ? ( + {detailTab === "knowledge" ? ( + handleOpenBook(selectedBook.bookId, selectedBook.title, cfi)} + t={t} + /> + ) : currentList.length === 0 ? (

@@ -553,6 +717,146 @@ export function NotesPage() { ); } +// --- Knowledge home workspace --- + +interface KnowledgeHomePanelProps { + book: { + bookId: string; + title: string; + author: string; + highlights: HighlightWithBook[]; + notesCount: number; + highlightsOnlyCount: number; + }; + document: KnowledgeDocument | null; + content: string; + isLoading: boolean; + isSaving: boolean; + isSaved: boolean; + onChange: (value: string) => void; + onOpenBook: (cfi?: string) => void; + t: (key: string) => string; +} + +function KnowledgeHomePanel({ + book, + document, + content, + isLoading, + isSaving, + isSaved, + onChange, + onOpenBook, + t, +}: KnowledgeHomePanelProps) { + const recentHighlights = useMemo( + () => sortAnnotationsByPosition(book.highlights).slice(0, 4), + [book.highlights], + ); + + if (isLoading || !document) { + return ( +

+
+
+

{t("notes.knowledgeLoading")}

+
+
+ ); + } + + return ( +
+
+
+
+
+

+ {t("notes.knowledgeEyebrow")} +

+

{book.title}

+
+
+ + {isSaving + ? t("notes.knowledgeSaving") + : isSaved + ? t("notes.knowledgeSaved") + : t("notes.knowledgePending")} +
+
+ + +
+ + +
+
+ ); +} + // --- Notebook card (BookCard-inspired style) --- interface NotebookCardProps { @@ -570,7 +874,11 @@ interface NotebookCardProps { function NotebookCard({ book, onClick }: NotebookCardProps) { return ( -
+
+ ); } @@ -654,12 +962,13 @@ function NoteDetailCard({
{/* Quoted highlight text */} -

"{highlight.text}" -

+ {/* Note content */} {isEditing ? ( @@ -689,11 +998,15 @@ function NoteDetailCard({
) : ( -
+
+ )} {/* Footer */} @@ -751,12 +1064,13 @@ function HighlightDetailCard({ highlight, onDelete, onNavigate, t }: HighlightDe />
-

"{highlight.text}" -

+
diff --git a/packages/app/src/components/ui/markdown-editor.tsx b/packages/app/src/components/ui/markdown-editor.tsx index dbe1495a..05c0fefc 100644 --- a/packages/app/src/components/ui/markdown-editor.tsx +++ b/packages/app/src/components/ui/markdown-editor.tsx @@ -31,6 +31,7 @@ interface MarkdownEditorProps { onChange: (value: string) => void; placeholder?: string; className?: string; + contentClassName?: string; autoFocus?: boolean; } @@ -39,6 +40,7 @@ export function MarkdownEditor({ onChange, placeholder, className, + contentClassName, autoFocus = false, }: MarkdownEditorProps) { const { t } = useTranslation(); @@ -123,7 +125,7 @@ export function MarkdownEditor({ return; } editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); - }, [editor]); + }, [editor, t]); if (!editor) { return null; @@ -271,6 +273,7 @@ export function MarkdownEditor({ "[&_.is-editor-empty:first-child::before]:float-left", "[&_.is-editor-empty:first-child::before]:h-0", "[&_.is-editor-empty:first-child::before]:text-[13px]", + contentClassName, )} />
diff --git a/packages/app/src/lib/db/database.ts b/packages/app/src/lib/db/database.ts index f70eee80..d204b7fc 100644 --- a/packages/app/src/lib/db/database.ts +++ b/packages/app/src/lib/db/database.ts @@ -28,6 +28,22 @@ export { insertNote, updateNote, deleteNote, + getKnowledgeDocument, + getKnowledgeDocuments, + getBookHomeDocument, + createKnowledgeDocument, + ensureBookHomeDocument, + insertKnowledgeDocument, + updateKnowledgeDocument, + deleteKnowledgeDocument, + getKnowledgeLinks, + insertKnowledgeLink, + deleteKnowledgeLink, + getKnowledgeAttachments, + insertKnowledgeAttachment, + deleteKnowledgeAttachment, + getKnowledgeCardTemplates, + upsertKnowledgeCardTemplate, getBookmarks, insertBookmark, deleteBookmark, @@ -53,4 +69,8 @@ export { deleteSkill, } from "@readany/core/db/database"; -export type { HighlightWithBook } from "@readany/core/db/database"; +export type { + CreateKnowledgeDocumentInput, + HighlightWithBook, + KnowledgeDocumentFilters, +} from "@readany/core/db/database"; diff --git a/packages/core/src/i18n/locales/en/notes.json b/packages/core/src/i18n/locales/en/notes.json index 8bf5f09e..83451971 100644 --- a/packages/core/src/i18n/locales/en/notes.json +++ b/packages/core/src/i18n/locales/en/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "Copied to clipboard", "downloaded": "Downloaded", "deleteHighlightConfirm": "Delete this highlight?", - "deleteNoteConfirm": "Delete this note?" + "deleteNoteConfirm": "Delete this note?", + "knowledgeTab": "Knowledge", + "knowledgeEyebrow": "Knowledge Base", + "knowledgePlaceholder": "Capture this book's summary, questions, ideas, and lasting knowledge...", + "knowledgeSignals": "Signals", + "knowledgeSources": "Recent excerpts", + "knowledgeNoSources": "No excerpts yet", + "knowledgeLoading": "Opening knowledge home...", + "knowledgeSaving": "Saving", + "knowledgeSaved": "Saved", + "knowledgePending": "Pending", + "knowledgeLoadFailed": "Failed to load knowledge home", + "knowledgeSaveFailed": "Failed to save knowledge home" } } diff --git a/packages/core/src/i18n/locales/es/notes.json b/packages/core/src/i18n/locales/es/notes.json index 8fc0045e..681fbe99 100644 --- a/packages/core/src/i18n/locales/es/notes.json +++ b/packages/core/src/i18n/locales/es/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "Copiado al portapapeles", "downloaded": "Descargado", "deleteHighlightConfirm": "¿Eliminar este subrayado?", - "deleteNoteConfirm": "¿Eliminar esta nota?" + "deleteNoteConfirm": "¿Eliminar esta nota?", + "knowledgeTab": "Conocimiento", + "knowledgeEyebrow": "Base de conocimiento", + "knowledgePlaceholder": "Guarda el resumen, preguntas, ideas y conocimiento duradero de este libro...", + "knowledgeSignals": "Señales", + "knowledgeSources": "Extractos recientes", + "knowledgeNoSources": "Aún no hay extractos", + "knowledgeLoading": "Abriendo página de conocimiento...", + "knowledgeSaving": "Guardando", + "knowledgeSaved": "Guardado", + "knowledgePending": "Pendiente", + "knowledgeLoadFailed": "No se pudo cargar la página de conocimiento", + "knowledgeSaveFailed": "No se pudo guardar la página de conocimiento" } } diff --git a/packages/core/src/i18n/locales/fr/notes.json b/packages/core/src/i18n/locales/fr/notes.json index 3e7e21f2..78397dec 100644 --- a/packages/core/src/i18n/locales/fr/notes.json +++ b/packages/core/src/i18n/locales/fr/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "Copié dans le presse-papiers", "downloaded": "Téléchargé", "deleteHighlightConfirm": "Supprimer ce surlignage ?", - "deleteNoteConfirm": "Supprimer cette note ?" + "deleteNoteConfirm": "Supprimer cette note ?", + "knowledgeTab": "Savoir", + "knowledgeEyebrow": "Base de connaissances", + "knowledgePlaceholder": "Notez le résumé, les questions, les idées et les connaissances durables de ce livre...", + "knowledgeSignals": "Repères", + "knowledgeSources": "Extraits récents", + "knowledgeNoSources": "Aucun extrait pour le moment", + "knowledgeLoading": "Ouverture de la page de savoir...", + "knowledgeSaving": "Enregistrement", + "knowledgeSaved": "Enregistré", + "knowledgePending": "En attente", + "knowledgeLoadFailed": "Impossible de charger la page de savoir", + "knowledgeSaveFailed": "Impossible d'enregistrer la page de savoir" } } diff --git a/packages/core/src/i18n/locales/ja/notes.json b/packages/core/src/i18n/locales/ja/notes.json index 7a63a88e..c4ebc670 100644 --- a/packages/core/src/i18n/locales/ja/notes.json +++ b/packages/core/src/i18n/locales/ja/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "クリップボードにコピーしました", "downloaded": "ダウンロードしました", "deleteHighlightConfirm": "このハイライトを削除しますか?", - "deleteNoteConfirm": "このノートを削除しますか?" + "deleteNoteConfirm": "このノートを削除しますか?", + "knowledgeTab": "ナレッジ", + "knowledgeEyebrow": "ナレッジベース", + "knowledgePlaceholder": "この本の要約、問い、考え、残しておきたい知識を記録...", + "knowledgeSignals": "手がかり", + "knowledgeSources": "最近の抜粋", + "knowledgeNoSources": "抜粋はまだありません", + "knowledgeLoading": "ナレッジホームを開いています...", + "knowledgeSaving": "保存中", + "knowledgeSaved": "保存済み", + "knowledgePending": "未保存", + "knowledgeLoadFailed": "ナレッジホームの読み込みに失敗しました", + "knowledgeSaveFailed": "ナレッジホームの保存に失敗しました" } } diff --git a/packages/core/src/i18n/locales/ko/notes.json b/packages/core/src/i18n/locales/ko/notes.json index d741034b..3d74c511 100644 --- a/packages/core/src/i18n/locales/ko/notes.json +++ b/packages/core/src/i18n/locales/ko/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "클립보드에 복사됨", "downloaded": "다운로드 완료", "deleteHighlightConfirm": "이 하이라이트를 삭제할까요?", - "deleteNoteConfirm": "이 노트를 삭제할까요?" + "deleteNoteConfirm": "이 노트를 삭제할까요?", + "knowledgeTab": "지식 홈", + "knowledgeEyebrow": "지식 베이스", + "knowledgePlaceholder": "이 책의 요약, 질문, 생각, 오래 남길 지식을 기록하세요...", + "knowledgeSignals": "단서", + "knowledgeSources": "최근 발췌", + "knowledgeNoSources": "아직 발췌가 없어요", + "knowledgeLoading": "지식 홈을 여는 중...", + "knowledgeSaving": "저장 중", + "knowledgeSaved": "저장됨", + "knowledgePending": "저장 대기", + "knowledgeLoadFailed": "지식 홈을 불러오지 못했어요", + "knowledgeSaveFailed": "지식 홈을 저장하지 못했어요" } } diff --git a/packages/core/src/i18n/locales/zh-TW/notes.json b/packages/core/src/i18n/locales/zh-TW/notes.json index 4f4b458b..1f7c2385 100644 --- a/packages/core/src/i18n/locales/zh-TW/notes.json +++ b/packages/core/src/i18n/locales/zh-TW/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "已複製到剪貼簿", "downloaded": "已下載", "deleteHighlightConfirm": "確定刪除此醒目標示?", - "deleteNoteConfirm": "確定刪除此筆記?" + "deleteNoteConfirm": "確定刪除此筆記?", + "knowledgeTab": "知識首頁", + "knowledgeEyebrow": "知識庫", + "knowledgePlaceholder": "記錄這本書的摘要、問題、想法和長期知識...", + "knowledgeSignals": "線索", + "knowledgeSources": "最近摘錄", + "knowledgeNoSources": "暫無摘錄", + "knowledgeLoading": "正在開啟知識首頁...", + "knowledgeSaving": "儲存中", + "knowledgeSaved": "已儲存", + "knowledgePending": "待儲存", + "knowledgeLoadFailed": "知識首頁載入失敗", + "knowledgeSaveFailed": "知識首頁儲存失敗" } } diff --git a/packages/core/src/i18n/locales/zh/notes.json b/packages/core/src/i18n/locales/zh/notes.json index 425b199d..5bec291e 100644 --- a/packages/core/src/i18n/locales/zh/notes.json +++ b/packages/core/src/i18n/locales/zh/notes.json @@ -51,6 +51,18 @@ "copiedToClipboard": "已复制到剪贴板", "downloaded": "已下载", "deleteHighlightConfirm": "确定删除此高亮?", - "deleteNoteConfirm": "确定删除此笔记?" + "deleteNoteConfirm": "确定删除此笔记?", + "knowledgeTab": "知识主页", + "knowledgeEyebrow": "知识库", + "knowledgePlaceholder": "记录这本书的摘要、问题、想法和长期知识...", + "knowledgeSignals": "线索", + "knowledgeSources": "最近摘录", + "knowledgeNoSources": "暂无摘录", + "knowledgeLoading": "正在打开知识主页...", + "knowledgeSaving": "保存中", + "knowledgeSaved": "已保存", + "knowledgePending": "待保存", + "knowledgeLoadFailed": "知识主页加载失败", + "knowledgeSaveFailed": "知识主页保存失败" } } diff --git a/packages/core/src/knowledge/editor-projection.ts b/packages/core/src/knowledge/editor-projection.ts index 230f5eae..3c259c74 100644 --- a/packages/core/src/knowledge/editor-projection.ts +++ b/packages/core/src/knowledge/editor-projection.ts @@ -42,7 +42,7 @@ export function isTiptapNode(value: JSONValue | unknown): value is TiptapNode { export function normalizeTiptapDocument(content: JSONValue | null | undefined): TiptapNode { if (isTiptapNode(content)) return content; - return EMPTY_TIPTAP_DOCUMENT as TiptapNode; + return EMPTY_TIPTAP_DOCUMENT as unknown as TiptapNode; } function escapeMarkdownText(text: string): string { From bf613dccb21da400bd644fffe48188145e1bc9b3 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 02:03:59 +0800 Subject: [PATCH 005/409] feat(mobile): add book knowledge home workspace --- packages/app-expo/src/screens/NotesView.tsx | 339 ++++++++++++++++-- .../src/screens/notes/notes-styles.ts | 195 +++++++++- 2 files changed, 500 insertions(+), 34 deletions(-) diff --git a/packages/app-expo/src/screens/NotesView.tsx b/packages/app-expo/src/screens/NotesView.tsx index e923fcf4..2bdda1f5 100644 --- a/packages/app-expo/src/screens/NotesView.tsx +++ b/packages/app-expo/src/screens/NotesView.tsx @@ -1,12 +1,17 @@ import { BookOpenIcon, + CheckCheckIcon, ChevronLeftIcon, HighlighterIcon, NotebookPenIcon, + ScrollTextIcon, SearchIcon, ShareIcon, + SparklesIcon, XIcon, } from "@/components/ui/Icon"; +import { KeyboardAwareScrollView } from "@/components/ui/KeyboardAwareScrollView"; +import { RichTextEditor } from "@/components/ui/RichTextEditor"; import { SyncButton } from "@/components/ui/SyncButton"; import { openMobileBook } from "@/lib/library/open-mobile-book"; import type { RootStackParamList } from "@/navigation/RootNavigator"; @@ -14,17 +19,23 @@ import { useAnnotationStore, useLibraryStore } from "@/stores"; import { useColors, useTheme } from "@/styles/theme"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import type { HighlightWithBook } from "@readany/core/db/database"; +import { + type HighlightWithBook, + ensureBookHomeDocument, + updateKnowledgeDocument, +} from "@readany/core/db/database"; import { AnnotationExporter, type ExportFormat } from "@readany/core/export"; +import { markdownToBasicTiptap } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; -import type { Highlight } from "@readany/core/types"; +import type { Highlight, KnowledgeDocument } from "@readany/core/types"; import { eventBus } from "@readany/core/utils/event-bus"; +import type { TFunction } from "i18next"; /** * NotesScreen — matching Tauri mobile NotesPage exactly. * Features: stats header, book notebooks list with covers, detail view with * highlights/notes tabs, chapter grouping, color dots, edit/delete, export, search. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, @@ -50,7 +61,16 @@ const NOTE_PNG = require("../../assets/note.png"); const NOTE_DARK_PNG = require("../../assets/note-dark.png"); type Nav = NativeStackNavigationProp; -type DetailTab = "notes" | "highlights"; +type DetailTab = "knowledge" | "notes" | "highlights"; + +function createKnowledgeExcerpt(markdown: string): string | undefined { + const text = markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/[#>*_`~\-[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return text ? text.slice(0, 220) : undefined; +} export function NotesView({ initialBookId, @@ -82,10 +102,16 @@ export function NotesView({ const [searchQuery, setSearchQuery] = useState(""); const [showSearch, setShowSearch] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [detailTab, setDetailTab] = useState("notes"); + const [detailTab, setDetailTab] = useState(initialBookId ? "notes" : "knowledge"); const [editingId, setEditingId] = useState(null); const [editNote, setEditNote] = useState(""); const [showExportMenu, setShowExportMenu] = useState(false); + const [knowledgeHome, setKnowledgeHome] = useState(null); + const [knowledgeContent, setKnowledgeContent] = useState(""); + const [savedKnowledgeContent, setSavedKnowledgeContent] = useState(""); + const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); + const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); + const knowledgeSaveVersionRef = useRef(0); useFocusEffect( useCallback(() => { @@ -121,7 +147,7 @@ export function NotesView({ } }, [initialBookId]); - // Group by book — matching Tauri exactly + // Group by book, while keeping every library book available as a knowledge workspace. const bookNotebooks = useMemo(() => { const grouped = new Map< string, @@ -137,13 +163,27 @@ export function NotesView({ } >(); + for (const book of books) { + if (book.deletedAt) continue; + grouped.set(book.id, { + bookId: book.id, + title: book.meta.title || t("notes.unknownBook", "未知书籍"), + author: book.meta.author || t("notes.unknownAuthor", "未知作者"), + coverUrl: book.meta.coverUrl || null, + highlights: [], + notesCount: 0, + highlightsOnlyCount: 0, + latestAt: book.lastOpenedAt || book.updatedAt || book.addedAt, + }); + } + for (const h of highlightsWithBooks) { const existing = grouped.get(h.bookId); if (existing) { existing.highlights.push(h); if (h.note) existing.notesCount++; else existing.highlightsOnlyCount++; - if (h.createdAt > existing.latestAt) existing.latestAt = h.createdAt; + if (h.updatedAt > existing.latestAt) existing.latestAt = h.updatedAt; } else { grouped.set(h.bookId, { bookId: h.bookId, @@ -159,7 +199,7 @@ export function NotesView({ } return Array.from(grouped.values()).sort((a, b) => b.latestAt - a.latestAt); - }, [highlightsWithBooks, t]); + }, [books, highlightsWithBooks, t]); // Resolve cover URLs using shared hook const resolvedCovers = useResolvedCovers(bookNotebooks); @@ -168,6 +208,18 @@ export function NotesView({ if (!selectedBookId) return null; return bookNotebooks.find((b) => b.bookId === selectedBookId) || null; }, [selectedBookId, bookNotebooks]); + const selectedKnowledgeBookId = selectedBook?.bookId ?? null; + const selectedKnowledgeBookTitle = selectedBook?.title ?? ""; + + useEffect(() => { + if (!selectedBookId) return; + if (bookNotebooks.some((book) => book.bookId === selectedBookId)) return; + + setSelectedBookId(null); + setDetailTab("knowledge"); + setSearchQuery(""); + setEditingId(null); + }, [bookNotebooks, selectedBookId]); const { notesList, highlightsList } = useMemo(() => { if (!selectedBook) return { notesList: [], highlightsList: [] }; @@ -188,7 +240,8 @@ export function NotesView({ }; }, [selectedBook, searchQuery]); - const currentList = detailTab === "notes" ? notesList : highlightsList; + const currentList = + detailTab === "notes" ? notesList : detailTab === "highlights" ? highlightsList : []; // Group by chapter const itemsByChapter = useMemo(() => { @@ -213,6 +266,78 @@ export function NotesView({ [nav, t], ); + useEffect(() => { + let cancelled = false; + + async function loadKnowledgeHome() { + knowledgeSaveVersionRef.current += 1; + + if (!selectedKnowledgeBookId) { + setKnowledgeHome(null); + setKnowledgeContent(""); + setSavedKnowledgeContent(""); + setIsKnowledgeSaving(false); + return; + } + + setIsKnowledgeLoading(true); + setIsKnowledgeSaving(false); + try { + const document = await ensureBookHomeDocument( + selectedKnowledgeBookId, + selectedKnowledgeBookTitle, + ); + if (cancelled) return; + setKnowledgeHome(document); + setKnowledgeContent(document.contentMd); + setSavedKnowledgeContent(document.contentMd); + } catch (error) { + console.error("[Notes] Failed to load knowledge home:", error); + Alert.alert(t("common.error", "错误"), t("notes.knowledgeLoadFailed", "知识主页加载失败")); + } finally { + if (!cancelled) setIsKnowledgeLoading(false); + } + } + + void loadKnowledgeHome(); + + return () => { + cancelled = true; + }; + }, [selectedKnowledgeBookId, selectedKnowledgeBookTitle, t]); + + useEffect(() => { + if (!knowledgeHome || knowledgeContent === savedKnowledgeContent) return; + + const saveVersion = knowledgeSaveVersionRef.current + 1; + knowledgeSaveVersionRef.current = saveVersion; + + const timeout = setTimeout(async () => { + setIsKnowledgeSaving(true); + try { + await updateKnowledgeDocument(knowledgeHome.id, { + contentMd: knowledgeContent, + contentJson: markdownToBasicTiptap( + knowledgeContent, + ) as unknown as KnowledgeDocument["contentJson"], + excerpt: createKnowledgeExcerpt(knowledgeContent), + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return; + setSavedKnowledgeContent(knowledgeContent); + } catch (error) { + if (knowledgeSaveVersionRef.current !== saveVersion) return; + console.error("[Notes] Failed to save knowledge home:", error); + Alert.alert(t("common.error", "错误"), t("notes.knowledgeSaveFailed", "知识主页保存失败")); + } finally { + if (knowledgeSaveVersionRef.current === saveVersion) { + setIsKnowledgeSaving(false); + } + } + }, 700); + + return () => clearTimeout(timeout); + }, [knowledgeHome, knowledgeContent, savedKnowledgeContent, t]); + const handleDeleteNote = useCallback( (highlight: HighlightWithBook) => { Alert.alert(t("common.confirm", "确认"), t("notes.deleteNoteConfirm", "确定删除此笔记?"), [ @@ -383,6 +508,20 @@ export function NotesView({ {/* Tabs + search */} + setDetailTab("knowledge")} + > + + + {t("notes.knowledgeTab", "知识主页")} + + setDetailTab("notes")} @@ -414,22 +553,53 @@ export function NotesView({ - - - - + {detailTab === "knowledge" ? ( + + + + + {isKnowledgeSaving + ? t("notes.knowledgeSaving", "保存中") + : knowledgeContent === savedKnowledgeContent + ? t("notes.knowledgeSaved", "已保存") + : t("notes.knowledgePending", "待保存")} + + + + {selectedBook.highlights.length} {t("notes.highlightsCount", "条高亮")} + + + ) : ( + + + + + )} )} {/* Detail content */} - {currentList.length === 0 ? ( + {detailTab === "knowledge" ? ( + handleOpenBook(selectedBook.bookId, cfi)} + t={t} + styles={s} + colors={colors} + /> + ) : currentList.length === 0 ? ( {searchQuery @@ -587,7 +757,7 @@ export function NotesView({ setSelectedBookId(item.bookId); setSearchQuery(""); setEditingId(null); - setDetailTab("notes"); + setDetailTab("knowledge"); }} /> )} @@ -596,4 +766,131 @@ export function NotesView({ ); } +function KnowledgeHomePanel({ + book, + document, + content, + isLoading, + onChange, + onOpenBook, + t, + styles, + colors, +}: { + book: { + bookId: string; + title: string; + author: string; + highlights: HighlightWithBook[]; + notesCount: number; + highlightsOnlyCount: number; + }; + document: KnowledgeDocument | null; + content: string; + isLoading: boolean; + onChange: (value: string) => void; + onOpenBook: (cfi?: string) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const recentHighlights = useMemo( + () => sortAnnotationsByPosition(book.highlights).slice(0, 3), + [book.highlights], + ); + + if (isLoading || !document) { + return ( + + + {t("notes.knowledgeLoading", "正在打开知识主页...")} + + ); + } + + return ( + + + + + + + {t("notes.knowledgeEyebrow", "知识库")} + + + {book.title} + + + {book.author} + + + + {book.notesCount} + {t("notes.notesCount", "条笔记")} + + + {book.highlightsOnlyCount} + {t("notes.highlightsCount", "条高亮")} + + + + + + {t("notes.knowledgeTab", "知识主页")} + + + + + + + + + {t("notes.knowledgeSources", "最近摘录")} + + onOpenBook()}> + {t("notes.openBook", "打开书籍")} + + + + {recentHighlights.length === 0 ? ( + + {t("notes.knowledgeNoSources", "暂无摘录")} + + ) : ( + + {recentHighlights.map((highlight) => ( + onOpenBook(highlight.cfi)} + > + + "{highlight.text}" + + {!!highlight.chapterTitle && ( + + {highlight.chapterTitle} + + )} + + ))} + + )} + + + ); +} + /** Note detail card — matching Tauri NoteDetailCard */ diff --git a/packages/app-expo/src/screens/notes/notes-styles.ts b/packages/app-expo/src/screens/notes/notes-styles.ts index 7c254d67..03ed861b 100644 --- a/packages/app-expo/src/screens/notes/notes-styles.ts +++ b/packages/app-expo/src/screens/notes/notes-styles.ts @@ -1,11 +1,5 @@ +import { type ThemeColors, fontSize, fontWeight, radius, withOpacity } from "@/styles/theme"; import { StyleSheet } from "react-native"; -import { - type ThemeColors, - fontSize, - fontWeight, - radius, - withOpacity, -} from "@/styles/theme"; export const makeStyles = (colors: ThemeColors) => StyleSheet.create({ @@ -97,7 +91,12 @@ export const makeStyles = (colors: ThemeColors) => marginBottom: 12, gap: 12, }, - notebookCover: { width: 44, height: 64, borderRadius: radius.sm, backgroundColor: colors.muted }, + notebookCover: { + width: 44, + height: 64, + borderRadius: radius.sm, + backgroundColor: colors.muted, + }, notebookCoverFallback: { width: 44, height: 64, @@ -107,7 +106,11 @@ export const makeStyles = (colors: ThemeColors) => justifyContent: "center", }, notebookInfo: { flex: 1, gap: 4 }, - notebookTitle: { fontSize: fontSize.sm, fontWeight: fontWeight.medium, color: colors.foreground }, + notebookTitle: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, notebookAuthor: { fontSize: fontSize.xs, color: colors.mutedForeground }, notebookStats: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 4 }, notebookStatItem: { flexDirection: "row", alignItems: "center", gap: 4 }, @@ -144,7 +147,11 @@ export const makeStyles = (colors: ThemeColors) => justifyContent: "center", }, detailHeaderInfo: { flex: 1, minWidth: 0 }, - detailTitle: { fontSize: fontSize.sm, fontWeight: fontWeight.semibold, color: colors.foreground }, + detailTitle: { + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, detailAuthor: { fontSize: fontSize.xs, color: colors.mutedForeground }, exportBtn: { width: 32, @@ -172,7 +179,11 @@ export const makeStyles = (colors: ThemeColors) => borderRadius: radius.md, }, tabBtnActive: { backgroundColor: colors.primary }, - tabBtnText: { fontSize: fontSize.xs, fontWeight: fontWeight.medium, color: colors.mutedForeground }, + tabBtnText: { + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + color: colors.mutedForeground, + }, tabBtnTextActive: { color: colors.primaryForeground }, detailSearch: { flexDirection: "row", @@ -184,9 +195,158 @@ export const makeStyles = (colors: ThemeColors) => height: 32, }, detailSearchInput: { flex: 1, fontSize: fontSize.sm, color: colors.foreground, padding: 0 }, + knowledgeStatusBar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + minHeight: 32, + }, + knowledgeStatusPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: colors.muted, + borderRadius: radius.md, + paddingHorizontal: 10, + paddingVertical: 6, + }, + knowledgeStatusText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeStatusMeta: { + flexShrink: 1, + fontSize: fontSize.xs, + color: colors.mutedForeground, + textAlign: "right", + }, detailEmpty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, detailEmptyText: { fontSize: fontSize.sm, color: colors.mutedForeground }, detailList: { flex: 1, paddingHorizontal: 16 }, + knowledgeLoading: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 }, + knowledgeScroll: { flex: 1 }, + knowledgeContent: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 28, + gap: 14, + }, + knowledgeHero: { + backgroundColor: colors.card, + borderRadius: radius.xl, + borderWidth: 0.5, + borderColor: colors.border, + padding: 16, + gap: 10, + }, + knowledgeHeroTop: { flexDirection: "row", alignItems: "center", gap: 8 }, + knowledgeHeroIcon: { + width: 28, + height: 28, + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.12), + alignItems: "center", + justifyContent: "center", + }, + knowledgeHeroEyebrow: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.semibold, + letterSpacing: 0.5, + }, + knowledgeHeroTitle: { + fontSize: fontSize.xl, + lineHeight: 28, + color: colors.foreground, + fontWeight: fontWeight.bold, + }, + knowledgeHeroSubtitle: { fontSize: fontSize.sm, color: colors.mutedForeground }, + knowledgeMetricRow: { flexDirection: "row", gap: 10, marginTop: 2 }, + knowledgeMetric: { + flex: 1, + backgroundColor: colors.muted, + borderRadius: radius.lg, + paddingHorizontal: 12, + paddingVertical: 10, + }, + knowledgeMetricValue: { + fontSize: fontSize.lg, + color: colors.foreground, + fontWeight: fontWeight.bold, + }, + knowledgeMetricLabel: { marginTop: 2, fontSize: fontSize.xs, color: colors.mutedForeground }, + knowledgeEditorCard: { + backgroundColor: colors.card, + borderRadius: radius.xl, + borderWidth: 0.5, + borderColor: colors.border, + padding: 12, + gap: 10, + }, + knowledgeSectionTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeEditorFrame: { + minHeight: 360, + borderRadius: radius.lg, + borderWidth: 0.5, + borderColor: colors.border, + overflow: "hidden", + backgroundColor: colors.background, + }, + knowledgeSourcesCard: { + backgroundColor: colors.card, + borderRadius: radius.xl, + borderWidth: 0.5, + borderColor: colors.border, + padding: 12, + gap: 10, + }, + knowledgeSourcesHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, + knowledgeOpenButton: { + borderRadius: radius.md, + backgroundColor: colors.muted, + paddingHorizontal: 10, + paddingVertical: 6, + }, + knowledgeOpenButtonText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeEmptySources: { + borderRadius: radius.lg, + backgroundColor: colors.muted, + color: colors.mutedForeground, + fontSize: fontSize.sm, + lineHeight: 20, + paddingHorizontal: 12, + paddingVertical: 14, + }, + knowledgeSourceList: { gap: 8 }, + knowledgeSourceItem: { + borderRadius: radius.lg, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 12, + paddingVertical: 10, + }, + knowledgeSourceText: { fontSize: fontSize.sm, lineHeight: 20, color: colors.foreground }, + knowledgeSourceChapter: { + marginTop: 6, + fontSize: fontSize.xs, + color: colors.mutedForeground, + }, chapterGroup: { marginBottom: 16 }, chapterDivider: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, chapterLine: { flex: 1, height: 0.5, backgroundColor: colors.border }, @@ -238,9 +398,18 @@ export const makeStyles = (colors: ThemeColors) => editTabs: { flexDirection: "row", gap: 12, marginBottom: 8, paddingHorizontal: 4 }, editTabBtn: { paddingBottom: 4 }, editTabBtnActive: { borderBottomWidth: 2, borderBottomColor: colors.primary }, - editTabText: { fontSize: fontSize.xs, color: colors.mutedForeground, fontWeight: fontWeight.medium }, + editTabText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, editTabTextActive: { color: colors.primary }, - editPreviewArea: { minHeight: 80, backgroundColor: colors.muted, borderRadius: radius.md, padding: 12 }, + editPreviewArea: { + minHeight: 80, + backgroundColor: colors.muted, + borderRadius: radius.md, + padding: 12, + }, editActions: { flexDirection: "row", justifyContent: "flex-end", gap: 8, marginTop: 8 }, editCancelBtn: { flexDirection: "row", From 535e8f631d0275379b69673d1091433f2f6a6907 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 02:10:17 +0800 Subject: [PATCH 006/409] feat(core): add knowledge markdown exporter --- packages/core/package.json | 3 +- packages/core/src/export/index.ts | 2 + .../src/export/knowledge-exporter.test.ts | 184 ++++++++++++++ .../core/src/export/knowledge-exporter.ts | 233 ++++++++++++++++++ 4 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/export/index.ts create mode 100644 packages/core/src/export/knowledge-exporter.test.ts create mode 100644 packages/core/src/export/knowledge-exporter.ts diff --git a/packages/core/package.json b/packages/core/package.json index c8a48655..90da4be2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,7 +32,8 @@ "./stats/*": "./src/stats/*.ts", "./translation": "./src/translation/translator.ts", "./translation/*": "./src/translation/*.ts", - "./export": "./src/export/annotation-exporter.ts", + "./export": "./src/export/index.ts", + "./export/*": "./src/export/*.ts", "./sync": "./src/sync/index.ts", "./sync/*": "./src/sync/*.ts", "./update": "./src/update/index.ts", diff --git a/packages/core/src/export/index.ts b/packages/core/src/export/index.ts new file mode 100644 index 00000000..a7a55292 --- /dev/null +++ b/packages/core/src/export/index.ts @@ -0,0 +1,2 @@ +export * from "./annotation-exporter"; +export * from "./knowledge-exporter"; diff --git a/packages/core/src/export/knowledge-exporter.test.ts b/packages/core/src/export/knowledge-exporter.test.ts new file mode 100644 index 00000000..88039d0e --- /dev/null +++ b/packages/core/src/export/knowledge-exporter.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { Book, KnowledgeAttachment, KnowledgeDocument, KnowledgeLink } from "../types"; +import { KnowledgeExporter } from "./knowledge-exporter"; + +const baseBook: Book = { + id: "book-1", + filePath: "books/book.epub", + format: "epub", + meta: { + title: "The Book: A Study", + author: "Ada Reader", + language: "en", + }, + addedAt: 1000, + updatedAt: 2000, + progress: 0.4, + isVectorized: false, + vectorizeProgress: 0, + tags: ["philosophy"], + syncStatus: "local", +}; + +function knowledgeDocument(overrides: Partial = {}): KnowledgeDocument { + return { + id: "doc-1", + bookId: "book-1", + type: "book_home", + title: "Book Home", + contentJson: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Notes" }], + }, + { + type: "readanyCard", + attrs: { + cardType: "bookQuote", + title: "Quote", + text: "Reading is thinking.", + sourceTitle: "Chapter 1", + }, + }, + ], + }, + contentMd: "", + contentSchemaVersion: 1, + tags: ["reading"], + sourceKind: "book", + sourceId: "book-1", + createdAt: 1700000000000, + updatedAt: 1700000100000, + ...overrides, + }; +} + +describe("KnowledgeExporter", () => { + it("exports book home documents as Obsidian-friendly README files", () => { + const exporter = new KnowledgeExporter(); + const files = exporter.export({ + books: [baseBook], + documents: [knowledgeDocument()], + }); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe("Books/The Book A Study/README.md"); + expect(files[0].mimeType).toBe("text/markdown"); + expect(files[0].content).toContain("type: readany-knowledge"); + expect(files[0].content).toContain('title: "Book Home"'); + expect(files[0].content).toContain('book: "The Book: A Study"'); + expect(files[0].content).toContain("# Book Home"); + expect(files[0].content).toContain("## Notes"); + expect(files[0].content).toContain("> [!note] Quote"); + expect(files[0].content).toContain("> Reading is thinking."); + }); + + it("renders links and attachments into readable Markdown sections", () => { + const exporter = new KnowledgeExporter(); + const related = knowledgeDocument({ + id: "doc-2", + type: "standalone_note", + title: "Related Idea", + bookId: undefined, + sourceKind: undefined, + sourceId: undefined, + contentJson: { type: "doc", content: [] }, + }); + const links: KnowledgeLink[] = [ + { + id: "link-1", + fromDocumentId: "doc-1", + toKind: "document", + toId: "doc-2", + relation: "related", + createdAt: 1000, + updatedAt: 1000, + }, + { + id: "link-2", + fromDocumentId: "doc-1", + toKind: "highlight", + toId: "hl-1", + relation: "source", + label: "Original highlight", + cfi: "epubcfi(/6/2)", + createdAt: 1000, + updatedAt: 1000, + }, + ]; + const attachments: KnowledgeAttachment[] = [ + { + id: "att-1", + documentId: "doc-1", + kind: "image", + fileName: "diagram.png", + localPath: "attachments/diagram.png", + size: 128, + createdAt: 1000, + updatedAt: 1000, + }, + ]; + + const files = exporter.export({ + books: [baseBook], + documents: [knowledgeDocument(), related], + links, + attachments, + }); + const home = files.find((file) => file.path.endsWith("README.md")); + + expect(home?.content).toContain("## ReadAny Links"); + expect(home?.content).toContain("- **related:** [[Related Idea]]"); + expect(home?.content).toContain( + "- **source:** [Original highlight](readany://cfi/epubcfi(%2F6%2F2))", + ); + expect(home?.content).toContain("## Attachments"); + expect(home?.content).toContain("- [diagram.png](attachments/diagram.png)"); + }); + + it("skips deleted documents by default and disambiguates duplicate paths", () => { + const exporter = new KnowledgeExporter(); + const files = exporter.export({ + documents: [ + knowledgeDocument({ + id: "doc-a", + bookId: undefined, + type: "standalone_note", + title: "Same Name", + }), + knowledgeDocument({ + id: "doc-b", + bookId: undefined, + type: "standalone_note", + title: "Same Name", + }), + knowledgeDocument({ + id: "doc-deleted", + bookId: undefined, + type: "standalone_note", + title: "Deleted", + deletedAt: 2000, + }), + ], + }); + + expect(files.map((file) => file.path)).toEqual(["Notes/Same Name.md", "Notes/Same Name-2.md"]); + }); + + it("can preserve ReadAny card metadata for round-tripping exports", () => { + const exporter = new KnowledgeExporter(); + const [file] = exporter.export( + { + documents: [knowledgeDocument({ bookId: undefined })], + }, + { format: "markdown", includeReadAnyCardMetadata: true }, + ); + + expect(file.content).toContain(':::readany-card type="bookQuote" version="1"'); + expect(file.content).toContain("Reading is thinking."); + expect(file.content).not.toContain("type: readany-knowledge"); + }); +}); diff --git a/packages/core/src/export/knowledge-exporter.ts b/packages/core/src/export/knowledge-exporter.ts new file mode 100644 index 00000000..0d6716af --- /dev/null +++ b/packages/core/src/export/knowledge-exporter.ts @@ -0,0 +1,233 @@ +import { renderKnowledgeJsonToMarkdown } from "../knowledge/editor-projection"; +import type { Book, KnowledgeAttachment, KnowledgeDocument, KnowledgeLink } from "../types"; + +export type KnowledgeExportFormat = "markdown" | "obsidian"; + +export interface KnowledgeExportFile { + path: string; + content: string; + mimeType: "text/markdown"; +} + +export interface KnowledgeExportInput { + documents: KnowledgeDocument[]; + books?: Book[]; + links?: KnowledgeLink[]; + attachments?: KnowledgeAttachment[]; +} + +export interface KnowledgeExportOptions { + format?: KnowledgeExportFormat; + rootDir?: string; + includeDeleted?: boolean; + includeReadAnyCardMetadata?: boolean; +} + +interface ExportContext { + booksById: Map; + documentsById: Map; + linksByDocumentId: Map; + attachmentsByDocumentId: Map; +} + +function slugPart(value: string, fallback: string): string { + const cleaned = value + .replace(/[\\/:*?"<>|#^[\]]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return (cleaned || fallback).slice(0, 80); +} + +function joinPath(...parts: string[]): string { + return parts + .map((part) => part.replace(/^\/+|\/+$/g, "")) + .filter(Boolean) + .join("/"); +} + +function yamlString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function yamlList(values: string[]): string[] { + if (values.length === 0) return ["tags: []"]; + return ["tags:", ...values.map((value) => ` - ${yamlString(value)}`)]; +} + +function isoDate(timestamp: number): string { + return new Date(timestamp).toISOString(); +} + +function createContext(input: KnowledgeExportInput): ExportContext { + const booksById = new Map((input.books ?? []).map((book) => [book.id, book])); + const documentsById = new Map(input.documents.map((document) => [document.id, document])); + const linksByDocumentId = new Map(); + const attachmentsByDocumentId = new Map(); + + for (const link of input.links ?? []) { + const links = linksByDocumentId.get(link.fromDocumentId) ?? []; + links.push(link); + linksByDocumentId.set(link.fromDocumentId, links); + } + + for (const attachment of input.attachments ?? []) { + if (!attachment.documentId) continue; + const attachments = attachmentsByDocumentId.get(attachment.documentId) ?? []; + attachments.push(attachment); + attachmentsByDocumentId.set(attachment.documentId, attachments); + } + + return { booksById, documentsById, linksByDocumentId, attachmentsByDocumentId }; +} + +function documentBody(document: KnowledgeDocument, options: Required) { + return ( + renderKnowledgeJsonToMarkdown(document.contentJson, { + includeReadAnyCardMetadata: options.includeReadAnyCardMetadata, + }) || + document.contentMd || + "" + ).trim(); +} + +function documentPath( + document: KnowledgeDocument, + context: ExportContext, + options: Required, +): string { + const book = document.bookId ? context.booksById.get(document.bookId) : undefined; + const fileName = + document.type === "book_home" + ? "README" + : slugPart(document.title, document.type || "knowledge"); + + const scopedPath = book + ? joinPath("Books", slugPart(book.meta.title, document.bookId ?? "book"), `${fileName}.md`) + : joinPath("Notes", `${slugPart(document.title, document.id)}.md`); + + return joinPath(options.rootDir, scopedPath); +} + +function renderLinkItem(link: KnowledgeLink, context: ExportContext): string { + const label = link.label || link.relation; + + if (link.toKind === "document") { + const target = context.documentsById.get(link.toId); + return `- **${link.relation}:** ${target ? `[[${target.title}]]` : `[[${link.toId}]]`}`; + } + + const target = + link.toKind === "url" + ? link.toId + : link.cfi + ? `readany://cfi/${encodeURIComponent(link.cfi)}` + : `readany://${link.toKind}/${encodeURIComponent(link.toId)}`; + return `- **${link.relation}:** [${label}](${target})`; +} + +function renderLinks(document: KnowledgeDocument, context: ExportContext): string[] { + const links = context.linksByDocumentId.get(document.id) ?? []; + if (links.length === 0) return []; + + return ["## ReadAny Links", "", ...links.map((link) => renderLinkItem(link, context))]; +} + +function renderAttachments(document: KnowledgeDocument, context: ExportContext): string[] { + const attachments = context.attachmentsByDocumentId.get(document.id) ?? []; + if (attachments.length === 0) return []; + + return [ + "## Attachments", + "", + ...attachments.map((attachment) => { + const target = attachment.remotePath || attachment.localPath || attachment.fileName; + return `- [${attachment.fileName}](${target})`; + }), + ]; +} + +function renderFrontmatter(document: KnowledgeDocument, context: ExportContext): string[] { + const book = document.bookId ? context.booksById.get(document.bookId) : undefined; + const lines = [ + "---", + "type: readany-knowledge", + `id: ${yamlString(document.id)}`, + `documentType: ${yamlString(document.type)}`, + `title: ${yamlString(document.title)}`, + ]; + + if (document.bookId) lines.push(`bookId: ${yamlString(document.bookId)}`); + if (book) { + lines.push(`book: ${yamlString(book.meta.title)}`); + if (book.meta.author) lines.push(`author: ${yamlString(book.meta.author)}`); + } + if (document.sourceKind) lines.push(`sourceKind: ${yamlString(document.sourceKind)}`); + if (document.sourceId) lines.push(`sourceId: ${yamlString(document.sourceId)}`); + lines.push(`created: ${yamlString(isoDate(document.createdAt))}`); + lines.push(`updated: ${yamlString(isoDate(document.updatedAt))}`); + lines.push(...yamlList(document.tags)); + lines.push("---"); + return lines; +} + +function renderDocument( + document: KnowledgeDocument, + context: ExportContext, + options: Required, +): string { + const body = documentBody(document, options); + const sections = [ + ...(options.format === "obsidian" ? renderFrontmatter(document, context) : []), + `# ${document.title}`, + "", + body, + "", + ...renderLinks(document, context), + "", + ...renderAttachments(document, context), + ]; + + return sections + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd() + .concat("\n"); +} + +function withUniquePaths(files: KnowledgeExportFile[]): KnowledgeExportFile[] { + const seen = new Map(); + + return files.map((file) => { + const count = seen.get(file.path) ?? 0; + seen.set(file.path, count + 1); + if (count === 0) return file; + + const nextPath = file.path.replace(/\.md$/i, `-${count + 1}.md`); + return { ...file, path: nextPath }; + }); +} + +export class KnowledgeExporter { + export(input: KnowledgeExportInput, options: KnowledgeExportOptions = {}): KnowledgeExportFile[] { + const opts: Required = { + format: options.format ?? "obsidian", + rootDir: options.rootDir ?? "", + includeDeleted: options.includeDeleted ?? false, + includeReadAnyCardMetadata: options.includeReadAnyCardMetadata ?? false, + }; + const context = createContext(input); + const documents = opts.includeDeleted + ? input.documents + : input.documents.filter((document) => !document.deletedAt); + + const files = documents.map((document) => ({ + path: documentPath(document, context, opts), + content: renderDocument(document, context, opts), + mimeType: "text/markdown", + })); + + return withUniquePaths(files); + } +} + +export const knowledgeExporter = new KnowledgeExporter(); From 9d738b963864cc39c0e7f43146939db562270d63 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 02:17:53 +0800 Subject: [PATCH 007/409] feat(ai): add read-only knowledge base tools --- packages/core/src/ai/system-prompt.ts | 9 + packages/core/src/ai/tools/index.ts | 9 +- .../core/src/ai/tools/knowledge-tools.test.ts | 103 ++++++++++ packages/core/src/ai/tools/knowledge-tools.ts | 184 ++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/ai/tools/knowledge-tools.test.ts create mode 100644 packages/core/src/ai/tools/knowledge-tools.ts diff --git a/packages/core/src/ai/system-prompt.ts b/packages/core/src/ai/system-prompt.ts index 0aa42ca0..ea6c140f 100644 --- a/packages/core/src/ai/system-prompt.ts +++ b/packages/core/src/ai/system-prompt.ts @@ -99,6 +99,9 @@ function buildToolsSection( tools.push( "- **searchAllNotes**: Get notes across all books (params: reasoning, days, bookTitle, limit)", ); + tools.push( + "- **searchKnowledgeBase**: Search durable ReadAny knowledge documents, book home pages, reviews, summaries, and standalone notes (params: reasoning, query, bookId, type, limit)", + ); tools.push("- **getReadingStats**: Get reading statistics (params: reasoning, days)"); tools.push("- **getSkills**: Query available skills/SOPs for guidance (params: reasoning, task)"); tools.push( @@ -168,6 +171,9 @@ function buildToolsSection( if (hasBookContext) { tools.push("- **getAnnotations**: Get user's highlights and notes (params: type)"); + tools.push( + "- **getBookKnowledge**: Get the current book's durable knowledge documents before answering from the user's own notes (params: reasoning, type, includeContent, limit)", + ); if (isVectorized) { tools.push( "- **addCitation**: CRITICAL - Register a citation with CFI for precise navigation. You MUST extract the 'cfi' field from ragSearch/tool results and pass it here. The citationIndex param determines which [N] marker it maps to (params: citationIndex [REQUIRED - the number N for [N]], chapterTitle, chapterIndex, cfi [REQUIRED from tool results], quotedText, reasoning)", @@ -219,6 +225,9 @@ function buildWorkflowSection(isVectorized: boolean, hasBookContext: boolean): s } steps.push(" - **getSurroundingContext**: for current page content"); + steps.push( + " - **getBookKnowledge/searchKnowledgeBase**: for the user's durable notes, reviews, summaries, and book home pages", + ); steps.push("3. **Register citations before answering** — If your answer uses book content:"); steps.push(" - Call **addCitation** before writing the final response body"); diff --git a/packages/core/src/ai/tools/index.ts b/packages/core/src/ai/tools/index.ts index ac2306c0..1f2bd159 100644 --- a/packages/core/src/ai/tools/index.ts +++ b/packages/core/src/ai/tools/index.ts @@ -8,6 +8,7 @@ * - Annotation Tools: getAnnotations, addCitation * - Library Tools: listBooks, searchAllHighlights, searchAllNotes, readingStats, classifyBooks, * tagBooks, manageBookTags, updateBookMetadata, manageBookGroups + * - Knowledge Tools: searchKnowledgeBase, getBookKnowledge * - Skill Tools: getSkills, skillToTool * - Mindmap Tools: mindmap * - Context Tools: getCurrentChapter, getSelection, getReadingProgress, getRecentHighlights, getSurroundingContext @@ -27,6 +28,7 @@ import { createFallbackSearchTool, createFallbackTocTool, } from "./fallback-content-tools"; +import { createGetBookKnowledgeTool, createSearchKnowledgeBaseTool } from "./knowledge-tools"; import { createClassifyBooksTool, createListBooksTool, @@ -53,6 +55,7 @@ function getGeneralTools(): ToolDefinition[] { createListBooksTool(), createSearchAllHighlightsTool(), createSearchAllNotesTool(), + createSearchKnowledgeBaseTool(), createReadingStatsTool(), createGetSkillsTool(), createMindmapTool(), @@ -105,7 +108,11 @@ export function getAvailableTools(options: { // Citations are available for indexed chunks and for fallback sources that // can be validated against concrete reader segments. - tools.push(createGetAnnotationsTool(options.bookId), createAddCitationTool(options.bookId)); + tools.push( + createGetAnnotationsTool(options.bookId), + createGetBookKnowledgeTool(options.bookId), + createAddCitationTool(options.bookId), + ); } // Add custom skills diff --git a/packages/core/src/ai/tools/knowledge-tools.test.ts b/packages/core/src/ai/tools/knowledge-tools.test.ts new file mode 100644 index 00000000..050e7382 --- /dev/null +++ b/packages/core/src/ai/tools/knowledge-tools.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { KnowledgeDocument } from "../../types"; + +const dbMocks = vi.hoisted(() => ({ + getKnowledgeDocuments: vi.fn(), +})); + +vi.mock("../../db/database", () => dbMocks); + +const { createGetBookKnowledgeTool, createSearchKnowledgeBaseTool } = await import( + "./knowledge-tools" +); + +function doc(overrides: Partial = {}): KnowledgeDocument { + return { + id: "doc-1", + bookId: "book-1", + type: "book_home", + title: "Deep Reading Home", + contentJson: { type: "doc", content: [] }, + contentMd: "Reading slowly helps memory and reflection.", + contentSchemaVersion: 1, + excerpt: "Reading slowly helps memory.", + tags: ["reading", "memory"], + sourceKind: "book", + sourceId: "book-1", + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +describe("knowledge tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("searches knowledge documents by title, tags, excerpt, and content", async () => { + dbMocks.getKnowledgeDocuments.mockResolvedValue([ + doc({ id: "doc-1", title: "Deep Reading Home", updatedAt: 3000 }), + doc({ + id: "doc-2", + title: "Cooking", + contentMd: "Nothing about reading here.", + excerpt: "Kitchen notes.", + tags: ["food"], + updatedAt: 1000, + }), + doc({ + id: "doc-3", + title: "Memory", + contentMd: "Spaced repetition.", + excerpt: "Memory note.", + tags: ["memory"], + updatedAt: 2000, + }), + ]); + + const tool = createSearchKnowledgeBaseTool(); + const result = (await tool.execute({ + reasoning: "Need user knowledge", + query: "memory", + bookId: "book-1", + limit: 2, + })) as { total: number; showing: number; documents: Array<{ id: string; snippet: string }> }; + + expect(dbMocks.getKnowledgeDocuments).toHaveBeenCalledWith({ + bookId: "book-1", + type: undefined, + limit: 200, + }); + expect(result.total).toBe(2); + expect(result.showing).toBe(2); + expect(result.documents.map((item) => item.id)).toEqual(["doc-3", "doc-1"]); + expect(result.documents[0].snippet).toContain("Memory note"); + }); + + it("returns current book knowledge and can include full content", async () => { + dbMocks.getKnowledgeDocuments.mockResolvedValue([doc()]); + + const tool = createGetBookKnowledgeTool("book-1"); + const result = (await tool.execute({ + reasoning: "Need the user's book notes", + includeContent: true, + type: "book_home", + })) as { + bookId: string; + documents: Array<{ id: string; content?: string; snippet: string }>; + }; + + expect(dbMocks.getKnowledgeDocuments).toHaveBeenCalledWith({ + bookId: "book-1", + type: "book_home", + limit: 8, + }); + expect(result.bookId).toBe("book-1"); + expect(result.documents[0]).toMatchObject({ + id: "doc-1", + content: "Reading slowly helps memory and reflection.", + snippet: "Reading slowly helps memory.", + }); + }); +}); diff --git a/packages/core/src/ai/tools/knowledge-tools.ts b/packages/core/src/ai/tools/knowledge-tools.ts new file mode 100644 index 00000000..c1dd3a1d --- /dev/null +++ b/packages/core/src/ai/tools/knowledge-tools.ts @@ -0,0 +1,184 @@ +/** + * Knowledge Tools — let AI read the user's ReadAny knowledge base. + * + * These tools are intentionally read-only. Mutating knowledge documents should + * go through a confirmation-capable UI flow so AI never silently overwrites a + * user's durable notes. + */ +import { getKnowledgeDocuments } from "../../db/database"; +import type { KnowledgeDocument, KnowledgeDocumentType } from "../../types"; +import type { ToolDefinition } from "./tool-types"; + +const SEARCH_SCAN_LIMIT = 200; +const DEFAULT_RESULT_LIMIT = 8; + +function asPositiveLimit(value: unknown, fallback: number): number { + const limit = Number(value); + return Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 30) : fallback; +} + +function normalizeQuery(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function normalizeType(value: unknown): KnowledgeDocumentType | undefined { + const type = String(value ?? "").trim(); + if (!type || type === "all") return undefined; + const allowed = new Set([ + "book_home", + "standalone_note", + "highlight_note", + "review", + "summary", + "imported_markdown", + ]); + return allowed.has(type as KnowledgeDocumentType) ? (type as KnowledgeDocumentType) : undefined; +} + +function compactText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function createSnippet(document: KnowledgeDocument, query: string): string { + const source = compactText(document.excerpt || document.contentMd || ""); + if (!source) return ""; + if (!query) return source.slice(0, 320); + + const lower = source.toLowerCase(); + const index = lower.indexOf(query); + if (index === -1) return source.slice(0, 320); + + const start = Math.max(0, index - 120); + const end = Math.min(source.length, index + query.length + 200); + const prefix = start > 0 ? "..." : ""; + const suffix = end < source.length ? "..." : ""; + return `${prefix}${source.slice(start, end)}${suffix}`; +} + +function scoreDocument(document: KnowledgeDocument, query: string): number { + if (!query) return 1; + + let score = 0; + const title = document.title.toLowerCase(); + const excerpt = (document.excerpt || "").toLowerCase(); + const content = document.contentMd.toLowerCase(); + const tags = document.tags.join(" ").toLowerCase(); + + if (title.includes(query)) score += 8; + if (tags.includes(query)) score += 5; + if (excerpt.includes(query)) score += 3; + if (content.includes(query)) score += 1; + return score; +} + +function documentSummary(document: KnowledgeDocument, query = "", includeContent = false) { + return { + id: document.id, + bookId: document.bookId, + type: document.type, + title: document.title, + tags: document.tags, + excerpt: document.excerpt, + snippet: createSnippet(document, query), + updatedAt: document.updatedAt, + content: includeContent ? document.contentMd : undefined, + }; +} + +export function createSearchKnowledgeBaseTool(): ToolDefinition { + return { + name: "searchKnowledgeBase", + description: + "Search the user's ReadAny knowledge base documents across books and standalone notes. Use this when the user asks about their saved knowledge, book home pages, reviews, summaries, or long-form notes.", + parameters: { + reasoning: { + type: "string", + description: "Brief explanation of why you are searching the knowledge base", + required: true, + }, + query: { + type: "string", + description: "Keyword or phrase to search for in titles, tags, excerpts, and content", + }, + bookId: { + type: "string", + description: "Optional book id to restrict the search to one book", + }, + type: { + type: "string", + description: + "Optional document type: book_home, standalone_note, highlight_note, review, summary, imported_markdown, or all", + }, + limit: { + type: "number", + description: "Maximum number of results to return (default 8, max 30)", + }, + }, + execute: async (args) => { + const query = normalizeQuery(args.query); + const bookId = String(args.bookId ?? "").trim() || undefined; + const type = normalizeType(args.type); + const limit = asPositiveLimit(args.limit, DEFAULT_RESULT_LIMIT); + const documents = await getKnowledgeDocuments({ + bookId, + type, + limit: SEARCH_SCAN_LIMIT, + }); + + const scored = documents + .map((document) => ({ document, score: scoreDocument(document, query) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || b.document.updatedAt - a.document.updatedAt); + + return { + total: scored.length, + showing: Math.min(scored.length, limit), + documents: scored + .slice(0, limit) + .map((item) => documentSummary(item.document, query, false)), + }; + }, + }; +} + +export function createGetBookKnowledgeTool(bookId: string): ToolDefinition { + return { + name: "getBookKnowledge", + description: + "Get ReadAny knowledge documents for the current book, including the book home page, reviews, summaries, and expanded highlight notes. Use this to incorporate the user's own durable notes before answering.", + parameters: { + reasoning: { + type: "string", + description: "Brief explanation of why you need this book's knowledge documents", + required: true, + }, + type: { + type: "string", + description: + "Optional document type: book_home, standalone_note, highlight_note, review, summary, imported_markdown, or all", + }, + includeContent: { + type: "boolean", + description: "Return full Markdown content instead of only snippets and excerpts", + }, + limit: { + type: "number", + description: "Maximum number of documents to return (default 8, max 30)", + }, + }, + execute: async (args) => { + const type = normalizeType(args.type); + const includeContent = args.includeContent === true; + const limit = asPositiveLimit(args.limit, DEFAULT_RESULT_LIMIT); + const documents = await getKnowledgeDocuments({ bookId, type, limit }); + + return { + bookId, + total: documents.length, + documents: documents.map((document) => documentSummary(document, "", includeContent)), + }; + }, + }; +} From 5cc50cc8ef8f2395852176cbcd56745db474f811 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 02:31:40 +0800 Subject: [PATCH 008/409] feat(core): add knowledge card registry --- .../03-editor-cards-obsidian.md | 19 +++ .../src/export/knowledge-exporter.test.ts | 2 +- packages/core/src/knowledge/card-registry.ts | 143 ++++++++++++++++++ .../src/knowledge/editor-projection.test.ts | 35 ++++- .../core/src/knowledge/editor-projection.ts | 21 +-- packages/core/src/knowledge/index.ts | 11 +- 6 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/knowledge/card-registry.ts diff --git a/docs/knowledge-base-notes/03-editor-cards-obsidian.md b/docs/knowledge-base-notes/03-editor-cards-obsidian.md index a37810ce..887b266d 100644 --- a/docs/knowledge-base-notes/03-editor-cards-obsidian.md +++ b/docs/knowledge-base-notes/03-editor-cards-obsidian.md @@ -103,6 +103,25 @@ Use three editor tiers and map every surface to one of them: The tier is part of the editor configuration, not a different storage format. All tiers still save canonical Tiptap JSON and derive Markdown from that JSON. +### Product Rules + +Rich text is only kept where it helps the user's current job. The editor should +therefore be configured by surface: + +| Surface | Tier | UX shape | Keep rich text for | Do not show by default | +| --- | --- | --- | --- | --- | +| Reader quick note | `inline_note` | Lightweight popover or bottom sheet. | Short thoughts, emphasis, links, lists, and quoted context. | Headings, tables, images, diagrams, AI cards, metadata fields. | +| Highlight detail note | `knowledge_doc` when expanded | Opens from the highlight into a focused editor. | Turning a source-attached note into a reusable idea. | App-only layout blocks and unrelated book metadata fields. | +| Book home | `knowledge_doc` | The main book knowledge workspace. | Outline, summary, source quotes, callouts, cards, backlinks, attachments, diagrams. | Raw sync metadata and fields that must be filterable. | +| Standalone knowledge note | `knowledge_doc` | Full knowledge document. | Durable notes, cross-book links, tags, attachments, AI-assisted blocks. | Book-only controls unless the note is linked to a book. | +| Book review / essay | `publishable_doc` | Writing-first editor with export preview. | Polished prose, headings, citations, images, export-friendly callouts. | Interactive AI controls and cards with no Markdown fallback. | +| AI generated block | Card inside `knowledge_doc` | Inserted as an editable card. | Summary, Q&A, timeline, concepts, mindmap, cited source snippets. | Silent writes to user documents. | +| Book metadata | Structured form, not rich text | Native fields and selectors. | Searchable and sync-safe title, author, language, ISBN, rating, tags, dates. | Freeform editor blocks. | + +This gives ReadAny one canonical editor engine, but not one overpowered toolbar +everywhere. A quick reading note stays fast, while book knowledge pages can still +grow into real documents. + ### Baseline Feature Matrix | Feature | Quick annotation | Knowledge document | Review / export | diff --git a/packages/core/src/export/knowledge-exporter.test.ts b/packages/core/src/export/knowledge-exporter.test.ts index 88039d0e..5931fe42 100644 --- a/packages/core/src/export/knowledge-exporter.test.ts +++ b/packages/core/src/export/knowledge-exporter.test.ts @@ -72,7 +72,7 @@ describe("KnowledgeExporter", () => { expect(files[0].content).toContain('book: "The Book: A Study"'); expect(files[0].content).toContain("# Book Home"); expect(files[0].content).toContain("## Notes"); - expect(files[0].content).toContain("> [!note] Quote"); + expect(files[0].content).toContain("> [!quote] Quote"); expect(files[0].content).toContain("> Reading is thinking."); }); diff --git a/packages/core/src/knowledge/card-registry.ts b/packages/core/src/knowledge/card-registry.ts new file mode 100644 index 00000000..d271092d --- /dev/null +++ b/packages/core/src/knowledge/card-registry.ts @@ -0,0 +1,143 @@ +export interface ReadAnyCardAttrs { + cardType?: string; + id?: string; + version?: number; + title?: string; + text?: string; + sourceTitle?: string; + sourceId?: string; + cfi?: string; + markdown?: string; + data?: unknown; +} + +export interface ReadAnyCardMarkdownContext { + body: string; +} + +export interface ReadAnyCardDefinition { + cardType: string; + version: number; + insertLabel: string; + markdownFallback: (attrs: ReadAnyCardAttrs, context: ReadAnyCardMarkdownContext) => string; +} + +function prefixLines(text: string, prefix: string): string { + return text + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); +} + +function callout(kind: string, title: string, body: string, footer?: string): string { + const lines = [`> [!${kind}] ${title}`]; + if (body) lines.push(prefixLines(body, "> ")); + if (footer) lines.push(`> ${footer}`); + return lines.join("\n"); +} + +function cardTitle(attrs: ReadAnyCardAttrs, fallback: string): string { + return attrs.title || attrs.sourceTitle || fallback; +} + +function bodyFromAttrs(attrs: ReadAnyCardAttrs, context: ReadAnyCardMarkdownContext): string { + return attrs.markdown || attrs.text || context.body; +} + +export const builtInReadAnyCards: ReadAnyCardDefinition[] = [ + { + cardType: "bookQuote", + version: 1, + insertLabel: "Quote", + markdownFallback: (attrs, context) => + callout( + "quote", + cardTitle(attrs, "Quote"), + bodyFromAttrs(attrs, context), + attrs.sourceTitle ? `Source: ${attrs.sourceTitle}` : undefined, + ), + }, + { + cardType: "callout", + version: 1, + insertLabel: "Callout", + markdownFallback: (attrs, context) => + callout("note", cardTitle(attrs, "Note"), bodyFromAttrs(attrs, context)), + }, + { + cardType: "bookMetadata", + version: 1, + insertLabel: "Book metadata", + markdownFallback: (attrs, context) => + callout("info", cardTitle(attrs, "Book metadata"), bodyFromAttrs(attrs, context)), + }, + { + cardType: "aiSummary", + version: 1, + insertLabel: "AI summary", + markdownFallback: (attrs, context) => + callout("summary", cardTitle(attrs, "AI summary"), bodyFromAttrs(attrs, context)), + }, + { + cardType: "qa", + version: 1, + insertLabel: "Q&A", + markdownFallback: (attrs, context) => + callout("question", cardTitle(attrs, "Question"), bodyFromAttrs(attrs, context)), + }, + { + cardType: "review", + version: 1, + insertLabel: "Review", + markdownFallback: (attrs, context) => + callout("tip", cardTitle(attrs, "Review"), bodyFromAttrs(attrs, context)), + }, + { + cardType: "mindmap", + version: 1, + insertLabel: "Mindmap", + markdownFallback: (attrs, context) => { + const body = bodyFromAttrs(attrs, context); + return [`> [!abstract] ${cardTitle(attrs, "Mindmap")}`, "", "```markmap", body, "```"] + .filter(Boolean) + .join("\n"); + }, + }, + { + cardType: "mermaid", + version: 1, + insertLabel: "Mermaid", + markdownFallback: (attrs, context) => { + const body = bodyFromAttrs(attrs, context); + return [`> [!abstract] ${cardTitle(attrs, "Diagram")}`, "", "```mermaid", body, "```"] + .filter(Boolean) + .join("\n"); + }, + }, + { + cardType: "relatedNotes", + version: 1, + insertLabel: "Related notes", + markdownFallback: (attrs, context) => + callout("link", cardTitle(attrs, "Related notes"), bodyFromAttrs(attrs, context)), + }, +]; + +const builtInCardMap = new Map( + builtInReadAnyCards.map((definition) => [definition.cardType, definition]), +); + +export function getReadAnyCardDefinition(cardType: string): ReadAnyCardDefinition | undefined { + return builtInCardMap.get(cardType); +} + +export function renderReadAnyCardMarkdownFallback( + attrs: ReadAnyCardAttrs, + context: ReadAnyCardMarkdownContext, +): string { + const cardType = attrs.cardType || "custom"; + const definition = getReadAnyCardDefinition(cardType); + if (definition) return definition.markdownFallback(attrs, context); + + return callout("note", cardTitle(attrs, cardType), bodyFromAttrs(attrs, context)); +} diff --git a/packages/core/src/knowledge/editor-projection.test.ts b/packages/core/src/knowledge/editor-projection.test.ts index 7570500e..9e8e3af3 100644 --- a/packages/core/src/knowledge/editor-projection.test.ts +++ b/packages/core/src/knowledge/editor-projection.test.ts @@ -86,7 +86,40 @@ describe("editor projection", () => { ], }); - expect(markdown).toBe("> [!note] Important Quote\n> Reading is thinking.\n> Source: Chapter 1"); + expect(markdown).toBe( + "> [!quote] Important Quote\n> Reading is thinking.\n> Source: Chapter 1", + ); + }); + + it("uses card registry fallbacks for built-in ReadAny card types", () => { + const markdown = renderKnowledgeJsonToMarkdown({ + type: "doc", + content: [ + { + type: "readanyCard", + attrs: { + cardType: "aiSummary", + title: "AI Summary", + markdown: "A compact summary.", + }, + }, + { + type: "readanyCard", + attrs: { + cardType: "mermaid", + title: "Flow", + markdown: "graph TD\n A --> B", + }, + }, + ], + }); + + expect(markdown).toBe( + [ + "> [!summary] AI Summary\n> A compact summary.", + "> [!abstract] Flow\n```mermaid\ngraph TD\n A --> B\n```", + ].join("\n\n"), + ); }); it("can preserve ReadAny card metadata for round-tripping", () => { diff --git a/packages/core/src/knowledge/editor-projection.ts b/packages/core/src/knowledge/editor-projection.ts index 3c259c74..aae8f34d 100644 --- a/packages/core/src/knowledge/editor-projection.ts +++ b/packages/core/src/knowledge/editor-projection.ts @@ -1,5 +1,7 @@ import type { JSONValue } from "../types"; import { EMPTY_TIPTAP_DOCUMENT } from "../types"; +import { type ReadAnyCardAttrs, renderReadAnyCardMarkdownFallback } from "./card-registry"; +export type { ReadAnyCardAttrs } from "./card-registry"; export interface TiptapMark { type: string; @@ -14,19 +16,6 @@ export interface TiptapNode { content?: TiptapNode[]; } -export interface ReadAnyCardAttrs { - cardType?: string; - id?: string; - version?: number; - title?: string; - text?: string; - sourceTitle?: string; - sourceId?: string; - cfi?: string; - markdown?: string; - data?: unknown; -} - export interface MarkdownProjectionOptions { /** Preserve custom card metadata with fenced ReadAny blocks. */ includeReadAnyCardMetadata?: boolean; @@ -105,7 +94,6 @@ function prefixLines(text: string, prefix: string): string { function renderReadAnyCard(node: TiptapNode, options: MarkdownProjectionOptions): string { const attrs = (node.attrs ?? {}) as ReadAnyCardAttrs; const cardType = attrs.cardType || "custom"; - const title = attrs.title || attrs.sourceTitle || cardType; const body = attrs.markdown || attrs.text || @@ -115,10 +103,7 @@ function renderReadAnyCard(node: TiptapNode, options: MarkdownProjectionOptions) .trim(); if (!options.includeReadAnyCardMetadata) { - const lines = [`> [!note] ${title}`]; - if (body) lines.push(prefixLines(body, "> ")); - if (attrs.sourceTitle) lines.push(`> Source: ${attrs.sourceTitle}`); - return lines.join("\n"); + return renderReadAnyCardMarkdownFallback(attrs, { body }); } const attrText = [ diff --git a/packages/core/src/knowledge/index.ts b/packages/core/src/knowledge/index.ts index 7d095e40..6dde43d6 100644 --- a/packages/core/src/knowledge/index.ts +++ b/packages/core/src/knowledge/index.ts @@ -6,7 +6,16 @@ export { } from "./editor-projection"; export type { MarkdownProjectionOptions, - ReadAnyCardAttrs, TiptapMark, TiptapNode, } from "./editor-projection"; +export { + builtInReadAnyCards, + getReadAnyCardDefinition, + renderReadAnyCardMarkdownFallback, +} from "./card-registry"; +export type { + ReadAnyCardAttrs, + ReadAnyCardDefinition, + ReadAnyCardMarkdownContext, +} from "./card-registry"; From cad2fe20bd4e5e1a1de76ad6c28c6a954d18744d Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 03:02:01 +0800 Subject: [PATCH 009/409] feat(desktop): add tiptap knowledge editor --- .../components/knowledge/KnowledgeEditor.tsx | 520 ++++++++++++++++++ .../app/src/components/notes/NotesPage.tsx | 93 +++- packages/core/src/i18n/locales/en/notes.json | 16 +- packages/core/src/i18n/locales/es/notes.json | 16 +- packages/core/src/i18n/locales/fr/notes.json | 16 +- packages/core/src/i18n/locales/ja/notes.json | 16 +- packages/core/src/i18n/locales/ko/notes.json | 16 +- .../core/src/i18n/locales/zh-TW/notes.json | 16 +- packages/core/src/i18n/locales/zh/notes.json | 16 +- 9 files changed, 694 insertions(+), 31 deletions(-) create mode 100644 packages/app/src/components/knowledge/KnowledgeEditor.tsx diff --git a/packages/app/src/components/knowledge/KnowledgeEditor.tsx b/packages/app/src/components/knowledge/KnowledgeEditor.tsx new file mode 100644 index 00000000..be6587bb --- /dev/null +++ b/packages/app/src/components/knowledge/KnowledgeEditor.tsx @@ -0,0 +1,520 @@ +import { + type ReadAnyCardAttrs, + builtInReadAnyCards, + normalizeTiptapDocument, + renderKnowledgeJsonToMarkdown, +} from "@readany/core/knowledge"; +import type { JSONValue } from "@readany/core/types"; +import { cn } from "@readany/core/utils"; +import Placeholder from "@tiptap/extension-placeholder"; +import { + EditorContent, + Node, + NodeViewWrapper, + ReactNodeViewRenderer, + mergeAttributes, + useEditor, +} from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { + Bold, + BookOpen, + Brain, + Code, + FileQuestion, + Heading1, + Heading2, + Heading3, + Italic, + Link2, + List, + ListOrdered, + Map as MapIcon, + MessageSquareQuote, + Minus, + Network, + Quote, + Redo2, + Sparkles, + Strikethrough, + TextQuote, + Undo2, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +export interface KnowledgeEditorValue { + contentJson: JSONValue; + contentMd: string; + plainText: string; +} + +interface KnowledgeEditorProps { + value: KnowledgeEditorValue; + onChange: (value: KnowledgeEditorValue) => void; + placeholder?: string; + className?: string; + contentClassName?: string; + autoFocus?: boolean; +} + +const cardIconMap = { + bookQuote: MessageSquareQuote, + callout: TextQuote, + bookMetadata: BookOpen, + aiSummary: Sparkles, + qa: FileQuestion, + review: Quote, + mindmap: MapIcon, + mermaid: Network, + relatedNotes: Brain, +}; + +const ReadAnyCardExtension = Node.create({ + name: "readanyCard", + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { + cardType: { default: "callout" }, + id: { default: null }, + version: { default: 1 }, + title: { default: null }, + text: { default: null }, + sourceTitle: { default: null }, + sourceId: { default: null }, + cfi: { default: null }, + markdown: { default: null }, + data: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "readany-card" }]; + }, + + renderHTML({ HTMLAttributes }) { + const attrs = { + "data-card-type": HTMLAttributes.cardType || "callout", + "data-card-version": String(HTMLAttributes.version || 1), + }; + return ["readany-card", mergeAttributes(attrs), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ReadAnyCardView); + }, +}); + +function contentJsonEquals(left: JSONValue, right: JSONValue): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function createDefaultCardAttrs( + cardType: string, + title: string, + version: number, +): ReadAnyCardAttrs { + if (cardType === "mermaid") { + return { + cardType, + version, + title, + markdown: "graph TD\n A[Idea] --> B[Note]", + }; + } + if (cardType === "mindmap") { + return { + cardType, + version, + title, + markdown: "# Topic\n## Branch", + }; + } + return { + cardType, + version, + title, + markdown: "", + }; +} + +export function KnowledgeEditor({ + value, + onChange, + placeholder, + className, + contentClassName, + autoFocus = false, +}: KnowledgeEditorProps) { + const { t } = useTranslation(); + const [isInsertOpen, setIsInsertOpen] = useState(false); + const isInternalUpdate = useRef(false); + + const extensions = useMemo( + () => [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + dropcursor: false, + gapcursor: false, + }), + ReadAnyCardExtension, + Placeholder.configure({ + placeholder: placeholder || "", + emptyEditorClass: "is-editor-empty", + }), + ], + [placeholder], + ); + + const editor = useEditor({ + extensions, + content: normalizeTiptapDocument(value.contentJson), + editorProps: { + attributes: { + class: cn( + "prose prose-sm dark:prose-invert max-w-none min-h-[80px] outline-none", + "prose-headings:font-semibold prose-headings:tracking-tight", + "prose-h1:text-xl prose-h1:mb-3 prose-h1:mt-4", + "prose-h2:text-base prose-h2:mb-2 prose-h2:mt-4", + "prose-h3:text-sm prose-h3:mb-1.5 prose-h3:mt-3", + "prose-p:my-2 prose-p:leading-relaxed prose-p:text-[13px]", + "prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:text-[13px]", + "prose-blockquote:border-l-primary/50 prose-blockquote:bg-muted/30 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic prose-blockquote:text-muted-foreground", + "prose-code:px-1.5 prose-code:py-0.5 prose-code:bg-muted prose-code:rounded prose-code:text-[12px] prose-code:font-mono prose-code:before:content-none prose-code:after:content-none", + "prose-pre:bg-muted prose-pre:border prose-pre:border-border prose-pre:rounded-md prose-pre:text-[12px]", + "prose-hr:border-border prose-hr:my-4", + "prose-a:text-primary prose-a:no-underline hover:prose-a:underline", + "prose-strong:font-semibold prose-strong:text-foreground", + "prose-em:text-foreground/90", + ), + }, + }, + onUpdate: ({ editor }) => { + const contentJson = editor.getJSON() as unknown as JSONValue; + isInternalUpdate.current = true; + onChange({ + contentJson, + contentMd: renderKnowledgeJsonToMarkdown(contentJson), + plainText: editor.getText(), + }); + }, + immediatelyRender: false, + }); + + useEffect(() => { + if (!editor || isInternalUpdate.current) { + isInternalUpdate.current = false; + return; + } + + const currentJson = editor.getJSON() as unknown as JSONValue; + if (!contentJsonEquals(currentJson, value.contentJson)) { + editor.commands.setContent(normalizeTiptapDocument(value.contentJson)); + } + }, [editor, value.contentJson]); + + useEffect(() => { + if (editor && autoFocus) { + editor.commands.focus(); + } + }, [editor, autoFocus]); + + const setLink = useCallback(() => { + if (!editor) return; + const previousUrl = editor.getAttributes("link").href; + const url = window.prompt(t("editor.enterLink"), previousUrl); + if (url === null) return; + if (url === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + }, [editor, t]); + + const insertCard = useCallback( + (cardType: string) => { + if (!editor) return; + const definition = builtInReadAnyCards.find((card) => card.cardType === cardType); + if (!definition) return; + const title = t(`notes.knowledgeCards.${cardType}`, { + defaultValue: definition.insertLabel, + }); + + editor + .chain() + .focus() + .insertContent({ + type: "readanyCard", + attrs: createDefaultCardAttrs(cardType, title, definition.version), + }) + .run(); + setIsInsertOpen(false); + }, + [editor, t], + ); + + if (!editor) return null; + + return ( +
+
+ + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title={t("editor.undo")} + > + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title={t("editor.redo")} + > + + + + + + + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive("heading", { level: 1 })} + title={t("editor.heading1")} + > + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive("heading", { level: 2 })} + title={t("editor.heading2")} + > + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive("heading", { level: 3 })} + title={t("editor.heading3")} + > + + + + + + + + editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + title={t("editor.bold")} + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + title={t("editor.italic")} + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + title={t("editor.strikethrough")} + > + + + editor.chain().focus().toggleCode().run()} + isActive={editor.isActive("code")} + title={t("editor.inlineCode")} + > + + + + + + + + + + + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive("bulletList")} + title={t("editor.bulletList")} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive("orderedList")} + title={t("editor.orderedList")} + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive("blockquote")} + title={t("editor.blockquote")} + > + + + editor.chain().focus().setHorizontalRule().run()} + title={t("editor.horizontalRule")} + > + + + + + + +
+ setIsInsertOpen((open) => !open)} + isActive={isInsertOpen} + title={t("notes.knowledgeInsertCard", { defaultValue: "Insert card" })} + > + + + + {isInsertOpen && ( +
+ {builtInReadAnyCards.map((card) => { + const Icon = cardIconMap[card.cardType as keyof typeof cardIconMap] ?? Sparkles; + return ( + + ); + })} +
+ )} +
+
+ + +
+ ); +} + +function ReadAnyCardView({ node }: { node: { attrs: ReadAnyCardAttrs } }) { + const { t } = useTranslation(); + const attrs = node.attrs; + const cardType = attrs.cardType || "callout"; + const Icon = cardIconMap[cardType as keyof typeof cardIconMap] ?? Sparkles; + const title = attrs.title || t(`notes.knowledgeCards.${cardType}`, { defaultValue: cardType }); + const body = attrs.markdown || attrs.text || ""; + + return ( + +
+
+ +
+
+
+

{title}

+ + {cardType} + +
+ {body ? ( +

+ {body} +

+ ) : ( +

+ {t("notes.knowledgeCardEmpty", { defaultValue: "Empty card, ready to edit later." })} +

+ )} + {attrs.sourceTitle && ( +

+ {t("notes.knowledgeCardSource", { defaultValue: "Source" })}: {attrs.sourceTitle} +

+ )} +
+
+
+ ); +} + +/* --- Toolbar Components --- */ + +interface ToolbarButtonProps { + onClick: () => void; + isActive?: boolean; + disabled?: boolean; + title?: string; + children: ReactNode; +} + +function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) { + return ( + + ); +} + +function ToolbarGroup({ children }: { children: ReactNode }) { + return
{children}
; +} + +function ToolbarDivider() { + return
; +} diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index c235af08..c49ebe51 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -1,3 +1,4 @@ +import { KnowledgeEditor, type KnowledgeEditorValue } from "@/components/knowledge/KnowledgeEditor"; import { SyncButton } from "@/components/ui/SyncButton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -14,7 +15,7 @@ import { useAnnotationStore } from "@/stores/annotation-store"; import { useAppStore } from "@/stores/app-store"; import { useLibraryStore } from "@/stores/library-store"; import { type ExportFormat, annotationExporter } from "@readany/core/export"; -import { markdownToBasicTiptap } from "@readany/core/knowledge"; +import { markdownToBasicTiptap, renderKnowledgeJsonToMarkdown } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; import type { Highlight, KnowledgeDocument, Note } from "@readany/core/types"; import { HIGHLIGHT_COLOR_HEX } from "@readany/core/types"; @@ -48,6 +49,43 @@ import { ExportDropdown } from "./ExportDropdown"; type DetailTab = "knowledge" | "notes" | "highlights"; +function createEmptyKnowledgeValue(): KnowledgeEditorValue { + return { + contentJson: { type: "doc", content: [] }, + contentMd: "", + plainText: "", + }; +} + +function isEmptyTiptapDocument(content: KnowledgeDocument["contentJson"]): boolean { + return ( + !!content && + typeof content === "object" && + !Array.isArray(content) && + content.type === "doc" && + (!Array.isArray(content.content) || content.content.length === 0) + ); +} + +function knowledgeValueFingerprint(value: KnowledgeEditorValue): string { + return `${value.contentMd}\n${JSON.stringify(value.contentJson)}`; +} + +function createKnowledgeValueFromDocument(document: KnowledgeDocument): KnowledgeEditorValue { + const shouldImportMarkdown = + !!document.contentMd.trim() && isEmptyTiptapDocument(document.contentJson); + const contentJson = shouldImportMarkdown + ? (markdownToBasicTiptap(document.contentMd) as unknown as KnowledgeDocument["contentJson"]) + : document.contentJson; + const contentMd = document.contentMd || renderKnowledgeJsonToMarkdown(contentJson); + + return { + contentJson, + contentMd, + plainText: createKnowledgeExcerpt(contentMd) ?? "", + }; +} + function createKnowledgeExcerpt(markdown: string): string | undefined { const text = markdown .replace(/```[\s\S]*?```/g, " ") @@ -106,11 +144,18 @@ export function NotesPage() { const [editingId, setEditingId] = useState(null); const [editNote, setEditNote] = useState(""); const [knowledgeHome, setKnowledgeHome] = useState(null); - const [knowledgeContent, setKnowledgeContent] = useState(""); - const [savedKnowledgeContent, setSavedKnowledgeContent] = useState(""); + const [knowledgeValue, setKnowledgeValue] = + useState(createEmptyKnowledgeValue); + const [savedKnowledgeFingerprint, setSavedKnowledgeFingerprint] = useState( + knowledgeValueFingerprint(createEmptyKnowledgeValue()), + ); const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); const knowledgeSaveVersionRef = useRef(0); + const currentKnowledgeFingerprint = useMemo( + () => knowledgeValueFingerprint(knowledgeValue), + [knowledgeValue], + ); useEffect(() => { if (activeTabId !== "notes") return; @@ -242,8 +287,9 @@ export function NotesPage() { if (!selectedKnowledgeBookId) { setKnowledgeHome(null); - setKnowledgeContent(""); - setSavedKnowledgeContent(""); + const emptyValue = createEmptyKnowledgeValue(); + setKnowledgeValue(emptyValue); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(emptyValue)); setIsKnowledgeSaving(false); return; } @@ -256,9 +302,10 @@ export function NotesPage() { selectedKnowledgeBookTitle, ); if (cancelled) return; + const nextValue = createKnowledgeValueFromDocument(document); setKnowledgeHome(document); - setKnowledgeContent(document.contentMd); - setSavedKnowledgeContent(document.contentMd); + setKnowledgeValue(nextValue); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(nextValue)); } catch (error) { console.error("[Notes] Failed to load knowledge home:", error); toast.error(t("notes.knowledgeLoadFailed")); @@ -275,7 +322,7 @@ export function NotesPage() { }, [selectedKnowledgeBookId, selectedKnowledgeBookTitle, t]); useEffect(() => { - if (!knowledgeHome || knowledgeContent === savedKnowledgeContent) return; + if (!knowledgeHome || currentKnowledgeFingerprint === savedKnowledgeFingerprint) return; const saveVersion = knowledgeSaveVersionRef.current + 1; knowledgeSaveVersionRef.current = saveVersion; @@ -284,14 +331,12 @@ export function NotesPage() { setIsKnowledgeSaving(true); try { await updateKnowledgeDocument(knowledgeHome.id, { - contentMd: knowledgeContent, - contentJson: markdownToBasicTiptap( - knowledgeContent, - ) as unknown as KnowledgeDocument["contentJson"], - excerpt: createKnowledgeExcerpt(knowledgeContent), + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), }); if (knowledgeSaveVersionRef.current !== saveVersion) return; - setSavedKnowledgeContent(knowledgeContent); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(knowledgeValue)); } catch (error) { if (knowledgeSaveVersionRef.current !== saveVersion) return; console.error("[Notes] Failed to save knowledge home:", error); @@ -304,7 +349,7 @@ export function NotesPage() { }, 700); return () => window.clearTimeout(timeout); - }, [knowledgeHome, knowledgeContent, savedKnowledgeContent, t]); + }, [knowledgeHome, knowledgeValue, currentKnowledgeFingerprint, savedKnowledgeFingerprint, t]); const handleOpenBook = async (bookId: string, _title: string, cfi?: string) => { const book = @@ -619,7 +664,7 @@ export function NotesPage() { {isKnowledgeSaving ? t("notes.knowledgeSaving") - : knowledgeContent === savedKnowledgeContent + : currentKnowledgeFingerprint === savedKnowledgeFingerprint ? t("notes.knowledgeSaved") : t("notes.knowledgePending")} @@ -644,11 +689,11 @@ export function NotesPage() { handleOpenBook(selectedBook.bookId, selectedBook.title, cfi)} t={t} /> @@ -729,11 +774,11 @@ interface KnowledgeHomePanelProps { highlightsOnlyCount: number; }; document: KnowledgeDocument | null; - content: string; + value: KnowledgeEditorValue; isLoading: boolean; isSaving: boolean; isSaved: boolean; - onChange: (value: string) => void; + onChange: (value: KnowledgeEditorValue) => void; onOpenBook: (cfi?: string) => void; t: (key: string) => string; } @@ -741,7 +786,7 @@ interface KnowledgeHomePanelProps { function KnowledgeHomePanel({ book, document, - content, + value, isLoading, isSaving, isSaved, @@ -786,8 +831,8 @@ function KnowledgeHomePanel({
- Date: Thu, 11 Jun 2026 03:07:22 +0800 Subject: [PATCH 010/409] feat(desktop): export knowledge home documents --- .../app/src/components/notes/NotesPage.tsx | 95 +++++++++++++++++-- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index c49ebe51..ecf32d38 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -1,6 +1,12 @@ import { KnowledgeEditor, type KnowledgeEditorValue } from "@/components/knowledge/KnowledgeEditor"; import { SyncButton } from "@/components/ui/SyncButton"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { MarkdownEditor } from "@/components/ui/markdown-editor"; import { useResolvedSrc, useSyncVersion } from "@/hooks/use-resolved-src"; @@ -14,7 +20,12 @@ import { openDesktopBook } from "@/lib/library/open-book"; import { useAnnotationStore } from "@/stores/annotation-store"; import { useAppStore } from "@/stores/app-store"; import { useLibraryStore } from "@/stores/library-store"; -import { type ExportFormat, annotationExporter } from "@readany/core/export"; +import { + type ExportFormat, + type KnowledgeExportFormat, + annotationExporter, + knowledgeExporter, +} from "@readany/core/export"; import { markdownToBasicTiptap, renderKnowledgeJsonToMarkdown } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; import type { Highlight, KnowledgeDocument, Note } from "@readany/core/types"; @@ -25,6 +36,7 @@ import { BookOpen, Check, ChevronLeft, + Download, Edit3, FileText, Highlighter, @@ -415,6 +427,38 @@ export function NotesPage() { } }; + const handleKnowledgeExport = async (format: KnowledgeExportFormat) => { + if (!selectedBook || !knowledgeHome) return; + const book = books.find((b) => b.id === selectedBook.bookId); + if (!book) return; + + try { + const liveDocument: KnowledgeDocument = { + ...knowledgeHome, + contentJson: knowledgeValue.contentJson, + contentMd: knowledgeValue.contentMd, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + updatedAt: Date.now(), + }; + const files = knowledgeExporter.export( + { documents: [liveDocument], books: [book] }, + { format, rootDir: "ReadAny" }, + ); + const file = files[0]; + if (!file) throw new Error("No knowledge export file generated"); + + const filename = + file.path.split("/").filter(Boolean).pop() || `${book.meta.title}-knowledge.md`; + await annotationExporter.downloadAsFile(file.content, filename, format); + toast.success(t("notes.exportSuccess"), { + description: file.path, + }); + } catch (error) { + toast.error(t("notes.exportFailed")); + console.error("[Notes] Knowledge export failed:", error); + } + }; + const handleSingleBookExport = (format: ExportFormat) => { if (!selectedBook) return; const book = books.find((b) => b.id === selectedBook.bookId); @@ -694,6 +738,7 @@ export function NotesPage() { isSaving={isKnowledgeSaving} isSaved={currentKnowledgeFingerprint === savedKnowledgeFingerprint} onChange={setKnowledgeValue} + onExport={handleKnowledgeExport} onOpenBook={(cfi) => handleOpenBook(selectedBook.bookId, selectedBook.title, cfi)} t={t} /> @@ -779,6 +824,7 @@ interface KnowledgeHomePanelProps { isSaving: boolean; isSaved: boolean; onChange: (value: KnowledgeEditorValue) => void; + onExport: (format: KnowledgeExportFormat) => void; onOpenBook: (cfi?: string) => void; t: (key: string) => string; } @@ -791,6 +837,7 @@ function KnowledgeHomePanel({ isSaving, isSaved, onChange, + onExport, onOpenBook, t, }: KnowledgeHomePanelProps) { @@ -821,13 +868,16 @@ function KnowledgeHomePanel({

{book.title}

-
- - {isSaving - ? t("notes.knowledgeSaving") - : isSaved - ? t("notes.knowledgeSaved") - : t("notes.knowledgePending")} +
+
+ + {isSaving + ? t("notes.knowledgeSaving") + : isSaved + ? t("notes.knowledgeSaved") + : t("notes.knowledgePending")} +
+
@@ -902,6 +952,35 @@ function KnowledgeHomePanel({ ); } +function KnowledgeExportMenu({ + onExport, + t, +}: { + onExport: (format: KnowledgeExportFormat) => void; + t: (key: string) => string; +}) { + return ( + + + + + + onExport("obsidian")}> + + {t("notes.exportObsidian")} + + onExport("markdown")}> + + {t("notes.exportMarkdown")} + + + + ); +} + // --- Notebook card (BookCard-inspired style) --- interface NotebookCardProps { From dea6e282a00ef08016281ed872f26b2b1e411429 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 03:19:54 +0800 Subject: [PATCH 011/409] feat(ai): add knowledge write proposals --- packages/core/src/ai/__tests__/tools.test.ts | 5 + packages/core/src/ai/system-prompt.ts | 11 + packages/core/src/ai/tools/index.ts | 12 +- .../core/src/ai/tools/knowledge-tools.test.ts | 100 ++++++++- packages/core/src/ai/tools/knowledge-tools.ts | 205 +++++++++++++++++- 5 files changed, 326 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ai/__tests__/tools.test.ts b/packages/core/src/ai/__tests__/tools.test.ts index bce3b395..456ac31e 100644 --- a/packages/core/src/ai/__tests__/tools.test.ts +++ b/packages/core/src/ai/__tests__/tools.test.ts @@ -7,6 +7,8 @@ vi.mock("../../db/database", () => ({ getChunks: vi.fn(), getHighlights: vi.fn(), getNotes: vi.fn(), + getKnowledgeDocument: vi.fn(), + getKnowledgeDocuments: vi.fn(), getAllHighlights: vi.fn(), getAllNotes: vi.fn(), getSkills: vi.fn(), @@ -125,6 +127,9 @@ describe("getAvailableTools", () => { expect(names).toContain("listBooks"); expect(names).toContain("searchAllHighlights"); expect(names).toContain("searchAllNotes"); + expect(names).toContain("searchKnowledgeBase"); + expect(names).toContain("proposeKnowledgeDocumentCreate"); + expect(names).toContain("proposeKnowledgeDocumentUpdate"); expect(names).toContain("getReadingStats"); expect(names).toContain("getSkills"); expect(names).toContain("mindmap"); diff --git a/packages/core/src/ai/system-prompt.ts b/packages/core/src/ai/system-prompt.ts index ea6c140f..633957d9 100644 --- a/packages/core/src/ai/system-prompt.ts +++ b/packages/core/src/ai/system-prompt.ts @@ -102,6 +102,12 @@ function buildToolsSection( tools.push( "- **searchKnowledgeBase**: Search durable ReadAny knowledge documents, book home pages, reviews, summaries, and standalone notes (params: reasoning, query, bookId, type, limit)", ); + tools.push( + "- **proposeKnowledgeDocumentCreate**: Draft a new knowledge document for user confirmation only; it does NOT save anything (params: reasoning, title, contentMd, type, bookId, tags)", + ); + tools.push( + "- **proposeKnowledgeDocumentUpdate**: Draft a patch for an existing knowledge document for user confirmation only; it does NOT save anything (params: reasoning, documentId, title, contentMd, tags)", + ); tools.push("- **getReadingStats**: Get reading statistics (params: reasoning, days)"); tools.push("- **getSkills**: Query available skills/SOPs for guidance (params: reasoning, task)"); tools.push( @@ -185,6 +191,11 @@ function buildToolsSection( } } + tools.push(""); + tools.push( + "Knowledge write safety: proposeKnowledgeDocumentCreate and proposeKnowledgeDocumentUpdate only return confirmation-required drafts. Never tell the user a knowledge document was saved or changed until the app has explicitly confirmed applying the proposal.", + ); + // Custom skills if (skills.length > 0) { tools.push(""); diff --git a/packages/core/src/ai/tools/index.ts b/packages/core/src/ai/tools/index.ts index 1f2bd159..1da014cd 100644 --- a/packages/core/src/ai/tools/index.ts +++ b/packages/core/src/ai/tools/index.ts @@ -8,7 +8,8 @@ * - Annotation Tools: getAnnotations, addCitation * - Library Tools: listBooks, searchAllHighlights, searchAllNotes, readingStats, classifyBooks, * tagBooks, manageBookTags, updateBookMetadata, manageBookGroups - * - Knowledge Tools: searchKnowledgeBase, getBookKnowledge + * - Knowledge Tools: searchKnowledgeBase, getBookKnowledge, proposeKnowledgeDocumentCreate, + * proposeKnowledgeDocumentUpdate * - Skill Tools: getSkills, skillToTool * - Mindmap Tools: mindmap * - Context Tools: getCurrentChapter, getSelection, getReadingProgress, getRecentHighlights, getSurroundingContext @@ -28,7 +29,12 @@ import { createFallbackSearchTool, createFallbackTocTool, } from "./fallback-content-tools"; -import { createGetBookKnowledgeTool, createSearchKnowledgeBaseTool } from "./knowledge-tools"; +import { + createGetBookKnowledgeTool, + createProposeKnowledgeDocumentCreateTool, + createProposeKnowledgeDocumentUpdateTool, + createSearchKnowledgeBaseTool, +} from "./knowledge-tools"; import { createClassifyBooksTool, createListBooksTool, @@ -56,6 +62,8 @@ function getGeneralTools(): ToolDefinition[] { createSearchAllHighlightsTool(), createSearchAllNotesTool(), createSearchKnowledgeBaseTool(), + createProposeKnowledgeDocumentCreateTool(), + createProposeKnowledgeDocumentUpdateTool(), createReadingStatsTool(), createGetSkillsTool(), createMindmapTool(), diff --git a/packages/core/src/ai/tools/knowledge-tools.test.ts b/packages/core/src/ai/tools/knowledge-tools.test.ts index 050e7382..7efee3cb 100644 --- a/packages/core/src/ai/tools/knowledge-tools.test.ts +++ b/packages/core/src/ai/tools/knowledge-tools.test.ts @@ -2,14 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { KnowledgeDocument } from "../../types"; const dbMocks = vi.hoisted(() => ({ + getKnowledgeDocument: vi.fn(), getKnowledgeDocuments: vi.fn(), })); vi.mock("../../db/database", () => dbMocks); -const { createGetBookKnowledgeTool, createSearchKnowledgeBaseTool } = await import( - "./knowledge-tools" -); +const { + createGetBookKnowledgeTool, + createProposeKnowledgeDocumentCreateTool, + createProposeKnowledgeDocumentUpdateTool, + createSearchKnowledgeBaseTool, +} = await import("./knowledge-tools"); function doc(overrides: Partial = {}): KnowledgeDocument { return { @@ -100,4 +104,94 @@ describe("knowledge tools", () => { snippet: "Reading slowly helps memory.", }); }); + + it("creates confirmation-required drafts without saving knowledge documents", async () => { + const tool = createProposeKnowledgeDocumentCreateTool(); + const result = (await tool.execute({ + reasoning: "User asked to save a summary", + title: "Reading Summary", + contentMd: "## Summary\nSlow reading helps memory.", + type: "summary", + bookId: "book-1", + tags: '["reading","memory","reading"]', + })) as { + success: boolean; + requiresConfirmation: boolean; + draft: { + title: string; + type: string; + bookId: string; + tags: string[]; + contentMd: string; + contentJson: { type: string }; + excerpt: string; + }; + }; + + expect(result.success).toBe(true); + expect(result.requiresConfirmation).toBe(true); + expect(result.draft).toMatchObject({ + title: "Reading Summary", + type: "summary", + bookId: "book-1", + tags: ["reading", "memory"], + contentMd: "## Summary\nSlow reading helps memory.", + contentJson: { type: "doc" }, + excerpt: "Summary Slow reading helps memory.", + }); + expect(dbMocks.getKnowledgeDocument).not.toHaveBeenCalled(); + expect(dbMocks.getKnowledgeDocuments).not.toHaveBeenCalled(); + }); + + it("creates confirmation-required update patches for existing knowledge documents", async () => { + dbMocks.getKnowledgeDocument.mockResolvedValue(doc()); + + const tool = createProposeKnowledgeDocumentUpdateTool(); + const result = (await tool.execute({ + reasoning: "User asked to refine the note", + documentId: "doc-1", + title: "Deep Reading Notes", + contentMd: "Updated durable note.", + tags: "reading, reflection", + })) as { + success: boolean; + requiresConfirmation: boolean; + documentId: string; + patch: { + title?: string; + contentMd?: string; + contentJson?: { type: string }; + tags?: string[]; + }; + changedFields: string[]; + }; + + expect(dbMocks.getKnowledgeDocument).toHaveBeenCalledWith("doc-1"); + expect(result.success).toBe(true); + expect(result.requiresConfirmation).toBe(true); + expect(result.documentId).toBe("doc-1"); + expect(result.patch).toMatchObject({ + title: "Deep Reading Notes", + contentMd: "Updated durable note.", + contentJson: { type: "doc" }, + tags: ["reading", "reflection"], + }); + expect(result.changedFields).toEqual(["title", "contentMd", "contentJson", "excerpt", "tags"]); + }); + + it("does not create an update proposal when nothing changes", async () => { + dbMocks.getKnowledgeDocument.mockResolvedValue(doc()); + + const tool = createProposeKnowledgeDocumentUpdateTool(); + const result = (await tool.execute({ + reasoning: "Check no-op", + documentId: "doc-1", + title: "Deep Reading Home", + contentMd: "Reading slowly helps memory and reflection.", + tags: '["reading","memory"]', + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("No changes were proposed"); + }); }); diff --git a/packages/core/src/ai/tools/knowledge-tools.ts b/packages/core/src/ai/tools/knowledge-tools.ts index c1dd3a1d..3873043b 100644 --- a/packages/core/src/ai/tools/knowledge-tools.ts +++ b/packages/core/src/ai/tools/knowledge-tools.ts @@ -5,8 +5,9 @@ * go through a confirmation-capable UI flow so AI never silently overwrites a * user's durable notes. */ -import { getKnowledgeDocuments } from "../../db/database"; -import type { KnowledgeDocument, KnowledgeDocumentType } from "../../types"; +import { getKnowledgeDocument, getKnowledgeDocuments } from "../../db/database"; +import { markdownToBasicTiptap } from "../../knowledge/editor-projection"; +import type { JSONValue, KnowledgeDocument, KnowledgeDocumentType } from "../../types"; import type { ToolDefinition } from "./tool-types"; const SEARCH_SCAN_LIMIT = 200; @@ -37,10 +38,42 @@ function normalizeType(value: unknown): KnowledgeDocumentType | undefined { return allowed.has(type as KnowledgeDocumentType) ? (type as KnowledgeDocumentType) : undefined; } +function normalizeDocumentType(value: unknown): KnowledgeDocumentType { + return normalizeType(value) ?? "standalone_note"; +} + +function parseTags(value: unknown): string[] | undefined { + if (value === undefined || value === null) return undefined; + const raw = String(value).trim(); + if (!raw) return []; + + let values: unknown[]; + if (raw.startsWith("[")) { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error("tags JSON must be an array"); + values = parsed; + } else { + values = raw.split(/[,,\n]/); + } + + return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))]; +} + function compactText(text: string): string { return text.replace(/\s+/g, " ").trim(); } +function createExcerpt(markdown: string): string | undefined { + const text = compactText( + markdown.replace(/```[\s\S]*?```/g, " ").replace(/[#>*_`~\-[\]()]/g, " "), + ); + return text ? text.slice(0, 220) : undefined; +} + +function markdownToKnowledgeJson(markdown: string): JSONValue { + return markdownToBasicTiptap(markdown) as unknown as JSONValue; +} + function createSnippet(document: KnowledgeDocument, query: string): string { const source = compactText(document.excerpt || document.contentMd || ""); if (!source) return ""; @@ -182,3 +215,171 @@ export function createGetBookKnowledgeTool(bookId: string): ToolDefinition { }, }; } + +export function createProposeKnowledgeDocumentCreateTool(): ToolDefinition { + return { + name: "proposeKnowledgeDocumentCreate", + description: + "Create a confirmation-required draft for a new ReadAny knowledge document. This tool NEVER saves data. Use it when the user asks AI to create a durable note, summary, review, or knowledge document, then ask the user to confirm applying the draft.", + parameters: { + reasoning: { + type: "string", + description: "Brief explanation of why you are drafting a new knowledge document", + required: true, + }, + title: { + type: "string", + description: "Proposed document title", + required: true, + }, + contentMd: { + type: "string", + description: "Proposed Markdown content for the document", + required: true, + }, + type: { + type: "string", + description: + "Document type: standalone_note, review, summary, highlight_note, imported_markdown, or book_home. Defaults to standalone_note.", + }, + bookId: { + type: "string", + description: "Optional book id to attach the draft to a book", + }, + tags: { + type: "string", + description: 'Optional tags as comma-separated text or JSON array, e.g. "reading,summary"', + }, + }, + execute: async (args) => { + const title = String(args.title ?? "").trim(); + const contentMd = String(args.contentMd ?? ""); + if (!title) return { success: false, error: "title is required" }; + + let tags: string[] | undefined; + try { + tags = parseTags(args.tags); + } catch (error) { + return { success: false, error: `Invalid tags: ${(error as Error).message}` }; + } + + const bookId = String(args.bookId ?? "").trim() || undefined; + const type = normalizeDocumentType(args.type); + const contentJson = markdownToKnowledgeJson(contentMd); + + return { + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + message: "Draft generated only. No knowledge document has been saved.", + draft: { + type, + title, + bookId, + tags: tags ?? [], + contentMd, + contentJson, + excerpt: createExcerpt(contentMd), + sourceKind: bookId ? "book" : undefined, + sourceId: bookId, + }, + }; + }, + }; +} + +export function createProposeKnowledgeDocumentUpdateTool(): ToolDefinition { + return { + name: "proposeKnowledgeDocumentUpdate", + description: + "Create a confirmation-required patch for an existing ReadAny knowledge document. This tool NEVER saves data. Use it when the user asks AI to update a knowledge note, summary, review, tags, or title, then ask the user to confirm applying the patch.", + parameters: { + reasoning: { + type: "string", + description: "Brief explanation of why you are drafting a document update", + required: true, + }, + documentId: { + type: "string", + description: "Knowledge document id to update", + required: true, + }, + title: { + type: "string", + description: "Optional replacement title", + }, + contentMd: { + type: "string", + description: "Optional replacement Markdown content", + }, + tags: { + type: "string", + description: "Optional replacement tags as comma-separated text or JSON array", + }, + }, + execute: async (args) => { + const documentId = String(args.documentId ?? "").trim(); + if (!documentId) return { success: false, error: "documentId is required" }; + + const document = await getKnowledgeDocument(documentId); + if (!document) return { success: false, error: "Knowledge document not found" }; + + const patch: Partial< + Pick + > = {}; + const changedFields: string[] = []; + + if (Object.prototype.hasOwnProperty.call(args, "title")) { + const title = String(args.title ?? "").trim(); + if (title && title !== document.title) { + patch.title = title; + changedFields.push("title"); + } + } + + if (Object.prototype.hasOwnProperty.call(args, "contentMd")) { + const contentMd = String(args.contentMd ?? ""); + if (contentMd !== document.contentMd) { + patch.contentMd = contentMd; + patch.contentJson = markdownToKnowledgeJson(contentMd); + patch.excerpt = createExcerpt(contentMd); + changedFields.push("contentMd", "contentJson", "excerpt"); + } + } + + if (Object.prototype.hasOwnProperty.call(args, "tags")) { + let tags: string[] | undefined; + try { + tags = parseTags(args.tags) ?? []; + } catch (error) { + return { success: false, error: `Invalid tags: ${(error as Error).message}` }; + } + if (JSON.stringify(tags) !== JSON.stringify(document.tags)) { + patch.tags = tags; + changedFields.push("tags"); + } + } + + if (changedFields.length === 0) { + return { + success: false, + error: "No changes were proposed", + documentId, + }; + } + + return { + success: true, + action: "update", + requiresConfirmation: true, + confirmationKind: "knowledge_document_update", + message: "Patch generated only. The existing knowledge document has not been changed.", + documentId, + current: documentSummary(document, "", false), + patch, + changedFields, + }; + }, + }; +} From 87c17057a2a292f86f683e628c3b96642161cac9 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 03:23:02 +0800 Subject: [PATCH 012/409] feat(mobile): export knowledge home documents --- packages/app-expo/src/screens/NotesView.tsx | 91 +++++++++++++++++---- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/app-expo/src/screens/NotesView.tsx b/packages/app-expo/src/screens/NotesView.tsx index 2bdda1f5..b8867c13 100644 --- a/packages/app-expo/src/screens/NotesView.tsx +++ b/packages/app-expo/src/screens/NotesView.tsx @@ -24,7 +24,12 @@ import { ensureBookHomeDocument, updateKnowledgeDocument, } from "@readany/core/db/database"; -import { AnnotationExporter, type ExportFormat } from "@readany/core/export"; +import { + AnnotationExporter, + type ExportFormat, + type KnowledgeExportFormat, + knowledgeExporter, +} from "@readany/core/export"; import { markdownToBasicTiptap } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; import type { Highlight, KnowledgeDocument } from "@readany/core/types"; @@ -420,6 +425,46 @@ export function NotesView({ [selectedBook, books, t], ); + const handleKnowledgeExport = useCallback( + async (format: KnowledgeExportFormat) => { + setShowExportMenu(false); + if (!selectedBook || !knowledgeHome) return; + + const book = books.find((b) => b.id === selectedBook.bookId); + if (!book) return; + + const exporter = new AnnotationExporter(); + const liveDocument: KnowledgeDocument = { + ...knowledgeHome, + contentMd: knowledgeContent, + contentJson: markdownToBasicTiptap( + knowledgeContent, + ) as unknown as KnowledgeDocument["contentJson"], + excerpt: createKnowledgeExcerpt(knowledgeContent), + updatedAt: Date.now(), + }; + const files = knowledgeExporter.export( + { documents: [liveDocument], books: [book] }, + { format, rootDir: "ReadAny" }, + ); + const file = files[0]; + if (!file) { + Alert.alert(t("common.error", "错误"), t("notes.exportFailed", "导出失败")); + return; + } + + try { + const filename = + file.path.split("/").filter(Boolean).pop() || `${selectedBook.title}-knowledge.md`; + await exporter.downloadAsFile(file.content, filename, format); + } catch (err) { + console.error("Knowledge export failed:", err); + Alert.alert(t("common.error", "错误"), t("notes.exportFailed", "导出失败")); + } + }, + [selectedBook, knowledgeHome, knowledgeContent, books, t], + ); + const totalHighlights = stats?.totalHighlights ?? 0; const totalNotes = stats?.highlightsWithNotes ?? 0; const totalBooks = stats?.totalBooks ?? 0; @@ -659,19 +704,37 @@ export function NotesView({ > setShowExportMenu(false)} /> - {(["markdown", "json", "obsidian", "notion"] as const).map((fmt) => ( - handleExport(fmt)}> - - {fmt === "markdown" - ? "Markdown" - : fmt === "json" - ? "JSON" - : fmt === "obsidian" - ? "Obsidian" - : "Notion"} - - - ))} + {detailTab === "knowledge" + ? (["obsidian", "markdown"] as const).map((fmt) => ( + handleKnowledgeExport(fmt)} + > + + {fmt === "obsidian" + ? t("notes.exportObsidian", "Obsidian") + : t("notes.exportMarkdown", "Markdown")} + + + )) + : (["markdown", "json", "obsidian", "notion"] as const).map((fmt) => ( + handleExport(fmt)} + > + + {fmt === "markdown" + ? t("notes.exportMarkdown", "Markdown") + : fmt === "json" + ? t("notes.exportJSON", "JSON") + : fmt === "obsidian" + ? t("notes.exportObsidian", "Obsidian") + : t("notes.exportNotion", "Notion")} + + + ))} From c707e3c99a8a8f1e66cfb9b686c1ee0d2ee19600 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 03:58:54 +0800 Subject: [PATCH 013/409] feat(ai): confirm knowledge write proposals --- .../src/components/chat/PartRenderer.tsx | 312 +++++++++++++++++- .../app/src/components/chat/PartRenderer.tsx | 179 +++++++++- packages/core/src/ai/tools/knowledge-tools.ts | 2 + packages/core/src/i18n/locales/en/chat.json | 30 +- packages/core/src/i18n/locales/es/chat.json | 30 +- packages/core/src/i18n/locales/fr/chat.json | 30 +- packages/core/src/i18n/locales/ja/chat.json | 30 +- packages/core/src/i18n/locales/ko/chat.json | 30 +- .../core/src/i18n/locales/zh-TW/chat.json | 30 +- packages/core/src/i18n/locales/zh/chat.json | 30 +- packages/core/src/knowledge/proposals.test.ts | 147 +++++++++ packages/core/src/knowledge/proposals.ts | 234 +++++++++++++ 12 files changed, 1051 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/knowledge/proposals.test.ts create mode 100644 packages/core/src/knowledge/proposals.ts diff --git a/packages/app-expo/src/components/chat/PartRenderer.tsx b/packages/app-expo/src/components/chat/PartRenderer.tsx index cb57d44d..77f0d4ba 100644 --- a/packages/app-expo/src/components/chat/PartRenderer.tsx +++ b/packages/app-expo/src/components/chat/PartRenderer.tsx @@ -4,6 +4,11 @@ import { BrainIcon, CheckIcon, ChevronDownIcon, OctagonXIcon, XIcon } from "@/co import { useThrottledValue } from "@/hooks"; import { fontSize as fs, fontWeight as fw, radius, useColors, withOpacity } from "@/styles/theme"; import type { ThemeColors } from "@/styles/theme"; +import { + type KnowledgeWriteProposal, + applyKnowledgeWriteProposal, + getKnowledgeWriteProposal, +} from "@readany/core/knowledge/proposals"; import type { AbortedPart, CitationPart, @@ -14,10 +19,11 @@ import type { TextPart, ToolCallPart, } from "@readany/core/types/message"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, + Alert, ScrollView, StyleSheet, Text, @@ -160,19 +166,53 @@ const TOOL_LABEL_KEYS: Record = { fallbackToc: "toolLabels.fallbackToc", fallbackSearch: "toolLabels.fallbackSearch", fallbackChapterContext: "toolLabels.fallbackChapterContext", + searchKnowledgeBase: "toolLabels.searchKnowledgeBase", + getBookKnowledge: "toolLabels.getBookKnowledge", + proposeKnowledgeDocumentCreate: "toolLabels.proposeKnowledgeDocumentCreate", + proposeKnowledgeDocumentUpdate: "toolLabels.proposeKnowledgeDocumentUpdate", +}; + +type KnowledgeProposalApplyState = "idle" | "applying" | "applied"; + +const KNOWLEDGE_DOCUMENT_TYPE_KEYS: Record = { + book_home: "knowledgeProposal.types.bookHome", + standalone_note: "knowledgeProposal.types.standaloneNote", + highlight_note: "knowledgeProposal.types.highlightNote", + review: "knowledgeProposal.types.review", + summary: "knowledgeProposal.types.summary", + imported_markdown: "knowledgeProposal.types.importedMarkdown", }; function ToolCallPartView({ part }: { part: ToolCallPart }) { const hasError = part.status === "error" || Boolean(part.error); + const proposal = useMemo(() => getKnowledgeWriteProposal(part.result), [part.result]); - const [isOpen, setIsOpen] = useState(hasError); + const [isOpen, setIsOpen] = useState(hasError || Boolean(proposal)); + const [proposalApplyState, setProposalApplyState] = useState("idle"); const { t } = useTranslation(); const colors = useColors(); const s = makeToolStyles(colors); useEffect(() => { - if (hasError) setIsOpen(true); - }, [hasError]); + if (hasError || proposal) setIsOpen(true); + setProposalApplyState("idle"); + }, [hasError, proposal]); + + const handleApplyProposal = async () => { + if (!proposal || proposalApplyState !== "idle") return; + setProposalApplyState("applying"); + try { + await applyKnowledgeWriteProposal(proposal); + setProposalApplyState("applied"); + } catch (error) { + setProposalApplyState("idle"); + console.error("[KnowledgeProposal] Failed to apply proposal:", error); + Alert.alert( + t("knowledgeProposal.applyFailed", "应用失败"), + error instanceof Error ? error.message : t("knowledgeProposal.applyFailed", "应用失败"), + ); + } + }; const getStatusIcon = () => { switch (part.status) { @@ -210,6 +250,15 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {t("streaming.toolFailed", "调用失败")} ) : null} + {proposal && !hasError ? ( + + + {proposalApplyState === "applied" + ? t("knowledgeProposal.savedBadge", "已应用") + : t("knowledgeProposal.pendingBadge", "待确认")} + + + ) : null} {queryText ? ( {queryText.slice(0, 30)} @@ -240,21 +289,31 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {part.result !== undefined && ( {t("common.result", "结果")} - - - - {typeof part.result === "string" && part.result.length > 500 - ? `${part.result.slice(0, 500)}...` - : JSON.stringify(part.result, null, 2)} - - - + {proposal ? ( + + ) : ( + + + + {typeof part.result === "string" && part.result.length > 500 + ? `${part.result.slice(0, 500)}...` + : JSON.stringify(part.result, null, 2)} + + + + )} )} {hasError && ( {t("streaming.toolFailedDetail", "工具调用失败")} - {errorMessage || t("streaming.toolFailed", "调用失败")} + + {errorMessage || t("streaming.toolFailed", "调用失败")} + )} @@ -263,6 +322,110 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { ); } +function KnowledgeProposalCard({ + proposal, + applyState, + onApply, +}: { + proposal: KnowledgeWriteProposal; + applyState: KnowledgeProposalApplyState; + onApply: () => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const s = makeToolStyles(colors); + const isCreate = proposal.action === "create"; + const title = isCreate + ? proposal.draft.title + : (proposal.patch.title ?? proposal.current?.title ?? proposal.documentId); + const type = isCreate ? proposal.draft.type : proposal.current?.type; + const tags = isCreate + ? (proposal.draft.tags ?? []) + : (proposal.patch.tags ?? proposal.current?.tags ?? []); + const preview = isCreate + ? proposal.draft.excerpt || proposal.draft.contentMd + : proposal.patch.excerpt || proposal.patch.contentMd || proposal.current?.excerpt || ""; + const changedFields = proposal.action === "update" ? proposal.changedFields : []; + + return ( + + + + + {isCreate + ? t("knowledgeProposal.create", "创建知识文档") + : t("knowledgeProposal.update", "更新知识文档")} + + + {title} + + + + + {type + ? t(KNOWLEDGE_DOCUMENT_TYPE_KEYS[type], { defaultValue: type }) + : t("knowledgeProposal.types.knowledgeDocument", "知识文档")} + + + + + + + {t("knowledgeProposal.safeHint", "AI 只生成了草稿,确认后才会写入你的知识库。")} + + + {tags.length > 0 ? ( + + {tags.slice(0, 6).map((tag) => ( + + {tag} + + ))} + {tags.length > 6 ? ( + + +{tags.length - 6} + + ) : null} + + ) : null} + + {changedFields.length > 0 ? ( + + {t("knowledgeProposal.changes", "变更")} + {changedFields.join(", ")} + + ) : null} + + {preview ? ( + + + {t("knowledgeProposal.contentPreview", "内容预览")} + + + {preview.length > 520 ? `${preview.slice(0, 520)}...` : preview} + + + ) : null} + + + + {applyState === "applying" + ? t("knowledgeProposal.applying", "应用中...") + : applyState === "applied" + ? t("knowledgeProposal.applied", "已应用") + : t("knowledgeProposal.apply", "应用到知识库")} + + + + + ); +} + function AbortedPartView({ part }: { part: AbortedPart }) { const colors = useColors(); return ( @@ -390,6 +553,17 @@ const makeToolStyles = (colors: ThemeColors) => color: colors.destructive, fontWeight: fw.medium, }, + proposalBadge: { + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.1), + paddingHorizontal: 6, + paddingVertical: 2, + }, + proposalBadgeText: { + fontSize: fs.xs, + color: colors.primary, + fontWeight: fw.medium, + }, chevron: {}, chevronOpen: { transform: [{ rotate: "180deg" }] }, body: { @@ -427,6 +601,116 @@ const makeToolStyles = (colors: ThemeColors) => lineHeight: 16, }, codeKey: { color: colors.mutedForeground }, + proposalCard: { + overflow: "hidden", + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.24), + backgroundColor: colors.card, + borderRadius: radius.md, + }, + proposalHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + backgroundColor: withOpacity(colors.primary, 0.04), + paddingHorizontal: 10, + paddingVertical: 9, + }, + proposalTitleWrap: { + flex: 1, + gap: 3, + }, + proposalActionText: { + fontSize: fs.xs, + fontWeight: fw.medium, + color: colors.primary, + }, + proposalTitleText: { + fontSize: fs.sm, + lineHeight: 18, + fontWeight: fw.semibold, + color: colors.foreground, + }, + proposalTypeBadge: { + maxWidth: 110, + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 7, + paddingVertical: 4, + }, + proposalTypeText: { + fontSize: fs.xs, + color: colors.mutedForeground, + }, + proposalBody: { + gap: 9, + padding: 10, + }, + proposalHintText: { + fontSize: fs.xs, + lineHeight: 17, + color: colors.mutedForeground, + }, + proposalTagRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + proposalTag: { + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 7, + paddingVertical: 4, + }, + proposalTagText: { + fontSize: fs.xs, + color: colors.mutedForeground, + }, + proposalMetaBlock: { + gap: 3, + }, + proposalMetaLabel: { + fontSize: fs.xs, + fontWeight: fw.medium, + color: colors.mutedForeground, + }, + proposalMetaText: { + fontSize: fs.xs, + color: colors.foreground, + }, + proposalPreviewBlock: { + gap: 5, + }, + proposalPreviewText: { + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.45), + borderRadius: radius.sm, + padding: 8, + fontSize: fs.xs, + lineHeight: 17, + color: colors.foreground, + }, + proposalApplyButton: { + alignSelf: "flex-end", + minHeight: 34, + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: colors.primary, + paddingHorizontal: 12, + paddingVertical: 8, + }, + proposalApplyButtonDisabled: { + opacity: 0.65, + }, + proposalApplyText: { + fontSize: fs.xs, + fontWeight: fw.semibold, + color: colors.primaryForeground, + }, errorBlock: { borderWidth: 0.5, borderColor: colors.destructive, diff --git a/packages/app/src/components/chat/PartRenderer.tsx b/packages/app/src/components/chat/PartRenderer.tsx index 47f6b928..ad28d12c 100644 --- a/packages/app/src/components/chat/PartRenderer.tsx +++ b/packages/app/src/components/chat/PartRenderer.tsx @@ -2,7 +2,13 @@ * Message Part Components * Renders individual parts of a message (text, reasoning, tool calls, citations) */ +import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + type KnowledgeWriteProposal, + applyKnowledgeWriteProposal, + getKnowledgeWriteProposal, +} from "@readany/core/knowledge/proposals"; import type { AbortedPart, CitationPart, @@ -23,8 +29,9 @@ import { Wrench, XCircle, } from "lucide-react"; -import { Suspense, lazy, useEffect, useRef, useState } from "react"; +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { MarkdownRenderer } from "./MarkdownRenderer"; const TEXT_RENDER_THROTTLE_MS = 100; @@ -216,13 +223,30 @@ const TOOL_LABEL_KEYS: Record = { fallbackToc: "toolLabels.fallbackToc", fallbackSearch: "toolLabels.fallbackSearch", fallbackChapterContext: "toolLabels.fallbackChapterContext", + searchKnowledgeBase: "toolLabels.searchKnowledgeBase", + getBookKnowledge: "toolLabels.getBookKnowledge", + proposeKnowledgeDocumentCreate: "toolLabels.proposeKnowledgeDocumentCreate", + proposeKnowledgeDocumentUpdate: "toolLabels.proposeKnowledgeDocumentUpdate", +}; + +type KnowledgeProposalApplyState = "idle" | "applying" | "applied"; + +const KNOWLEDGE_DOCUMENT_TYPE_KEYS: Record = { + book_home: "knowledgeProposal.types.bookHome", + standalone_note: "knowledgeProposal.types.standaloneNote", + highlight_note: "knowledgeProposal.types.highlightNote", + review: "knowledgeProposal.types.review", + summary: "knowledgeProposal.types.summary", + imported_markdown: "knowledgeProposal.types.importedMarkdown", }; function ToolCallPartView({ part }: { part: ToolCallPart }) { const { t } = useTranslation(); const hasError = part.status === "error" || Boolean(part.error); + const proposal = useMemo(() => getKnowledgeWriteProposal(part.result), [part.result]); - const [isOpen, setIsOpen] = useState(hasError); + const [isOpen, setIsOpen] = useState(hasError || Boolean(proposal)); + const [proposalApplyState, setProposalApplyState] = useState("idle"); const getStatusIcon = () => { switch (part.status) { @@ -249,8 +273,23 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { : ""); useEffect(() => { - if (hasError) setIsOpen(true); - }, [hasError]); + if (hasError || proposal) setIsOpen(true); + setProposalApplyState("idle"); + }, [hasError, proposal]); + + const handleApplyProposal = async () => { + if (!proposal || proposalApplyState !== "idle") return; + setProposalApplyState("applying"); + try { + await applyKnowledgeWriteProposal(proposal); + setProposalApplyState("applied"); + toast.success(t("knowledgeProposal.applySuccess")); + } catch (error) { + setProposalApplyState("idle"); + console.error("[KnowledgeProposal] Failed to apply proposal:", error); + toast.error(error instanceof Error ? error.message : t("knowledgeProposal.applyFailed")); + } + }; return (
@@ -277,6 +316,13 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {t("streaming.toolFailed")} )} + {proposal && !hasError && ( + + {proposalApplyState === "applied" + ? t("knowledgeProposal.savedBadge") + : t("knowledgeProposal.pendingBadge")} + + )} {queryText && ( {queryText.slice(0, 50)} @@ -315,7 +361,7 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {key}:{" "} {typeof value === "string" && value.length > 100 - ? value.slice(0, 100) + "..." + ? `${value.slice(0, 100)}...` : String(value)}
@@ -329,13 +375,21 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) {

{t("common.result")}

-
-
-                      {typeof part.result === "string" && part.result.length > 500
-                        ? part.result.slice(0, 500) + "..."
-                        : JSON.stringify(part.result, null, 2)}
-                    
-
+ {proposal ? ( + + ) : ( +
+
+                        {typeof part.result === "string" && part.result.length > 500
+                          ? `${part.result.slice(0, 500)}...`
+                          : JSON.stringify(part.result, null, 2)}
+                      
+
+ )}
)} @@ -353,6 +407,107 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { ); } +function KnowledgeProposalCard({ + proposal, + applyState, + onApply, +}: { + proposal: KnowledgeWriteProposal; + applyState: KnowledgeProposalApplyState; + onApply: () => void; +}) { + const { t } = useTranslation(); + const isCreate = proposal.action === "create"; + const title = isCreate + ? proposal.draft.title + : (proposal.patch.title ?? proposal.current?.title ?? proposal.documentId); + const type = isCreate ? proposal.draft.type : proposal.current?.type; + const tags = isCreate + ? (proposal.draft.tags ?? []) + : (proposal.patch.tags ?? proposal.current?.tags ?? []); + const preview = isCreate + ? proposal.draft.excerpt || proposal.draft.contentMd + : proposal.patch.excerpt || proposal.patch.contentMd || proposal.current?.excerpt || ""; + const changedFields = proposal.action === "update" ? proposal.changedFields : []; + + return ( +
+
+
+
+
+ {isCreate ? t("knowledgeProposal.create") : t("knowledgeProposal.update")} +
+
{title}
+
+
+ {type + ? t(KNOWLEDGE_DOCUMENT_TYPE_KEYS[type], { defaultValue: type }) + : t("knowledgeProposal.types.knowledgeDocument")} +
+
+
+ +
+

+ {t("knowledgeProposal.safeHint")} +

+ + {tags.length > 0 && ( +
+ {tags.slice(0, 6).map((tag) => ( + + {tag} + + ))} + {tags.length > 6 && ( + + +{tags.length - 6} + + )} +
+ )} + + {changedFields.length > 0 && ( +
+
+ {t("knowledgeProposal.changes")} +
+
{changedFields.join(", ")}
+
+ )} + + {preview && ( +
+
+ {t("knowledgeProposal.contentPreview")} +
+
+ {preview.length > 520 ? `${preview.slice(0, 520)}...` : preview} +
+
+ )} + +
+ +
+
+
+ ); +} + function MindmapPartView({ part }: { part: MindmapPart }) { const { t } = useTranslation(); return ( diff --git a/packages/core/src/ai/tools/knowledge-tools.ts b/packages/core/src/ai/tools/knowledge-tools.ts index 3873043b..e1cde5ae 100644 --- a/packages/core/src/ai/tools/knowledge-tools.ts +++ b/packages/core/src/ai/tools/knowledge-tools.ts @@ -8,6 +8,7 @@ import { getKnowledgeDocument, getKnowledgeDocuments } from "../../db/database"; import { markdownToBasicTiptap } from "../../knowledge/editor-projection"; import type { JSONValue, KnowledgeDocument, KnowledgeDocumentType } from "../../types"; +import { generateId } from "../../utils/generate-id"; import type { ToolDefinition } from "./tool-types"; const SEARCH_SCAN_LIMIT = 200; @@ -274,6 +275,7 @@ export function createProposeKnowledgeDocumentCreateTool(): ToolDefinition { confirmationKind: "knowledge_document_create", message: "Draft generated only. No knowledge document has been saved.", draft: { + id: generateId(), type, title, bookId, diff --git a/packages/core/src/i18n/locales/en/chat.json b/packages/core/src/i18n/locales/en/chat.json index 68f1af4d..804b7bae 100644 --- a/packages/core/src/i18n/locales/en/chat.json +++ b/packages/core/src/i18n/locales/en/chat.json @@ -98,7 +98,35 @@ "mindmap": "Generating mindmap", "fallbackToc": "Reading original table of contents", "fallbackSearch": "Scanning original file", - "fallbackChapterContext": "Reading chapter content" + "fallbackChapterContext": "Reading chapter content", + "searchKnowledgeBase": "Searching knowledge base", + "getBookKnowledge": "Reading book knowledge", + "proposeKnowledgeDocumentCreate": "Drafting knowledge document", + "proposeKnowledgeDocumentUpdate": "Drafting document update" + }, + "knowledgeProposal": { + "title": "Knowledge write proposal", + "create": "Create knowledge document", + "update": "Update knowledge document", + "pendingBadge": "Needs confirmation", + "savedBadge": "Applied", + "safeHint": "AI has only drafted this. It will write to your knowledge base after confirmation.", + "changes": "Changes", + "contentPreview": "Preview", + "apply": "Apply to knowledge base", + "applying": "Applying...", + "applied": "Applied", + "applySuccess": "Saved to knowledge base", + "applyFailed": "Failed to apply knowledge proposal", + "types": { + "knowledgeDocument": "Knowledge document", + "bookHome": "Book home", + "standaloneNote": "Standalone note", + "highlightNote": "Highlight note", + "review": "Review", + "summary": "Summary", + "importedMarkdown": "Imported document" + } }, "mindmap": { "title": "Mindmap", diff --git a/packages/core/src/i18n/locales/es/chat.json b/packages/core/src/i18n/locales/es/chat.json index 50fbea53..c7228342 100644 --- a/packages/core/src/i18n/locales/es/chat.json +++ b/packages/core/src/i18n/locales/es/chat.json @@ -98,7 +98,35 @@ "mindmap": "Generando mapa mental", "fallbackToc": "Leyendo tabla de contenidos original", "fallbackSearch": "Escaneando archivo original", - "fallbackChapterContext": "Leyendo contenido del capítulo" + "fallbackChapterContext": "Leyendo contenido del capítulo", + "searchKnowledgeBase": "Buscando en la base de conocimiento", + "getBookKnowledge": "Leyendo conocimiento del libro", + "proposeKnowledgeDocumentCreate": "Redactando documento de conocimiento", + "proposeKnowledgeDocumentUpdate": "Redactando actualización del documento" + }, + "knowledgeProposal": { + "title": "Propuesta de escritura de conocimiento", + "create": "Crear documento de conocimiento", + "update": "Actualizar documento de conocimiento", + "pendingBadge": "Necesita confirmación", + "savedBadge": "Aplicado", + "safeHint": "La IA solo creó un borrador. Se escribirá en tu base de conocimiento tras confirmarlo.", + "changes": "Cambios", + "contentPreview": "Vista previa", + "apply": "Aplicar a la base de conocimiento", + "applying": "Aplicando...", + "applied": "Aplicado", + "applySuccess": "Guardado en la base de conocimiento", + "applyFailed": "No se pudo aplicar la propuesta", + "types": { + "knowledgeDocument": "Documento de conocimiento", + "bookHome": "Inicio del libro", + "standaloneNote": "Nota independiente", + "highlightNote": "Nota de subrayado", + "review": "Reseña", + "summary": "Resumen", + "importedMarkdown": "Documento importado" + } }, "mindmap": { "title": "Mapa mental", diff --git a/packages/core/src/i18n/locales/fr/chat.json b/packages/core/src/i18n/locales/fr/chat.json index 35322ab7..0a19d86f 100644 --- a/packages/core/src/i18n/locales/fr/chat.json +++ b/packages/core/src/i18n/locales/fr/chat.json @@ -98,7 +98,35 @@ "mindmap": "Génération de la carte mentale", "fallbackToc": "Lecture de la table des matières originale", "fallbackSearch": "Analyse du fichier original", - "fallbackChapterContext": "Lecture du contenu du chapitre" + "fallbackChapterContext": "Lecture du contenu du chapitre", + "searchKnowledgeBase": "Recherche dans la base de connaissances", + "getBookKnowledge": "Lecture des connaissances du livre", + "proposeKnowledgeDocumentCreate": "Brouillon de document de connaissance", + "proposeKnowledgeDocumentUpdate": "Brouillon de mise à jour du document" + }, + "knowledgeProposal": { + "title": "Proposition d'écriture de connaissance", + "create": "Créer un document de connaissance", + "update": "Mettre à jour le document de connaissance", + "pendingBadge": "Confirmation requise", + "savedBadge": "Appliqué", + "safeHint": "L'IA a seulement créé un brouillon. Il sera écrit dans votre base après confirmation.", + "changes": "Modifications", + "contentPreview": "Aperçu", + "apply": "Appliquer à la base de connaissances", + "applying": "Application...", + "applied": "Appliqué", + "applySuccess": "Enregistré dans la base de connaissances", + "applyFailed": "Impossible d'appliquer la proposition", + "types": { + "knowledgeDocument": "Document de connaissance", + "bookHome": "Accueil du livre", + "standaloneNote": "Note autonome", + "highlightNote": "Note de surlignage", + "review": "Critique", + "summary": "Résumé", + "importedMarkdown": "Document importé" + } }, "mindmap": { "title": "Carte mentale", diff --git a/packages/core/src/i18n/locales/ja/chat.json b/packages/core/src/i18n/locales/ja/chat.json index 6ba2f22f..ede87f66 100644 --- a/packages/core/src/i18n/locales/ja/chat.json +++ b/packages/core/src/i18n/locales/ja/chat.json @@ -98,7 +98,35 @@ "mindmap": "マインドマップを生成中", "fallbackToc": "元ファイルの目次を読み取り中", "fallbackSearch": "元ファイルをスキャン中", - "fallbackChapterContext": "章の内容を読み取り中" + "fallbackChapterContext": "章の内容を読み取り中", + "searchKnowledgeBase": "ナレッジベースを検索中", + "getBookKnowledge": "この本のナレッジを読み取り中", + "proposeKnowledgeDocumentCreate": "ナレッジ文書を下書き中", + "proposeKnowledgeDocumentUpdate": "文書更新を下書き中" + }, + "knowledgeProposal": { + "title": "ナレッジ書き込み提案", + "create": "ナレッジ文書を作成", + "update": "ナレッジ文書を更新", + "pendingBadge": "確認待ち", + "savedBadge": "適用済み", + "safeHint": "AI は下書きだけを作成しました。確認後にナレッジベースへ保存されます。", + "changes": "変更", + "contentPreview": "プレビュー", + "apply": "ナレッジベースに適用", + "applying": "適用中...", + "applied": "適用済み", + "applySuccess": "ナレッジベースに保存しました", + "applyFailed": "ナレッジ提案の適用に失敗しました", + "types": { + "knowledgeDocument": "ナレッジ文書", + "bookHome": "本のホーム", + "standaloneNote": "独立ノート", + "highlightNote": "ハイライトノート", + "review": "レビュー", + "summary": "要約", + "importedMarkdown": "インポート文書" + } }, "mindmap": { "title": "マインドマップ", diff --git a/packages/core/src/i18n/locales/ko/chat.json b/packages/core/src/i18n/locales/ko/chat.json index d5ad4712..e0d620ed 100644 --- a/packages/core/src/i18n/locales/ko/chat.json +++ b/packages/core/src/i18n/locales/ko/chat.json @@ -98,7 +98,35 @@ "mindmap": "마인드맵 생성 중", "fallbackToc": "원본 목차 읽는 중", "fallbackSearch": "원본 파일 스캔 중", - "fallbackChapterContext": "챕터 내용 읽는 중" + "fallbackChapterContext": "챕터 내용 읽는 중", + "searchKnowledgeBase": "지식 베이스 검색 중", + "getBookKnowledge": "이 책의 지식 읽는 중", + "proposeKnowledgeDocumentCreate": "지식 문서 초안 작성 중", + "proposeKnowledgeDocumentUpdate": "문서 업데이트 초안 작성 중" + }, + "knowledgeProposal": { + "title": "지식 베이스 쓰기 제안", + "create": "지식 문서 만들기", + "update": "지식 문서 업데이트", + "pendingBadge": "확인 필요", + "savedBadge": "적용됨", + "safeHint": "AI는 초안만 만들었어요. 확인 후 지식 베이스에 저장됩니다.", + "changes": "변경 사항", + "contentPreview": "미리보기", + "apply": "지식 베이스에 적용", + "applying": "적용 중...", + "applied": "적용됨", + "applySuccess": "지식 베이스에 저장했어요", + "applyFailed": "지식 제안을 적용하지 못했어요", + "types": { + "knowledgeDocument": "지식 문서", + "bookHome": "책 홈", + "standaloneNote": "독립 노트", + "highlightNote": "하이라이트 노트", + "review": "리뷰", + "summary": "요약", + "importedMarkdown": "가져온 문서" + } }, "mindmap": { "title": "마인드맵", diff --git a/packages/core/src/i18n/locales/zh-TW/chat.json b/packages/core/src/i18n/locales/zh-TW/chat.json index e9859b8f..2892b649 100644 --- a/packages/core/src/i18n/locales/zh-TW/chat.json +++ b/packages/core/src/i18n/locales/zh-TW/chat.json @@ -98,7 +98,35 @@ "mindmap": "產生心智圖", "fallbackToc": "讀取原始目錄", "fallbackSearch": "掃描原始檔案", - "fallbackChapterContext": "讀取章節內容" + "fallbackChapterContext": "讀取章節內容", + "searchKnowledgeBase": "搜尋知識庫", + "getBookKnowledge": "讀取本書知識", + "proposeKnowledgeDocumentCreate": "草擬知識文件", + "proposeKnowledgeDocumentUpdate": "草擬文件更新" + }, + "knowledgeProposal": { + "title": "知識庫寫入提案", + "create": "建立知識文件", + "update": "更新知識文件", + "pendingBadge": "待確認", + "savedBadge": "已套用", + "safeHint": "AI 只產生了草稿,確認後才會寫入你的知識庫。", + "changes": "變更", + "contentPreview": "內容預覽", + "apply": "套用到知識庫", + "applying": "套用中...", + "applied": "已套用", + "applySuccess": "已儲存到知識庫", + "applyFailed": "套用知識庫提案失敗", + "types": { + "knowledgeDocument": "知識文件", + "bookHome": "書籍首頁", + "standaloneNote": "獨立筆記", + "highlightNote": "劃線筆記", + "review": "書評", + "summary": "摘要", + "importedMarkdown": "匯入文件" + } }, "mindmap": { "title": "心智圖", diff --git a/packages/core/src/i18n/locales/zh/chat.json b/packages/core/src/i18n/locales/zh/chat.json index 7211abd1..e5a667e1 100644 --- a/packages/core/src/i18n/locales/zh/chat.json +++ b/packages/core/src/i18n/locales/zh/chat.json @@ -98,7 +98,35 @@ "mindmap": "生成思维导图", "fallbackToc": "读取原始目录", "fallbackSearch": "扫描原始文件", - "fallbackChapterContext": "读取章节内容" + "fallbackChapterContext": "读取章节内容", + "searchKnowledgeBase": "搜索知识库", + "getBookKnowledge": "读取本书知识", + "proposeKnowledgeDocumentCreate": "草拟知识文档", + "proposeKnowledgeDocumentUpdate": "草拟文档更新" + }, + "knowledgeProposal": { + "title": "知识库写入提案", + "create": "创建知识文档", + "update": "更新知识文档", + "pendingBadge": "待确认", + "savedBadge": "已应用", + "safeHint": "AI 只生成了草稿,确认后才会写入你的知识库。", + "changes": "变更", + "contentPreview": "内容预览", + "apply": "应用到知识库", + "applying": "应用中...", + "applied": "已应用", + "applySuccess": "已保存到知识库", + "applyFailed": "应用知识库提案失败", + "types": { + "knowledgeDocument": "知识文档", + "bookHome": "书籍主页", + "standaloneNote": "独立笔记", + "highlightNote": "高亮笔记", + "review": "书评", + "summary": "摘要", + "importedMarkdown": "导入文档" + } }, "mindmap": { "title": "思维导图", diff --git a/packages/core/src/knowledge/proposals.test.ts b/packages/core/src/knowledge/proposals.test.ts new file mode 100644 index 00000000..c9a24b1b --- /dev/null +++ b/packages/core/src/knowledge/proposals.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMocks = vi.hoisted(() => ({ + createKnowledgeDocument: vi.fn(), + getKnowledgeDocument: vi.fn(), + updateKnowledgeDocument: vi.fn(), +})); + +vi.mock("../db/database", () => dbMocks); + +const { applyKnowledgeWriteProposal, getKnowledgeWriteProposal } = await import("./proposals"); + +describe("knowledge write proposals", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("normalizes confirmation-required create proposals", () => { + const proposal = getKnowledgeWriteProposal({ + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + draft: { + id: "proposal-doc-1", + type: "summary", + title: "Durable Summary", + tags: ["reading", "reading", "summary"], + contentMd: "A durable knowledge document.", + contentJson: { type: "doc", content: [] }, + sourceKind: "book", + sourceId: "book-1", + }, + }); + + expect(proposal).toMatchObject({ + action: "create", + draft: { + id: "proposal-doc-1", + type: "summary", + title: "Durable Summary", + tags: ["reading", "summary"], + sourceKind: "book", + sourceId: "book-1", + }, + }); + }); + + it("rejects ordinary tool results and malformed proposal payloads", () => { + expect(getKnowledgeWriteProposal({ success: true, documents: [] })).toBeNull(); + expect( + getKnowledgeWriteProposal({ + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + draft: { type: "summary", title: "", contentJson: { type: "doc" } }, + }), + ).toBeNull(); + expect( + getKnowledgeWriteProposal({ + success: true, + action: "update", + requiresConfirmation: true, + confirmationKind: "knowledge_document_update", + documentId: "doc-1", + patch: { contentMd: "Markdown without canonical JSON" }, + }), + ).toBeNull(); + }); + + it("applies create proposals once when the draft id already exists", async () => { + const proposal = getKnowledgeWriteProposal({ + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + draft: { + id: "proposal-doc-1", + type: "standalone_note", + title: "New Note", + contentMd: "Body", + contentJson: { type: "doc", content: [] }, + }, + }); + expect(proposal).not.toBeNull(); + if (!proposal) throw new Error("Expected create proposal"); + + dbMocks.getKnowledgeDocument.mockResolvedValue({ id: "proposal-doc-1" }); + + const result = await applyKnowledgeWriteProposal(proposal); + + expect(result).toEqual({ + action: "create", + documentId: "proposal-doc-1", + alreadyApplied: true, + }); + expect(dbMocks.createKnowledgeDocument).not.toHaveBeenCalled(); + }); + + it("creates documents and applies update patches", async () => { + const createProposal = getKnowledgeWriteProposal({ + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + draft: { + type: "review", + title: "Review", + contentMd: "Body", + contentJson: { type: "doc", content: [] }, + }, + }); + expect(createProposal).not.toBeNull(); + if (!createProposal) throw new Error("Expected create proposal"); + dbMocks.createKnowledgeDocument.mockResolvedValue({ id: "created-doc" }); + + await expect(applyKnowledgeWriteProposal(createProposal)).resolves.toEqual({ + action: "create", + documentId: "created-doc", + }); + + const updateProposal = getKnowledgeWriteProposal({ + success: true, + action: "update", + requiresConfirmation: true, + confirmationKind: "knowledge_document_update", + documentId: "doc-1", + patch: { + title: "Updated", + tags: ["done"], + }, + changedFields: ["title", "tags"], + }); + expect(updateProposal).not.toBeNull(); + if (!updateProposal) throw new Error("Expected update proposal"); + + await expect(applyKnowledgeWriteProposal(updateProposal)).resolves.toEqual({ + action: "update", + documentId: "doc-1", + }); + expect(dbMocks.updateKnowledgeDocument).toHaveBeenCalledWith("doc-1", { + title: "Updated", + tags: ["done"], + }); + }); +}); diff --git a/packages/core/src/knowledge/proposals.ts b/packages/core/src/knowledge/proposals.ts new file mode 100644 index 00000000..6ea53952 --- /dev/null +++ b/packages/core/src/knowledge/proposals.ts @@ -0,0 +1,234 @@ +import { + type CreateKnowledgeDocumentInput, + createKnowledgeDocument, + getKnowledgeDocument, + updateKnowledgeDocument, +} from "../db/database"; +import type { + JSONValue, + KnowledgeDocument, + KnowledgeDocumentType, + KnowledgeSourceKind, +} from "../types"; + +export type KnowledgeProposalAction = "create" | "update"; +export type KnowledgeProposalConfirmationKind = + | "knowledge_document_create" + | "knowledge_document_update"; + +export interface KnowledgeDocumentCreateProposal { + success: true; + action: "create"; + requiresConfirmation: true; + confirmationKind: "knowledge_document_create"; + message?: string; + draft: CreateKnowledgeDocumentInput; +} + +export interface KnowledgeDocumentUpdateProposal { + success: true; + action: "update"; + requiresConfirmation: true; + confirmationKind: "knowledge_document_update"; + message?: string; + documentId: string; + current?: { + id: string; + bookId?: string; + type?: KnowledgeDocumentType; + title?: string; + tags?: string[]; + excerpt?: string; + updatedAt?: number; + }; + patch: Partial< + Pick + >; + changedFields: string[]; +} + +export type KnowledgeWriteProposal = + | KnowledgeDocumentCreateProposal + | KnowledgeDocumentUpdateProposal; + +export interface KnowledgeProposalApplyResult { + action: KnowledgeProposalAction; + documentId: string; + alreadyApplied?: boolean; +} + +const DOCUMENT_TYPES = new Set([ + "book_home", + "standalone_note", + "highlight_note", + "review", + "summary", + "imported_markdown", +]); + +const SOURCE_KINDS = new Set([ + "book", + "highlight", + "note", + "cfi", + "ai_message", + "external", + "obsidian", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJSONValue(value: unknown): value is JSONValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (Array.isArray(value)) return value.every(isJSONValue); + if (!isRecord(value)) return false; + return Object.values(value).every(isJSONValue); +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((item) => String(item).trim()).filter(Boolean))]; +} + +function asDocumentType(value: unknown): KnowledgeDocumentType | null { + return typeof value === "string" && DOCUMENT_TYPES.has(value as KnowledgeDocumentType) + ? (value as KnowledgeDocumentType) + : null; +} + +function asSourceKind(value: unknown): KnowledgeSourceKind | undefined { + return typeof value === "string" && SOURCE_KINDS.has(value as KnowledgeSourceKind) + ? (value as KnowledgeSourceKind) + : undefined; +} + +function normalizeCreateProposal( + result: Record, +): KnowledgeDocumentCreateProposal | null { + if (result.action !== "create" || result.confirmationKind !== "knowledge_document_create") { + return null; + } + + const draft = result.draft; + if (!isRecord(draft)) return null; + + const type = asDocumentType(draft.type); + const title = stringOrUndefined(draft.title); + const contentMd = typeof draft.contentMd === "string" ? draft.contentMd : ""; + const contentJson = isJSONValue(draft.contentJson) ? draft.contentJson : null; + if (!type || !title || !contentJson) return null; + + return { + success: true, + action: "create", + requiresConfirmation: true, + confirmationKind: "knowledge_document_create", + message: stringOrUndefined(result.message), + draft: { + id: stringOrUndefined(draft.id), + bookId: stringOrUndefined(draft.bookId), + parentId: stringOrUndefined(draft.parentId), + type, + title, + contentJson, + contentMd, + contentSchemaVersion: + typeof draft.contentSchemaVersion === "number" ? draft.contentSchemaVersion : undefined, + excerpt: stringOrUndefined(draft.excerpt), + tags: asStringArray(draft.tags), + sourceKind: asSourceKind(draft.sourceKind), + sourceId: stringOrUndefined(draft.sourceId), + }, + }; +} + +function normalizeUpdateProposal( + result: Record, +): KnowledgeDocumentUpdateProposal | null { + if (result.action !== "update" || result.confirmationKind !== "knowledge_document_update") { + return null; + } + + const documentId = stringOrUndefined(result.documentId); + const patchResult = result.patch; + if (!documentId || !isRecord(patchResult)) return null; + + const patch: KnowledgeDocumentUpdateProposal["patch"] = {}; + if (typeof patchResult.title === "string") patch.title = patchResult.title; + if (typeof patchResult.contentMd === "string") { + if (!isJSONValue(patchResult.contentJson)) return null; + patch.contentMd = patchResult.contentMd; + patch.contentJson = patchResult.contentJson; + } else if (isJSONValue(patchResult.contentJson)) { + patch.contentJson = patchResult.contentJson; + } + if (Object.prototype.hasOwnProperty.call(patchResult, "excerpt")) { + patch.excerpt = stringOrUndefined(patchResult.excerpt); + } + if (Array.isArray(patchResult.tags)) patch.tags = asStringArray(patchResult.tags); + + if (Object.keys(patch).length === 0) return null; + + const current = isRecord(result.current) + ? { + id: String(result.current.id ?? documentId), + bookId: stringOrUndefined(result.current.bookId), + type: asDocumentType(result.current.type) ?? undefined, + title: stringOrUndefined(result.current.title), + tags: asStringArray(result.current.tags), + excerpt: stringOrUndefined(result.current.excerpt), + updatedAt: + typeof result.current.updatedAt === "number" ? result.current.updatedAt : undefined, + } + : undefined; + + return { + success: true, + action: "update", + requiresConfirmation: true, + confirmationKind: "knowledge_document_update", + message: stringOrUndefined(result.message), + documentId, + current, + patch, + changedFields: asStringArray(result.changedFields), + }; +} + +export function getKnowledgeWriteProposal(value: unknown): KnowledgeWriteProposal | null { + if (!isRecord(value) || value.success !== true || value.requiresConfirmation !== true) { + return null; + } + return normalizeCreateProposal(value) ?? normalizeUpdateProposal(value); +} + +export async function applyKnowledgeWriteProposal( + proposal: KnowledgeWriteProposal, +): Promise { + if (proposal.action === "create") { + if (proposal.draft.id) { + const existing = await getKnowledgeDocument(proposal.draft.id); + if (existing) { + return { action: "create", documentId: existing.id, alreadyApplied: true }; + } + } + const document = await createKnowledgeDocument(proposal.draft); + return { action: "create", documentId: document.id }; + } + + await updateKnowledgeDocument(proposal.documentId, proposal.patch); + return { action: "update", documentId: proposal.documentId }; +} From 4ea4ec17b9463e9a27a75fc52b9d6cb9ded0901c Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 04:56:11 +0800 Subject: [PATCH 014/409] feat(mobile): add webview knowledge editor --- .../assets/editor/knowledge-editor.html | 353 ++++++++ .../editor/knowledge-editor.template.html | 211 +++++ packages/app-expo/package.json | 10 +- .../scripts/build-knowledge-editor.js | 375 +++++++++ .../knowledge/MobileKnowledgeEditor.tsx | 781 ++++++++++++++++++ packages/app-expo/src/screens/NotesView.tsx | 113 ++- .../src/screens/notes/notes-styles.ts | 2 +- packages/core/src/i18n/locales/en/notes.json | 4 + packages/core/src/i18n/locales/es/notes.json | 4 + packages/core/src/i18n/locales/fr/notes.json | 4 + packages/core/src/i18n/locales/ja/notes.json | 4 + packages/core/src/i18n/locales/ko/notes.json | 4 + .../core/src/i18n/locales/zh-TW/notes.json | 4 + packages/core/src/i18n/locales/zh/notes.json | 4 + pnpm-lock.yaml | 9 + 15 files changed, 1846 insertions(+), 36 deletions(-) create mode 100644 packages/app-expo/assets/editor/knowledge-editor.html create mode 100644 packages/app-expo/assets/editor/knowledge-editor.template.html create mode 100644 packages/app-expo/scripts/build-knowledge-editor.js create mode 100644 packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx diff --git a/packages/app-expo/assets/editor/knowledge-editor.html b/packages/app-expo/assets/editor/knowledge-editor.html new file mode 100644 index 00000000..e2067902 --- /dev/null +++ b/packages/app-expo/assets/editor/knowledge-editor.html @@ -0,0 +1,353 @@ + + + + + + + + +
+ + + diff --git a/packages/app-expo/assets/editor/knowledge-editor.template.html b/packages/app-expo/assets/editor/knowledge-editor.template.html new file mode 100644 index 00000000..d2651854 --- /dev/null +++ b/packages/app-expo/assets/editor/knowledge-editor.template.html @@ -0,0 +1,211 @@ + + + + + + + + +
+ + + diff --git a/packages/app-expo/package.json b/packages/app-expo/package.json index cd8a0276..5be78663 100644 --- a/packages/app-expo/package.json +++ b/packages/app-expo/package.json @@ -7,10 +7,7 @@ "entryPoint": "./index.js", "doctor": { "reactNativeDirectoryCheck": { - "exclude": [ - "react-native-tcp-socket", - "react-native-track-player" - ], + "exclude": ["react-native-tcp-socket", "react-native-track-player"], "listUnknownPackages": false } } @@ -29,7 +26,7 @@ "ios:simulator": "APP_VARIANT=development pnpm run build:reader && APP_VARIANT=development node scripts/configure-native-variant.js && APP_VARIANT=development expo run:ios", "preprebuild": "pnpm run build:reader", "prebuild": "expo prebuild", - "build:reader": "node scripts/build-reader.js", + "build:reader": "node scripts/build-reader.js && node scripts/build-knowledge-editor.js", "sync:native-variant": "node scripts/configure-native-variant.js", "lint": "biome check .", "eas-build-pre-install": "bash scripts/eas-install-ios-build-tools.sh", @@ -58,6 +55,9 @@ "@react-navigation/native": "^7.1.33", "@react-navigation/native-stack": "^7.14.4", "@readany/core": "workspace:*", + "@tiptap/core": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", "@zip.js/zip.js": "^2.7.52", "expo": "~54.0.33", "expo-asset": "~12.0.12", diff --git a/packages/app-expo/scripts/build-knowledge-editor.js b/packages/app-expo/scripts/build-knowledge-editor.js new file mode 100644 index 00000000..9cae8d4d --- /dev/null +++ b/packages/app-expo/scripts/build-knowledge-editor.js @@ -0,0 +1,375 @@ +/** + * Build a self-contained Tiptap knowledge editor for React Native WebView. + * + * Run: node scripts/build-knowledge-editor.js + */ +const esbuild = require("esbuild"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ASSETS_DIR = path.resolve(__dirname, "../assets/editor"); +const TEMPLATE = path.resolve(ASSETS_DIR, "knowledge-editor.template.html"); +const OUTPUT = path.resolve(ASSETS_DIR, "knowledge-editor.html"); +const MARKER = ""; + +async function buildKnowledgeEditor() { + fs.mkdirSync(ASSETS_DIR, { recursive: true }); + + const entryContent = ` + import { Editor, Node, mergeAttributes } from "@tiptap/core"; + import Placeholder from "@tiptap/extension-placeholder"; + import StarterKit from "@tiptap/starter-kit"; + + const EMPTY_DOC = { type: "doc", content: [] }; + let editor = null; + let ready = false; + let pendingInit = null; + let changeTimer = null; + + const post = (payload) => { + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(payload)); + } + } catch (error) { + console.error("[KnowledgeEditor] postMessage failed", error); + } + }; + + const isDoc = (value) => value && typeof value === "object" && value.type === "doc"; + const normalizeDoc = (value) => (isDoc(value) ? value : EMPTY_DOC); + + const setTheme = (theme = {}) => { + const root = document.documentElement; + const entries = { + background: theme.background, + foreground: theme.foreground, + card: theme.card, + border: theme.border, + muted: theme.muted, + mutedForeground: theme.mutedForeground, + primary: theme.primary, + }; + for (const [key, value] of Object.entries(entries)) { + if (typeof value === "string" && value) { + root.style.setProperty("--" + key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()), value); + } + } + }; + + const scheduleHeight = () => { + requestAnimationFrame(() => { + const height = Math.ceil(document.documentElement.scrollHeight || document.body.scrollHeight || 260); + post({ type: "heightChanged", height }); + }); + }; + + const selectionState = () => { + if (!editor) return {}; + return { + marks: { + bold: editor.isActive("bold"), + italic: editor.isActive("italic"), + strike: editor.isActive("strike"), + code: editor.isActive("code"), + bulletList: editor.isActive("bulletList"), + orderedList: editor.isActive("orderedList"), + blockquote: editor.isActive("blockquote"), + link: editor.isActive("link"), + }, + linkHref: editor.getAttributes("link").href || null, + headingLevel: editor.isActive("heading", { level: 1 }) + ? 1 + : editor.isActive("heading", { level: 2 }) + ? 2 + : editor.isActive("heading", { level: 3 }) + ? 3 + : null, + canUndo: editor.can().undo(), + canRedo: editor.can().redo(), + }; + }; + + const postSelection = () => { + post({ type: "selectionChanged", ...selectionState() }); + }; + + const postContent = () => { + if (!editor) return; + clearTimeout(changeTimer); + changeTimer = setTimeout(() => { + post({ + type: "contentChanged", + contentJson: editor.getJSON(), + plainText: editor.getText(), + }); + scheduleHeight(); + }, 180); + }; + + const ReadAnyCard = Node.create({ + name: "readanyCard", + group: "block", + atom: true, + draggable: true, + selectable: true, + + addAttributes() { + return { + cardType: { default: "callout" }, + id: { default: null }, + version: { default: 1 }, + title: { default: null }, + text: { default: null }, + sourceTitle: { default: null }, + sourceId: { default: null }, + cfi: { default: null }, + markdown: { default: null }, + data: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "readany-card" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "readany-card", + mergeAttributes(HTMLAttributes, { + "data-card-type": HTMLAttributes.cardType || "callout", + "data-card-version": String(HTMLAttributes.version || 1), + }), + ]; + }, + + addNodeView() { + return ({ node }) => { + const attrs = node.attrs || {}; + const dom = document.createElement("div"); + dom.className = "readany-card"; + dom.contentEditable = "false"; + dom.dataset.cardType = attrs.cardType || "callout"; + + const icon = document.createElement("div"); + icon.className = "readany-card-icon"; + icon.textContent = "◇"; + + const body = document.createElement("div"); + body.className = "readany-card-body"; + + const title = document.createElement("div"); + title.className = "readany-card-title"; + title.textContent = attrs.title || attrs.cardType || "Card"; + body.appendChild(title); + + const text = attrs.markdown || attrs.text || ""; + if (text) { + const preview = document.createElement("div"); + preview.className = "readany-card-preview"; + preview.textContent = text; + body.appendChild(preview); + } + + if (attrs.sourceTitle) { + const source = document.createElement("div"); + source.className = "readany-card-source"; + source.textContent = attrs.sourceTitle; + body.appendChild(source); + } + + dom.appendChild(icon); + dom.appendChild(body); + return { dom }; + }; + }, + }); + + const createEditor = (payload = {}) => { + const el = document.getElementById("editor"); + if (!el) throw new Error("Editor root not found"); + setTheme(payload.theme); + editor?.destroy(); + editor = new Editor({ + element: el, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + dropcursor: false, + gapcursor: false, + }), + ReadAnyCard, + Placeholder.configure({ + placeholder: payload.placeholder || "", + emptyEditorClass: "is-editor-empty", + }), + ], + content: normalizeDoc(payload.contentJson), + editable: payload.readOnly !== true, + editorProps: { + attributes: { + class: "readany-prosemirror", + }, + }, + onCreate: () => { + post({ type: "ready" }); + postSelection(); + scheduleHeight(); + }, + onUpdate: () => postContent(), + onSelectionUpdate: () => postSelection(), + onTransaction: () => scheduleHeight(), + }); + ready = true; + }; + + const runCommand = (command, attrs = {}) => { + if (!editor) return; + const chain = editor.chain().focus(); + switch (command) { + case "undo": + editor.chain().focus().undo().run(); + break; + case "redo": + editor.chain().focus().redo().run(); + break; + case "bold": + chain.toggleBold().run(); + break; + case "italic": + chain.toggleItalic().run(); + break; + case "strike": + chain.toggleStrike().run(); + break; + case "code": + chain.toggleCode().run(); + break; + case "setLink": + if (typeof attrs.href === "string" && attrs.href.trim()) { + editor.chain().focus().extendMarkRange("link").setLink({ href: attrs.href.trim() }).run(); + } + break; + case "unsetLink": + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + break; + case "heading": + chain.toggleHeading({ level: attrs.level || 2 }).run(); + break; + case "bulletList": + chain.toggleBulletList().run(); + break; + case "orderedList": + chain.toggleOrderedList().run(); + break; + case "blockquote": + chain.toggleBlockquote().run(); + break; + case "horizontalRule": + chain.setHorizontalRule().run(); + break; + case "focus": + editor.commands.focus(attrs.position || "end"); + break; + case "blur": + editor.commands.blur(); + break; + default: + post({ type: "error", code: "unknown_command", message: "Unknown editor command: " + command }); + } + postSelection(); + scheduleHeight(); + }; + + const receive = (message) => { + try { + if (!message || typeof message !== "object") return; + switch (message.type) { + case "init": + pendingInit = message; + createEditor(message); + break; + case "setContent": + editor?.commands.setContent(normalizeDoc(message.contentJson)); + postContent(); + break; + case "setTheme": + setTheme(message.theme); + break; + case "setEditable": + editor?.setEditable(message.editable !== false); + break; + case "runCommand": + runCommand(message.command, message.attrs); + break; + case "requestContent": + if (editor) { + post({ + type: "contentChanged", + requestId: message.requestId, + contentJson: editor.getJSON(), + plainText: editor.getText(), + }); + } + break; + default: + post({ type: "error", code: "unknown_message", message: "Unknown bridge message: " + message.type }); + } + } catch (error) { + post({ + type: "error", + code: "bridge_error", + message: error && error.message ? error.message : String(error), + }); + } + }; + + window.__ReadAnyKnowledgeEditor = { receive }; + + window.addEventListener("message", (event) => { + try { + const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data; + receive(data); + } catch (error) { + post({ type: "error", code: "parse_error", message: String(error) }); + } + }); + + document.addEventListener("DOMContentLoaded", () => { + post({ type: "loaded" }); + if (pendingInit && !ready) createEditor(pendingInit); + }); + `; + + const entryFile = path.resolve(__dirname, "../.knowledge-editor-entry.mjs"); + fs.writeFileSync(entryFile, entryContent); + + try { + const result = await esbuild.build({ + entryPoints: [entryFile], + bundle: true, + format: "iife", + target: "es2020", + minify: true, + write: false, + resolveExtensions: [".js", ".mjs"], + }); + + const bundledJS = result.outputFiles[0].text; + const template = fs.readFileSync(TEMPLATE, "utf-8"); + const parts = template.split(MARKER); + if (parts.length < 2) { + throw new Error("Knowledge editor template marker not found"); + } + const html = `${parts[0]}${parts.slice(1).join(MARKER)}`; + fs.writeFileSync(OUTPUT, html); + console.log(`Built knowledge-editor.html (${Math.round(html.length / 1024)}KB)`); + } finally { + if (fs.existsSync(entryFile)) fs.unlinkSync(entryFile); + } +} + +buildKnowledgeEditor().catch((err) => { + console.error("Build failed:", err); + process.exit(1); +}); diff --git a/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx new file mode 100644 index 00000000..85889b79 --- /dev/null +++ b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx @@ -0,0 +1,781 @@ +import { + BoldIcon, + CodeIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + ItalicIcon, + Link2Icon, + ListIcon, + ListOrderedIcon, + MinusIcon, + QuoteIcon, + Redo2Icon, + StrikethroughIcon, + Undo2Icon, +} from "@/components/ui/Icon"; +import { RichTextEditor } from "@/components/ui/RichTextEditor"; +import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; +import { markdownToBasicTiptap, renderKnowledgeJsonToMarkdown } from "@readany/core/knowledge"; +import type { JSONValue } from "@readany/core/types"; +import { Asset } from "expo-asset"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Modal, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import WebView, { type WebViewMessageEvent } from "react-native-webview"; + +const EDITOR_HTML_ASSET = Asset.fromModule(require("../../../assets/editor/knowledge-editor.html")); + +export interface MobileKnowledgeEditorValue { + contentJson: JSONValue; + contentMd: string; + plainText: string; +} + +interface MobileKnowledgeEditorProps { + documentId?: string; + value: MobileKnowledgeEditorValue; + onChange: (value: MobileKnowledgeEditorValue) => void; + placeholder?: string; + autoFocus?: boolean; +} + +interface SelectionState { + marks: { + bold?: boolean; + italic?: boolean; + strike?: boolean; + code?: boolean; + bulletList?: boolean; + orderedList?: boolean; + blockquote?: boolean; + link?: boolean; + }; + linkHref: string | null; + headingLevel: number | null; + canUndo: boolean; + canRedo: boolean; +} + +type EditorBridgeMessage = + | { type: "loaded" } + | { type: "ready" } + | { type: "heightChanged"; height?: unknown } + | { type: "contentChanged"; contentJson?: unknown; plainText?: unknown } + | { + type: "selectionChanged"; + marks?: SelectionState["marks"]; + linkHref?: string | null; + headingLevel?: number | null; + canUndo?: boolean; + canRedo?: boolean; + } + | { type: "error"; code?: string; message?: string }; + +type EditorCommand = + | { + type: "init"; + contentJson: JSONValue; + theme: EditorTheme; + placeholder?: string; + readOnly?: boolean; + } + | { type: "setContent"; contentJson: JSONValue } + | { type: "setTheme"; theme: EditorTheme } + | { type: "runCommand"; command: string; attrs?: Record }; + +interface EditorTheme { + background: string; + foreground: string; + card: string; + border: string; + muted: string; + mutedForeground: string; + primary: string; +} + +const MIN_EDITOR_HEIGHT = 260; +const MAX_EDITOR_HEIGHT = 560; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJsonValue(value: unknown): value is JSONValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (Array.isArray(value)) return value.every(isJsonValue); + if (!isRecord(value)) return false; + return Object.values(value).every(isJsonValue); +} + +function parseBridgeMessage(data: string): EditorBridgeMessage | null { + try { + const parsed = JSON.parse(data); + if (!isRecord(parsed) || typeof parsed.type !== "string") return null; + return parsed as EditorBridgeMessage; + } catch { + return null; + } +} + +function fingerprintJson(value: JSONValue): string { + return JSON.stringify(value); +} + +function clampEditorHeight(height: number): number { + return Math.min(Math.max(Math.ceil(height), MIN_EDITOR_HEIGHT), MAX_EDITOR_HEIGHT); +} + +export function MobileKnowledgeEditor({ + documentId, + value, + onChange, + placeholder, + autoFocus = false, +}: MobileKnowledgeEditorProps) { + const { t } = useTranslation(); + const colors = useColors(); + const styles = makeStyles(colors); + const webViewRef = useRef(null); + const latestValueRef = useRef(value); + const localFingerprintRef = useRef(fingerprintJson(value.contentJson)); + const [htmlUri, setHtmlUri] = useState(null); + const [isBridgeReady, setIsBridgeReady] = useState(false); + const [isEditorReady, setIsEditorReady] = useState(false); + const [editorHeight, setEditorHeight] = useState(MIN_EDITOR_HEIGHT); + const [errorMessage, setErrorMessage] = useState(null); + const [useMarkdownFallback, setUseMarkdownFallback] = useState(false); + const [selection, setSelection] = useState({ + marks: {}, + linkHref: null, + headingLevel: null, + canUndo: false, + canRedo: false, + }); + const [showLinkModal, setShowLinkModal] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + + const theme = useMemo( + () => ({ + background: colors.background, + foreground: colors.foreground, + card: colors.card, + border: colors.border, + muted: colors.muted, + mutedForeground: colors.mutedForeground, + primary: colors.primary, + }), + [colors], + ); + + const valueFingerprint = useMemo(() => fingerprintJson(value.contentJson), [value.contentJson]); + + useEffect(() => { + latestValueRef.current = value; + }, [value]); + + useEffect(() => { + let mounted = true; + const loadAsset = async () => { + try { + const asset = EDITOR_HTML_ASSET; + await asset.downloadAsync(); + if (!mounted) return; + setHtmlUri(asset.localUri || asset.uri); + } catch (error) { + console.error("[MobileKnowledgeEditor] Failed to load editor asset:", error); + if (!mounted) return; + setErrorMessage(t("notes.knowledgeEditorLoadFailed", "编辑器加载失败")); + setUseMarkdownFallback(true); + } + }; + void loadAsset(); + return () => { + mounted = false; + }; + }, [t]); + + const injectCommand = useCallback((command: EditorCommand) => { + const script = ` + window.__ReadAnyKnowledgeEditor && window.__ReadAnyKnowledgeEditor.receive(${JSON.stringify(command)}); + true; + `; + webViewRef.current?.injectJavaScript(script); + }, []); + + const sendInit = useCallback(() => { + const current = latestValueRef.current; + localFingerprintRef.current = fingerprintJson(current.contentJson); + injectCommand({ + type: "init", + contentJson: current.contentJson, + placeholder, + readOnly: false, + theme, + }); + if (autoFocus) { + injectCommand({ type: "runCommand", command: "focus", attrs: { position: "end" } }); + } + }, [autoFocus, injectCommand, placeholder, theme]); + + useEffect(() => { + if (!isBridgeReady) return; + injectCommand({ type: "setTheme", theme }); + }, [injectCommand, isBridgeReady, theme]); + + useEffect(() => { + if (!isBridgeReady || !isEditorReady) return; + if (valueFingerprint === localFingerprintRef.current) return; + localFingerprintRef.current = valueFingerprint; + injectCommand({ type: "setContent", contentJson: value.contentJson }); + }, [injectCommand, isBridgeReady, isEditorReady, value.contentJson, valueFingerprint]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + const message = parseBridgeMessage(event.nativeEvent.data); + if (!message) return; + + switch (message.type) { + case "loaded": + setIsBridgeReady(true); + setErrorMessage(null); + sendInit(); + break; + case "ready": + setIsEditorReady(true); + setErrorMessage(null); + break; + case "heightChanged": + if (typeof message.height === "number" && Number.isFinite(message.height)) { + setEditorHeight(clampEditorHeight(message.height)); + } + break; + case "selectionChanged": + setSelection({ + marks: message.marks ?? {}, + linkHref: typeof message.linkHref === "string" ? message.linkHref : null, + headingLevel: typeof message.headingLevel === "number" ? message.headingLevel : null, + canUndo: message.canUndo === true, + canRedo: message.canRedo === true, + }); + break; + case "contentChanged": + if (!isJsonValue(message.contentJson)) return; + localFingerprintRef.current = fingerprintJson(message.contentJson); + onChange({ + contentJson: message.contentJson, + contentMd: renderKnowledgeJsonToMarkdown(message.contentJson), + plainText: typeof message.plainText === "string" ? message.plainText : "", + }); + break; + case "error": + console.error("[MobileKnowledgeEditor] WebView error:", message); + setErrorMessage(message.message || t("notes.knowledgeEditorError", "编辑器出错了")); + break; + } + }, + [onChange, sendInit, t], + ); + + const runCommand = useCallback( + (command: string, attrs?: Record) => { + injectCommand({ type: "runCommand", command, attrs }); + }, + [injectCommand], + ); + + const openLinkModal = useCallback(() => { + setLinkUrl(selection.linkHref ?? ""); + setShowLinkModal(true); + }, [selection.linkHref]); + + const applyLink = useCallback(() => { + const href = linkUrl.trim(); + runCommand(href ? "setLink" : "unsetLink", href ? { href } : undefined); + setShowLinkModal(false); + setLinkUrl(""); + }, [linkUrl, runCommand]); + + const handleFallbackChange = useCallback( + (markdown: string) => { + const contentJson = markdownToBasicTiptap(markdown) as unknown as JSONValue; + onChange({ + contentJson, + contentMd: markdown, + plainText: markdown, + }); + }, + [onChange], + ); + + if (useMarkdownFallback) { + return ( + + {errorMessage ? {errorMessage} : null} + + + ); + } + + return ( + + + runCommand("undo")} + disabled={!selection.canUndo || !isEditorReady} + styles={styles} + > + + + runCommand("redo")} + disabled={!selection.canRedo || !isEditorReady} + styles={styles} + > + + + + runCommand("heading", { level: 1 })} + isActive={selection.headingLevel === 1} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("heading", { level: 2 })} + isActive={selection.headingLevel === 2} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("heading", { level: 3 })} + isActive={selection.headingLevel === 3} + disabled={!isEditorReady} + styles={styles} + > + + + + runCommand("bold")} + isActive={selection.marks.bold} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("italic")} + isActive={selection.marks.italic} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("strike")} + isActive={selection.marks.strike} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("code")} + isActive={selection.marks.code} + disabled={!isEditorReady} + styles={styles} + > + + + + + + + runCommand("bulletList")} + isActive={selection.marks.bulletList} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("orderedList")} + isActive={selection.marks.orderedList} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("blockquote")} + isActive={selection.marks.blockquote} + disabled={!isEditorReady} + styles={styles} + > + + + runCommand("horizontalRule")} + disabled={!isEditorReady} + styles={styles} + > + + + + + + {!htmlUri ? ( + + + + {t("notes.knowledgeEditorLoading", "正在准备编辑器...")} + + + ) : ( + <> + { + console.error("[MobileKnowledgeEditor] WebView load error:", event.nativeEvent); + setErrorMessage(t("notes.knowledgeEditorLoadFailed", "编辑器加载失败")); + setUseMarkdownFallback(true); + }} + onContentProcessDidTerminate={() => { + setErrorMessage(t("notes.knowledgeEditorReloading", "编辑器正在恢复...")); + setIsBridgeReady(false); + setIsEditorReady(false); + webViewRef.current?.reload(); + }} + /> + {!isEditorReady && ( + + + + )} + + )} + + + {errorMessage ? {errorMessage} : null} + + setShowLinkModal(false)} + > + + + {t("common.insertLink", "插入链接")} + + + { + setShowLinkModal(false); + setLinkUrl(""); + }} + activeOpacity={0.75} + > + {t("common.cancel", "取消")} + + {selection.marks.link ? ( + { + runCommand("unsetLink"); + setShowLinkModal(false); + setLinkUrl(""); + }} + activeOpacity={0.75} + > + {t("common.remove", "移除")} + + ) : null} + + {t("common.confirm", "确定")} + + + + + + + ); +} + +function ToolbarButton({ + onPress, + children, + styles, + isActive, + disabled, +}: { + onPress: () => void; + children: React.ReactNode; + styles: ReturnType; + isActive?: boolean; + disabled?: boolean; +}) { + return ( + + {children} + + ); +} + +function ToolbarDivider({ styles }: { styles: ReturnType }) { + return ; +} + +const makeStyles = (colors: ReturnType) => + StyleSheet.create({ + container: { + minHeight: MIN_EDITOR_HEIGHT, + overflow: "hidden", + borderRadius: radius.lg, + backgroundColor: colors.background, + }, + fallbackWrap: { + minHeight: 360, + gap: 8, + }, + toolbar: { + minHeight: 45, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + backgroundColor: colors.muted, + }, + toolbarContent: { + flexDirection: "row", + alignItems: "center", + gap: 2, + paddingHorizontal: 8, + paddingVertical: 6, + }, + toolbarButton: { + width: 31, + height: 31, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + }, + toolbarButtonActive: { + backgroundColor: withOpacity(colors.primary, 0.12), + }, + toolbarButtonDisabled: { + opacity: 0.32, + }, + toolbarDivider: { + width: StyleSheet.hairlineWidth, + height: 20, + marginHorizontal: 5, + backgroundColor: colors.border, + }, + webViewFrame: { + minHeight: MIN_EDITOR_HEIGHT, + backgroundColor: colors.background, + }, + webView: { + flex: 1, + backgroundColor: colors.background, + }, + loadingWrap: { + flex: 1, + minHeight: MIN_EDITOR_HEIGHT, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + loadingText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + readyOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + backgroundColor: withOpacity(colors.background, 0.72), + }, + errorText: { + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: fontSize.xs, + lineHeight: 17, + color: colors.destructive, + backgroundColor: withOpacity(colors.destructive, 0.08), + }, + modalOverlay: { + flex: 1, + justifyContent: "center", + paddingHorizontal: 24, + backgroundColor: withOpacity("#000000", 0.42), + }, + linkModal: { + gap: 14, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.lg, + backgroundColor: colors.card, + padding: 16, + shadowColor: "#000000", + shadowOpacity: 0.16, + shadowRadius: 20, + shadowOffset: { width: 0, height: 10 }, + elevation: 8, + }, + linkModalTitle: { + color: colors.foreground, + fontSize: fontSize.base, + fontWeight: fontWeight.semibold, + }, + linkInput: { + minHeight: 44, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.md, + backgroundColor: colors.background, + paddingHorizontal: 12, + color: colors.foreground, + fontSize: fontSize.sm, + }, + linkActions: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: 8, + }, + linkGhostButton: { + minHeight: 36, + justifyContent: "center", + borderRadius: radius.md, + paddingHorizontal: 12, + }, + linkGhostText: { + color: colors.mutedForeground, + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + }, + linkDangerText: { + color: colors.destructive, + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + }, + linkPrimaryButton: { + minHeight: 36, + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: colors.primary, + paddingHorizontal: 14, + }, + linkPrimaryText: { + color: colors.primaryForeground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + }); diff --git a/packages/app-expo/src/screens/NotesView.tsx b/packages/app-expo/src/screens/NotesView.tsx index b8867c13..1e6af262 100644 --- a/packages/app-expo/src/screens/NotesView.tsx +++ b/packages/app-expo/src/screens/NotesView.tsx @@ -1,3 +1,7 @@ +import { + MobileKnowledgeEditor, + type MobileKnowledgeEditorValue, +} from "@/components/knowledge/MobileKnowledgeEditor"; import { BookOpenIcon, CheckCheckIcon, @@ -11,7 +15,6 @@ import { XIcon, } from "@/components/ui/Icon"; import { KeyboardAwareScrollView } from "@/components/ui/KeyboardAwareScrollView"; -import { RichTextEditor } from "@/components/ui/RichTextEditor"; import { SyncButton } from "@/components/ui/SyncButton"; import { openMobileBook } from "@/lib/library/open-mobile-book"; import type { RootStackParamList } from "@/navigation/RootNavigator"; @@ -30,7 +33,11 @@ import { type KnowledgeExportFormat, knowledgeExporter, } from "@readany/core/export"; -import { markdownToBasicTiptap } from "@readany/core/knowledge"; +import { + markdownToBasicTiptap, + normalizeTiptapDocument, + renderKnowledgeJsonToMarkdown, +} from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; import type { Highlight, KnowledgeDocument } from "@readany/core/types"; import { eventBus } from "@readany/core/utils/event-bus"; @@ -77,6 +84,44 @@ function createKnowledgeExcerpt(markdown: string): string | undefined { return text ? text.slice(0, 220) : undefined; } +function createEmptyKnowledgeValue(): MobileKnowledgeEditorValue { + return { + contentJson: { type: "doc", content: [] }, + contentMd: "", + plainText: "", + }; +} + +function isEmptyTiptapDocument(contentJson: KnowledgeDocument["contentJson"]): boolean { + const doc = normalizeTiptapDocument(contentJson); + return !doc.content || doc.content.length === 0; +} + +function createKnowledgeValue(document: KnowledgeDocument): MobileKnowledgeEditorValue { + const contentJson = + document.contentMd.trim() && isEmptyTiptapDocument(document.contentJson) + ? (markdownToBasicTiptap(document.contentMd) as unknown as KnowledgeDocument["contentJson"]) + : (normalizeTiptapDocument( + document.contentJson, + ) as unknown as KnowledgeDocument["contentJson"]); + const contentMd = document.contentMd || renderKnowledgeJsonToMarkdown(contentJson); + return { + contentJson, + contentMd, + plainText: contentMd + .replace(/[#>*_`~\-[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(), + }; +} + +function knowledgeValueFingerprint(value: MobileKnowledgeEditorValue): string { + return JSON.stringify({ + contentJson: value.contentJson, + contentMd: value.contentMd, + }); +} + export function NotesView({ initialBookId, showBackButton, @@ -112,11 +157,19 @@ export function NotesView({ const [editNote, setEditNote] = useState(""); const [showExportMenu, setShowExportMenu] = useState(false); const [knowledgeHome, setKnowledgeHome] = useState(null); - const [knowledgeContent, setKnowledgeContent] = useState(""); - const [savedKnowledgeContent, setSavedKnowledgeContent] = useState(""); + const [knowledgeValue, setKnowledgeValue] = useState(() => + createEmptyKnowledgeValue(), + ); + const [savedKnowledgeFingerprint, setSavedKnowledgeFingerprint] = useState(() => + knowledgeValueFingerprint(createEmptyKnowledgeValue()), + ); const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); const knowledgeSaveVersionRef = useRef(0); + const currentKnowledgeFingerprint = useMemo( + () => knowledgeValueFingerprint(knowledgeValue), + [knowledgeValue], + ); useFocusEffect( useCallback(() => { @@ -278,9 +331,10 @@ export function NotesView({ knowledgeSaveVersionRef.current += 1; if (!selectedKnowledgeBookId) { + const emptyValue = createEmptyKnowledgeValue(); setKnowledgeHome(null); - setKnowledgeContent(""); - setSavedKnowledgeContent(""); + setKnowledgeValue(emptyValue); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(emptyValue)); setIsKnowledgeSaving(false); return; } @@ -293,9 +347,10 @@ export function NotesView({ selectedKnowledgeBookTitle, ); if (cancelled) return; + const nextValue = createKnowledgeValue(document); setKnowledgeHome(document); - setKnowledgeContent(document.contentMd); - setSavedKnowledgeContent(document.contentMd); + setKnowledgeValue(nextValue); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(nextValue)); } catch (error) { console.error("[Notes] Failed to load knowledge home:", error); Alert.alert(t("common.error", "错误"), t("notes.knowledgeLoadFailed", "知识主页加载失败")); @@ -312,23 +367,22 @@ export function NotesView({ }, [selectedKnowledgeBookId, selectedKnowledgeBookTitle, t]); useEffect(() => { - if (!knowledgeHome || knowledgeContent === savedKnowledgeContent) return; + if (!knowledgeHome || currentKnowledgeFingerprint === savedKnowledgeFingerprint) return; const saveVersion = knowledgeSaveVersionRef.current + 1; knowledgeSaveVersionRef.current = saveVersion; + const valueToSave = knowledgeValue; const timeout = setTimeout(async () => { setIsKnowledgeSaving(true); try { await updateKnowledgeDocument(knowledgeHome.id, { - contentMd: knowledgeContent, - contentJson: markdownToBasicTiptap( - knowledgeContent, - ) as unknown as KnowledgeDocument["contentJson"], - excerpt: createKnowledgeExcerpt(knowledgeContent), + contentMd: valueToSave.contentMd, + contentJson: valueToSave.contentJson, + excerpt: createKnowledgeExcerpt(valueToSave.contentMd), }); if (knowledgeSaveVersionRef.current !== saveVersion) return; - setSavedKnowledgeContent(knowledgeContent); + setSavedKnowledgeFingerprint(knowledgeValueFingerprint(valueToSave)); } catch (error) { if (knowledgeSaveVersionRef.current !== saveVersion) return; console.error("[Notes] Failed to save knowledge home:", error); @@ -341,7 +395,7 @@ export function NotesView({ }, 700); return () => clearTimeout(timeout); - }, [knowledgeHome, knowledgeContent, savedKnowledgeContent, t]); + }, [knowledgeHome, knowledgeValue, currentKnowledgeFingerprint, savedKnowledgeFingerprint, t]); const handleDeleteNote = useCallback( (highlight: HighlightWithBook) => { @@ -436,11 +490,9 @@ export function NotesView({ const exporter = new AnnotationExporter(); const liveDocument: KnowledgeDocument = { ...knowledgeHome, - contentMd: knowledgeContent, - contentJson: markdownToBasicTiptap( - knowledgeContent, - ) as unknown as KnowledgeDocument["contentJson"], - excerpt: createKnowledgeExcerpt(knowledgeContent), + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), updatedAt: Date.now(), }; const files = knowledgeExporter.export( @@ -462,7 +514,7 @@ export function NotesView({ Alert.alert(t("common.error", "错误"), t("notes.exportFailed", "导出失败")); } }, - [selectedBook, knowledgeHome, knowledgeContent, books, t], + [selectedBook, knowledgeHome, knowledgeValue, books, t], ); const totalHighlights = stats?.totalHighlights ?? 0; @@ -605,7 +657,7 @@ export function NotesView({ {isKnowledgeSaving ? t("notes.knowledgeSaving", "保存中") - : knowledgeContent === savedKnowledgeContent + : currentKnowledgeFingerprint === savedKnowledgeFingerprint ? t("notes.knowledgeSaved", "已保存") : t("notes.knowledgePending", "待保存")} @@ -636,9 +688,9 @@ export function NotesView({ key={knowledgeHome?.id ?? selectedBook.bookId} book={selectedBook} document={knowledgeHome} - content={knowledgeContent} + value={knowledgeValue} isLoading={isKnowledgeLoading} - onChange={setKnowledgeContent} + onChange={setKnowledgeValue} onOpenBook={(cfi) => handleOpenBook(selectedBook.bookId, cfi)} t={t} styles={s} @@ -832,7 +884,7 @@ export function NotesView({ function KnowledgeHomePanel({ book, document, - content, + value, isLoading, onChange, onOpenBook, @@ -849,9 +901,9 @@ function KnowledgeHomePanel({ highlightsOnlyCount: number; }; document: KnowledgeDocument | null; - content: string; + value: MobileKnowledgeEditorValue; isLoading: boolean; - onChange: (value: string) => void; + onChange: (value: MobileKnowledgeEditorValue) => void; onOpenBook: (cfi?: string) => void; t: TFunction; styles: ReturnType; @@ -905,8 +957,9 @@ function KnowledgeHomePanel({ {t("notes.knowledgeTab", "知识主页")} - fontWeight: fontWeight.semibold, }, knowledgeEditorFrame: { - minHeight: 360, + minHeight: 0, borderRadius: radius.lg, borderWidth: 0.5, borderColor: colors.border, diff --git a/packages/core/src/i18n/locales/en/notes.json b/packages/core/src/i18n/locales/en/notes.json index 0656d5b3..4c8f8bd9 100644 --- a/packages/core/src/i18n/locales/en/notes.json +++ b/packages/core/src/i18n/locales/en/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "Pending", "knowledgeLoadFailed": "Failed to load knowledge home", "knowledgeSaveFailed": "Failed to save knowledge home", + "knowledgeEditorLoading": "Preparing editor...", + "knowledgeEditorLoadFailed": "Failed to load editor", + "knowledgeEditorReloading": "Recovering editor...", + "knowledgeEditorError": "The editor ran into a problem", "knowledgeInsertCard": "Insert card", "knowledgeCardEmpty": "Empty card, ready to edit later.", "knowledgeCardSource": "Source", diff --git a/packages/core/src/i18n/locales/es/notes.json b/packages/core/src/i18n/locales/es/notes.json index e39e86b7..13ff3f84 100644 --- a/packages/core/src/i18n/locales/es/notes.json +++ b/packages/core/src/i18n/locales/es/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "Pendiente", "knowledgeLoadFailed": "No se pudo cargar la página de conocimiento", "knowledgeSaveFailed": "No se pudo guardar la página de conocimiento", + "knowledgeEditorLoading": "Preparando el editor...", + "knowledgeEditorLoadFailed": "No se pudo cargar el editor", + "knowledgeEditorReloading": "Restaurando el editor...", + "knowledgeEditorError": "El editor tuvo un problema", "knowledgeInsertCard": "Insertar tarjeta", "knowledgeCardEmpty": "Tarjeta vacía, lista para editar más tarde.", "knowledgeCardSource": "Fuente", diff --git a/packages/core/src/i18n/locales/fr/notes.json b/packages/core/src/i18n/locales/fr/notes.json index 4f287f29..a3a731a8 100644 --- a/packages/core/src/i18n/locales/fr/notes.json +++ b/packages/core/src/i18n/locales/fr/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "En attente", "knowledgeLoadFailed": "Impossible de charger la page de savoir", "knowledgeSaveFailed": "Impossible d'enregistrer la page de savoir", + "knowledgeEditorLoading": "Préparation de l'éditeur...", + "knowledgeEditorLoadFailed": "Impossible de charger l'éditeur", + "knowledgeEditorReloading": "Restauration de l'éditeur...", + "knowledgeEditorError": "L'éditeur a rencontré un problème", "knowledgeInsertCard": "Insérer une carte", "knowledgeCardEmpty": "Carte vide, prête à être modifiée plus tard.", "knowledgeCardSource": "Source", diff --git a/packages/core/src/i18n/locales/ja/notes.json b/packages/core/src/i18n/locales/ja/notes.json index 9fe573ae..8ce9066a 100644 --- a/packages/core/src/i18n/locales/ja/notes.json +++ b/packages/core/src/i18n/locales/ja/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "未保存", "knowledgeLoadFailed": "ナレッジホームの読み込みに失敗しました", "knowledgeSaveFailed": "ナレッジホームの保存に失敗しました", + "knowledgeEditorLoading": "エディターを準備しています...", + "knowledgeEditorLoadFailed": "エディターの読み込みに失敗しました", + "knowledgeEditorReloading": "エディターを復元しています...", + "knowledgeEditorError": "エディターで問題が発生しました", "knowledgeInsertCard": "カードを挿入", "knowledgeCardEmpty": "空のカードです。あとで編集できます。", "knowledgeCardSource": "出典", diff --git a/packages/core/src/i18n/locales/ko/notes.json b/packages/core/src/i18n/locales/ko/notes.json index 96df62b3..3d795f39 100644 --- a/packages/core/src/i18n/locales/ko/notes.json +++ b/packages/core/src/i18n/locales/ko/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "저장 대기", "knowledgeLoadFailed": "지식 홈을 불러오지 못했어요", "knowledgeSaveFailed": "지식 홈을 저장하지 못했어요", + "knowledgeEditorLoading": "편집기를 준비하는 중...", + "knowledgeEditorLoadFailed": "편집기를 불러오지 못했어요", + "knowledgeEditorReloading": "편집기를 복구하는 중...", + "knowledgeEditorError": "편집기에 문제가 생겼어요", "knowledgeInsertCard": "카드 삽입", "knowledgeCardEmpty": "빈 카드입니다. 나중에 편집할 수 있어요.", "knowledgeCardSource": "출처", diff --git a/packages/core/src/i18n/locales/zh-TW/notes.json b/packages/core/src/i18n/locales/zh-TW/notes.json index 36123d4c..72e9b676 100644 --- a/packages/core/src/i18n/locales/zh-TW/notes.json +++ b/packages/core/src/i18n/locales/zh-TW/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "待儲存", "knowledgeLoadFailed": "知識首頁載入失敗", "knowledgeSaveFailed": "知識首頁儲存失敗", + "knowledgeEditorLoading": "正在準備編輯器...", + "knowledgeEditorLoadFailed": "編輯器載入失敗", + "knowledgeEditorReloading": "編輯器正在恢復...", + "knowledgeEditorError": "編輯器發生錯誤", "knowledgeInsertCard": "插入卡片", "knowledgeCardEmpty": "空卡片,稍後可編輯。", "knowledgeCardSource": "來源", diff --git a/packages/core/src/i18n/locales/zh/notes.json b/packages/core/src/i18n/locales/zh/notes.json index 004e4cc1..1e746ca4 100644 --- a/packages/core/src/i18n/locales/zh/notes.json +++ b/packages/core/src/i18n/locales/zh/notes.json @@ -64,6 +64,10 @@ "knowledgePending": "待保存", "knowledgeLoadFailed": "知识主页加载失败", "knowledgeSaveFailed": "知识主页保存失败", + "knowledgeEditorLoading": "正在准备编辑器...", + "knowledgeEditorLoadFailed": "编辑器加载失败", + "knowledgeEditorReloading": "编辑器正在恢复...", + "knowledgeEditorError": "编辑器出错了", "knowledgeInsertCard": "插入卡片", "knowledgeCardEmpty": "空卡片,稍后可编辑。", "knowledgeCardSource": "来源", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f04858d..1e189eb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,15 @@ importers: '@readany/core': specifier: workspace:* version: link:../core + '@tiptap/core': + specifier: ^3.20.0 + version: 3.20.1(@tiptap/pm@3.20.1) + '@tiptap/extension-placeholder': + specifier: ^3.20.0 + version: 3.20.1(@tiptap/extensions@3.20.1(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))(@tiptap/pm@3.20.1)) + '@tiptap/starter-kit': + specifier: ^3.20.0 + version: 3.20.1 '@zip.js/zip.js': specifier: ^2.7.52 version: 2.8.23 From f310ff639d169899f347ce27762aaa5b29e211a4 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 05:10:42 +0800 Subject: [PATCH 015/409] feat(editor): add knowledge editor profiles --- .../knowledge/MobileKnowledgeEditor.tsx | 385 +++++++++++------- packages/app-expo/src/screens/NotesView.tsx | 1 + .../components/knowledge/KnowledgeEditor.tsx | 383 ++++++++++------- .../app/src/components/notes/NotesPage.tsx | 1 + .../core/src/knowledge/editor-profile.test.ts | 31 ++ packages/core/src/knowledge/editor-profile.ts | 97 +++++ packages/core/src/knowledge/index.ts | 6 + 7 files changed, 602 insertions(+), 302 deletions(-) create mode 100644 packages/core/src/knowledge/editor-profile.test.ts create mode 100644 packages/core/src/knowledge/editor-profile.ts diff --git a/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx index 85889b79..75fd660a 100644 --- a/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx +++ b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx @@ -16,10 +16,17 @@ import { } from "@/components/ui/Icon"; import { RichTextEditor } from "@/components/ui/RichTextEditor"; import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; -import { markdownToBasicTiptap, renderKnowledgeJsonToMarkdown } from "@readany/core/knowledge"; +import { + type KnowledgeEditorFeature, + type KnowledgeEditorTier, + getKnowledgeEditorProfile, + hasKnowledgeEditorFeature, + markdownToBasicTiptap, + renderKnowledgeJsonToMarkdown, +} from "@readany/core/knowledge"; import type { JSONValue } from "@readany/core/types"; import { Asset } from "expo-asset"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -47,6 +54,7 @@ interface MobileKnowledgeEditorProps { onChange: (value: MobileKnowledgeEditorValue) => void; placeholder?: string; autoFocus?: boolean; + tier?: KnowledgeEditorTier; } interface SelectionState { @@ -148,6 +156,7 @@ export function MobileKnowledgeEditor({ onChange, placeholder, autoFocus = false, + tier = "knowledge_doc", }: MobileKnowledgeEditorProps) { const { t } = useTranslation(); const colors = useColors(); @@ -170,6 +179,11 @@ export function MobileKnowledgeEditor({ }); const [showLinkModal, setShowLinkModal] = useState(false); const [linkUrl, setLinkUrl] = useState(""); + const editorProfile = useMemo(() => getKnowledgeEditorProfile(tier), [tier]); + const canUse = useCallback( + (feature: KnowledgeEditorFeature) => hasKnowledgeEditorFeature(editorProfile, feature), + [editorProfile], + ); const theme = useMemo( () => ({ @@ -301,9 +315,10 @@ export function MobileKnowledgeEditor({ ); const openLinkModal = useCallback(() => { + if (!canUse("link")) return; setLinkUrl(selection.linkHref ?? ""); setShowLinkModal(true); - }, [selection.linkHref]); + }, [canUse, selection.linkHref]); const applyLink = useCallback(() => { const href = linkUrl.trim(); @@ -324,6 +339,219 @@ export function MobileKnowledgeEditor({ [onChange], ); + const toolbarGroupCandidates: ({ key: string; node: React.ReactNode } | null)[] = [ + canUse("undo") || canUse("redo") + ? { + key: "history", + node: ( + + {canUse("undo") ? ( + runCommand("undo")} + disabled={!selection.canUndo || !isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("redo") ? ( + runCommand("redo")} + disabled={!selection.canRedo || !isEditorReady} + styles={styles} + > + + + ) : null} + + ), + } + : null, + canUse("heading1") || canUse("heading2") || canUse("heading3") + ? { + key: "headings", + node: ( + + {canUse("heading1") ? ( + runCommand("heading", { level: 1 })} + isActive={selection.headingLevel === 1} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("heading2") ? ( + runCommand("heading", { level: 2 })} + isActive={selection.headingLevel === 2} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("heading3") ? ( + runCommand("heading", { level: 3 })} + isActive={selection.headingLevel === 3} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + + ), + } + : null, + { + key: "inline", + node: ( + + {canUse("bold") ? ( + runCommand("bold")} + isActive={selection.marks.bold} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("italic") ? ( + runCommand("italic")} + isActive={selection.marks.italic} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("strike") ? ( + runCommand("strike")} + isActive={selection.marks.strike} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("inlineCode") ? ( + runCommand("code")} + isActive={selection.marks.code} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("link") ? ( + + + + ) : null} + + ), + }, + canUse("bulletList") || + canUse("orderedList") || + canUse("blockquote") || + canUse("horizontalRule") + ? { + key: "blocks", + node: ( + + {canUse("bulletList") ? ( + runCommand("bulletList")} + isActive={selection.marks.bulletList} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("orderedList") ? ( + runCommand("orderedList")} + isActive={selection.marks.orderedList} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("blockquote") ? ( + runCommand("blockquote")} + isActive={selection.marks.blockquote} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + {canUse("horizontalRule") ? ( + runCommand("horizontalRule")} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + + ), + } + : null, + ]; + const toolbarGroups = toolbarGroupCandidates.filter( + (group): group is { key: string; node: React.ReactNode } => group !== null, + ); + if (useMarkdownFallback) { return ( @@ -346,151 +574,12 @@ export function MobileKnowledgeEditor({ style={styles.toolbar} contentContainerStyle={styles.toolbarContent} > - runCommand("undo")} - disabled={!selection.canUndo || !isEditorReady} - styles={styles} - > - - - runCommand("redo")} - disabled={!selection.canRedo || !isEditorReady} - styles={styles} - > - - - - runCommand("heading", { level: 1 })} - isActive={selection.headingLevel === 1} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("heading", { level: 2 })} - isActive={selection.headingLevel === 2} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("heading", { level: 3 })} - isActive={selection.headingLevel === 3} - disabled={!isEditorReady} - styles={styles} - > - - - - runCommand("bold")} - isActive={selection.marks.bold} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("italic")} - isActive={selection.marks.italic} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("strike")} - isActive={selection.marks.strike} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("code")} - isActive={selection.marks.code} - disabled={!isEditorReady} - styles={styles} - > - - - - - - - runCommand("bulletList")} - isActive={selection.marks.bulletList} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("orderedList")} - isActive={selection.marks.orderedList} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("blockquote")} - isActive={selection.marks.blockquote} - disabled={!isEditorReady} - styles={styles} - > - - - runCommand("horizontalRule")} - disabled={!isEditorReady} - styles={styles} - > - - + {toolbarGroups.map((group, index) => ( + + {index > 0 ? : null} + {group.node} + + ))} diff --git a/packages/app-expo/src/screens/NotesView.tsx b/packages/app-expo/src/screens/NotesView.tsx index 1e6af262..7ffdbc31 100644 --- a/packages/app-expo/src/screens/NotesView.tsx +++ b/packages/app-expo/src/screens/NotesView.tsx @@ -958,6 +958,7 @@ function KnowledgeHomePanel({ {t("notes.knowledgeTab", "知识主页")} getKnowledgeEditorProfile(tier), [tier]); + const canUse = useCallback( + (feature: KnowledgeEditorFeature) => hasKnowledgeEditorFeature(editorProfile, feature), + [editorProfile], + ); const extensions = useMemo( () => [ @@ -223,7 +234,7 @@ export function KnowledgeEditor({ }, [editor, autoFocus]); const setLink = useCallback(() => { - if (!editor) return; + if (!editor || !canUse("link")) return; const previousUrl = editor.getAttributes("link").href; const url = window.prompt(t("editor.enterLink"), previousUrl); if (url === null) return; @@ -232,11 +243,11 @@ export function KnowledgeEditor({ return; } editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); - }, [editor, t]); + }, [canUse, editor, t]); const insertCard = useCallback( (cardType: string) => { - if (!editor) return; + if (!editor || !canUse("readAnyCards")) return; const definition = builtInReadAnyCards.find((card) => card.cardType === cardType); if (!definition) return; const title = t(`notes.knowledgeCards.${cardType}`, { @@ -253,11 +264,218 @@ export function KnowledgeEditor({ .run(); setIsInsertOpen(false); }, - [editor, t], + [canUse, editor, t], ); if (!editor) return null; + const toolbarGroupCandidates: ({ key: string; node: ReactNode } | null)[] = [ + canUse("undo") || canUse("redo") + ? { + key: "history", + node: ( + + {canUse("undo") ? ( + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title={t("editor.undo")} + > + + + ) : null} + {canUse("redo") ? ( + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title={t("editor.redo")} + > + + + ) : null} + + ), + } + : null, + canUse("heading1") || canUse("heading2") || canUse("heading3") + ? { + key: "headings", + node: ( + + {canUse("heading1") ? ( + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive("heading", { level: 1 })} + title={t("editor.heading1")} + > + + + ) : null} + {canUse("heading2") ? ( + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive("heading", { level: 2 })} + title={t("editor.heading2")} + > + + + ) : null} + {canUse("heading3") ? ( + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive("heading", { level: 3 })} + title={t("editor.heading3")} + > + + + ) : null} + + ), + } + : null, + { + key: "inline", + node: ( + + {canUse("bold") ? ( + editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + title={t("editor.bold")} + > + + + ) : null} + {canUse("italic") ? ( + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + title={t("editor.italic")} + > + + + ) : null} + {canUse("strike") ? ( + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + title={t("editor.strikethrough")} + > + + + ) : null} + {canUse("inlineCode") ? ( + editor.chain().focus().toggleCode().run()} + isActive={editor.isActive("code")} + title={t("editor.inlineCode")} + > + + + ) : null} + {canUse("link") ? ( + + + + ) : null} + + ), + }, + canUse("bulletList") || + canUse("orderedList") || + canUse("blockquote") || + canUse("horizontalRule") + ? { + key: "blocks", + node: ( + + {canUse("bulletList") ? ( + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive("bulletList")} + title={t("editor.bulletList")} + > + + + ) : null} + {canUse("orderedList") ? ( + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive("orderedList")} + title={t("editor.orderedList")} + > + + + ) : null} + {canUse("blockquote") ? ( + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive("blockquote")} + title={t("editor.blockquote")} + > + + + ) : null} + {canUse("horizontalRule") ? ( + editor.chain().focus().setHorizontalRule().run()} + title={t("editor.horizontalRule")} + > + + + ) : null} + + ), + } + : null, + canUse("readAnyCards") + ? { + key: "cards", + node: ( +
+ setIsInsertOpen((open) => !open)} + isActive={isInsertOpen} + title={t("notes.knowledgeInsertCard", { defaultValue: "Insert card" })} + > + + + + {isInsertOpen && ( +
+ {builtInReadAnyCards.map((card) => { + const Icon = cardIconMap[card.cardType as keyof typeof cardIconMap] ?? Sparkles; + return ( + + ); + })} +
+ )} +
+ ), + } + : null, + ]; + const toolbarGroups = toolbarGroupCandidates.filter( + (group): group is { key: string; node: ReactNode } => group !== null, + ); + return (
- - editor.chain().focus().undo().run()} - disabled={!editor.can().undo()} - title={t("editor.undo")} - > - - - editor.chain().focus().redo().run()} - disabled={!editor.can().redo()} - title={t("editor.redo")} - > - - - - - - - - editor.chain().focus().toggleHeading({ level: 1 }).run()} - isActive={editor.isActive("heading", { level: 1 })} - title={t("editor.heading1")} - > - - - editor.chain().focus().toggleHeading({ level: 2 }).run()} - isActive={editor.isActive("heading", { level: 2 })} - title={t("editor.heading2")} - > - - - editor.chain().focus().toggleHeading({ level: 3 }).run()} - isActive={editor.isActive("heading", { level: 3 })} - title={t("editor.heading3")} - > - - - - - - - - editor.chain().focus().toggleBold().run()} - isActive={editor.isActive("bold")} - title={t("editor.bold")} - > - - - editor.chain().focus().toggleItalic().run()} - isActive={editor.isActive("italic")} - title={t("editor.italic")} - > - - - editor.chain().focus().toggleStrike().run()} - isActive={editor.isActive("strike")} - title={t("editor.strikethrough")} - > - - - editor.chain().focus().toggleCode().run()} - isActive={editor.isActive("code")} - title={t("editor.inlineCode")} - > - - - - - - - - - - - editor.chain().focus().toggleBulletList().run()} - isActive={editor.isActive("bulletList")} - title={t("editor.bulletList")} - > - - - editor.chain().focus().toggleOrderedList().run()} - isActive={editor.isActive("orderedList")} - title={t("editor.orderedList")} - > - - - editor.chain().focus().toggleBlockquote().run()} - isActive={editor.isActive("blockquote")} - title={t("editor.blockquote")} - > - - - editor.chain().focus().setHorizontalRule().run()} - title={t("editor.horizontalRule")} - > - - - - - - -
- setIsInsertOpen((open) => !open)} - isActive={isInsertOpen} - title={t("notes.knowledgeInsertCard", { defaultValue: "Insert card" })} - > - - - - {isInsertOpen && ( -
- {builtInReadAnyCards.map((card) => { - const Icon = cardIconMap[card.cardType as keyof typeof cardIconMap] ?? Sparkles; - return ( - - ); - })} -
- )} -
+ {toolbarGroups.map((group, index) => ( + + {index > 0 ? : null} + {group.node} + + ))}
{ + it("keeps quick annotation editing lightweight", () => { + const profile = getKnowledgeEditorProfile("inline_note"); + + expect(hasKnowledgeEditorFeature(profile, "bold")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "link")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "blockquote")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "heading1")).toBe(false); + expect(hasKnowledgeEditorFeature(profile, "horizontalRule")).toBe(false); + expect(hasKnowledgeEditorFeature(profile, "readAnyCards")).toBe(false); + }); + + it("allows rich ReadAny blocks in knowledge documents", () => { + const profile = getKnowledgeEditorProfile("knowledge_doc"); + + expect(hasKnowledgeEditorFeature(profile, "heading1")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "horizontalRule")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "readAnyCards")).toBe(true); + }); + + it("keeps publishable documents export-friendly", () => { + const profile = getKnowledgeEditorProfile("publishable_doc"); + + expect(hasKnowledgeEditorFeature(profile, "heading1")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "horizontalRule")).toBe(true); + expect(hasKnowledgeEditorFeature(profile, "readAnyCards")).toBe(false); + }); +}); diff --git a/packages/core/src/knowledge/editor-profile.ts b/packages/core/src/knowledge/editor-profile.ts new file mode 100644 index 00000000..f681d656 --- /dev/null +++ b/packages/core/src/knowledge/editor-profile.ts @@ -0,0 +1,97 @@ +export type KnowledgeEditorTier = "inline_note" | "knowledge_doc" | "publishable_doc"; + +export type KnowledgeEditorFeature = + | "undo" + | "redo" + | "bold" + | "italic" + | "strike" + | "inlineCode" + | "link" + | "heading1" + | "heading2" + | "heading3" + | "bulletList" + | "orderedList" + | "blockquote" + | "horizontalRule" + | "readAnyCards"; + +export interface KnowledgeEditorProfile { + tier: KnowledgeEditorTier; + features: readonly KnowledgeEditorFeature[]; +} + +const INLINE_NOTE_FEATURES = [ + "undo", + "redo", + "bold", + "italic", + "strike", + "inlineCode", + "link", + "bulletList", + "orderedList", + "blockquote", +] as const satisfies readonly KnowledgeEditorFeature[]; + +const KNOWLEDGE_DOCUMENT_FEATURES = [ + "undo", + "redo", + "heading1", + "heading2", + "heading3", + "bold", + "italic", + "strike", + "inlineCode", + "link", + "bulletList", + "orderedList", + "blockquote", + "horizontalRule", + "readAnyCards", +] as const satisfies readonly KnowledgeEditorFeature[]; + +const PUBLISHABLE_DOCUMENT_FEATURES = [ + "undo", + "redo", + "heading1", + "heading2", + "heading3", + "bold", + "italic", + "strike", + "inlineCode", + "link", + "bulletList", + "orderedList", + "blockquote", + "horizontalRule", +] as const satisfies readonly KnowledgeEditorFeature[]; + +const EDITOR_PROFILES: Record = { + inline_note: { + tier: "inline_note", + features: INLINE_NOTE_FEATURES, + }, + knowledge_doc: { + tier: "knowledge_doc", + features: KNOWLEDGE_DOCUMENT_FEATURES, + }, + publishable_doc: { + tier: "publishable_doc", + features: PUBLISHABLE_DOCUMENT_FEATURES, + }, +}; + +export function getKnowledgeEditorProfile(tier: KnowledgeEditorTier): KnowledgeEditorProfile { + return EDITOR_PROFILES[tier]; +} + +export function hasKnowledgeEditorFeature( + profile: KnowledgeEditorProfile, + feature: KnowledgeEditorFeature, +): boolean { + return profile.features.includes(feature); +} diff --git a/packages/core/src/knowledge/index.ts b/packages/core/src/knowledge/index.ts index 6dde43d6..aa00ad6d 100644 --- a/packages/core/src/knowledge/index.ts +++ b/packages/core/src/knowledge/index.ts @@ -9,6 +9,12 @@ export type { TiptapMark, TiptapNode, } from "./editor-projection"; +export { getKnowledgeEditorProfile, hasKnowledgeEditorFeature } from "./editor-profile"; +export type { + KnowledgeEditorFeature, + KnowledgeEditorProfile, + KnowledgeEditorTier, +} from "./editor-profile"; export { builtInReadAnyCards, getReadAnyCardDefinition, From 9dac1078c0edcacf0ef29d29df1d770140a3abe9 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 05:28:38 +0800 Subject: [PATCH 016/409] feat(notes): apply inline editor profile --- .../knowledge/MobileKnowledgeEditor.tsx | 1 + .../components/reader/SelectionPopover.tsx | 1 + .../src/components/ui/RichTextEditor.tsx | 175 ++++++---- .../app-expo/src/screens/notes/NoteCard.tsx | 17 +- .../screens/reader/ReaderNoteViewModal.tsx | 29 +- .../app/src/components/notes/NotesPage.tsx | 1 + .../src/components/reader/NotebookPanel.tsx | 46 +-- .../app/src/components/ui/markdown-editor.tsx | 313 +++++++++++------- 8 files changed, 358 insertions(+), 225 deletions(-) diff --git a/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx index 75fd660a..ef39880c 100644 --- a/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx +++ b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx @@ -557,6 +557,7 @@ export function MobileKnowledgeEditor({ {errorMessage ? {errorMessage} : null} void; placeholder?: string; autoFocus?: boolean; + tier?: KnowledgeEditorTier; } export function RichTextEditor({ @@ -40,6 +47,7 @@ export function RichTextEditor({ onChange, placeholder, autoFocus = false, + tier = "inline_note", }: RichTextEditorProps) { const colors = useColors(); const { t } = useTranslation(); @@ -50,6 +58,11 @@ export function RichTextEditor({ const [linkText, setLinkText] = useState(""); const [previewMode, setPreviewMode] = useState(false); const inputRef = useRef(null); + const editorProfile = useMemo(() => getKnowledgeEditorProfile(tier), [tier]); + const canUse = useCallback( + (feature: KnowledgeEditorFeature) => hasKnowledgeEditorFeature(editorProfile, feature), + [editorProfile], + ); const selectionRef = useRef<{ start: number; end: number }>({ start: initialContent.length, end: initialContent.length, @@ -143,6 +156,91 @@ export function RichTextEditor({ }, [linkUrl, linkText, value, handleChange]); const styles = makeStyles(colors); + const toolbarGroupCandidates: ({ key: string; node: React.ReactNode } | null)[] = [ + canUse("heading1") || canUse("heading2") || canUse("heading3") + ? { + key: "headings", + node: ( + + {canUse("heading1") ? ( + + + + ) : null} + {canUse("heading2") ? ( + + + + ) : null} + {canUse("heading3") ? ( + + + + ) : null} + + ), + } + : null, + { + key: "inline", + node: ( + + {canUse("bold") ? ( + + + + ) : null} + {canUse("italic") ? ( + + + + ) : null} + {canUse("strike") ? ( + + + + ) : null} + {canUse("inlineCode") ? ( + + + + ) : null} + {canUse("link") ? ( + + + + ) : null} + + ), + }, + canUse("bulletList") || canUse("orderedList") || canUse("blockquote") + ? { + key: "blocks", + node: ( + + {canUse("bulletList") ? ( + + + + ) : null} + {canUse("orderedList") ? ( + + + + ) : null} + {canUse("blockquote") ? ( + + + + ) : null} + + ), + } + : null, + ]; + const toolbarGroups = toolbarGroupCandidates.filter( + (group): group is { key: string; node: React.ReactNode } => group !== null, + ); return ( @@ -156,7 +254,6 @@ export function RichTextEditor({ setPreviewMode(!previewMode)} isActive={previewMode} - colors={colors} styles={styles} > {previewMode ? ( @@ -167,57 +264,13 @@ export function RichTextEditor({ - {!previewMode && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} + {!previewMode && + toolbarGroups.map((group) => ( + + + {group.node} + + ))} {previewMode ? ( @@ -232,7 +285,7 @@ export function RichTextEditor({ ) : ( - {placeholder} + {defaultPlaceholder} )} @@ -248,7 +301,7 @@ export function RichTextEditor({ value={value} onChangeText={handleChange} onSelectionChange={handleSelectionChange} - placeholder={placeholder} + placeholder={defaultPlaceholder} placeholderTextColor={colors.mutedForeground} autoFocus={autoFocus} multiline @@ -313,18 +366,10 @@ interface ToolbarButtonProps { isActive?: boolean; disabled?: boolean; children: React.ReactNode; - colors: ReturnType; styles: ReturnType; } -function ToolbarButton({ - onPress, - isActive, - disabled, - children, - colors, - styles, -}: ToolbarButtonProps) { +function ToolbarButton({ onPress, isActive, disabled, children, styles }: ToolbarButtonProps) { return ( ) => justifyContent: "center", }, toolbarButtonActive: { - backgroundColor: colors.primary + "20", + backgroundColor: `${colors.primary}20`, }, toolbarButtonDisabled: { opacity: 0.3, diff --git a/packages/app-expo/src/screens/notes/NoteCard.tsx b/packages/app-expo/src/screens/notes/NoteCard.tsx index 34837ff2..d97631d0 100644 --- a/packages/app-expo/src/screens/notes/NoteCard.tsx +++ b/packages/app-expo/src/screens/notes/NoteCard.tsx @@ -37,14 +37,25 @@ export function NoteCard({ return ( - - "{highlight.text}" + + + "{highlight.text}" + {isEditing ? ( - + + - + -
+
+
+ {vaultConflicts ? ( + + ) : null} + void; + t: (key: string, options?: Record) => string; +}) { + const visiblePaths = notice.paths.slice(0, 4); + const hiddenCount = Math.max(0, notice.paths.length - visiblePaths.length); + + return ( +
+
+
+ +
+
+
+
+

+ {notice.kind === "external_modified" + ? t("notes.knowledgeVaultConflictTitle") + : t("notes.knowledgeVaultExistingTitle")} +

+

+ {notice.kind === "external_modified" + ? t("notes.knowledgeVaultConflictDescription") + : t("notes.knowledgeVaultExistingDescription")} +

+
+ +
+ +
+

{notice.rootPath}

+
+ {visiblePaths.map((path) => ( +

+ {path} +

+ ))} + {hiddenCount > 0 ? ( +

+ {t("notes.knowledgeVaultConflictMore", { count: hiddenCount })} +

+ ) : null} +
+
+
+
+
+ ); +} + function KnowledgeExportMenu({ onExport, + onExportVault, + isVaultExporting, t, }: { onExport: (format: KnowledgeExportFormat) => void; - t: (key: string) => string; + onExportVault: () => void; + isVaultExporting: boolean; + t: (key: string, options?: Record) => string; }) { return ( @@ -977,6 +1243,11 @@ function KnowledgeExportMenu({ {t("notes.exportMarkdown")} + + + + {isVaultExporting ? t("notes.knowledgeVaultExporting") : t("notes.knowledgeExportVault")} + ); diff --git a/packages/core/src/i18n/locales/en/notes.json b/packages/core/src/i18n/locales/en/notes.json index 522dcb41..dc7cbe83 100644 --- a/packages/core/src/i18n/locales/en/notes.json +++ b/packages/core/src/i18n/locales/en/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "Export vault folder", + "knowledgeVaultExporting": "Exporting...", + "knowledgeVaultSelectFolder": "Choose a knowledge vault folder", + "knowledgeVaultExportSuccess": "Knowledge vault exported", + "knowledgeVaultExportSuccessDetail": "Wrote {{count}} files", + "knowledgeVaultExportFailed": "Failed to export knowledge vault", + "knowledgeVaultManifestInvalid": "Could not read the ReadAny manifest in this folder", + "knowledgeVaultConflictToast": "Export conflicts found. Resolve them before exporting again.", + "knowledgeVaultConflictTitle": "External edits found in Obsidian", + "knowledgeVaultConflictDescription": "These files changed after the last ReadAny export. Export stopped so your edits are not overwritten.", + "knowledgeVaultExistingTitle": "Matching files already exist", + "knowledgeVaultExistingDescription": "This folder does not have a ReadAny manifest yet. Choose an empty folder or move the matching files before exporting.", + "knowledgeVaultConflictMore": "{{count}} more files hidden", "exportNotion": "Notion (Copy)", "exportedOn": "Exported on {{date}}", "filters": "Filters", diff --git a/packages/core/src/i18n/locales/es/notes.json b/packages/core/src/i18n/locales/es/notes.json index 13ff3f84..e44bbbf0 100644 --- a/packages/core/src/i18n/locales/es/notes.json +++ b/packages/core/src/i18n/locales/es/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "Exportar carpeta de conocimiento", + "knowledgeVaultExporting": "Exportando...", + "knowledgeVaultSelectFolder": "Elegir carpeta de conocimiento", + "knowledgeVaultExportSuccess": "Carpeta de conocimiento exportada", + "knowledgeVaultExportSuccessDetail": "{{count}} archivos escritos", + "knowledgeVaultExportFailed": "No se pudo exportar la carpeta de conocimiento", + "knowledgeVaultManifestInvalid": "No se pudo leer el manifest de ReadAny en esta carpeta", + "knowledgeVaultConflictToast": "Se encontraron conflictos de exportación. Revísalos antes de volver a exportar.", + "knowledgeVaultConflictTitle": "Cambios externos detectados en Obsidian", + "knowledgeVaultConflictDescription": "Estos archivos cambiaron después de la última exportación de ReadAny. La exportación se detuvo para no sobrescribir tu contenido.", + "knowledgeVaultExistingTitle": "Ya existen archivos con el mismo nombre", + "knowledgeVaultExistingDescription": "Esta carpeta todavía no tiene un manifest de ReadAny. Elige una carpeta vacía o mueve los archivos coincidentes antes de exportar.", + "knowledgeVaultConflictMore": "{{count}} archivos más ocultos", "exportNotion": "Notion (Copiar)", "exportedOn": "Exportado el {{date}}", "filters": "Filtros", diff --git a/packages/core/src/i18n/locales/fr/notes.json b/packages/core/src/i18n/locales/fr/notes.json index a3a731a8..2b450f20 100644 --- a/packages/core/src/i18n/locales/fr/notes.json +++ b/packages/core/src/i18n/locales/fr/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "Exporter le dossier de connaissances", + "knowledgeVaultExporting": "Exportation...", + "knowledgeVaultSelectFolder": "Choisir un dossier de connaissances", + "knowledgeVaultExportSuccess": "Dossier de connaissances exporté", + "knowledgeVaultExportSuccessDetail": "{{count}} fichiers écrits", + "knowledgeVaultExportFailed": "Échec de l'export du dossier de connaissances", + "knowledgeVaultManifestInvalid": "Impossible de lire le manifest ReadAny de ce dossier", + "knowledgeVaultConflictToast": "Conflits d'export détectés. Résolvez-les avant de réessayer.", + "knowledgeVaultConflictTitle": "Modifications externes détectées dans Obsidian", + "knowledgeVaultConflictDescription": "Ces fichiers ont changé depuis le dernier export ReadAny. L'export est arrêté pour éviter d'écraser vos contenus.", + "knowledgeVaultExistingTitle": "Des fichiers du même nom existent déjà", + "knowledgeVaultExistingDescription": "Ce dossier n'a pas encore de manifest ReadAny. Choisissez un dossier vide ou déplacez les fichiers concernés avant d'exporter.", + "knowledgeVaultConflictMore": "{{count}} autres fichiers masqués", "exportNotion": "Notion (Copier)", "exportedOn": "Exporté le {{date}}", "filters": "Filtres", diff --git a/packages/core/src/i18n/locales/ja/notes.json b/packages/core/src/i18n/locales/ja/notes.json index 3aba2622..39105687 100644 --- a/packages/core/src/i18n/locales/ja/notes.json +++ b/packages/core/src/i18n/locales/ja/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "ナレッジフォルダーを書き出す", + "knowledgeVaultExporting": "書き出し中...", + "knowledgeVaultSelectFolder": "ナレッジの書き出し先フォルダーを選択", + "knowledgeVaultExportSuccess": "ナレッジフォルダーを書き出しました", + "knowledgeVaultExportSuccessDetail": "{{count}} 個のファイルを書き込みました", + "knowledgeVaultExportFailed": "ナレッジフォルダーの書き出しに失敗しました", + "knowledgeVaultManifestInvalid": "このフォルダーの ReadAny manifest を読み取れません", + "knowledgeVaultConflictToast": "書き出しの競合があります。先に競合ファイルを確認してください。", + "knowledgeVaultConflictTitle": "Obsidian で外部編集があります", + "knowledgeVaultConflictDescription": "これらのファイルは前回の ReadAny 書き出し後に変更されています。内容を上書きしないよう、書き出しを停止しました。", + "knowledgeVaultExistingTitle": "同名ファイルが既にあります", + "knowledgeVaultExistingDescription": "このフォルダーにはまだ ReadAny manifest がありません。空のフォルダーを選ぶか、同名ファイルを整理してから書き出してください。", + "knowledgeVaultConflictMore": "ほか {{count}} 件のファイルがあります", "exportNotion": "Notion(コピー)", "exportedOn": "{{date}}にエクスポート", "filters": "フィルター", diff --git a/packages/core/src/i18n/locales/ko/notes.json b/packages/core/src/i18n/locales/ko/notes.json index 719d4265..d2123d11 100644 --- a/packages/core/src/i18n/locales/ko/notes.json +++ b/packages/core/src/i18n/locales/ko/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "지식 보관함 폴더 내보내기", + "knowledgeVaultExporting": "내보내는 중...", + "knowledgeVaultSelectFolder": "지식 보관함 내보낼 폴더 선택", + "knowledgeVaultExportSuccess": "지식 보관함 폴더를 내보냈어요", + "knowledgeVaultExportSuccessDetail": "{{count}}개 파일을 썼어요", + "knowledgeVaultExportFailed": "지식 보관함 폴더 내보내기 실패", + "knowledgeVaultManifestInvalid": "이 폴더의 ReadAny manifest를 읽을 수 없어요", + "knowledgeVaultConflictToast": "내보내기 충돌이 있어요. 충돌 파일을 먼저 확인해 주세요.", + "knowledgeVaultConflictTitle": "Obsidian에서 외부 수정이 발견됐어요", + "knowledgeVaultConflictDescription": "이 파일들은 마지막 ReadAny 내보내기 이후 변경됐어요. 내용을 덮어쓰지 않도록 내보내기를 중단했어요.", + "knowledgeVaultExistingTitle": "같은 이름의 파일이 이미 있어요", + "knowledgeVaultExistingDescription": "이 폴더에는 아직 ReadAny manifest가 없어요. 빈 폴더를 선택하거나 같은 이름의 파일을 정리한 뒤 다시 내보내 주세요.", + "knowledgeVaultConflictMore": "그 외 {{count}}개 파일", "exportNotion": "Notion (복사)", "exportedOn": "{{date}} 내보냄", "filters": "필터", diff --git a/packages/core/src/i18n/locales/zh-TW/notes.json b/packages/core/src/i18n/locales/zh-TW/notes.json index c02f832e..cf7eda78 100644 --- a/packages/core/src/i18n/locales/zh-TW/notes.json +++ b/packages/core/src/i18n/locales/zh-TW/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "匯出知識庫資料夾", + "knowledgeVaultExporting": "匯出中...", + "knowledgeVaultSelectFolder": "選擇知識庫匯出資料夾", + "knowledgeVaultExportSuccess": "知識庫資料夾已匯出", + "knowledgeVaultExportSuccessDetail": "已寫入 {{count}} 個檔案", + "knowledgeVaultExportFailed": "知識庫資料夾匯出失敗", + "knowledgeVaultManifestInvalid": "無法讀取此資料夾的 ReadAny manifest", + "knowledgeVaultConflictToast": "偵測到匯出衝突,請先處理衝突檔案", + "knowledgeVaultConflictTitle": "Obsidian 中有外部修改", + "knowledgeVaultConflictDescription": "這些檔案在上次 ReadAny 匯出後被修改過。為避免覆蓋你的內容,本次匯出已停止。", + "knowledgeVaultExistingTitle": "目標資料夾已有同名檔案", + "knowledgeVaultExistingDescription": "這個資料夾還沒有 ReadAny manifest。請換一個空資料夾,或手動處理同名檔案後再匯出。", + "knowledgeVaultConflictMore": "還有 {{count}} 個檔案未顯示", "exportNotion": "Notion (複製)", "exportedOn": "匯出時間:{{date}}", "filters": "篩選", diff --git a/packages/core/src/i18n/locales/zh/notes.json b/packages/core/src/i18n/locales/zh/notes.json index 9bc9396a..a2985ea0 100644 --- a/packages/core/src/i18n/locales/zh/notes.json +++ b/packages/core/src/i18n/locales/zh/notes.json @@ -26,6 +26,19 @@ "exportMarkdown": "Markdown", "exportJSON": "JSON", "exportObsidian": "Obsidian", + "knowledgeExportVault": "导出知识库文件夹", + "knowledgeVaultExporting": "导出中...", + "knowledgeVaultSelectFolder": "选择知识库导出文件夹", + "knowledgeVaultExportSuccess": "知识库文件夹已导出", + "knowledgeVaultExportSuccessDetail": "已写入 {{count}} 个文件", + "knowledgeVaultExportFailed": "知识库文件夹导出失败", + "knowledgeVaultManifestInvalid": "无法读取此文件夹的 ReadAny manifest", + "knowledgeVaultConflictToast": "检测到导出冲突,请先处理冲突文件", + "knowledgeVaultConflictTitle": "Obsidian 中有外部修改", + "knowledgeVaultConflictDescription": "这些文件在上次 ReadAny 导出后被修改过。为避免覆盖你的内容,本次导出已停止。", + "knowledgeVaultExistingTitle": "目标文件夹已有同名文件", + "knowledgeVaultExistingDescription": "这个文件夹还没有 ReadAny manifest。请换一个空文件夹,或手动处理同名文件后再导出。", + "knowledgeVaultConflictMore": "还有 {{count}} 个文件未显示", "exportNotion": "Notion (复制)", "exportedOn": "导出时间:{{date}}", "filters": "筛选", From d0fb91dd2e167ee7c4613ab559fb10aeacb69e13 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 06:49:29 +0800 Subject: [PATCH 022/409] feat(desktop): export full knowledge vault --- .../app/src/components/notes/NotesPage.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index e5f7257e..8c721d2b 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -15,6 +15,9 @@ import type { HighlightWithBook } from "@/lib/db/database"; import { ensureBookHomeDocument, getBook as getBookRecord, + getKnowledgeAttachments, + getKnowledgeDocuments, + getKnowledgeLinks, updateKnowledgeDocument, } from "@/lib/db/database"; import { openDesktopBook } from "@/lib/library/open-book"; @@ -31,7 +34,7 @@ import { } from "@readany/core/export"; import { markdownToBasicTiptap, renderKnowledgeJsonToMarkdown } from "@readany/core/knowledge"; import { sortAnnotationsByPosition } from "@readany/core/reader"; -import type { Highlight, KnowledgeDocument, Note } from "@readany/core/types"; +import type { Book, Highlight, KnowledgeDocument, Note } from "@readany/core/types"; import { HIGHLIGHT_COLOR_HEX } from "@readany/core/types"; import { cn } from "@readany/core/utils"; import { eventBus } from "@readany/core/utils/event-bus"; @@ -186,6 +189,25 @@ async function writeKnowledgeVaultFiles( } } +async function collectKnowledgeVaultInput(liveDocument: KnowledgeDocument, books: Book[]) { + const documents = await getKnowledgeDocuments({ limit: 5000 }); + const documentMap = new Map(documents.map((document) => [document.id, document])); + documentMap.set(liveDocument.id, liveDocument); + const mergedDocuments = Array.from(documentMap.values()); + + const [linksByDocument, attachmentsByDocument] = await Promise.all([ + Promise.all(mergedDocuments.map((document) => getKnowledgeLinks(document.id))), + Promise.all(mergedDocuments.map((document) => getKnowledgeAttachments(document.id))), + ]); + + return { + documents: mergedDocuments, + books, + links: linksByDocument.flat(), + attachments: attachmentsByDocument.flat(), + }; +} + // Helper component to resolve and display cover images interface CoverImageProps extends React.ImgHTMLAttributes { url: string | undefined | null; @@ -543,8 +565,6 @@ export function NotesPage() { const handleKnowledgeVaultExport = async () => { if (!selectedBook || !knowledgeHome || isKnowledgeVaultExporting) return; - const book = books.find((b) => b.id === selectedBook.bookId); - if (!book) return; setIsKnowledgeVaultExporting(true); setKnowledgeVaultConflicts(null); @@ -565,7 +585,7 @@ export function NotesPage() { excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), updatedAt: Date.now(), }; - const input = { documents: [liveDocument], books: [book] }; + const input = await collectKnowledgeVaultInput(liveDocument, books); let previousManifest: KnowledgeExportManifest | undefined; try { From 8996f43959e51120a456e0ca1f0bc30dbf5fb506 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 11 Jun 2026 07:11:54 +0800 Subject: [PATCH 023/409] feat(desktop): manage book knowledge documents --- .../app/src/components/notes/NotesPage.tsx | 329 +++++++++++++++++- packages/core/src/i18n/locales/en/notes.json | 12 + packages/core/src/i18n/locales/es/notes.json | 12 + packages/core/src/i18n/locales/fr/notes.json | 12 + packages/core/src/i18n/locales/ja/notes.json | 12 + packages/core/src/i18n/locales/ko/notes.json | 12 + .../core/src/i18n/locales/zh-TW/notes.json | 12 + packages/core/src/i18n/locales/zh/notes.json | 12 + 8 files changed, 401 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/notes/NotesPage.tsx b/packages/app/src/components/notes/NotesPage.tsx index 8c721d2b..ebec7ced 100644 --- a/packages/app/src/components/notes/NotesPage.tsx +++ b/packages/app/src/components/notes/NotesPage.tsx @@ -13,6 +13,7 @@ import { MarkdownEditor } from "@/components/ui/markdown-editor"; import { useResolvedSrc, useSyncVersion } from "@/hooks/use-resolved-src"; import type { HighlightWithBook } from "@/lib/db/database"; import { + createKnowledgeDocument, ensureBookHomeDocument, getBook as getBookRecord, getKnowledgeAttachments, @@ -49,6 +50,7 @@ import { FolderUp, Highlighter, NotebookPen, + Plus, Save, Search, Sparkles, @@ -97,6 +99,26 @@ function knowledgeValueFingerprint(value: KnowledgeEditorValue): string { return `${value.contentMd}\n${JSON.stringify(value.contentJson)}`; } +function knowledgeDocumentFingerprint(title: string, value: KnowledgeEditorValue): string { + return `${title.trim()}\n${knowledgeValueFingerprint(value)}`; +} + +function orderKnowledgeDocuments( + documents: KnowledgeDocument[], + homeDocumentId?: string, +): KnowledgeDocument[] { + const uniqueDocuments = Array.from( + new Map(documents.map((document) => [document.id, document])).values(), + ); + return uniqueDocuments.sort((left, right) => { + if (left.id === homeDocumentId) return -1; + if (right.id === homeDocumentId) return 1; + if (left.type === "book_home") return -1; + if (right.type === "book_home") return 1; + return right.updatedAt - left.updatedAt || right.createdAt - left.createdAt; + }); +} + function createKnowledgeValueFromDocument(document: KnowledgeDocument): KnowledgeEditorValue { const shouldImportMarkdown = !!document.contentMd.trim() && isEmptyTiptapDocument(document.contentJson); @@ -257,20 +279,26 @@ export function NotesPage() { const [editingId, setEditingId] = useState(null); const [editNote, setEditNote] = useState(""); const [knowledgeHome, setKnowledgeHome] = useState(null); + const [knowledgeDocuments, setKnowledgeDocuments] = useState([]); + const [selectedKnowledgeDocumentId, setSelectedKnowledgeDocumentId] = useState( + null, + ); + const [knowledgeTitle, setKnowledgeTitle] = useState(""); const [knowledgeValue, setKnowledgeValue] = useState(createEmptyKnowledgeValue); const [savedKnowledgeFingerprint, setSavedKnowledgeFingerprint] = useState( - knowledgeValueFingerprint(createEmptyKnowledgeValue()), + knowledgeDocumentFingerprint("", createEmptyKnowledgeValue()), ); const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); + const [isKnowledgeDocumentCreating, setIsKnowledgeDocumentCreating] = useState(false); const [isKnowledgeVaultExporting, setIsKnowledgeVaultExporting] = useState(false); const [knowledgeVaultConflicts, setKnowledgeVaultConflicts] = useState(null); const knowledgeSaveVersionRef = useRef(0); const currentKnowledgeFingerprint = useMemo( - () => knowledgeValueFingerprint(knowledgeValue), - [knowledgeValue], + () => knowledgeDocumentFingerprint(knowledgeTitle, knowledgeValue), + [knowledgeTitle, knowledgeValue], ); useEffect(() => { @@ -403,9 +431,12 @@ export function NotesPage() { if (!selectedKnowledgeBookId) { setKnowledgeHome(null); + setKnowledgeDocuments([]); + setSelectedKnowledgeDocumentId(null); + setKnowledgeTitle(""); const emptyValue = createEmptyKnowledgeValue(); setKnowledgeValue(emptyValue); - setSavedKnowledgeFingerprint(knowledgeValueFingerprint(emptyValue)); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint("", emptyValue)); setIsKnowledgeSaving(false); return; } @@ -413,15 +444,27 @@ export function NotesPage() { setIsKnowledgeLoading(true); setIsKnowledgeSaving(false); try { - const document = await ensureBookHomeDocument( + const homeDocument = await ensureBookHomeDocument( selectedKnowledgeBookId, selectedKnowledgeBookTitle, ); + const bookDocuments = await getKnowledgeDocuments({ + bookId: selectedKnowledgeBookId, + limit: 200, + }); if (cancelled) return; - const nextValue = createKnowledgeValueFromDocument(document); - setKnowledgeHome(document); + const nextDocuments = orderKnowledgeDocuments( + [homeDocument, ...bookDocuments], + homeDocument.id, + ); + const activeDocument = nextDocuments[0] ?? homeDocument; + const nextValue = createKnowledgeValueFromDocument(activeDocument); + setKnowledgeDocuments(nextDocuments); + setSelectedKnowledgeDocumentId(activeDocument.id); + setKnowledgeHome(activeDocument); + setKnowledgeTitle(activeDocument.title); setKnowledgeValue(nextValue); - setSavedKnowledgeFingerprint(knowledgeValueFingerprint(nextValue)); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(activeDocument.title, nextValue)); } catch (error) { console.error("[Notes] Failed to load knowledge home:", error); toast.error(t("notes.knowledgeLoadFailed")); @@ -442,17 +485,39 @@ export function NotesPage() { const saveVersion = knowledgeSaveVersionRef.current + 1; knowledgeSaveVersionRef.current = saveVersion; + const normalizedTitle = knowledgeTitle.trim() || knowledgeHome.title; + const nextExcerpt = createKnowledgeExcerpt(knowledgeValue.contentMd); const timeout = window.setTimeout(async () => { + if (knowledgeSaveVersionRef.current !== saveVersion) return; setIsKnowledgeSaving(true); try { await updateKnowledgeDocument(knowledgeHome.id, { + title: normalizedTitle, contentMd: knowledgeValue.contentMd, contentJson: knowledgeValue.contentJson, - excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + excerpt: nextExcerpt, }); if (knowledgeSaveVersionRef.current !== saveVersion) return; - setSavedKnowledgeFingerprint(knowledgeValueFingerprint(knowledgeValue)); + const updatedDocument: KnowledgeDocument = { + ...knowledgeHome, + title: normalizedTitle, + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: nextExcerpt, + updatedAt: Date.now(), + }; + setKnowledgeHome(updatedDocument); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((document) => + document.id === updatedDocument.id ? updatedDocument : document, + ), + documents.find((document) => document.type === "book_home")?.id, + ), + ); + if (normalizedTitle !== knowledgeTitle) setKnowledgeTitle(normalizedTitle); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(normalizedTitle, knowledgeValue)); } catch (error) { if (knowledgeSaveVersionRef.current !== saveVersion) return; console.error("[Notes] Failed to save knowledge home:", error); @@ -465,7 +530,120 @@ export function NotesPage() { }, 700); return () => window.clearTimeout(timeout); - }, [knowledgeHome, knowledgeValue, currentKnowledgeFingerprint, savedKnowledgeFingerprint, t]); + }, [ + knowledgeHome, + knowledgeTitle, + knowledgeValue, + currentKnowledgeFingerprint, + savedKnowledgeFingerprint, + t, + ]); + + const saveActiveKnowledgeDocumentNow = async (): Promise => { + if (!knowledgeHome || currentKnowledgeFingerprint === savedKnowledgeFingerprint) return true; + + const saveVersion = knowledgeSaveVersionRef.current + 1; + knowledgeSaveVersionRef.current = saveVersion; + const normalizedTitle = knowledgeTitle.trim() || knowledgeHome.title; + const nextExcerpt = createKnowledgeExcerpt(knowledgeValue.contentMd); + + setIsKnowledgeSaving(true); + try { + await updateKnowledgeDocument(knowledgeHome.id, { + title: normalizedTitle, + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: nextExcerpt, + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return false; + const updatedDocument: KnowledgeDocument = { + ...knowledgeHome, + title: normalizedTitle, + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: nextExcerpt, + updatedAt: Date.now(), + }; + setKnowledgeHome(updatedDocument); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((document) => + document.id === updatedDocument.id ? updatedDocument : document, + ), + documents.find((document) => document.type === "book_home")?.id, + ), + ); + if (normalizedTitle !== knowledgeTitle) setKnowledgeTitle(normalizedTitle); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(normalizedTitle, knowledgeValue)); + return true; + } catch (error) { + if (knowledgeSaveVersionRef.current === saveVersion) { + console.error("[Notes] Failed to save knowledge document:", error); + toast.error(t("notes.knowledgeSaveFailed")); + } + return false; + } finally { + if (knowledgeSaveVersionRef.current === saveVersion) { + setIsKnowledgeSaving(false); + } + } + }; + + const openKnowledgeDocument = async (document: KnowledgeDocument) => { + if (document.id === knowledgeHome?.id) return; + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + knowledgeSaveVersionRef.current += 1; + const nextValue = createKnowledgeValueFromDocument(document); + setSelectedKnowledgeDocumentId(document.id); + setKnowledgeHome(document); + setKnowledgeTitle(document.title); + setKnowledgeValue(nextValue); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(document.title, nextValue)); + setIsKnowledgeSaving(false); + }; + + const handleCreateKnowledgeDocument = async () => { + if (!selectedKnowledgeBookId || isKnowledgeDocumentCreating) return; + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + setIsKnowledgeDocumentCreating(true); + try { + const document = await createKnowledgeDocument({ + bookId: selectedKnowledgeBookId, + type: "standalone_note", + title: t("notes.knowledgeNewDocumentTitle", { + count: Math.max(1, knowledgeDocuments.length), + }), + contentJson: createEmptyKnowledgeValue().contentJson, + contentMd: "", + excerpt: undefined, + tags: [], + sourceKind: "book", + sourceId: selectedKnowledgeBookId, + }); + const nextValue = createKnowledgeValueFromDocument(document); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + [document, ...documents], + documents.find((item) => item.type === "book_home")?.id, + ), + ); + setSelectedKnowledgeDocumentId(document.id); + setKnowledgeHome(document); + setKnowledgeTitle(document.title); + setKnowledgeValue(nextValue); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(document.title, nextValue)); + toast.success(t("notes.knowledgeDocumentCreated")); + } catch (error) { + console.error("[Notes] Failed to create knowledge document:", error); + toast.error(t("notes.knowledgeDocumentCreateFailed")); + } finally { + setIsKnowledgeDocumentCreating(false); + } + }; const handleOpenBook = async (bookId: string, _title: string, cfi?: string) => { const book = @@ -539,6 +717,7 @@ export function NotesPage() { try { const liveDocument: KnowledgeDocument = { ...knowledgeHome, + title: knowledgeTitle.trim() || knowledgeHome.title, contentJson: knowledgeValue.contentJson, contentMd: knowledgeValue.contentMd, excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), @@ -580,6 +759,7 @@ export function NotesPage() { const liveDocument: KnowledgeDocument = { ...knowledgeHome, + title: knowledgeTitle.trim() || knowledgeHome.title, contentJson: knowledgeValue.contentJson, contentMd: knowledgeValue.contentMd, excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), @@ -927,11 +1107,18 @@ export function NotesPage() { handleOpenBook(selectedBook.bookId, selectedBook.title, cfi)} @@ -1017,11 +1204,18 @@ interface KnowledgeHomePanelProps { highlightsOnlyCount: number; }; document: KnowledgeDocument | null; + documents: KnowledgeDocument[]; + activeDocumentId: string | null; + title: string; value: KnowledgeEditorValue; isLoading: boolean; isSaving: boolean; + isCreatingDocument: boolean; isSaved: boolean; + onTitleChange: (title: string) => void; onChange: (value: KnowledgeEditorValue) => void; + onSelectDocument: (document: KnowledgeDocument) => void; + onCreateDocument: () => void; onExport: (format: KnowledgeExportFormat) => void; onExportVault: () => void; onOpenBook: (cfi?: string) => void; @@ -1034,11 +1228,18 @@ interface KnowledgeHomePanelProps { function KnowledgeHomePanel({ book, document, + documents, + activeDocumentId, + title, value, isLoading, isSaving, + isCreatingDocument, isSaved, + onTitleChange, onChange, + onSelectDocument, + onCreateDocument, onExport, onExportVault, onOpenBook, @@ -1072,7 +1273,14 @@ function KnowledgeHomePanel({

{t("notes.knowledgeEyebrow")}

-

{book.title}

+ onTitleChange(event.target.value)} + aria-label={t("notes.knowledgeDocumentTitle")} + placeholder={t("notes.knowledgeUntitledDocument")} + className="mt-1 w-full min-w-0 truncate bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground focus-visible:text-primary" + /> +

{book.title}

@@ -1111,6 +1319,15 @@ function KnowledgeHomePanel({