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..ac42afa5 --- /dev/null +++ b/docs/knowledge-base-notes/01-current-state.md @@ -0,0 +1,160 @@ +# Current State + +## Data Model + +The original 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. + +The current knowledge-base branch adds the document graph that this model was +missing: + +- `knowledge_documents` + - Stores canonical Tiptap JSON plus deterministic Markdown projection. + - Covers book home pages, standalone notes, highlight notes, reviews, + summaries, imported Markdown, and compact AI memory fields. +- `knowledge_links` + - Connects documents to books, highlights, CFIs, URLs, Obsidian paths, and + other knowledge documents. +- `knowledge_attachments` + - Represents document-owned assets for editor rendering, export, and file + sync workflows. + - Desktop and mobile can persist local image attachments into the app data + directory, attach them to a knowledge document, and insert portable image + nodes that keep the attachment ID. +- `knowledge_card_templates` + - Stores built-in and user-created card templates. + +## Desktop Editing + +Desktop still has the legacy Tiptap-based Markdown editor for older note flows: + +- 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. + +The knowledge-base branch also introduces the reusable document editor: + +- File: `packages/app/src/components/knowledge/KnowledgeEditor.tsx` +- Uses canonical Tiptap JSON and shared Markdown projection. +- Supports surface profiles, inline marks, headings, lists, task lists, source + references, internal links, and ReadAny card nodes. +- Loads synced card templates and renders card node views for desktop. + +## Mobile Editing + +Mobile still uses the native React Native editor in legacy quick-note surfaces: + +- 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. + +The knowledge-base branch now ships a WebView-backed Tiptap editor for knowledge +documents: + +- File: `packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx` +- Bundle: `packages/app-expo/assets/editor/knowledge-editor.html`, generated by + `packages/app-expo/scripts/build-knowledge-editor.js`. +- Uses a typed RN bridge for init, content changes, selection state, height, + commands, errors, retry, fallback editing, and draft recovery. +- Reuses the shared editor profiles and card registry from core. + +## 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` + +The original tabs are essentially: + +- Notes: highlights with `note` +- Highlights: highlights without `note` + +The knowledge-base branch adds a Knowledge tab with: + +- Auto-created book home documents. +- Additional standalone/review/summary documents per book. +- Tags, document strip navigation, backlinks, related links, recent excerpts, + export, and compact AI memory controls. + +## Export + +The original annotation exporter supports: + +- Markdown +- JSON +- Obsidian Markdown with frontmatter and callouts +- Notion-friendly clipboard text + +The knowledge-base branch adds a document graph exporter: + +- File: `packages/core/src/export/knowledge-exporter.ts` +- Exports book home, highlight notes, standalone notes, reviews, summaries, + links, attachments, frontmatter, manifests, and linked-vault packages. +- ReadAny cards render to Obsidian-friendly callouts by default. +- Optional `:::readany-card ... :::` metadata blocks preserve card attrs for + round-tripping. + +## 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. + +The knowledge tables are now part of simple sync. File-backed knowledge +attachments are also included in file sync manifests: local attachment files can +upload to `/readany/data/knowledge/attachments`, missing local files can be +downloaded from manifest paths, and stale local remote paths are reconciled from +the manifest. Obsidian vault outputs remain export/import artifacts rather than +the sync backbone. + +## 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` +- `packages/core/src/db/__tests__/knowledge-queries.test.ts` +- `packages/core/src/knowledge/*.test.ts` +- `packages/core/src/export/knowledge-exporter.test.ts` +- `packages/core/src/ai/tools/knowledge-tools.test.ts` + +The next feature should add tests at the DB, store, conversion, and sync layers +before large UI work lands. + +## Current Risks + +- Legacy `highlights.note` and knowledge documents can diverge if both remain + writable without compatibility projection. +- Markdown projection and Markdown import must keep expanding as cards, + attachments, and Obsidian round-trips get more capable. +- Mobile WebView editing has robust bridge/error/draft behavior now, but still + needs device-level validation across iOS and Android keyboards. +- 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..b7a0c5c8 --- /dev/null +++ b/docs/knowledge-base-notes/02-target-architecture.md @@ -0,0 +1,270 @@ +# 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. + - Participates in a vault-style hierarchy through `parent_id`; folders and + documents are first-class siblings, not a flat note list with fake labels. +- 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. + +## Vault Hierarchy + +The knowledge base should feel closer to an Obsidian vault than a notes table. +The tree is part of the product model, not only a view concern. + +This hierarchy is not the same thing as tags or book groups. Tags answer +"what is this about?" while the vault tree answers "where does this document +live?" ReadAny must support both, but folder structure is the user's spatial +organization and should remain visible in navigation, breadcrumbs, export paths, +and sync reconciliation. + +Rules: + +- A book owns one `book_home` document at the root of its knowledge vault. +- `folder` documents are structural nodes. They can contain other folders, + standalone notes, reviews, summaries, imported Markdown, and AI-created + documents. +- Non-folder documents can have a `parent_id`, but they cannot contain children. +- `book_home` is pinned at the top and cannot be moved under another document. +- Moving a document updates only `parent_id`; content and links remain stable. +- Cycles are invalid. If a parent is missing after import or sync, the document + is treated as an orphaned root and surfaced in the UI rather than hidden. +- Search should ignore collapse state and search the whole vault. +- AI tools and importers must accept `parentId` so proposed documents land in + the user's chosen folder instead of always appearing at the root. +- WYSIWYG document editing is the primary surface. Markdown paths are kept for + projection, import/export, search, Obsidian, and fallback, not as the default + editing UI. + +Navigation model: + +- Desktop uses a persistent left tree, a center editor, and optional right + context. The tree must show indentation, folder expand/collapse, active path, + and quick create/move affordances. +- Mobile uses a native vault browser first, then a focused editor screen. It + should show the current path and use bottom sheets for create/move actions so + the editor remains calm. +- The current path is a real breadcrumb: `Knowledge base / Folder / Document`. + It should be visible near the document title and preserved through export. +- Folder nodes open a folder browsing surface. They should not show a blank + writing editor unless the product later introduces folder README content. + +Export model: + +- Obsidian export maps the ReadAny hierarchy to folders where possible. +- Stable document IDs stay in frontmatter and manifest data so renames and moves + can be reconciled safely. +- Folder documents can export as folder-level `README.md` or index files when + they contain body content; empty folders can still appear in the manifest. + +## 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..53612a2e --- /dev/null +++ b/docs/knowledge-base-notes/03-editor-cards-obsidian.md @@ -0,0 +1,842 @@ +# Editor, Cards, and Obsidian + +## Editor Direction + +Use one shared Tiptap editor model across desktop and mobile. + +The editor is the user's document surface. It should not feel like a Markdown +source editor, a JSON-backed card builder, or a long metadata form. The user +should be able to click into the title/body, write directly, insert structured +blocks, and trust autosave. + +The editor must also understand the vault hierarchy around it: + +- Internal links should show document titles while preserving target IDs and + paths. +- Slash/insert menus should create blocks inside the current document, not open + unrelated management pages. +- Source cards should preserve book position and CFI so citations can jump back + to the reader. +- Exported Markdown should follow the document's folder path. +- AI tools should propose edits against a specific document ID/path, not an + ambiguous note title. + +Desktop: + +- Use the reusable `KnowledgeEditor` for knowledge documents while keeping the + current note-only `MarkdownEditor` for legacy quick-note surfaces until those + flows are migrated. +- Keep React Tiptap and the existing visual language. +- Save canonical JSON plus Markdown projection. + +Mobile: + +- Use the WebView-backed Tiptap editor for knowledge documents while the native + `RichTextEditor` remains limited to legacy quick-note surfaces. +- 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 now ships a generated Tiptap editor bundle at + `packages/app-expo/assets/editor/knowledge-editor.html`; rebuild it with + `packages/app-expo/scripts/build-knowledge-editor.js` when WebView editor + extensions change. +- 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. + +### WYSIWYG Authoring Boundary + +ReadAny should store Markdown for portability, but users should author in rich +blocks by default. + +Default authoring: + +- Directly editable title. +- Paragraphs, headings, lists, quotes, links, images, source cards, AI cards, + callouts, diagrams, reviews, and custom ReadAny cards rendered in-place. +- Contextual toolbar for selection. +- Slash/insert menu for block creation. +- Autosave with quiet status. + +Advanced or secondary affordances: + +- Markdown import/export. +- Obsidian linked-folder reconcile. +- Raw Markdown/source inspection for debugging or power users later. + +Do not make raw Markdown the normal editing UI. If the user sees Markdown syntax +while writing, it should be because they intentionally typed a shortcut and the +editor is about to transform it into a rich block. + +## Markdown and Obsidian Import + +Desktop now has two confirmation-based import paths: + +- Markdown file import: choose one or more `.md` / `.markdown` files and turn + them into new knowledge document proposals for the currently open book + workspace. +- Obsidian vault reconcile: choose a previously exported ReadAny vault folder, + read `.readany/manifest.json`, detect modified, missing, and unreadable files, + then create update proposals for changed documents. + +Both paths reuse the same knowledge write proposal safety contract used by AI +tools. Reading files and generating proposals does not mutate the local +database. ReadAny writes only after the user confirms the preview card. + +Import rules: + +- Ordinary Markdown becomes an `imported_markdown` document unless ReadAny + frontmatter provides a stronger document type. +- ReadAny frontmatter preserves stable document IDs, document type, book ID, + source fields, title, tags, and timestamps when present. +- Generated export sections such as `ReadAny Links` and `Attachments` are + stripped on re-import so they do not pollute the editable body. +- ReadAny card metadata blocks are preserved as readable Markdown fallbacks plus + card attrs where the importer understands them. +- Missing or unreadable vault files are surfaced in the preview and are not + silently treated as deletes. + +## Rich Text Preservation Contract + +The editor must be designed around what ReadAny must never lose. This is more +important than exposing a large toolbar. + +Rich text must survive this full loop: + +```text +desktop edit + -> save + -> sync + -> mobile edit + -> export / Obsidian projection + -> AI retrieval +``` + +Required preservation: + +- Document structure: paragraphs, headings H1-H3, ordered lists, bullet lists, + task lists, blockquotes, horizontal rules, and code blocks where the surface + allows them. +- Inline marks: bold, italic, strikethrough, inline code, and links. +- Source evidence: quoted text, book id, highlight id, chapter title, CFI, + source range, and source card attrs. +- Knowledge links: internal document links, backlinks, related-note cards, and + external URLs. +- ReadAny cards: card type, version, attrs, readable body, and Markdown fallback. +- Attachments: image/file asset id, original filename, mime type, local/cloud + reference, and alt text. +- AI provenance: AI card type, generated content, source citations, tool failure + state, and whether the card has been converted into normal editable content. + +Lossy rendering is acceptable only when the user leaves ReadAny. For example, +Obsidian export can render a ReadAny card as a callout plus frontmatter. Inside +ReadAny, unsupported or older card versions should render as safe fallback +cards, not silently strip data. + +Not preserved in v1: + +- Arbitrary font family, font size, text color, background color, or alignment. +- Raw HTML, iframe/embed blocks, freeform CSS, and multi-column layout blocks. +- Table editing until desktop, mobile WebView, Markdown projection, and + Obsidian export all pass the same table tests. +- Collaborative comments and CRDT metadata. + +This keeps the editor closer to Notion/Obsidian-style knowledge writing than a +Word processor. The schema stays strict so sync, mobile editing, export, and AI +tools remain reliable. + +## Rich Text Scope by Scenario + +The knowledge editor should not expose every rich-text feature everywhere. Each +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. + +### Retention Principle + +Rich text should be retained for five product jobs: + +- Capture: preserve quick emphasis, links, lists, and source quotes while the + user is still reading. +- Structure: let book home pages and standalone notes grow into outlines, + summaries, tasks, callouts, backlinks, and visual cards. +- Evidence: keep quotes, highlight links, CFI references, and citation cards + connected to the original book position. +- Publish: support clean long-form writing that exports to Markdown, Obsidian, + and shareable text without app-only blocks. +- Ownership: AI output can enter as structured cards, but the user can confirm, + edit, convert to normal content, or delete it. + +Rich text should not replace structured fields. Title, author, language, ISBN, +rating, tags, groups, dates, source paths, and sync metadata stay in native +forms/tables so they remain searchable, sortable, sync-safe, and AI-tool-safe. + +### 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. + +### 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. + +### Scenario Decisions + +The important product decision is not "support rich text" in the abstract. It is +where ReadAny must preserve rich structure, and where ReadAny should deliberately +stay structured or lightweight. + +| Scenario | Rich-text role | Required preservation | Product boundary | +| --- | --- | --- | --- | +| Reading selection note | Fast capture. | Inline marks, links, short lists, blockquote, source quote relation. | No full-page document controls while the user is reading. | +| Highlight note preview | Compatibility and continuity. | Existing note text, quote relation, CFI/source ID, Markdown fallback. | The preview can stay compact; expanding opens the real knowledge document. | +| Expanded highlight note | Turning a quote into reusable knowledge. | H2/H3, task list, callout, related-note link, source reference card. | No book metadata fields or large unrelated layout blocks. | +| Book home document | The main knowledge workspace for one book. | Headings, lists, callouts, quote cards, highlight collections, AI summary cards, diagrams, attachments, backlinks. | Metadata remains outside the editor so it stays queryable. | +| Standalone knowledge document | A normal knowledge-base note. | Headings, links, backlinks, tags, attachments, callouts, AI-assisted blocks. | Book-only cards appear only when the note is linked to a book. | +| Book review / essay | Publishable writing. | Export-safe headings, prose, images, source references, quote cards, Obsidian callouts. | Interactive AI controls and non-exportable cards must be converted before export. | +| AI generated content | Structured draft material. | Summary, Q&A, concept, timeline, mindmap, and cited quote cards. | AI can propose and insert after confirmation; it should not silently rewrite user text. | +| Obsidian import/export | Interoperability. | Markdown headings/lists/quotes/links/images/frontmatter/callouts/wikilinks. | Unsupported blocks degrade to readable Markdown plus ReadAny metadata. | + +### Capability Groups + +The toolbar and schema should be built from capability groups instead of one long +flat list. This keeps desktop powerful while preventing mobile and reader +surfaces from becoming visually heavy. + +| Group | Features | Used by default in | +| --- | --- | --- | +| Inline writing | paragraph, bold, italic, strike, inline code, links | all editor tiers | +| Lightweight structure | bullet list, ordered list, blockquote, undo/redo | quick notes, highlight notes, full documents | +| Document structure | H1-H3, horizontal rule, task list, code block | knowledge documents, reviews | +| Source evidence | source reference, quote card, CFI/highlight/book link | highlight notes, book home, reviews | +| Knowledge graph | internal links, backlinks, related-note card, tags outside editor | knowledge documents, reviews | +| Media and attachment | image, file attachment, asset reference | book home, standalone notes, reviews | +| ReadAny cards | callout, metadata, highlight collection, review, AI, diagram, related notes | book home and standalone knowledge documents | +| Export discipline | Markdown projection, Obsidian callout fallback, frontmatter | all persisted documents | + +Do not expose arbitrary colors, font family, font size, raw HTML, iframes, +multi-column layouts, or freeform CSS in v1. They look powerful, but they make +mobile editing, sync merging, Obsidian export, and AI/tool safety much harder. + +### 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 | + +The current core profile treats tables as a deferred feature. They are useful, +but they should only be exposed after the desktop editor, mobile WebView editor, +Markdown projection, and Obsidian export all handle tables consistently. + +### Quick Annotation Notes + +Used when the user selects text in the reader and quickly adds a note. + +Keep: + +- Paragraphs. +- Bold, italic, strikethrough. +- Inline code. +- Links. +- Bullet list and ordered list. +- Blockquote. +- Undo and redo. + +Avoid: + +- 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. 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. + +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 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 without losing the original quote position. + +### Book Home Documents + +Used as the main knowledge page for each book. + +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 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. + +Keep: + +- 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. + +Keep: + +- 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. +- App-only AI controls inside the final exported document. + +Reason: + +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. + +Keep: + +- ReadAny AI summary card. +- Q&A card. +- Concept card. +- Timeline card. +- Mermaid and mindmap cards. +- Source quote cards with citations. +- AI/tool failure card with readable error context. + +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 +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. +- Source/backlink panel. +- 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. +- AI/tool failure 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. +If a custom card has structured fields, exported Markdown and AI context should +render those fields as ordinary labeled text before any hidden ReadAny metadata. +The user should not need to inspect encoded JSON to understand the card. +Structured fields can be text, long text, number, checkbox, single choice, or +multiple choice fields. Choice fields keep stable values in card data while +rendering their user-facing labels in Markdown export, read-only HTML, and AI +context. +Fields can also declare simple visibility rules based on another field's value. +Hidden fields are kept in card data for safety, but they are not rendered in the +card details, read-only output, Markdown export, or AI context until their rule +matches. + +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. +The detailed workspace contract lives in +[Vault Workspace Layout](05-vault-workspace-layout.md). This section records the +editor-specific rules that must stay aligned with that layout. + +### Product Shape + +ReadAny's knowledge space should be a book-centered vault: + +- The left/navigation layer answers "where am I in this vault?" +- The editor answers "what am I writing?" +- The context layer answers "what sources, backlinks, AI outputs, and related + notes are connected to this document?" + +These jobs should not collapse into one large form. Metadata, tags, source +links, and AI proposals can support the document, but the center of gravity is a +WYSIWYG document canvas. + +The workspace should never make users feel they are editing raw Markdown or a +database row. They should be able to look at the screen and immediately +understand that they are inside a document at a specific path in a vault. + +### Desktop Layout + +Use a quiet three-zone layout: + +- Navigation rail: book/workspace switcher and a vault tree with folders, + document nodes, search, create, move, and collapse state. +- Editor canvas: breadcrumb, editable title, small status row, then the Tiptap + WYSIWYG page. The toolbar should be compact and contextual rather than a + permanent wall of buttons. +- Context panel: sources, backlinks, selected-card details, document outline, + AI proposals, and export/sync status. + +Visual rules: + +- Keep the editor page calm, with enough breathing room and no nested card + stacks around the main writing area. +- Use indentation, connector lines, and icon weight to communicate hierarchy. +- Prefer inline title editing and autosave over explicit save buttons. +- Empty folder states should invite one clear action: create a note or folder + inside the current folder. +- Long documents need an outline/context panel instead of making the main canvas + visually noisy. +- Folder screens should be navigational surfaces with child documents and + folders, not a blank editor wrapped in a decorative empty state. +- The document title, breadcrumb, body, and source cards should carry the + experience. Hero-style metric cards should stay out of the writing path. + +### Mobile Layout + +Mobile should not mirror the desktop grid. It needs two focused modes: + +- Vault browse mode: native list/tree, search, path display, create/move bottom + sheets, and compact folder overviews. +- Writing mode: a full-screen WYSIWYG editor with a native compact toolbar, + keyboard-aware controls, and card/image insertion through focused sheets. + +Mobile visual rules: + +- Show the current path near the title, but keep it compact. +- Avoid modal-on-modal editing. Deep edits should push into a focused screen or + bottom sheet. +- The document body should feel like a document, not a stack of input boxes. +- Folder screens should feel browsable: children as clear rows/cards, not a + hidden dropdown. +- Prefer a two-screen flow: a vault browser for hierarchy, then a focused + editor screen for writing. Do not stack explorer, editor, sources, and AI + panels in one long dashboard scroll. + +### WYSIWYG Contract + +The user should edit the thing they will later read, sync, export, and ask AI +about. Markdown remains a projection, not the primary interface. + +Required behavior: + +- Desktop and mobile both use Tiptap-backed document editing for knowledge + documents. +- Blocks such as headings, lists, quotes, images, source cards, callouts, and AI + cards render as real blocks in the editor. +- Markdown import/export must preserve enough structure that Obsidian round trips + do not destroy the document. +- Unsupported card versions must show readable fallback cards rather than raw + JSON or stripped content. +- URL images plus desktop and mobile local image attachments are the first asset + slice. Local image attachments now persist through `knowledge_attachments`, + render through portable attachment IDs, and participate in file sync + manifests. Broader non-image file cards and deeper Obsidian asset import are + still later polish. + +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..137989d5 --- /dev/null +++ b/docs/knowledge-base-notes/04-implementation-roadmap.md @@ -0,0 +1,337 @@ +# 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: completed as the initial design baseline. The current branch has moved +into layered runtime implementation. + +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 + +Status: implemented on the current branch; keep expanding tests when the model +changes. + +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 + +Status: implemented as an active MVP on the current branch; polish and +compatibility work continues. The next desktop milestone is layout correction: +make hierarchy and the WYSIWYG document surface obvious before adding more +secondary features. + +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. +- Treat knowledge documents as a vault hierarchy, not a flat list: + - Add folder documents. + - Build a tree from `parent_id`. + - Support create-inside-folder, move-to-folder, breadcrumbs, orphan surfacing, + and search across the whole tree. +- Align the desktop UI with the workspace contract: + - Left vault navigator for hierarchy. + - Center WYSIWYG document canvas. + - Right collapsible context panel for sources, backlinks, outline, and AI + memory. + - Reduce card nesting around the editor so the document, not the chrome, is + the visual focus. + - Make folder nodes open folder browsers, not blank document editors. + - Keep breadcrumbs, tree ancestry, export paths, and move destinations + visually consistent. +- 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. +- Folder hierarchy persists, syncs, and survives invalid or missing parents + without hiding documents. +- The editor reads as a WYSIWYG document canvas, not a textarea or metadata + form. +- 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 + +Status: implemented for knowledge documents on the current branch. Legacy quick +annotation surfaces still use lightweight native editors by design. The next +mobile milestone is interaction correction: split vault browsing and document +editing into clear modes instead of one stacked screen. The mobile vault +browser now exposes labeled header actions for folder/note creation so the +first browsing mode is understandable without relying on icon recognition. + +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`. +- Rework the knowledge mobile layout into a native vault-browser screen and a + focused document-editor screen instead of one long stacked dashboard. +- Use bottom sheets for create, move, insert-card, image, and context actions. +- Preserve path awareness in mobile: current folder path, create destination, + move destination, and document header should all describe the same place. +- Keep long-form writing inside the WebView document editor; use sheets only + for short configuration and context. + +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. +- Folder screens behave like browsers with child rows, not empty editor pages. + +## Phase 4: Export and Obsidian v1 + +Status: implemented as a desktop v1 on the current branch; mobile Markdown file +picker import is now available, while mobile inbound share-extension import and +automated or block-level conflict merging remain future work. Document export, +vault package generation, manifests, attachment path planning, conflict +detection with resolution guidance, ReadAny card fallbacks, Markdown file +import, and linked-folder import/reconcile exist. +Linked-folder import also recognizes folder-level `README.md` and `index.md` +aliases when resolving path-backed internal links, and restores non-built-in +card template snapshots from the vault manifest after user confirmation while +surfacing newer local template conflicts with safe local-default guidance. +Desktop conflict and import-review cards now also expose optional Obsidian URI +actions for opening exported files and searching the selected vault folder; this +is a convenience layer only, not a sync backbone. + +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. +- Add desktop Markdown file import as confirmation-required create proposals. +- Add mobile Markdown file import as confirmation-required create proposals. +- Preserve ordinary Markdown file path hierarchy by generating confirmation + proposals for missing folder documents before child documents. +- Add desktop linked-folder import/reconcile as confirmation-required update + proposals. +- Preserve document hierarchy when exporting to an Obsidian-style vault and when + reconciling imported files. +- Add optional Obsidian URI helpers and desktop actions for opening exported + files or searching the selected vault from conflict/import review surfaces. + +Verification: + +- Exported Markdown opens cleanly in Obsidian. +- Wikilinks and assets resolve. +- Re-export updates existing files by ID. +- External edits are detected before overwrite. +- Folder moves and renames reconcile by stable document ID, not only by path. +- Markdown imports preview the target documents before saving. +- Vault imports surface modified, missing, unreadable, duplicate, and + local-and-external conflict states with safe resolution guidance before + applying updates. +- Obsidian URI actions encode vault, path, file, search, and append parameters + through shared helpers and fail visibly if the host platform cannot open them. + +## Phase 5: AI Knowledge Tools + +Status: partially implemented. Search/get/propose/create/update/tag/link +tooling, confirmation proposals, compact summaries, proposal cards, +vault-aware result context, prompt snapshots and exact document reads with +outgoing-link/backlink context, relation labels/CFIs in prompt snapshots, +relation-aware desktop/mobile result cards, current-workspace document opening +from AI result and applied proposal cards, update proposals that reject duplicate +sibling vault paths, write-safety status on desktop/mobile tool result cards, +confirmation-required proposal write-safety status on desktop/mobile proposal +cards, structured failure cards, and visible search match-field explanations on +desktop/mobile result cards exist. +Broader end-to-end validation still needs work. + +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, including + the failing knowledge tool, error reason, and safe no-write hint. + +## Phase 6: Custom Card Platform + +Status: partially implemented. Built-in card registry, card templates, desktop +node views, mobile WebView card rendering, Markdown fallbacks, shared card +attribute upgrades, visible unknown/future-version card fallback metadata, +user-created custom card templates from the desktop/mobile insert menus, +sync-safe disabled-template rendering for existing cards, export/import +template snapshots, and desktop/mobile structured-field schema editing exist. +The schema editor now supports text, +long text, number, checkbox, single-choice, and multi-choice fields with +placeholders, help text, required markers, options, defaults, and simple +conditional visibility rules. Fields can also be grouped into lightweight +sections with optional group-level visibility rules that survive +desktop/mobile editing, WebView rendering, Markdown fallbacks, and read-only +HTML projection. Richer card editing and JSON-based custom template schema +migrations now have a shared core path; deeply nested conditional groups and +richer layout rules remain future work. Lightweight field width rules, +covering auto, full, half, and third-width fields, now survive desktop/mobile +template editing, WebView rendering, static read-only HTML projection, +Markdown fallbacks, and automated acceptance checks. + +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. +- AI/tool failures remain visible and exportable as failure cards. +- 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. +8. `feat/kb-workspace-polish` + - Obsidian-style vault navigation, WYSIWYG-first desktop/mobile layout, + folder browsing screens, and context-panel polish. + +Current branch priority before PR: + +1. Finish the desktop vault shell so the tree, folder browser, document canvas, + and context panel match the workspace contract. +2. Finish the mobile vault browser/editor split with keyboard-safe WYSIWYG + editing. +3. Verify hierarchy through create, move, sync, export, import, search, and AI + tool results. +4. Only then continue custom card template authoring and richer card editing. + +## 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. +- Surface profiles expose only the rich-text features allowed for that scenario. +- Rich-text preservation tests cover desktop save, mobile save, sync apply, and + export projection for headings, lists, source refs, cards, links, attachments, + and AI provenance. +- 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/05-vault-workspace-layout.md b/docs/knowledge-base-notes/05-vault-workspace-layout.md new file mode 100644 index 00000000..18fc8fd2 --- /dev/null +++ b/docs/knowledge-base-notes/05-vault-workspace-layout.md @@ -0,0 +1,762 @@ +# Vault Workspace Layout + +This document is the product and UI contract for the knowledge-base workspace. +The knowledge base is not a prettier notes list. It is a book-centered vault with +folders, documents, source links, attachments, and a WYSIWYG writing canvas. + +## Design North Star + +ReadAny's knowledge base should feel like this: + +```text +Book -> Vault tree -> WYSIWYG document -> Sources / backlinks / AI context +``` + +The user should always understand three things: + +- Where this document lives in the vault hierarchy. +- What document they are editing. +- Which book positions, highlights, notes, files, and AI outputs are connected + to it. + +The interface must avoid the old "form full of fields" feeling. A knowledge +document is a document, not a settings panel. + +## Non-Negotiable Product Shape + +These decisions are part of the feature contract, not implementation details: + +- The knowledge base is a vault. A flat document list with filters is not enough. +- The folder tree is spatial navigation, like Obsidian. Tags and groups are + secondary organization layers. +- The editor is a WYSIWYG writing canvas. Users should not feel like they are + editing Markdown, JSON, or a large textarea. +- Desktop and mobile should share the same information model, but not the same + layout. +- The workspace must look like a focused reader/writer tool, not a dashboard, + settings page, or note-card wall. + +### User Experience Correction + +The knowledge-base UI must be judged by three simultaneous signals: + +- Hierarchy is visible before content density. The user should immediately see + folders, nested documents, the active path, and where a new document will be + created. +- Writing is direct manipulation. The document body is a WYSIWYG canvas powered + by Tiptap, not a Markdown textarea, JSON editor, or settings-style form. +- Layout supports the mental model. Desktop should feel like a vault sidebar + plus writing canvas plus quiet inspector; mobile should feel like native vault + browsing into a focused editor. A stacked card feed fails this feature even if + the underlying data model is correct. + +This means hierarchy, editor fidelity, and layout are inseparable acceptance +criteria. If any of them regresses, the feature has drifted back into the old +notes system. + +### Workspace Mental Model + +The workspace should be designed as a file-based vault, not as a notes database +with a nicer skin. + +The user-facing model is: + +```text +Knowledge Vault +├── Book Space +│ ├── Book Home.md +│ ├── Chapter Notes/ +│ │ ├── Chapter 01.md +│ │ └── Chapter 02.md +│ ├── Ideas/ +│ └── Reviews/ +└── Global Space, later +``` + +Important implications: + +- A path is a first-class product object. The tree path, breadcrumb, import + preview, export path, sync reconciliation, and AI tool result must describe + the same location. +- A folder is not a tag and not a group. It owns children in the tree. +- A document is not a card. It opens into a writing canvas. +- The active node can be root, folder, or document, and each state has a + different UI: root/folder shows browsing rows; document shows WYSIWYG editing. +- Book-scoped knowledge is the v1 default. A global vault can arrive later, but + it must use the same folder/document primitives instead of becoming another + flat list. +- "Recently edited", tags, search, backlinks, and AI-generated collections are + discovery views. They must always resolve back to a real folder/document path. + +This is the Obsidian-like part of the feature. It is not about copying +Obsidian's visuals; it is about preserving the user's spatial memory. + +### WYSIWYG Reading And Writing Standard + +The editor should feel like a clean document, not like a Markdown textarea with +formatting buttons. + +Rules: + +- The title and body should feel continuous. The title can be a large editable + heading at the top of the document canvas, not a form field floating above it. +- Markdown shortcuts are allowed, but they must immediately become rich blocks. +- Source references, AI output, callouts, reviews, diagrams, and custom cards + should render as Tiptap node views inside the document. They should not open + as unrelated forms unless a configuration sheet is needed. +- Display state and edit state should not be two separate modes for normal + writing. Click, type, autosave. +- Metadata belongs around the document: path, tags, source links, backlinks, + sync state, and AI context. It should not interrupt the document body. +- Raw Markdown is an export/import/debug affordance, not the default authoring + experience. + +The visual quality bar is closer to a native writing app plus an Obsidian-style +vault tree than to a CRUD management page. + +### Layout Redesign Target + +The layout should be optimized around one question per zone: + +| Zone | Question | Primary UI | +| --- | --- | --- | +| Left / browser | Where am I? | Vault tree, folder rows, search, create/move. | +| Center / canvas | What am I writing? | Breadcrumb, title, WYSIWYG body, folder browser. | +| Right / inspector | What is connected? | Sources, backlinks, outline, AI memory, export state. | + +The center canvas must remain visually dominant. The left tree can be dense, and +the right inspector can be useful, but neither should make the document feel +like a small card embedded in a dashboard. + +When reviewing any implementation, reject it if: + +- The first screen looks like a dashboard, metrics panel, or card grid. +- The folder hierarchy is only implied through filters, chips, or grouped + headings. +- The editor looks like a settings form with title/body/metadata fields stacked + together. +- Folder views show empty editor chrome instead of child folders/documents. +- Mobile compresses vault browsing, editing, tags, sources, backlinks, and AI + context into one long scroll. + +### Layout Correction After Review + +The product should be reviewed as a vault workspace before it is reviewed as a +notes feature. A user opening the knowledge base should not wonder whether the +documents are flat notes, grouped tags, or a settings page. The first visible +structure must communicate "this is a folder tree and I am editing a real +document inside it." + +Concrete correction: + +- Directory hierarchy is not optional polish. It is the main navigation model, + like Obsidian's file explorer. +- The root, folders, documents, and orphaned documents must be shown as a + spatial tree with indentation, active ancestry, and child counts where useful. +- Folder overview screens are browsing screens. They should feel like opening a + folder in a file-based note app, not like a dashboard section. +- Document screens are writing screens. Title and body should read as one + WYSIWYG document, with metadata and context staying quiet. +- If a layout makes folders look like tags, documents look like cards, or the + editor look like a form, the layout is wrong even if the data model is + technically correct. +- Mobile may use sheets and stacked screens, but it must still preserve the + same mental model: vault browser first, focused WYSIWYG editor second, + context/actions in sheets. + +### Visual Acceptance Rules + +The hierarchy must be visible in the interface language, not only in the data +model. + +- Breadcrumbs should read like file paths, with lightweight separators. They + should not look like unrelated tags or status chips. +- The vault navigator should look and behave like a file explorer: indentation, + active ancestry, folder disclosure, and quiet row actions. +- Folder screens should look like an opened folder. Use clear folder/document + rows and section dividers; avoid dashboard cards, metric tiles, or form-like + panels. +- Document screens should look like a writing canvas. Title and body should be + directly editable, with metadata, tags, sources, and AI context kept around + the edges. +- Mobile should not compress everything into one scroll of cards. Browsing the + vault and writing a document are separate modes with the same underlying + path. + +### Runtime Layout Correction + +The runtime UI should reinforce the vault model through its physical structure: + +- Desktop uses the full knowledge workspace width for three zones. Avoid a + centered page shell around the whole feature; that makes the vault feel like a + settings subpage instead of a workspace. +- Root and folder screens use file-browser rows with quiet metadata such as + document type, child count, excerpt, and updated date. They should not look + like a dashboard grid or repeated marketing cards. +- Document screens keep the editable title and Tiptap body visually continuous. + Tags, path, sync state, and context stay around the document instead of + becoming a form above it. +- Mobile uses two explicit modes: vault browsing and focused document editing. + The vault view should feel like a native file explorer with a path trail, + tree, and opened-folder rows. The document view should feel like one WYSIWYG + writing surface with temporary actions in sheets. +- Path affordances should read as lightweight file paths. They may be tappable, + but they should not look like unrelated filter chips. + +### Directory And WYSIWYG Gate + +Every runtime review should pass this gate before any secondary polish is +accepted: + +- A document must always have an address. Tree rows, folder rows, breadcrumbs, + move targets, search results, AI proposals, export previews, and sync + conflict messages must show or preserve the same vault path. +- Opening a folder should feel like opening a folder, not opening an empty note. + The main title is the real folder name, and the content area is a file list + with child folders/documents, counts, updated state, and quiet row actions. +- Opening a document should feel like writing in a document, not filling a + settings form. The title and Tiptap body are the primary surface; tags, + sources, backlinks, summaries, and AI memory stay in surrounding context. +- WYSIWYG is the default contract. Markdown textareas are acceptable only for + emergency recovery, import/export previews, or explicit debug/power-user + affordances. +- Mobile must keep the same address model as desktop. It can use a native + vault-browse screen and focused editor screen, but it cannot flatten folders + into chips, cards, or one long note feed. + +If a feature cannot answer "where is this document in the vault?" and "am I +editing the rendered document directly?", it is not ready to merge. + +### Workspace Information Architecture Gate + +The knowledge-base layout must be reviewed as an information architecture, not +as a beautified note screen. + +Required screen states: + +| Active target | Desktop center | Mobile state | What must be visible | +| --- | --- | --- | --- | +| Vault root | Folder browser | Vault browser | Book vault path, root children, create-in-root actions. | +| Folder | Folder browser | Vault browser inside folder | Folder path, child folders first, child documents second, create-in-folder actions. | +| Document | WYSIWYG canvas | Focused editor | File path, editable title, rendered Tiptap body, quiet save/sync state. | +| Search / AI result | Result rows | Result rows/sheet | Matching document title plus full vault path. | +| Import / export | Preview rows | Review sheet | Destination path, source path, conflict state, and stable document ID when known. | + +This gate exists because a flat list can still store `parent_id`, but users will +not build spatial memory from invisible hierarchy. The runtime must make the +directory address obvious before the user edits, moves, links, exports, imports, +or accepts an AI proposal. + +The default authoring surface is equally strict: + +- Users write in the rendered Tiptap document. +- Markdown is a projection for interoperability, import/export, and debugging. +- Title, body, source cards, AI cards, reviews, callouts, and custom cards are + document blocks or surrounding context, not a stack of unrelated form fields. +- Autosave is the normal save model. Explicit save buttons may exist only for + recovery, conflict resolution, or batch import/export review. + +The first visible impression should be "I am inside a book vault and writing a +document at this path." If it instead feels like a settings form, dashboard, +card feed, or Markdown textarea, the implementation is off direction. + +### Obsidian-Like Hierarchy Review + +The hierarchy must be reviewed as a real document address system, not as a +decorative grouping layer. + +- The left desktop tree and the mobile vault browser are the source of spatial + memory. They should show nested folders, active ancestry, and sibling + documents before showing secondary filters such as tags or recents. +- Folder opening is a navigation state. The screen should look like an opened + directory with rows for child folders and child documents, not like a card + dashboard, empty editor, or metadata panel. +- Breadcrumbs should read as file paths. They can be clickable, but they should + stay visually lightweight and must not become unrelated chips or pills. +- Document editing is WYSIWYG first. The title and body are the document + surface; Markdown text, raw JSON, import previews, and export paths are + supporting projections. +- Mobile is allowed to use native screens and sheets, but its hierarchy should + still feel like "browse folder -> open document -> write", not "scroll cards + until the right note appears". + +### Path Fidelity Acceptance + +The vault path must survive every surface where a document leaves the current +screen. This is how ReadAny avoids becoming a flat note list with decorative +folders. + +- Internal document links should display a friendly title, but target the stable + document ID or exported vault path behind the scenes. +- Obsidian export should render wikilinks with the resolved file path, not only + `[[Title]]`, because two folders may contain documents with the same title. +- Import previews, move dialogs, AI tool results, search rows, backlinks, and + sync conflict messages should all describe the same folder/document path. +- A document title is never a unique address. The path plus document ID is the + durable address. +- Folder `README.md` files, book home documents, and standalone notes must all + participate in the same path rules. + +## Directory Model + +The directory hierarchy is a first-class product model, similar to Obsidian. + +Rules: + +- Folders and documents are siblings in the same tree. +- A folder can contain folders and documents. +- A normal document cannot contain children. +- Each book has a pinned `book_home` document at the top of its vault. +- `parent_id` is the single source of truth for hierarchy. +- Tags and groups are filters/metadata. They must not fake folder structure. +- Search works across the whole vault, even inside collapsed folders. +- Missing parents after sync/import become visible orphaned roots. +- Moves and renames preserve stable document IDs so sync and Obsidian reconcile + by identity, not only by path text. + +The tree should support: + +- Expand/collapse. +- Indentation and subtle connector lines. +- Active document and active path. +- Create inside current folder. +- Move to another folder. +- Rename inline where it feels natural. +- Context actions without overwhelming every row. + +### Obsidian-Like Hierarchy Expectations + +Users should be able to build a mental map of their book knowledge: + +```text +Book Knowledge Vault +├── Book Home +├── Chapter Notes +│ ├── Chapter 01.md +│ ├── Chapter 02.md +│ └── Themes +│ └── Fate and Choice.md +├── Characters +│ ├── Main Characters.md +│ └── Relationships.md +└── Reading Reviews + └── First Read.md +``` + +Implications: + +- The breadcrumb, tree indentation, export path, and sync path must all describe + the same hierarchy. +- Creating a document while a folder is selected creates it inside that folder. +- Moving a folder moves the whole subtree and must update visible paths + immediately. +- Duplicate names are allowed in different folders, but not ambiguous inside the + same folder unless the UI clearly disambiguates them. +- Deleted or missing parents after sync are shown in an orphan area instead of + silently flattening the document. +- Obsidian export should preserve this tree as real folders and Markdown files, + not only as frontmatter fields. + +### Vault Navigation Contract + +The vault tree is the primary navigation surface. It should never be treated as +a decorative filter beside a flat notes list. + +Interaction rules: + +- Selecting the vault root opens the root folder browser. +- Selecting a folder opens that folder browser and expands its branch in the + tree. +- Selecting a document opens the WYSIWYG document editor immediately. +- Returning from a document on mobile goes back to the vault browser with the + same document highlighted in the tree; it must not insert a large intermediate + "current document" card. +- The active path is visible in three places: tree ancestry, breadcrumb/path + text, and export/import destination previews. +- Create actions always inherit the current folder context. If a document is + active, create beside that document under its parent folder. +- Create affordances must name the real destination folder. If a document is + active, the UI should show the parent folder as the target, not imply that + documents can contain children. +- Move actions show the real folder tree and reject cycles before writing. +- Tags, groups, search results, and recent documents are secondary views. They + can help discovery, but they must route back to a real folder/document path. + +The user should be able to answer "where will this document live if I create it +now?" before pressing the create button. + +### WYSIWYG Product Contract + +ReadAny should feel like a modern writing app with book-aware evidence, not like +a Markdown field with better CSS. + +Editor rules: + +- The default document body is Tiptap-rendered rich content. +- The title behaves like the top of the document, not like a settings field. +- Source cards, AI cards, callouts, images, review cards, and custom ReadAny + cards render as document blocks with readable fallback text. +- Markdown shortcuts are allowed only when they immediately transform into + rich blocks. +- Raw Markdown/source mode can be an advanced mode later, but it cannot be the + default knowledge authoring surface. +- Empty documents show a writing placeholder and insert affordance, not setup + cards. + +This is the dividing line between a knowledge base and a prettier notes CRUD +page. + +## WYSIWYG Contract + +The primary editing surface must be WYSIWYG. Markdown is an export and +interoperability projection, not the UI the user writes in by default. + +Required behavior: + +- Desktop and mobile knowledge documents use the Tiptap document model. +- The title is edited as a real document title, not as a small form input. +- Headings, lists, quotes, images, source cards, callouts, AI cards, and custom + ReadAny cards render as real editor blocks. +- Placeholder and empty states should look like a writing canvas, not a textarea. +- Toolbar actions should be contextual: compact top toolbar, slash menu, floating + bubble menu, or focused insert sheet depending on platform. +- Autosave is the default. Save status is quiet and never competes with writing. +- Unsupported cards render readable fallback blocks rather than raw JSON. + +Do not expose arbitrary font, color, layout, raw HTML, or iframe editing in v1. +Those make mobile editing, sync, Obsidian export, and AI retrieval unreliable. + +### WYSIWYG Interaction Expectations + +The editor should behave like a modern document editor: + +- Click in the body and type directly. +- Use a small floating toolbar for selected text. +- Use a slash menu or insert button for blocks: heading, quote, list, divider, + image, source card, AI card, review card, callout, and custom ReadAny cards. +- Drag or use block handles for block reordering on desktop when feasible. +- Mobile uses a keyboard-aware insert toolbar and focused bottom sheets for + block configuration. +- Markdown shortcuts are welcome, but they transform into rich blocks + immediately. +- Markdown source view can exist later as an advanced/export/debug mode, never + as the default authoring experience. + +## Desktop Workspace + +Desktop should use a calm three-zone workspace. + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Book switcher / vault title / search / compact actions │ +├───────────────┬───────────────────────────────────────┬─────────────────────┤ +│ Vault tree │ WYSIWYG document canvas │ Context panel │ +│ folders/docs │ breadcrumb, title, body, blocks │ sources, backlinks │ +│ search/move │ folder overview when folder selected │ outline, AI memory │ +└───────────────┴───────────────────────────────────────┴─────────────────────┘ +``` + +### Left: Vault Navigator + +Purpose: answer "where am I?" + +The left zone contains: + +- Current book knowledge vault. +- Document/folder tree. +- Search. +- Create button with document type menu. +- Move and delete row actions. +- Optional book switcher when space allows. + +Visual direction: + +- Dense but breathable rows. +- Small icons, not oversized decorative icons. +- Active row uses theme primary subtly. +- Folders show child counts only when useful. +- Row actions appear on hover/focus. +- No giant hero card above the tree. +- Width should be stable, roughly 240-300px, with graceful collapse on small + desktop windows. +- The tree is the primary navigation, not a side decoration. + +### Center: Document Canvas + +Purpose: answer "what am I writing?" + +The center zone contains: + +- Breadcrumb path. +- Large inline title. +- Compact metadata line: document type, book, sync/save state. +- Tags as quiet chips when relevant. +- Tiptap WYSIWYG canvas. +- Folder overview when the active node is a folder. + +Visual direction: + +- The editor should feel like a page on the app background, not a card nested + inside more cards. +- Keep the main writing width readable, around 680-820px. +- The outer shell can have borders, but the editable document should not feel + boxed in. +- Use CSS variables from the app theme for all colors. +- Avoid marketing-style hero sections, large metrics blocks, and decorative + panels inside the writing path. +- Empty documents should show one elegant writing placeholder and focused insert + affordances, not a stack of setup cards. +- Folder nodes should show a compact folder overview with child rows; they must + not show a blank editor pretending to be a note. + +### Right: Context Panel + +Purpose: answer "what is connected?" + +The right zone contains: + +- Source links and CFI/highlight references. +- Backlinks. +- Document outline for long documents. +- AI memory/summary state. +- Selected card details. +- Export/import conflict notices when needed. + +Rules: + +- The right panel is supportive and collapsible. +- It should not steal vertical space from the document. +- On smaller desktop widths, collapse it behind a context button. +- The right panel should feel like an inspector, not another feed. Keep it + quiet, scannable, and secondary to the document. + +### Desktop Layout Anti-Patterns + +Do not ship a desktop knowledge layout that: + +- Starts with a large dashboard/header card instead of the vault tree and + current document. +- Shows every document as a card grid while hiding hierarchy. +- Puts the editor inside multiple nested cards. +- Treats title, tags, source, and body as a long settings form. +- Uses oversized empty states that push the editor below the fold. +- Makes the right context panel visually heavier than the writing canvas. + +## Mobile Workspace + +Mobile should not mirror the desktop grid. It needs a native, focused flow. + +```text +Vault Browser -> Document Editor -> Context / Insert Sheets +``` + +### Screen 1: Vault Browser + +Purpose: navigate the hierarchy quickly. + +The browser screen contains: + +- Compact book header. +- Current vault path. +- Search. +- Native tree/list with folder indentation. +- Create button. +- Folder overview and children. +- Recently edited documents can be a small section, but never replace the tree. + +Visual direction: + +- No large knowledge hero card. +- Avoid stacking explorer, editor, relations, and AI cards in one long scroll. +- Use rows and grouped sections, not a dashboard. +- Create/move actions use bottom sheets. +- The current path should be visible and horizontally scrollable when long. +- The create sheet should display the actual target folder path before the user + chooses a document type. +- Folder rows open into the folder; document rows open into the editor. + +### Screen 2: Document Editor + +Purpose: write without distractions. + +The editor screen contains: + +- Sticky compact header with back, path, title, status, and more actions. +- Full-screen WYSIWYG WebView editor. +- Keyboard-aware toolbar. +- Insert card/image/link actions through focused sheets. +- Tags and source/context can live in a secondary sheet, not above the editor. + +Rules: + +- Editing should feel like a document screen, not a form. +- The keyboard must not cover editor controls. +- Title editing can be inline, but long-form body editing owns most of the + viewport. +- Folder nodes open a folder browser, not an empty editor. +- Context, tags, sources, backlinks, and AI actions open from a compact header + action or bottom sheet. They should not sit above the writing surface as a + permanent block. + +### Mobile Layout Anti-Patterns + +Do not ship a mobile knowledge layout that: + +- Stacks vault navigation, document body, sources, AI, tags, and stats in one + long scroll. +- Uses desktop-like side panels squeezed into mobile. +- Forces long-form body editing through a tiny modal input. +- Lets the keyboard cover the editor toolbar or the active line. +- Hides the user's folder path while they are browsing. +- Uses decorative hero/metric cards before the user can reach their documents. + +## Folder View + +A folder is a browsing surface, not a document editor pretending to be empty. + +Folder view should show: + +- Folder title and path. +- Child folders first, then documents, using rows that preserve the same + indentation and icons as the vault tree. +- Root folder views may pin the book home document first, then separate child + folders and documents into quiet file-browser sections. +- Small metadata per child: type, updated time or excerpt. +- Empty state with two direct actions: new folder, new note. +- Create destination preview, for example `Create in Chapter Notes / Themes`. +- Path-aware actions: rename, move, export this folder, and create inside. + +The visual sectioning matters. A mixed flat list makes folders feel like tags; +folder and document sections make the hierarchy readable before the user opens +anything. + +It should not show: + +- A giant decorative icon block. +- Blank editor space. +- Heavy cards that make the folder feel like a dashboard. +- A fake "current document" panel when no document is selected. + +### Document View + +A document is the writing surface. + +Document view should show: + +- File-path breadcrumb above the title. +- A large editable title that reads as the first line of the document. +- Quiet status text for autosave/sync and document type. +- The Tiptap body as the main surface. +- Inline source/card blocks where they belong in the body. +- Optional context panel on desktop or context sheet on mobile. + +It should not show: + +- Title, author, tags, body, and source as one long form. +- A second "preview" mode that users must enter before the document feels + polished. +- Nested cards around the editor. +- Persistent metadata panels that push the writing area below the fold. + +### Mobile Interaction Shape + +Mobile should feel like two native modes sharing the same vault model: + +- Vault browser mode: browse folders, search, create, move, and open documents. +- Document editor mode: write in a focused WYSIWYG editor with keyboard-aware + toolbar and compact path/title header. + +Mobile sheets are for temporary actions: + +- Create folder/document. +- Move to folder. +- Insert block/card/image. +- Edit card options. +- View sources/backlinks/AI context. + +Sheets should not replace the main editor. Long-form editing must happen in the +document editor, not inside a tiny bottom-sheet textarea. + +## Layout Quality Bar + +The knowledge workspace should feel closer to a quiet writing app with a +powerful vault sidebar than to a CRUD admin page. + +Quality requirements: + +- The first screen must make hierarchy obvious within two seconds. +- The current document title and body must be visually dominant. +- The editor body should preserve readable line length and calm whitespace. +- Navigation density should be high enough for real libraries, but never cramped. +- Metadata appears as supporting context, not as the main content. +- Every editable affordance should be discoverable through placement, hover, + focus, or a small icon button with accessible label. +- Visual states must be clear: active folder, active document, unsaved/synced, + collapsed, orphaned, missing attachment, and conflict. +- Desktop should support keyboard-heavy users; mobile should support thumb-first + navigation and keyboard-aware editing. + +## Implementation Order for Layout + +Build the workspace in this order so the feature does not drift back into a +flat notes page: + +1. Lock the data hierarchy: folder nodes, document nodes, path/breadcrumb, + move/rename/delete, orphan handling, and export path projection. +2. Build the desktop vault shell: left tree, center canvas, right inspector, + responsive collapse. +3. Build the mobile vault browser and focused editor as separate screens. +4. Replace form-like editing with Tiptap WYSIWYG block editing and contextual + toolbars. +5. Add custom ReadAny cards and attachment rendering inside the editor canvas. +6. Add Obsidian export/import polish once hierarchy and editor state are stable. + +## Visual Principles + +Use a quiet reader/productivity style: + +- Semantic theme variables only. +- 8px radius or the app's existing radius tokens. +- Fine borders and restrained shadows. +- Primary color as accent, not a full-page wash. +- Compact typography in navigation; comfortable typography in document body. +- Real empty states, not feature explanations. +- No nested cards around cards. +- No decorative orbs, blobs, or marketing visuals in the workspace. + +The strongest visual moment should be the document itself: title, readable body, +source cards, and custom knowledge cards. + +## Implementation Implications + +Current MVP pieces are useful, but the layout should move toward this structure: + +- Keep the existing `parent_id`, folder document type, tree builder, breadcrumbs, + and move validation. +- Desktop should reduce card nesting around the editor and make the document + canvas the center of the page. +- Mobile should split the current single scrolling knowledge page into a vault + browser and a focused editor screen. +- The mobile knowledge hero/metric treatment should be removed or reduced to a + compact book header. +- Context panels/cards should move out of the main writing flow where possible. +- Attachment sync must make image blocks reliable across devices before local + image insertion is advertised as complete. + +## Acceptance Checklist + +Before this ships: + +- Creating folders and documents produces a visible hierarchy. +- Moving a document updates the tree, breadcrumb, export path, and sync state. +- The active path is visible in the desktop vault sidebar, desktop document + canvas, and mobile vault browser. +- Selecting a folder behaves like browsing a folder; selecting a document opens + a real writing surface. +- Search finds documents inside collapsed folders. +- Desktop can edit a document while seeing the vault tree and context. +- Mobile can browse the vault and open a focused WYSIWYG editor. +- Folder screens never look like broken empty document screens. +- Export to Obsidian preserves folder paths and stable IDs. +- Imported Obsidian changes reconcile by document ID where possible. +- The editor never exposes raw Markdown or JSON as the default writing surface. diff --git a/docs/knowledge-base-notes/06-acceptance-runbook.md b/docs/knowledge-base-notes/06-acceptance-runbook.md new file mode 100644 index 00000000..abb1df5e --- /dev/null +++ b/docs/knowledge-base-notes/06-acceptance-runbook.md @@ -0,0 +1,286 @@ +# Acceptance Runbook + +This runbook turns the knowledge-base design docs into evidence that can be +checked before the branch is considered ready. Passing one narrow test is not +enough; the feature is accepted only when the data model, desktop UX, mobile UX, +sync/export, AI tools, and ReadAny cards all preserve the same vault document +model. + +## Release Gate + +The branch is ready for final PR review when all of these are true: + +- The worktree is clean and pushed. +- Core knowledge, AI, sync, export, desktop, and mobile TypeScript checks pass. +- The desktop Vite production bundle builds successfully. +- `07-manual-qa-evidence.md` is filled with the commit under test, platforms, + screenshots/logs/exports, and pass/fail/blocker status for all required + manual checks. +- `pnpm acceptance:knowledge:manual` passes after `07-manual-qa-evidence.md` + is filled. +- Desktop and mobile both show a vault hierarchy before editing. +- Folder nodes open folder browsers, not empty document editors. +- Document nodes open a WYSIWYG Tiptap surface with quiet autosave. +- The same document path appears in tree rows, breadcrumbs, search results, + import/export previews, AI results, proposal cards, and failure cards. +- AI write tools create confirmation-required proposals only; applying a + proposal is the first database write. +- ReadAny cards preserve type, version, source attrs, structured data, schema + migrations, and Markdown fallback on desktop, mobile, export, and AI context. +- Unsupported or future card versions render safe fallback cards instead of raw + JSON or disappearing content. + +## Automated Evidence + +Run these before each stable commit: + +```bash +pnpm acceptance:knowledge +``` + +The script above runs the full automated gate. Expanded manually, it is: + +```bash +pnpm --filter @readany/core exec vitest run \ + src/db/__tests__/knowledge-queries.test.ts \ + src/db/__tests__/knowledge-source-writeback.test.ts \ + src/db/__tests__/db-core.test.ts \ + src/db/__tests__/highlight-queries.test.ts \ + src/db/__tests__/note-queries.test.ts \ + src/sync/__tests__/simple-sync.integration.test.ts \ + src/sync/__tests__/sync-files.test.ts \ + src/knowledge/document-utils.test.ts \ + src/knowledge/vault-path-fidelity.test.ts \ + src/knowledge/editor-profile.test.ts \ + src/knowledge/editor-projection.test.ts \ + src/knowledge/editor-draft.test.ts \ + src/knowledge/mobile-editor-bridge.test.ts \ + src/knowledge/rich-text-preservation.test.ts \ + src/knowledge/card-registry.test.ts \ + src/knowledge/attachments.test.ts \ + src/knowledge/internal-links.test.ts \ + src/knowledge/source-links.test.ts \ + src/knowledge/proposals.test.ts \ + src/knowledge/compact-summary.test.ts \ + src/i18n/locales.test.ts \ + src/ai/__tests__/system-prompt.test.ts \ + src/ai/__tests__/streaming.test.ts \ + src/ai/__tests__/reading-agent-tools.test.ts \ + src/ai/__tests__/tools.test.ts \ + src/ai/__tests__/knowledge-context.test.ts \ + src/ai/__tests__/knowledge-memory.test.ts \ + src/ai/__tests__/tool-call-state.test.ts \ + src/ai/__tests__/knowledge-tool-result.test.ts \ + src/ai/__tests__/tool-result.test.ts \ + src/ai/tools/knowledge-tools.test.ts \ + src/export/knowledge-exporter.test.ts \ + src/export/knowledge-importer.test.ts \ + src/export/obsidian-uri.test.ts + +pnpm --filter @readany/core exec tsc --noEmit +pnpm --filter app exec tsc --noEmit +pnpm --filter app exec vite build +# The acceptance script also scans packages/app/dist for required desktop +# knowledge editor, AI proposal/result, card, link, export, and attachment +# fragments. +pnpm --filter @readany/app-expo exec tsc --noEmit +pnpm --filter @readany/app-expo exec node scripts/build-knowledge-editor.js +git diff --exit-code -- packages/app-expo/assets/editor/knowledge-editor.html +# The acceptance script also checks the generated WebView bundle for required +# bridge messages, commands, cards, internal/source links, and image attachment +# fallbacks, including the card-to-normal-text conversion control and custom +# card structured-field rendering. +# The acceptance script also checks the desktop knowledge editor source for +# ReadAny card editing controls, card-to-normal-text conversion, and custom card +# field schema editing. +# The acceptance script also checks the mobile knowledge editor source for +# custom card field schema editing before WebView insertion. +# The acceptance script also checks the desktop and mobile chat renderer sources +# for knowledge proposal, result, failure-card, path, and confirmation-write UI +# contracts. +# The acceptance script also checks desktop and mobile knowledge workspace +# source contracts for vault trees, root/folder browser surfaces, WYSIWYG +# document editors, path-aware creation/search, and import/review surfaces. +# It also checks that the desktop Vite browser-preview runtime is guarded from +# Tauri-only platform services, so UI smoke checks can load without false +# `invoke` console errors when the app is opened outside the Tauri shell. +git diff --check +``` + +After the runtime manual checks are complete, run the strict manual evidence +gate: + +```bash +pnpm acceptance:knowledge:manual +``` + +While evidence is still being collected, this non-blocking command can show the +remaining missing rows without failing the shell: + +```bash +pnpm acceptance:knowledge:manual -- --allow-incomplete +``` + +To generate the guided manual QA run sheet from the evidence table, run: + +```bash +pnpm acceptance:knowledge:manual:plan +``` + +The generated `08-manual-qa-run-sheet.md` is only an execution checklist. Copy +the real pass/fail/blocker status and concrete evidence back into +`07-manual-qa-evidence.md`; the run sheet alone does not satisfy the manual +gate. + +`pnpm acceptance:knowledge` compares the mobile WebView editor bundle before and +after rebuilding it. If the generated HTML changes, commit +`packages/app-expo/assets/editor/knowledge-editor.html` and rerun the gate. +It also verifies that the generated HTML still contains the RN bridge entry +point, ready/error/content/selection messages, command routing, ReadAny cards, +internal/source links, and image attachment fallback UI. +It also checks the desktop knowledge editor source and mobile WebView bundle for +the ReadAny card conversion control so AI/card blocks can be turned back into +ordinary editable content instead of becoming permanent special blocks. +Custom card field schema editing is checked in the desktop and mobile editor +sources, including text, long-text, number, checkbox, single-choice, and +multi-choice fields with required markers, help text, options, defaults, +simple visibility rules, optional field group labels, group-level visibility +rules, and lightweight field width layout rules. The WebView bundle is checked +for structured-field rendering, including grouped section headings and +field-width markers, so +synced custom cards can be edited without dropping to raw JSON. The core card, +projection, export, and AI context tests also verify that visible structured +field values, group labels, and choice labels stay readable in Markdown/Obsidian +output and prompt previews, while width metadata survives the HTML/WebView +surfaces and hidden fields stay out of readable exports. +The desktop production bundle check scans the built browser assets for the +knowledge editor shell, AI proposal/result renderers, ReadAny cards, +internal/source links, Obsidian export markers, and portable attachment URIs. +The desktop and mobile chat renderer contract checks scan the chat renderer +sources for AI knowledge proposal/result/failure cards, visible vault paths, +search match-field explanations, current-workspace document open actions from +result and applied proposal cards, safe no-write hints, visible write-safety +status for read-only, memory-write, skipped, and failed tool calls, +confirmation-required proposal write-safety status, confirmation-required apply +behavior, and persistent proposal-apply failure +states with retry affordances and localized conflict reasons instead of raw +internal error codes. +The desktop chat contract also checks that standalone AI citations open the +matching reader tab with the registered CFI instead of only logging the click, +so citations produced outside the reader sidebar still lead back to source text. +The desktop and mobile knowledge workspace contract checks scan the runtime UI +sources for the vault tree, root/folder browser, document editor, breadcrumb/path, +search, create target, import review, desktop vault-import conflict resolution +guidance, and keyboard-safe mobile editor entry points that make the vault mental +model visible before editing. +The desktop browser-preview runtime contract checks that direct Vite/browser +loads use a non-persistent preview platform service and defer Tauri-only fetch, +vector DB, data-root migration, and fallback-content-provider setup until a real +Tauri runtime is present. This keeps browser smoke testing useful without +weakening the production desktop path. +They also check that saved and imported knowledge documents keep the compact +AI-memory maintenance path wired through source fingerprints and background +summary queues on both desktop and mobile, so long-form notes do not become +stale retrieval sources after ordinary editing or Markdown import. +The mobile workspace contract also checks that Markdown import review keeps file +picker display names available, so review cards do not expose cache-only picker +URIs as the user's source file identity. +The desktop knowledge workspace contract also checks that optional Obsidian URI +actions stay wired through shared URI helpers and the platform external-URL +opener, so opening a file or searching the vault remains a convenience layer +rather than a sync dependency. +`pnpm acceptance:knowledge:manual` checks that `07-manual-qa-evidence.md` +contains session metadata, allowed status values, non-empty evidence for passing +rows, owner-approved exception notes for `Blocked`/`N/A` rows, and a final +ready decision with no blocking failures. + +Evidence mapping: + +| Contract | Evidence | +| --- | --- | +| Knowledge tables, queries, tombstones, and sync metadata exist. | `knowledge-queries.test.ts`, `simple-sync.integration.test.ts` | +| Legacy highlight/note projections keep old UI compatible while knowledge documents become primary. | `knowledge-source-writeback.test.ts`, `highlight-queries.test.ts`, `note-queries.test.ts`, `document-utils.test.ts` | +| Knowledge attachment files upload, download, and reconcile manifest paths during file sync. | `sync-files.test.ts` | +| Vault paths survive folders, moves, orphans, search, AI, import, and export. | `document-utils.test.ts`, `vault-path-fidelity.test.ts`, `knowledge-tools.test.ts`, `knowledge-importer.test.ts` | +| Missing or cyclic parents surface as visible orphaned roots in desktop and mobile root browsers. | `document-utils.test.ts`, desktop/mobile knowledge workspace contract checks, desktop and mobile TypeScript checks | +| Vault roots and folder documents open browsing surfaces; ordinary documents open editor surfaces. | `document-utils.test.ts`, desktop/mobile knowledge workspace contract checks, desktop and mobile TypeScript checks | +| Create and Markdown import actions inherit the current vault root, folder, or sibling context consistently. Folder-level `README.md` and `index.md` links resolve back to the manifest document id, linked-vault import conflicts surface safe resolution guidance before any write, and mobile import review keeps picker file names visible instead of cache-only URIs. | `document-utils.test.ts`, `knowledge-importer.test.ts`, desktop/mobile knowledge workspace contract checks, desktop and mobile TypeScript checks | +| Optional Obsidian URI actions encode open/new/search paths safely and surface desktop open/search actions from conflict and import-review cards. | `obsidian-uri.test.ts`, desktop knowledge workspace contract check, core/desktop/mobile TypeScript checks | +| Knowledge and card UI strings and interpolation placeholders stay available across supported locales. | `locales.test.ts`, desktop and mobile TypeScript checks | +| Desktop knowledge workspace code is included in a valid production browser bundle. | Desktop production bundle contract check | +| Desktop/mobile editor profiles expose the right rich-text features by scenario. | `editor-profile.test.ts`, TypeScript checks | +| Tiptap JSON projects to Markdown/HTML without losing supported rich blocks. | `editor-projection.test.ts`, `rich-text-preservation.test.ts` | +| Draft recovery, mobile WebView messages, and error states are typed and present in the generated bundle. | `editor-draft.test.ts`, `mobile-editor-bridge.test.ts`, mobile WebView bundle contract check, `app-expo` TypeScript | +| Attachments and source/internal links remain portable through editor, sync, and export paths. | `attachments.test.ts`, `internal-links.test.ts`, `source-links.test.ts`, `rich-text-preservation.test.ts` | +| AI reads knowledge safely, keeps prompt/exact-read outgoing links, backlinks, relation labels, CFIs, relation directions, vault paths, search match-field explanations, and write-safety state visible, can open matching result/applied-proposal documents in the current knowledge workspace, keeps saved/imported documents queued for compact-memory maintenance, and writes only through confirmation proposals or explicit compact-memory tools. | `system-prompt.test.ts`, `streaming.test.ts`, `reading-agent-tools.test.ts`, `knowledge-context.test.ts`, `knowledge-tool-result.test.ts`, `knowledge-tools.test.ts`, `proposals.test.ts`, desktop production bundle contract check, desktop/mobile AI knowledge chat contract checks, desktop/mobile knowledge workspace contract checks | +| Non-vectorized books keep fallback exploration and validated citations available. | `system-prompt.test.ts`, `reading-agent-tools.test.ts`, `tools.test.ts` | +| Failed tool calls become visible failure cards with tool names, reasons, no-write hints, and available vault paths instead of endless loading states, and export as readable Obsidian callouts. | `tool-call-state.test.ts`, `tool-result.test.ts`, `knowledge-tool-result.test.ts`, `knowledge-exporter.test.ts`, desktop production bundle contract check, desktop/mobile AI knowledge chat contract checks | +| Compact summaries are retrieval memory, not user-content rewrites. | `compact-summary.test.ts`, `knowledge-memory.test.ts`, `tools.test.ts`, `knowledge-tools.test.ts` | +| ReadAny cards preserve attrs, data, schema migrations, fallback rendering, unknown versions, conversion back to normal editable content, user-authored structured field schemas, field groups, group-level visibility, field-width layout metadata, sync-safe disabled templates for existing cards, and readable field values in export/AI context. | `card-registry.test.ts`, `knowledge-queries.test.ts`, `editor-projection.test.ts`, `knowledge-context.test.ts`, `knowledge-exporter.test.ts`, `rich-text-preservation.test.ts`, desktop knowledge editor contract check, mobile knowledge editor contract check, mobile WebView bundle contract check | + +## Desktop Manual Checks + +Use the desktop app with a book that has existing highlights and notes. +Record the result in `07-manual-qa-evidence.md` before final PR review. + +1. Open the notes/knowledge entry for the book. +2. Confirm the first visible structure is a left vault tree, center workspace, + and quiet right context panel. +3. Select the vault root. The center should show child folders/documents. +4. Create a folder, then create a standalone note inside it. The create target + must show that folder path. +5. Create another folder and move the note into it. The tree, breadcrumb, search + result, and move target preview should all update to the same path. +6. Open a document and edit the title/body directly in the WYSIWYG surface. + Markdown source or JSON should not be the default editing UI. +7. Insert headings, lists, quote/callout/source cards, an image block, an + internal link, and a custom ReadAny card. +8. Expand the ReadAny card details and edit source title, source id, CFI, and + structured data. Invalid JSON should show an inline error and not corrupt the + card. +9. Trigger AI knowledge tools from chat: search, exact get, propose create, + propose update, tag update, link create, and summary compression. +10. Confirm successful proposals render confirmation cards, and failed tool calls + render visible failure cards with tool name, reason, path when available, + and a no-write hint. +11. Export an Obsidian vault and open it. Wikilinks, frontmatter IDs, folder + paths, images, and ReadAny card fallbacks should be readable. + +## Mobile Manual Checks + +Use a real iOS or Android device because keyboard, safe area, and WebView focus +are part of the acceptance criteria. +Record the result in `07-manual-qa-evidence.md` before final PR review. + +1. Open the book knowledge area from the mobile notes screen. +2. Confirm the first mode is vault browsing, not one long stacked dashboard. +3. Navigate into a folder. The path should remain visible and rows should show + child folders before child documents. +4. Open a document. The editor should become focused, WYSIWYG, and + keyboard-aware. +5. Type long body content, use the toolbar, insert a link, image, source + reference, and ReadAny card. +6. Open card details in the WebView editor and edit source attrs/data. Invalid + JSON should show an error and preserve the previous valid attrs. +7. Background and reopen the app. Draft recovery should offer the latest unsaved + content instead of silently losing it. +8. Run AI chat with a knowledge proposal and a failing knowledge tool. Proposal + cards and failure cards should be visible and actionable on mobile. +9. Sync with a second device. Folder hierarchy, content JSON/Markdown, links, + attachments metadata, card templates, and card attrs should arrive with the + same document paths. + +## Regression Traps + +Reject the branch if any of these appear: + +- Folder hierarchy is hidden behind tags, groups, or filters. +- A folder opens a blank editor. +- The body editor looks like a raw Markdown textarea by default. +- A create/move/import/AI proposal does not show its destination path. +- AI says a document was saved before the user confirms the proposal. +- Tool failures spin forever or disappear without a failure card. +- Custom card data can be replaced by invalid JSON. +- Unsupported card versions lose their metadata. +- Mobile keyboard covers the editor controls. +- Obsidian export flattens folders or makes duplicate titles ambiguous. diff --git a/docs/knowledge-base-notes/07-manual-qa-evidence.md b/docs/knowledge-base-notes/07-manual-qa-evidence.md new file mode 100644 index 00000000..b398e572 --- /dev/null +++ b/docs/knowledge-base-notes/07-manual-qa-evidence.md @@ -0,0 +1,135 @@ +# Manual QA Evidence + +Use this file as the final manual evidence record for the knowledge-base branch. +Automated checks prove the shared contracts; this checklist proves the runtime +experience that still needs real desktop and mobile interaction. + +Do not mark the feature ready for PR review until every required row is `Pass` +or has an explicit owner-approved exception, and +`pnpm acceptance:knowledge:manual` passes. + +## Session Metadata + +| Field | Value | +| --- | --- | +| Branch | `feat/knowledge-base-notes-research` | +| Commit under test | `4a901716` | +| Tester | Codex automated baseline | +| Test date | 2026-06-15 | +| `pnpm acceptance:knowledge` result | Pass on 2026-06-15 for commit `4a901716`: 34 core test files / 501 tests, core TS, desktop TS, desktop production bundle, mobile TS, generated WebView bundle, workspace/chat/editor/browser-preview contract checks, standalone chat citation navigation contract, optional Obsidian URI actions, mobile Markdown import picker file names, compact summary source-fingerprint/background-queue maintenance contracts, persistent proposal-apply failure states with localized conflict reasons, confirmation-required proposal write-safety status, grouped custom-card field sections, imported custom-card group visibility rules, localized custom-card default option labels, notes-scoped custom-card field translations, knowledge notes UI source-key translation coverage, dynamic knowledge chat UI key coverage, custom-card field width layout markers, AI tool write-safety status, AI search match-field explanations, vault import conflict resolution guidance, and whitespace check passed. | +| Desktop platform/build | macOS local automated desktop production bundle via `pnpm acceptance:knowledge`; browser-preview smoke on `http://127.0.0.1:5174/` showed the app shell and no current-port warn/error logs after the startup/update-check window; interactive Tauri desktop QA still pending. | +| Mobile platform/build | Expo TypeScript and generated WebView editor bundle via `pnpm acceptance:knowledge`; mobile knowledge workspace/editor contracts include the document editor keyboard-avoidance entry point, shared keyboard inset usage, visible WebView failure states, and draft restore affordances; real-device iOS/Android QA still pending. | +| Second sync device/build | Pending manual QA. | +| Sync backend and account | Pending manual QA. | +| AI provider/model | Pending manual QA. | +| Obsidian/export test folder | Pending manual QA. | + +## Evidence Rules + +- Status values: `Pass`, `Fail`, `Blocked`, `N/A`. +- Each `Pass` should include a screenshot, short video, log excerpt, exported + file path, or clear written observation. +- Each `Fail` should include reproduction steps and an issue or follow-up task. +- Each `Blocked` should explain the missing device, account, credential, test + data, or external service. +- `Blocked` and `N/A` rows that are accepted for final PR review must include an + owner-approved exception note, for example `Exception approved: ...`. +- Set `Ready for PR review?` to `Yes` and `Blocking failures` to `None` only + after all required rows are complete. +- Keep this file updated on the branch so the final PR can link to one source of + truth. + +## Automated Baseline + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Clean branch | Worktree is clean and pushed before manual QA starts. | Pass | Runtime commit `4a901716` was tested by the automated gate; this evidence refresh records that baseline and should be pushed before manual QA starts. | +| Full automated gate | `pnpm acceptance:knowledge` passes. | Pass | Passed on 2026-06-15 for commit `4a901716`: 34 core test files / 501 tests plus core TS, desktop TS, desktop production bundle, mobile TS, WebView bundle, workspace/chat/editor/browser-preview contract checks, standalone chat citation navigation contract, optional Obsidian URI actions, mobile Markdown import picker file names, compact summary source-fingerprint/background-queue maintenance contracts, persistent proposal-apply failure states with localized conflict reasons, confirmation-required proposal write-safety status, grouped custom-card field sections, imported custom-card group visibility rules, localized custom-card default option labels, notes-scoped custom-card field translations, knowledge notes UI source-key translation coverage, dynamic knowledge chat UI key coverage, custom-card field width layout markers, AI tool write-safety status, AI search match-field explanations, vault import conflict resolution guidance, and whitespace check. | +| Bundle warnings reviewed | Existing Vite chunk/dynamic import warnings are non-blocking and no new error appears. | Pass | The desktop Vite production bundle completed successfully; warnings were the known dynamic-import/chunk-size warnings documented as non-blocking in the runbook. | +| Browser preview smoke | Opening the desktop Vite shell directly in a browser should not trigger Tauri-only `invoke` errors before manual QA can start. | Pass | Local smoke on 2026-06-15: started `pnpm --filter app exec vite --host 127.0.0.1 --port 5174 --strictPort false`, opened `http://127.0.0.1:5174/`, waited 6.5s past startup/update-check timing, DOM showed the ReadAny shell/sidebar/empty library, and page console warn/error logs were `[]`. | + +## Desktop QA + +Use the desktop app with a book that already has highlights and notes. + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Open knowledge entry | The first visible structure is left vault tree, center workspace, and quiet right context panel. | | | +| Root browser | Selecting the vault root shows child folders/documents, not an editor. | | | +| Folder browser | Selecting a folder shows child folders before child documents, not an empty document editor. | | | +| Create inside folder | Creating a folder and note from a folder uses that folder as the destination. | | | +| Move consistency | After moving a note, tree row, breadcrumb, search result, and move target preview show the same path. | | | +| WYSIWYG editing | Opening a document gives direct title/body editing in Tiptap, not raw Markdown or JSON by default. | | | +| Rich blocks | Headings, lists, quote/callout/source cards, image, internal link, and custom ReadAny card insert and render. | | | +| Card editing safety | Editing card source attrs/data works, and invalid JSON shows an inline error without corrupting the last valid card. | | | +| Autosave state | Quiet saving/saved/pending state matches edits without requiring an explicit save button. | | | +| Context panel | Sources, backlinks, outline, and AI memory/context stay attached to the active document path. | | | + +## Mobile QA + +Use a real iOS or Android device. Simulator-only evidence is not enough for +keyboard, WebView focus, safe area, or sync acceptance. + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Open knowledge area | The first mode is vault browsing, not one long stacked dashboard. | | | +| Navigate folder | Path remains visible, and folder rows show child folders before child documents. | | | +| Open document | The document view becomes focused, WYSIWYG, and keyboard-aware. | | | +| Long editing | Long body edits, toolbar actions, link, image, source reference, and ReadAny card insertion remain usable. | | | +| Card details | Editing card attrs/data in WebView works; invalid JSON shows an error and preserves previous valid attrs. | | | +| Keyboard and safe area | Keyboard never covers editor controls or chat input, including Chinese and system keyboards. | | | +| Background recovery | Backgrounding and reopening offers the latest draft instead of silently losing unsaved content. | | | +| Mobile import review | Markdown import previews destination paths and readable picker file names before applying writes; it should not show cache-only picker URIs as the source identity. | | | + +## AI Knowledge QA + +Run on desktop and mobile with an AI configuration that can call tools. + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Search knowledge | AI search returns document rows with titles and full vault paths. | | | +| Exact document read | Reading a specific knowledge document shows the document id/path and does not mutate data. | | | +| Book knowledge read | Book-scoped knowledge is available in context with bounded summaries. | | | +| Create proposal | AI create tool renders a confirmation-required proposal card with target path and preview. | | | +| Update proposal | AI update tool renders changed fields and path before applying. | | | +| Tag proposal | AI tag update remains a proposal and does not write until confirmed. | | | +| Link proposal | AI link creation remains a proposal and shows the involved document path(s). | | | +| Apply proposal | Applying a proposal is the first database write, and the card changes to applied/saved. | | | +| Failed tool card | A failing knowledge tool renders a visible failure card with tool name, reason, safe no-write hint, and path when available. | | | +| Summary compression | Compact summaries update retrieval memory without rewriting user-authored content. | | | + +## Obsidian And Import/Export QA + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Export vault | Export creates an Obsidian-style folder tree with frontmatter ids and readable Markdown. | | | +| Wikilinks | Internal links export as usable wikilinks or readable fallbacks. | | | +| Attachments | Image attachments export to portable paths and render after opening the vault folder. | | | +| ReadAny cards | Built-in, custom, unsupported, and future-version cards degrade to readable Markdown fallback. | | | +| Re-export | Re-export updates by stable document id and does not flatten folders or duplicate ambiguous titles. | | | +| Obsidian URI actions | Desktop conflict and import-review cards can open an exported file or search the selected vault through Obsidian URI actions, and show a visible failure toast if the platform cannot open the URI. | | | +| Markdown import | Markdown file import shows confirmation proposals with destination paths before writing. | | | +| Vault import | Linked-folder import surfaces modified, missing, unreadable, and conflict states before applying updates. | | | + +## Sync QA + +Use two real app instances and the selected sync backend. + +| Check | Expected | Status | Evidence | +| --- | --- | --- | --- | +| Folder hierarchy | Created and moved folders/documents arrive on the second device with the same paths. | | | +| Body content | Tiptap JSON and Markdown projection sync without losing rich blocks. | | | +| Attachments | Image attachment metadata and files arrive and render on the second device. | | | +| Links | Internal links, source links, backlinks, and paths resolve after sync. | | | +| Card attrs | Card type, version, source attrs, structured data, and schema migrations survive sync. | | | +| Card templates | Custom card template create/update/disable syncs without deleting existing card documents. | | | +| Tombstones | Deleted documents do not reappear after sync unless explicitly recreated. | | | + +## Final Decision + +| Decision | Value | +| --- | --- | +| Ready for PR review? | | +| Blocking failures | | +| Follow-up issues | | +| Reviewer notes | | diff --git a/docs/knowledge-base-notes/08-manual-qa-run-sheet.md b/docs/knowledge-base-notes/08-manual-qa-run-sheet.md new file mode 100644 index 00000000..64e625fa --- /dev/null +++ b/docs/knowledge-base-notes/08-manual-qa-run-sheet.md @@ -0,0 +1,519 @@ +# Knowledge Manual QA Run Sheet + +This file is generated from `07-manual-qa-evidence.md`. Use it while running +desktop, real-device mobile, AI, Obsidian, and sync checks, then copy the +actual status and evidence back into the evidence file. Do not treat this run +sheet as proof by itself. + +## Session + +- Evidence file: `docs/knowledge-base-notes/07-manual-qa-evidence.md` +- Branch: `feat/knowledge-base-notes-research` +- Commit under test: `4a901716` +- Required final gate: `pnpm acceptance:knowledge:manual` + +## Preflight + +- [ ] Pull the branch and confirm the worktree is clean. +- [ ] Run `pnpm acceptance:knowledge` on the commit under test. +- [ ] Prepare a desktop build, a real mobile device, a second sync device, an AI provider/model, a sync backend/account, and an Obsidian export folder. +- [ ] Keep screenshots, short videos, logs, exported file paths, and sync observations named so they can be pasted into the evidence rows. + +## Desktop QA + +Evidence hint: Screenshot or short screen recording, plus console log excerpt when behavior changes. + +### Open knowledge entry + +- Evidence row: `Desktop QA / Open knowledge entry` +- Expected: The first visible structure is left vault tree, center workspace, and quiet right context panel. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-open-knowledge-entry` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Root browser + +- Evidence row: `Desktop QA / Root browser` +- Expected: Selecting the vault root shows child folders/documents, not an editor. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-root-browser` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Folder browser + +- Evidence row: `Desktop QA / Folder browser` +- Expected: Selecting a folder shows child folders before child documents, not an empty document editor. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-folder-browser` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Create inside folder + +- Evidence row: `Desktop QA / Create inside folder` +- Expected: Creating a folder and note from a folder uses that folder as the destination. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-create-inside-folder` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Move consistency + +- Evidence row: `Desktop QA / Move consistency` +- Expected: After moving a note, tree row, breadcrumb, search result, and move target preview show the same path. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-move-consistency` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### WYSIWYG editing + +- Evidence row: `Desktop QA / WYSIWYG editing` +- Expected: Opening a document gives direct title/body editing in Tiptap, not raw Markdown or JSON by default. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-wysiwyg-editing` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Rich blocks + +- Evidence row: `Desktop QA / Rich blocks` +- Expected: Headings, lists, quote/callout/source cards, image, internal link, and custom ReadAny card insert and render. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-rich-blocks` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Card editing safety + +- Evidence row: `Desktop QA / Card editing safety` +- Expected: Editing card source attrs/data works, and invalid JSON shows an inline error without corrupting the last valid card. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-card-editing-safety` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Autosave state + +- Evidence row: `Desktop QA / Autosave state` +- Expected: Quiet saving/saved/pending state matches edits without requiring an explicit save button. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-autosave-state` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Context panel + +- Evidence row: `Desktop QA / Context panel` +- Expected: Sources, backlinks, outline, and AI memory/context stay attached to the active document path. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `desktop-qa-context-panel` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +## Mobile QA + +Evidence hint: Real-device screenshot/video, keyboard-safe-area observation, and device log excerpt when relevant. + +### Open knowledge area + +- Evidence row: `Mobile QA / Open knowledge area` +- Expected: The first mode is vault browsing, not one long stacked dashboard. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-open-knowledge-area` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Navigate folder + +- Evidence row: `Mobile QA / Navigate folder` +- Expected: Path remains visible, and folder rows show child folders before child documents. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-navigate-folder` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Open document + +- Evidence row: `Mobile QA / Open document` +- Expected: The document view becomes focused, WYSIWYG, and keyboard-aware. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-open-document` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Long editing + +- Evidence row: `Mobile QA / Long editing` +- Expected: Long body edits, toolbar actions, link, image, source reference, and ReadAny card insertion remain usable. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-long-editing` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Card details + +- Evidence row: `Mobile QA / Card details` +- Expected: Editing card attrs/data in WebView works; invalid JSON shows an error and preserves previous valid attrs. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-card-details` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Keyboard and safe area + +- Evidence row: `Mobile QA / Keyboard and safe area` +- Expected: Keyboard never covers editor controls or chat input, including Chinese and system keyboards. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-keyboard-and-safe-area` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Background recovery + +- Evidence row: `Mobile QA / Background recovery` +- Expected: Backgrounding and reopening offers the latest draft instead of silently losing unsaved content. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-background-recovery` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Mobile import review + +- Evidence row: `Mobile QA / Mobile import review` +- Expected: Markdown import previews destination paths and readable picker file names before applying writes; it should not show cache-only picker URIs as the source identity. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `mobile-qa-mobile-import-review` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +## AI Knowledge QA + +Evidence hint: Chat transcript excerpt showing tool cards/proposals/failure states and the visible vault path. + +### Search knowledge + +- Evidence row: `AI Knowledge QA / Search knowledge` +- Expected: AI search returns document rows with titles and full vault paths. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-search-knowledge` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Exact document read + +- Evidence row: `AI Knowledge QA / Exact document read` +- Expected: Reading a specific knowledge document shows the document id/path and does not mutate data. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-exact-document-read` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Book knowledge read + +- Evidence row: `AI Knowledge QA / Book knowledge read` +- Expected: Book-scoped knowledge is available in context with bounded summaries. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-book-knowledge-read` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Create proposal + +- Evidence row: `AI Knowledge QA / Create proposal` +- Expected: AI create tool renders a confirmation-required proposal card with target path and preview. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-create-proposal` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Update proposal + +- Evidence row: `AI Knowledge QA / Update proposal` +- Expected: AI update tool renders changed fields and path before applying. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-update-proposal` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Tag proposal + +- Evidence row: `AI Knowledge QA / Tag proposal` +- Expected: AI tag update remains a proposal and does not write until confirmed. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-tag-proposal` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Link proposal + +- Evidence row: `AI Knowledge QA / Link proposal` +- Expected: AI link creation remains a proposal and shows the involved document path(s). +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-link-proposal` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Apply proposal + +- Evidence row: `AI Knowledge QA / Apply proposal` +- Expected: Applying a proposal is the first database write, and the card changes to applied/saved. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-apply-proposal` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Failed tool card + +- Evidence row: `AI Knowledge QA / Failed tool card` +- Expected: A failing knowledge tool renders a visible failure card with tool name, reason, safe no-write hint, and path when available. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-failed-tool-card` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Summary compression + +- Evidence row: `AI Knowledge QA / Summary compression` +- Expected: Compact summaries update retrieval memory without rewriting user-authored content. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `ai-knowledge-qa-summary-compression` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +## Obsidian And Import/Export QA + +Evidence hint: Export/import folder path, representative Markdown file path, and any conflict preview screenshot. + +### Export vault + +- Evidence row: `Obsidian And Import/Export QA / Export vault` +- Expected: Export creates an Obsidian-style folder tree with frontmatter ids and readable Markdown. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-export-vault` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Wikilinks + +- Evidence row: `Obsidian And Import/Export QA / Wikilinks` +- Expected: Internal links export as usable wikilinks or readable fallbacks. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-wikilinks` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Attachments + +- Evidence row: `Obsidian And Import/Export QA / Attachments` +- Expected: Image attachments export to portable paths and render after opening the vault folder. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-attachments` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### ReadAny cards + +- Evidence row: `Obsidian And Import/Export QA / ReadAny cards` +- Expected: Built-in, custom, unsupported, and future-version cards degrade to readable Markdown fallback. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-readany-cards` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Re-export + +- Evidence row: `Obsidian And Import/Export QA / Re-export` +- Expected: Re-export updates by stable document id and does not flatten folders or duplicate ambiguous titles. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-re-export` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Obsidian URI actions + +- Evidence row: `Obsidian And Import/Export QA / Obsidian URI actions` +- Expected: Desktop conflict and import-review cards can open an exported file or search the selected vault through Obsidian URI actions, and show a visible failure toast if the platform cannot open the URI. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-obsidian-uri-actions` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Markdown import + +- Evidence row: `Obsidian And Import/Export QA / Markdown import` +- Expected: Markdown file import shows confirmation proposals with destination paths before writing. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-markdown-import` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Vault import + +- Evidence row: `Obsidian And Import/Export QA / Vault import` +- Expected: Linked-folder import surfaces modified, missing, unreadable, and conflict states before applying updates. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `obsidian-and-import-export-qa-vault-import` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +## Sync QA + +Evidence hint: Two-device observation, sync backend/account, and before/after path/content/card evidence. + +### Folder hierarchy + +- Evidence row: `Sync QA / Folder hierarchy` +- Expected: Created and moved folders/documents arrive on the second device with the same paths. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-folder-hierarchy` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Body content + +- Evidence row: `Sync QA / Body content` +- Expected: Tiptap JSON and Markdown projection sync without losing rich blocks. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-body-content` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Attachments + +- Evidence row: `Sync QA / Attachments` +- Expected: Image attachment metadata and files arrive and render on the second device. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-attachments` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Links + +- Evidence row: `Sync QA / Links` +- Expected: Internal links, source links, backlinks, and paths resolve after sync. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-links` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Card attrs + +- Evidence row: `Sync QA / Card attrs` +- Expected: Card type, version, source attrs, structured data, and schema migrations survive sync. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-card-attrs` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Card templates + +- Evidence row: `Sync QA / Card templates` +- Expected: Custom card template create/update/disable syncs without deleting existing card documents. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-card-templates` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +### Tombstones + +- Evidence row: `Sync QA / Tombstones` +- Expected: Deleted documents do not reappear after sync unless explicitly recreated. +- Current status: (empty) +- Current evidence: (empty) +- Evidence anchor: `sync-qa-tombstones` +- [ ] Run the check. +- [ ] Record pass/fail/blocker status. +- [ ] Paste concrete evidence into `07-manual-qa-evidence.md`. + +## Finalization + +- [ ] Fill `Ready for PR review?`, `Blocking failures`, `Follow-up issues`, and `Reviewer notes`. +- [ ] Run `pnpm acceptance:knowledge:manual` without `--allow-incomplete`. +- [ ] Commit and push the filled evidence file only after the strict manual gate passes. diff --git a/docs/knowledge-base-notes/README.md b/docs/knowledge-base-notes/README.md new file mode 100644 index 00000000..4c7e10d6 --- /dev/null +++ b/docs/knowledge-base-notes/README.md @@ -0,0 +1,111 @@ +# 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. +- Scenario-based editor profiles: quick annotation stays lightweight, book + knowledge pages stay powerful, reviews stay export-friendly, and metadata + stays in structured fields instead of rich-text blocks. +- 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. +- A real vault-style hierarchy with folders, breadcrumbs, and Obsidian-friendly + paths, not a flat notes list disguised with tags. +- A WYSIWYG document canvas on desktop and mobile; Markdown stays as a + projection for export, search, Obsidian, and fallback. + +## 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) +- [Vault Workspace Layout](05-vault-workspace-layout.md) +- [Acceptance Runbook](06-acceptance-runbook.md) +- [Manual QA Evidence](07-manual-qa-evidence.md) + +## Acceptance Commands + +```bash +pnpm acceptance:knowledge +pnpm acceptance:knowledge:manual +``` + +Use `pnpm acceptance:knowledge:manual -- --allow-incomplete` while manual +desktop, mobile, AI, Obsidian, and sync evidence is still being collected. diff --git a/package.json b/package.json index fd92c132..3a121c7c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "eas:build:ios": "pnpm --filter @readany/app-expo eas:build:ios", "lint": "biome check .", "lint:fix": "biome check --write .", + "acceptance:knowledge": "node scripts/check-knowledge-acceptance.js", + "acceptance:knowledge:manual": "node scripts/check-knowledge-manual-evidence.js", + "acceptance:knowledge:manual:plan": "node scripts/check-knowledge-manual-evidence.js --allow-incomplete --write-plan", "version:check": "node scripts/bump-version.js --check", "version:set": "node scripts/bump-version.js" }, 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..bc0397e7 --- /dev/null +++ b/packages/app-expo/assets/editor/knowledge-editor.html @@ -0,0 +1,827 @@ + + + + + + + + +
+ + + 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..a9184d31 --- /dev/null +++ b/packages/app-expo/assets/editor/knowledge-editor.template.html @@ -0,0 +1,668 @@ + + + + + + + + +
+ + + diff --git a/packages/app-expo/package.json b/packages/app-expo/package.json index cd8a0276..8499a161 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,12 @@ "@react-navigation/native": "^7.1.33", "@react-navigation/native-stack": "^7.14.4", "@readany/core": "workspace:*", + "@tiptap/core": "^3.20.0", + "@tiptap/extension-link": "^3.20.1", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-task-item": "^3.20.1", + "@tiptap/extension-task-list": "^3.20.1", + "@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..d79309c1 --- /dev/null +++ b/packages/app-expo/scripts/build-knowledge-editor.js @@ -0,0 +1,1321 @@ +/** + * 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 Link from "@tiptap/extension-link"; + import Placeholder from "@tiptap/extension-placeholder"; + import TaskItem from "@tiptap/extension-task-item"; + import TaskList from "@tiptap/extension-task-list"; + import StarterKit from "@tiptap/starter-kit"; + import { + createReadAnyCardAttrsFromTemplate, + createReadAnyCardReadOnlyModel, + createReadAnyCardTiptapContent, + formatReadAnyCardDataForEditor, + getReadAnyCardTemplateFields, + getVisibleReadAnyCardTemplateFields, + isReadAnyCardTemplateRequiredValueMissing, + parseReadAnyCardDataFromEditor, + } from "@readany/core/knowledge"; + + const EMPTY_DOC = { type: "doc", content: [] }; + let editor = null; + let ready = false; + let pendingInit = null; + let changeTimer = null; + let cardTemplates = []; + let cardBodyPlaceholder = "Write inside this card..."; + let cardConvertToTextLabel = "Convert card to normal text"; + let imageUnavailableTitle = "Image attachment is not available on this device yet."; + let imageUnavailableHint = "Sync again or keep the original device online to restore it."; + + 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 setCardTemplates = (value) => { + cardTemplates = Array.isArray(value) + ? value.filter((template) => template && typeof template === "object" && !template.builtIn) + : []; + }; + const createCardModel = (attrs = {}) => + createReadAnyCardReadOnlyModel(attrs, { body: "", cardTemplates }); + const findCardTemplate = (cardType) => + cardTemplates.find( + (template) => createReadAnyCardAttrsFromTemplate(template).cardType === cardType, + ); + const getCardDataRecord = (value) => + value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; + const getFieldInputValue = (value) => (value === undefined || value === null ? "" : String(value)); + const getFieldSelectedValues = (value) => + Array.isArray(value) ? value.map(String) : typeof value === "string" && value ? [value] : []; + + 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, + destructive: theme.destructive, + }; + 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"), + taskList: editor.isActive("taskList") || editor.isActive("taskItem"), + 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 createContentPayload = (requestId) => { + if (!editor) return; + return { + type: "contentChanged", + ...(typeof requestId === "string" && requestId ? { requestId } : {}), + contentJson: editor.getJSON(), + plainText: editor.getText(), + }; + }; + + const postContent = () => { + if (!editor) return; + clearTimeout(changeTimer); + changeTimer = setTimeout(() => { + const payload = createContentPayload(); + if (!payload) return; + post(payload); + scheduleHeight(); + }, 180); + }; + + const postContentNow = (requestId) => { + if (!editor) return; + clearTimeout(changeTimer); + const payload = createContentPayload(requestId); + if (!payload) return; + post(payload); + scheduleHeight(); + }; + + const syncEditableControls = () => { + if (!editor) return; + const editable = editor.isEditable; + document.documentElement.classList.toggle("readany-editor-readonly", !editable); + document + .querySelectorAll(".readany-card-title, .readany-card-preview, .readany-card-field, .readany-card-data") + .forEach((element) => { + element.readOnly = !editable; + element.tabIndex = editable ? 0 : -1; + element.setAttribute("aria-readonly", editable ? "false" : "true"); + }); + document.querySelectorAll(".readany-card-convert").forEach((element) => { + element.disabled = !editable; + element.tabIndex = editable ? 0 : -1; + element.setAttribute("aria-hidden", editable ? "false" : "true"); + }); + }; + + const updateCardAttrs = (node, getPos, attrs) => { + if (!editor || !editor.isEditable || typeof getPos !== "function") return; + const pos = getPos(); + if (typeof pos !== "number") return; + const nextAttrs = { ...(node.attrs || {}), ...attrs }; + editor.view.dispatch(editor.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs)); + postContent(); + scheduleHeight(); + }; + + const fitTextArea = (element) => { + if (!element) return; + element.style.height = "auto"; + element.style.height = Math.max(72, element.scrollHeight) + "px"; + }; + + const cardMetaText = (attrs = {}) => { + const model = createCardModel(attrs); + return [ + model.cardType, + model.state === "supported" ? "" : model.stateLabel || "v" + model.version, + ] + .filter(Boolean) + .join(" · "); + }; + + 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, getPos }) => { + let currentNode = node; + const attrs = currentNode.attrs || {}; + const readOnlyModel = createCardModel(attrs); + const modelAttrs = readOnlyModel.attrs || attrs; + const dom = document.createElement("div"); + dom.className = "readany-card"; + dom.contentEditable = "false"; + dom.dataset.cardType = readOnlyModel.cardType; + + const icon = document.createElement("div"); + icon.className = "readany-card-icon"; + icon.textContent = "◇"; + + const body = document.createElement("div"); + body.className = "readany-card-body"; + + const header = document.createElement("div"); + header.className = "readany-card-header"; + + const meta = document.createElement("div"); + meta.className = "readany-card-meta"; + meta.textContent = cardMetaText(attrs); + header.appendChild(meta); + + const convertButton = document.createElement("button"); + convertButton.className = "readany-card-convert"; + convertButton.type = "button"; + convertButton.textContent = "Aa"; + convertButton.title = cardConvertToTextLabel; + convertButton.setAttribute("aria-label", cardConvertToTextLabel); + convertButton.disabled = editor?.isEditable === false; + convertButton.tabIndex = editor?.isEditable === false ? -1 : 0; + convertButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!editor?.isEditable || typeof getPos !== "function") return; + const pos = getPos(); + if (typeof pos !== "number") return; + editor + .chain() + .focus() + .insertContentAt( + { from: pos, to: pos + currentNode.nodeSize }, + createReadAnyCardTiptapContent( + createCardModel(currentNode.attrs || {}).attrs || currentNode.attrs || {}, + ), + ) + .run(); + }); + header.appendChild(convertButton); + body.appendChild(header); + + const title = document.createElement("input"); + title.className = "readany-card-title"; + title.type = "text"; + title.value = modelAttrs.title || ""; + title.placeholder = readOnlyModel.title; + title.readOnly = editor?.isEditable === false; + title.tabIndex = editor?.isEditable === false ? -1 : 0; + title.addEventListener("input", () => { + if (!editor?.isEditable) return; + updateCardAttrs(currentNode, getPos, { title: title.value }); + }); + body.appendChild(title); + + const text = readOnlyModel.body; + const preview = document.createElement("textarea"); + preview.className = "readany-card-preview"; + preview.value = text; + preview.placeholder = cardBodyPlaceholder; + preview.rows = Math.max(3, Math.min(8, String(text).split("\\n").length + 1)); + preview.readOnly = editor?.isEditable === false; + preview.tabIndex = editor?.isEditable === false ? -1 : 0; + requestAnimationFrame(() => fitTextArea(preview)); + preview.addEventListener("input", () => { + if (!editor?.isEditable) return; + preview.rows = Math.max(3, Math.min(8, preview.value.split("\\n").length + 1)); + fitTextArea(preview); + updateCardAttrs(currentNode, getPos, { + markdown: preview.value, + text: preview.value, + }); + }); + body.appendChild(preview); + + const source = document.createElement("div"); + source.className = "readany-card-source"; + source.style.display = readOnlyModel.sourceTitle ? "block" : "none"; + source.textContent = readOnlyModel.sourceTitle || ""; + body.appendChild(source); + + const details = document.createElement("details"); + details.className = "readany-card-details"; + + const detailsSummary = document.createElement("summary"); + detailsSummary.className = "readany-card-details-summary"; + detailsSummary.textContent = "Details"; + details.appendChild(detailsSummary); + + const detailGrid = document.createElement("div"); + detailGrid.className = "readany-card-detail-grid"; + details.appendChild(detailGrid); + + const structuredFields = document.createElement("div"); + structuredFields.className = "readany-card-structured-fields"; + details.appendChild(structuredFields); + let dataInput = null; + let dataError = null; + + const updateStructuredData = (key, value) => { + const nextData = { + ...getCardDataRecord(createCardModel(currentNode.attrs || {}).attrs?.data), + [key]: value, + }; + if (dataError) { + dataError.style.display = "none"; + dataError.textContent = ""; + } + if (dataInput) { + dataInput.value = formatReadAnyCardDataForEditor(nextData); + } + updateCardAttrs(currentNode, getPos, { data: nextData }); + }; + + const renderStructuredFields = (model) => { + structuredFields.textContent = ""; + const template = findCardTemplate(model.cardType); + const currentData = getCardDataRecord(model.attrs?.data); + const allFields = template ? getReadAnyCardTemplateFields(template) : []; + const fields = template ? getVisibleReadAnyCardTemplateFields(template, currentData) : []; + if (fields.length === 0) { + structuredFields.style.display = "none"; + return; + } + structuredFields.style.display = "block"; + + const heading = document.createElement("div"); + heading.className = "readany-card-structured-heading"; + heading.textContent = "Structured fields"; + const count = document.createElement("span"); + count.className = "readany-card-structured-count"; + count.textContent = allFields.length === fields.length ? String(fields.length) : fields.length + "/" + allFields.length; + heading.appendChild(count); + const missingCount = fields.filter((field) => + isReadAnyCardTemplateRequiredValueMissing(field, currentData[field.key]), + ).length; + if (missingCount > 0) { + const missing = document.createElement("span"); + missing.className = "readany-card-structured-missing-count"; + missing.textContent = missingCount + " missing"; + heading.appendChild(missing); + } + structuredFields.appendChild(heading); + + const grid = document.createElement("div"); + grid.className = "readany-card-structured-grid"; + let currentGroup = ""; + fields.forEach((field) => { + const fieldGroup = typeof field.group === "string" ? field.group.trim() : ""; + if (fieldGroup && fieldGroup !== currentGroup) { + const groupHeading = document.createElement("div"); + groupHeading.className = "readany-card-structured-group-heading"; + groupHeading.textContent = fieldGroup; + grid.appendChild(groupHeading); + } + currentGroup = fieldGroup; + const currentValue = currentData[field.key]; + const isRequiredMissing = isReadAnyCardTemplateRequiredValueMissing(field, currentValue); + const applyFieldLayout = (element) => { + if (field.width === "full" || field.width === "half" || field.width === "third") { + element.classList.add("readany-card-field-width-" + field.width); + element.setAttribute("data-readany-card-field-width", field.width); + } + }; + const applyMissingState = (element) => { + element.classList.toggle("readany-card-field-missing", isRequiredMissing); + if (isRequiredMissing) { + element.setAttribute("data-readany-card-field-state", "missing"); + } else { + element.removeAttribute("data-readany-card-field-state"); + } + }; + const appendRequiredMarker = (element) => { + if (!field.required) return; + const marker = document.createElement("span"); + marker.className = "readany-card-field-required-marker"; + marker.textContent = " *"; + element.appendChild(marker); + }; + const appendMissingHint = (element) => { + if (!isRequiredMissing) return; + const hint = document.createElement("span"); + hint.className = "readany-card-field-missing-hint"; + hint.textContent = "Required value missing."; + element.appendChild(hint); + }; + if (field.type === "checkbox") { + const label = document.createElement("label"); + label.className = "readany-card-structured-checkbox"; + applyFieldLayout(label); + applyMissingState(label); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = currentValue === true; + if (isRequiredMissing) input.setAttribute("aria-invalid", "true"); + input.disabled = editor?.isEditable === false; + input.addEventListener("change", () => { + if (!editor?.isEditable) return; + updateStructuredData(field.key, input.checked); + }); + label.appendChild(input); + const text = document.createElement("span"); + text.textContent = field.label; + appendRequiredMarker(text); + label.appendChild(text); + appendMissingHint(label); + grid.appendChild(label); + return; + } + + const label = document.createElement("label"); + label.className = "readany-card-field-label"; + applyFieldLayout(label); + applyMissingState(label); + const caption = document.createElement("span"); + caption.textContent = field.label; + appendRequiredMarker(caption); + label.appendChild(caption); + + if (field.type === "multiline") { + const textarea = document.createElement("textarea"); + textarea.className = "readany-card-data readany-card-structured-textarea"; + textarea.value = getFieldInputValue(currentValue); + textarea.placeholder = field.placeholder || ""; + textarea.rows = 3; + textarea.readOnly = editor?.isEditable === false; + textarea.tabIndex = editor?.isEditable === false ? -1 : 0; + if (isRequiredMissing) textarea.setAttribute("aria-invalid", "true"); + textarea.addEventListener("blur", () => { + if (!editor?.isEditable) return; + updateStructuredData(field.key, textarea.value); + }); + label.appendChild(textarea); + } else if (field.type === "select") { + const select = document.createElement("select"); + select.className = "readany-card-field readany-card-select"; + select.value = getFieldInputValue(currentValue); + select.disabled = editor?.isEditable === false; + select.tabIndex = editor?.isEditable === false ? -1 : 0; + if (isRequiredMissing) select.setAttribute("aria-invalid", "true"); + const emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = field.placeholder || "Choose..."; + select.appendChild(emptyOption); + for (const option of field.options || []) { + const optionElement = document.createElement("option"); + optionElement.value = option.value; + optionElement.textContent = option.label; + select.appendChild(optionElement); + } + select.addEventListener("change", () => { + if (!editor?.isEditable) return; + updateStructuredData(field.key, select.value || null); + }); + label.appendChild(select); + } else if (field.type === "multiselect") { + const selectedValues = getFieldSelectedValues(currentValue); + const choices = document.createElement("div"); + choices.className = "readany-card-multiselect"; + if (isRequiredMissing) choices.setAttribute("aria-invalid", "true"); + for (const option of field.options || []) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "readany-card-choice"; + const isSelected = selectedValues.includes(option.value); + button.classList.toggle("readany-card-choice-selected", isSelected); + button.textContent = option.label; + button.disabled = editor?.isEditable === false; + button.addEventListener("click", () => { + if (!editor?.isEditable) return; + const nextValues = isSelected + ? selectedValues.filter((value) => value !== option.value) + : [...selectedValues, option.value]; + updateStructuredData(field.key, nextValues); + }); + choices.appendChild(button); + } + label.appendChild(choices); + } else { + const input = document.createElement("input"); + input.className = "readany-card-field"; + input.type = field.type === "number" ? "number" : "text"; + input.value = getFieldInputValue(currentValue); + input.placeholder = field.placeholder || ""; + input.readOnly = editor?.isEditable === false; + input.tabIndex = editor?.isEditable === false ? -1 : 0; + if (isRequiredMissing) input.setAttribute("aria-invalid", "true"); + input.addEventListener("blur", () => { + if (!editor?.isEditable) return; + if (field.type === "number") { + const rawValue = input.value.trim(); + if (!rawValue) { + updateStructuredData(field.key, null); + return; + } + const numberValue = Number(rawValue); + if (!Number.isFinite(numberValue)) { + if (dataError) { + dataError.textContent = field.label + " must be a valid number."; + dataError.style.display = "block"; + } + return; + } + updateStructuredData(field.key, numberValue); + return; + } + updateStructuredData(field.key, input.value); + }); + label.appendChild(input); + } + appendMissingHint(label); + grid.appendChild(label); + }); + structuredFields.appendChild(grid); + }; + + const createTextField = (labelText, key, placeholder = "") => { + const label = document.createElement("label"); + label.className = "readany-card-field-label"; + const labelCaption = document.createElement("span"); + labelCaption.textContent = labelText; + label.appendChild(labelCaption); + const input = document.createElement("input"); + input.className = "readany-card-field"; + input.type = "text"; + input.placeholder = placeholder; + input.value = modelAttrs?.[key] || ""; + input.readOnly = editor?.isEditable === false; + input.tabIndex = editor?.isEditable === false ? -1 : 0; + input.addEventListener("blur", () => { + if (!editor?.isEditable) return; + updateCardAttrs(currentNode, getPos, { [key]: input.value.trim() || null }); + }); + label.appendChild(input); + detailGrid.appendChild(label); + return input; + }; + + const sourceTitleInput = createTextField("Source", "sourceTitle", "Chapter"); + const sourceIdInput = createTextField("Source ID", "sourceId", "highlight-1"); + const cfiInput = createTextField("CFI", "cfi", "epubcfi(...)"); + + const dataLabel = document.createElement("label"); + dataLabel.className = "readany-card-field-label readany-card-data-label"; + const dataCaption = document.createElement("span"); + dataCaption.textContent = "Data JSON"; + dataLabel.appendChild(dataCaption); + dataInput = document.createElement("textarea"); + dataInput.className = "readany-card-data"; + dataInput.value = formatReadAnyCardDataForEditor(modelAttrs?.data); + dataInput.placeholder = '{"key":"value"}'; + dataInput.rows = 4; + dataInput.readOnly = editor?.isEditable === false; + dataInput.tabIndex = editor?.isEditable === false ? -1 : 0; + dataError = document.createElement("div"); + dataError.className = "readany-card-data-error"; + dataError.style.display = "none"; + dataInput.addEventListener("input", () => { + dataError.style.display = "none"; + dataError.textContent = ""; + }); + dataInput.addEventListener("blur", () => { + if (!editor?.isEditable) return; + const parsed = parseReadAnyCardDataFromEditor(dataInput.value); + if (!parsed.ok) { + dataError.textContent = "Invalid JSON: " + parsed.error; + dataError.style.display = "block"; + return; + } + dataError.style.display = "none"; + dataError.textContent = ""; + dataInput.value = formatReadAnyCardDataForEditor(parsed.data); + updateCardAttrs(currentNode, getPos, { data: parsed.data }); + }); + dataLabel.appendChild(dataInput); + dataLabel.appendChild(dataError); + details.appendChild(dataLabel); + body.appendChild(details); + renderStructuredFields(readOnlyModel); + + dom.appendChild(icon); + dom.appendChild(body); + return { + dom, + update(nextNode) { + if (nextNode.type.name !== "readanyCard") return false; + currentNode = nextNode; + const nextAttrs = nextNode.attrs || {}; + const nextModel = createCardModel(nextAttrs); + const nextModelAttrs = nextModel.attrs || nextAttrs; + dom.dataset.cardType = nextModel.cardType; + meta.textContent = cardMetaText(nextAttrs); + title.value = nextModelAttrs.title || ""; + title.placeholder = nextModel.title; + title.readOnly = editor?.isEditable === false; + title.tabIndex = editor?.isEditable === false ? -1 : 0; + convertButton.disabled = editor?.isEditable === false; + convertButton.tabIndex = editor?.isEditable === false ? -1 : 0; + const nextText = nextModel.body; + if (preview.value !== nextText) preview.value = nextText; + preview.rows = Math.max(3, Math.min(8, String(nextText).split("\\n").length + 1)); + preview.readOnly = editor?.isEditable === false; + preview.tabIndex = editor?.isEditable === false ? -1 : 0; + fitTextArea(preview); + source.style.display = nextModel.sourceTitle ? "block" : "none"; + source.textContent = nextModel.sourceTitle || ""; + renderStructuredFields(nextModel); + const editable = editor?.isEditable !== false; + [sourceTitleInput, sourceIdInput, cfiInput, dataInput].forEach((field) => { + field.readOnly = !editable; + field.tabIndex = editable ? 0 : -1; + field.setAttribute("aria-readonly", editable ? "false" : "true"); + }); + if (document.activeElement !== sourceTitleInput) { + sourceTitleInput.value = nextModelAttrs.sourceTitle || ""; + } + if (document.activeElement !== sourceIdInput) { + sourceIdInput.value = nextModelAttrs.sourceId || ""; + } + if (document.activeElement !== cfiInput) { + cfiInput.value = nextModelAttrs.cfi || ""; + } + if (document.activeElement !== dataInput) { + dataInput.value = formatReadAnyCardDataForEditor(nextModelAttrs.data); + dataError.style.display = "none"; + dataError.textContent = ""; + } + return true; + }, + }; + }; + }, + }); + + const ReadAnyInternalLink = Node.create({ + name: "readanyInternalLink", + group: "inline", + inline: true, + atom: true, + selectable: true, + + addAttributes() { + return { + documentId: { default: null }, + targetPath: { default: null }, + label: { default: null }, + title: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-readany-internal-link]" }]; + }, + + renderHTML({ HTMLAttributes }) { + const label = + HTMLAttributes.label || + HTMLAttributes.title || + HTMLAttributes.documentId || + HTMLAttributes.targetPath || + ""; + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-readany-internal-link": + HTMLAttributes.documentId || HTMLAttributes.targetPath || label, + class: "readany-internal-link", + }), + label, + ]; + }, + + addNodeView() { + return ({ node }) => { + const span = document.createElement("span"); + span.className = "readany-internal-link"; + span.contentEditable = "false"; + const update = (nextNode) => { + const attrs = nextNode.attrs || {}; + const label = + attrs.label || attrs.title || attrs.documentId || attrs.targetPath || "Linked note"; + span.dataset.readanyInternalLink = attrs.documentId || attrs.targetPath || label; + span.textContent = label; + }; + update(node); + return { + dom: span, + update(nextNode) { + if (nextNode.type.name !== "readanyInternalLink") return false; + update(nextNode); + return true; + }, + }; + }; + }, + }); + + const ReadAnySourceReference = Node.create({ + name: "readanySourceReference", + group: "inline", + inline: true, + atom: true, + selectable: true, + + addAttributes() { + return { + label: { + default: null, + parseHTML: (element) => element.getAttribute("data-label") || element.textContent || null, + renderHTML: (attributes) => + attributes.label ? { "data-label": attributes.label } : {}, + }, + sourceTitle: { + default: null, + parseHTML: (element) => + element.getAttribute("data-source-title") || element.textContent || null, + renderHTML: (attributes) => + attributes.sourceTitle ? { "data-source-title": attributes.sourceTitle } : {}, + }, + sourceId: { + default: null, + parseHTML: (element) => + element.getAttribute("data-source-id") || + element.getAttribute("data-readany-source-id") || + null, + renderHTML: (attributes) => + attributes.sourceId ? { "data-source-id": attributes.sourceId } : {}, + }, + cfi: { + default: null, + parseHTML: (element) => { + const cfi = element.getAttribute("data-cfi"); + if (cfi) return cfi; + const legacyReference = element.getAttribute("data-readany-source-reference") || ""; + return legacyReference.startsWith("epubcfi(") ? legacyReference : null; + }, + renderHTML: (attributes) => (attributes.cfi ? { "data-cfi": attributes.cfi } : {}), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-readany-source-reference]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const label = node.attrs.label || node.attrs.sourceTitle || "Source reference"; + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-readany-source-reference": node.attrs.cfi || node.attrs.sourceId || label, + class: "readany-source-reference", + }), + label, + ]; + }, + + addNodeView() { + return ({ node }) => { + const span = document.createElement("span"); + span.className = "readany-source-reference"; + span.contentEditable = "false"; + const update = (nextNode) => { + const attrs = nextNode.attrs || {}; + const label = attrs.label || attrs.sourceTitle || "Source reference"; + span.dataset.readanySourceReference = attrs.cfi || attrs.sourceId || label; + if (attrs.sourceId) span.dataset.readanySourceId = attrs.sourceId; + else delete span.dataset.readanySourceId; + span.textContent = label; + }; + update(node); + return { + dom: span, + update(nextNode) { + if (nextNode.type.name !== "readanySourceReference") return false; + update(nextNode); + return true; + }, + }; + }; + }, + }); + + const KnowledgeImage = Node.create({ + name: "image", + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { + src: { default: null }, + alt: { default: null }, + title: { default: null }, + attachmentId: { default: null }, + fileName: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "img[src]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["img", mergeAttributes(HTMLAttributes, { "data-readany-image": "true" })]; + }, + + addNodeView() { + return ({ node }) => { + const attrs = node.attrs || {}; + let currentAttrs = attrs; + const figure = document.createElement("figure"); + figure.className = "readany-image"; + figure.contentEditable = "false"; + + const image = document.createElement("img"); + const fallback = document.createElement("div"); + fallback.className = "readany-image-missing"; + + const icon = document.createElement("div"); + icon.className = "readany-image-missing-icon"; + icon.textContent = "!"; + fallback.appendChild(icon); + + const text = document.createElement("div"); + text.className = "readany-image-missing-text"; + fallback.appendChild(text); + + const title = document.createElement("div"); + title.className = "readany-image-missing-title"; + text.appendChild(title); + + const hint = document.createElement("div"); + hint.className = "readany-image-missing-hint"; + text.appendChild(hint); + + const updateFallback = (nextAttrs = {}, failed = false) => { + const src = typeof nextAttrs.src === "string" ? nextAttrs.src.trim() : ""; + const attachmentId = + typeof nextAttrs.attachmentId === "string" ? nextAttrs.attachmentId.trim() : ""; + const unresolved = attachmentId && (!src || src.startsWith("readany-attachment://")); + const missing = failed || !src || unresolved; + title.textContent = + nextAttrs.fileName || nextAttrs.title || nextAttrs.alt || imageUnavailableTitle; + hint.textContent = imageUnavailableHint; + image.style.display = missing ? "none" : "block"; + fallback.style.display = missing ? "flex" : "none"; + }; + + image.src = attrs.src || ""; + image.alt = attrs.alt || ""; + image.title = attrs.title || ""; + image.addEventListener("error", () => updateFallback(currentAttrs, true)); + figure.appendChild(image); + figure.appendChild(fallback); + + if (attrs.alt) { + const caption = document.createElement("figcaption"); + caption.textContent = attrs.alt; + figure.appendChild(caption); + } + + updateFallback(attrs); + + return { + dom: figure, + update(nextNode) { + if (nextNode.type.name !== "image") return false; + const nextAttrs = nextNode.attrs || {}; + currentAttrs = nextAttrs; + image.src = nextAttrs.src || ""; + image.alt = nextAttrs.alt || ""; + image.title = nextAttrs.title || ""; + updateFallback(nextAttrs); + const nextAlt = nextAttrs.alt || ""; + let caption = figure.querySelector("figcaption"); + if (nextAlt && !caption) { + caption = document.createElement("figcaption"); + figure.appendChild(caption); + } + if (caption) { + if (nextAlt) caption.textContent = nextAlt; + else caption.remove(); + } + return true; + }, + }; + }; + }, + }); + + const createEditor = (payload = {}) => { + const el = document.getElementById("editor"); + if (!el) throw new Error("Editor root not found"); + setTheme(payload.theme); + setCardTemplates(payload.cardTemplates); + cardBodyPlaceholder = + typeof payload.cardBodyPlaceholder === "string" && payload.cardBodyPlaceholder + ? payload.cardBodyPlaceholder + : "Write inside this card..."; + cardConvertToTextLabel = + typeof payload.cardConvertToTextLabel === "string" && payload.cardConvertToTextLabel + ? payload.cardConvertToTextLabel + : "Convert card to normal text"; + imageUnavailableTitle = + typeof payload.imageUnavailableTitle === "string" && payload.imageUnavailableTitle + ? payload.imageUnavailableTitle + : "Image attachment is not available on this device yet."; + imageUnavailableHint = + typeof payload.imageUnavailableHint === "string" && payload.imageUnavailableHint + ? payload.imageUnavailableHint + : "Sync again or keep the original device online to restore it."; + editor?.destroy(); + editor = new Editor({ + element: el, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + dropcursor: false, + gapcursor: false, + }), + Link.configure({ + autolink: true, + openOnClick: false, + }), + TaskList, + TaskItem.configure({ + nested: true, + }), + ReadAnyInternalLink, + ReadAnySourceReference, + KnowledgeImage, + 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" }); + syncEditableControls(); + postSelection(); + scheduleHeight(); + }, + onUpdate: () => postContent(), + onSelectionUpdate: () => postSelection(), + onTransaction: () => { + syncEditableControls(); + scheduleHeight(); + }, + onFocus: () => post({ type: "focusChanged", focused: true }), + onBlur: () => { + postContentNow(); + post({ type: "focusChanged", focused: false }); + }, + }); + ready = true; + }; + + const scrollToOutline = (index) => { + if (!editor) return; + const numericIndex = Number(index); + if (!Number.isFinite(numericIndex) || numericIndex < 0) return; + const headings = Array.from( + document.querySelectorAll(".readany-prosemirror h1, .readany-prosemirror h2, .readany-prosemirror h3, .readany-prosemirror h4, .readany-prosemirror h5, .readany-prosemirror h6"), + ); + const target = headings[Math.floor(numericIndex)]; + if (!target) return; + target.scrollIntoView({ block: "center", behavior: "smooth" }); + target.animate?.( + [ + { outline: "0 solid transparent", outlineOffset: "0px" }, + { outline: "2px solid var(--primary)", outlineOffset: "4px" }, + { outline: "0 solid transparent", outlineOffset: "8px" }, + ], + { duration: 900, easing: "ease-out" }, + ); + }; + + const runCommand = (command, attrs = {}) => { + if (!editor) return; + const readOnlyAllowedCommands = new Set(["focus", "blur", "scrollToOutline"]); + if (!editor.isEditable && !readOnlyAllowedCommands.has(command)) { + postSelection(); + scheduleHeight(); + 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 "taskList": + chain.toggleTaskList().run(); + break; + case "blockquote": + chain.toggleBlockquote().run(); + break; + case "codeBlock": + chain.toggleCodeBlock().run(); + break; + case "horizontalRule": + chain.setHorizontalRule().run(); + break; + case "insertImage": { + if (typeof attrs.src === "string" && attrs.src.trim()) { + chain + .insertContent({ + type: "image", + attrs: { + src: attrs.src.trim(), + alt: typeof attrs.alt === "string" ? attrs.alt.trim() : "", + title: typeof attrs.title === "string" ? attrs.title.trim() : "", + attachmentId: + typeof attrs.attachmentId === "string" ? attrs.attachmentId.trim() : "", + fileName: typeof attrs.fileName === "string" ? attrs.fileName.trim() : "", + }, + }) + .run(); + } + break; + } + case "insertInternalLink": { + const linkAttrs = attrs && typeof attrs === "object" ? attrs : {}; + const label = + typeof linkAttrs.label === "string" && linkAttrs.label.trim() + ? linkAttrs.label.trim() + : typeof linkAttrs.title === "string" && linkAttrs.title.trim() + ? linkAttrs.title.trim() + : typeof linkAttrs.documentId === "string" + ? linkAttrs.documentId.trim() + : ""; + if (label) { + chain + .insertContent({ + type: "readanyInternalLink", + attrs: { + label, + title: label, + documentId: + typeof linkAttrs.documentId === "string" && linkAttrs.documentId.trim() + ? linkAttrs.documentId.trim() + : null, + targetPath: + typeof linkAttrs.targetPath === "string" && linkAttrs.targetPath.trim() + ? linkAttrs.targetPath.trim() + : null, + }, + }) + .run(); + } + break; + } + case "insertSourceReference": { + const sourceAttrs = attrs && typeof attrs === "object" ? attrs : {}; + const label = + typeof sourceAttrs.label === "string" && sourceAttrs.label.trim() + ? sourceAttrs.label.trim() + : typeof sourceAttrs.sourceTitle === "string" && sourceAttrs.sourceTitle.trim() + ? sourceAttrs.sourceTitle.trim() + : ""; + if (label) { + chain + .insertContent([ + { + type: "readanySourceReference", + attrs: { + label, + sourceTitle: + typeof sourceAttrs.sourceTitle === "string" && + sourceAttrs.sourceTitle.trim() + ? sourceAttrs.sourceTitle.trim() + : label, + sourceId: + typeof sourceAttrs.sourceId === "string" && sourceAttrs.sourceId.trim() + ? sourceAttrs.sourceId.trim() + : null, + cfi: + typeof sourceAttrs.cfi === "string" && sourceAttrs.cfi.trim() + ? sourceAttrs.cfi.trim() + : null, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + } + break; + } + case "insertCard": { + const cardAttrs = attrs && typeof attrs === "object" ? attrs : {}; + chain + .insertContent({ + type: "readanyCard", + attrs: { + cardType: + typeof cardAttrs.cardType === "string" && cardAttrs.cardType + ? cardAttrs.cardType + : "callout", + version: typeof cardAttrs.version === "number" ? cardAttrs.version : 1, + id: typeof cardAttrs.id === "string" ? cardAttrs.id : null, + title: typeof cardAttrs.title === "string" ? cardAttrs.title : null, + text: typeof cardAttrs.text === "string" ? cardAttrs.text : null, + sourceTitle: + typeof cardAttrs.sourceTitle === "string" ? cardAttrs.sourceTitle : null, + sourceId: typeof cardAttrs.sourceId === "string" ? cardAttrs.sourceId : null, + cfi: typeof cardAttrs.cfi === "string" ? cardAttrs.cfi : null, + markdown: typeof cardAttrs.markdown === "string" ? cardAttrs.markdown : "", + data: cardAttrs.data ?? null, + }, + }) + .run(); + break; + } + case "focus": + editor.commands.focus(attrs.position || "end"); + break; + case "scrollToOutline": + scrollToOutline(attrs.index); + 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": + if ("cardTemplates" in message) setCardTemplates(message.cardTemplates); + editor?.commands.setContent(normalizeDoc(message.contentJson)); + postContent(); + break; + case "setCardTemplates": + setCardTemplates(message.cardTemplates); + break; + case "setTheme": + setTheme(message.theme); + break; + case "setEditable": + editor?.setEditable(message.editable !== false); + syncEditableControls(); + postSelection(); + scheduleHeight(); + break; + case "focus": + runCommand("focus", { position: message.position }); + break; + case "blur": + runCommand("blur"); + break; + case "runCommand": + runCommand(message.command, message.attrs); + break; + case "requestContent": + postContentNow(message.requestId); + 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: [".ts", ".tsx", ".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/chat/PartRenderer.tsx b/packages/app-expo/src/components/chat/PartRenderer.tsx index cb57d44d..18f5b453 100644 --- a/packages/app-expo/src/components/chat/PartRenderer.tsx +++ b/packages/app-expo/src/components/chat/PartRenderer.tsx @@ -1,9 +1,33 @@ import { MermaidView } from "@/components/common/MermaidView"; import { MindmapView } from "@/components/common/MindmapView"; -import { BrainIcon, CheckIcon, ChevronDownIcon, OctagonXIcon, XIcon } from "@/components/ui/Icon"; +import { + BrainIcon, + CheckIcon, + ChevronDownIcon, + NotebookPenIcon, + OctagonXIcon, + XIcon, +} from "@/components/ui/Icon"; import { useThrottledValue } from "@/hooks"; +import { resolveActiveAIConfig } from "@/lib/ai/resolve-active-ai-config"; +import { useSettingsStore } from "@/stores"; import { fontSize as fs, fontWeight as fw, radius, useColors, withOpacity } from "@/styles/theme"; import type { ThemeColors } from "@/styles/theme"; +import { + getKnowledgeToolResultDisplay, + getToolResultError, + maybeCompressKnowledgeDocumentsById, +} from "@readany/core/ai"; +import { getKnowledgeCardTemplates } from "@readany/core/db/database"; +import type { KnowledgeToolResultDisplay } from "@readany/core/ai"; +import { + type KnowledgeProposalApplyResult, + type KnowledgeWriteProposal, + applyKnowledgeWriteProposal, + createKnowledgeWriteProposalPreview, + getKnowledgeProposalApplyErrorDetails, + getKnowledgeWriteProposal, +} from "@readany/core/knowledge/proposals"; import type { AbortedPart, CitationPart, @@ -14,10 +38,13 @@ import type { TextPart, ToolCallPart, } from "@readany/core/types/message"; -import { useEffect, useState } from "react"; +import type { KnowledgeCardTemplate } from "@readany/core/types"; +import { eventBus } from "@readany/core/utils/event-bus"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, + Alert, ScrollView, StyleSheet, Text, @@ -32,6 +59,47 @@ interface PartProps { onCitationClick?: (citation: CitationPart) => void; } +let knowledgeCardTemplatesCache: KnowledgeCardTemplate[] | null = null; +let knowledgeCardTemplatesPromise: Promise | null = null; + +function loadKnowledgeCardTemplatesForPreview(): Promise { + if (knowledgeCardTemplatesCache) return Promise.resolve(knowledgeCardTemplatesCache); + if (knowledgeCardTemplatesPromise) return knowledgeCardTemplatesPromise; + + knowledgeCardTemplatesPromise = getKnowledgeCardTemplates({ includeDisabled: true }) + .then((templates) => { + knowledgeCardTemplatesCache = templates; + return templates; + }) + .catch((error) => { + console.warn("[KnowledgeProposal] Failed to load card templates:", error); + knowledgeCardTemplatesCache = []; + return []; + }) + .finally(() => { + knowledgeCardTemplatesPromise = null; + }); + + return knowledgeCardTemplatesPromise; +} + +function clearKnowledgeCardTemplatesForPreview(): void { + knowledgeCardTemplatesCache = null; + knowledgeCardTemplatesPromise = null; +} + +function queueKnowledgeProposalSummaryMaintenance(documentId: string | undefined): void { + if (!documentId) return; + + void (async () => { + const resolvedAIConfig = await resolveActiveAIConfig(useSettingsStore.getState()); + if (!resolvedAIConfig) return; + await maybeCompressKnowledgeDocumentsById([documentId], resolvedAIConfig); + })().catch((error) => { + console.warn("[KnowledgeProposal] Background summary maintenance failed:", error); + }); +} + export function PartRenderer({ part, citations, onCitationClick }: PartProps) { switch (part.type) { case "text": @@ -160,21 +228,497 @@ const TOOL_LABEL_KEYS: Record = { fallbackToc: "toolLabels.fallbackToc", fallbackSearch: "toolLabels.fallbackSearch", fallbackChapterContext: "toolLabels.fallbackChapterContext", + searchKnowledgeBase: "toolLabels.searchKnowledgeBase", + getKnowledgeDocument: "toolLabels.getKnowledgeDocument", + getBookKnowledge: "toolLabels.getBookKnowledge", + proposeKnowledgeDocumentCreate: "toolLabels.proposeKnowledgeDocumentCreate", + proposeKnowledgeDocumentUpdate: "toolLabels.proposeKnowledgeDocumentUpdate", + proposeKnowledgeDocumentTagsUpdate: "toolLabels.proposeKnowledgeDocumentTagsUpdate", + proposeKnowledgeLinkCreate: "toolLabels.proposeKnowledgeLinkCreate", + compressKnowledgeDocumentSummary: "toolLabels.compressKnowledgeDocumentSummary", +}; + +type KnowledgeProposalApplyState = "idle" | "applying" | "applied" | "failed"; +type KnowledgeOpenDocumentSource = "ai_result" | "ai_relation" | "ai_proposal"; +type KnowledgeOpenDocumentTarget = { + id?: string; + bookId?: string; + title?: string; + path?: string; }; +function requestKnowledgeDocumentOpen( + document: KnowledgeOpenDocumentTarget, + source: KnowledgeOpenDocumentSource, +): boolean { + if (!document.id) return false; + let handled = false; + eventBus.emit("knowledge:open-document", { + documentId: document.id, + bookId: document.bookId, + title: document.title, + path: document.path, + source, + timestamp: Date.now(), + respond: (nextHandled) => { + handled = handled || nextHandled; + }, + }); + return handled; +} + +const KNOWLEDGE_DOCUMENT_TYPE_KEYS: Record = { + book_home: "knowledgeProposal.types.bookHome", + folder: "knowledgeProposal.types.folder", + standalone_note: "knowledgeProposal.types.standaloneNote", + highlight_note: "knowledgeProposal.types.highlightNote", + review: "knowledgeProposal.types.review", + summary: "knowledgeProposal.types.summary", + imported_markdown: "knowledgeProposal.types.importedMarkdown", +}; + +const KNOWLEDGE_CHANGED_FIELD_KEYS: Record = { + parentId: "knowledgeProposal.fields.parentFolder", + title: "knowledgeProposal.fields.title", + contentMd: "knowledgeProposal.fields.content", + contentJson: "knowledgeProposal.fields.content", + excerpt: "knowledgeProposal.fields.content", + tags: "knowledgeProposal.fields.tags", +}; + +function formatKnowledgeChangedFields( + fields: string[], + t: (key: string, options?: Record) => string, +): string[] { + return [ + ...new Set( + fields.map((field) => + t(KNOWLEDGE_CHANGED_FIELD_KEYS[field] ?? `knowledgeProposal.fields.${field}`, { + defaultValue: field, + }), + ), + ), + ]; +} + +function KnowledgeToolResultDocumentRows({ + documents, + styles, + max = 5, +}: { + documents: KnowledgeToolResultDisplay["documents"]; + styles: ReturnType; + max?: number; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const handleOpenDocument = (document: KnowledgeToolResultDisplay["documents"][number]) => { + if (requestKnowledgeDocumentOpen(document, "ai_result")) return; + Alert.alert( + t("knowledgeToolResult.openDocument", "打开文档"), + t("knowledgeToolResult.openUnavailable", "请先打开这本书的知识库页,再查看这个文档。"), + ); + }; + + return ( + <> + {documents.slice(0, max).map((document) => { + const matchFieldLabels = + document.matchFields?.map((field) => + t(`knowledgeToolResult.matchFields.${field}`, { defaultValue: field }), + ) ?? []; + + return ( + + + + {document.title} + + {!!document.type && ( + + + + {t(`knowledgeToolResult.types.${document.type}`, { + defaultValue: document.type, + })} + + + {!!document.id && ( + handleOpenDocument(document)} + activeOpacity={0.75} + accessibilityLabel={t("knowledgeToolResult.openDocument", "打开文档")} + > + + + {t("knowledgeToolResult.open", "打开")} + + + )} + + )} + {!document.type && !!document.id && ( + handleOpenDocument(document)} + activeOpacity={0.75} + accessibilityLabel={t("knowledgeToolResult.openDocument", "打开文档")} + > + + + {t("knowledgeToolResult.open", "打开")} + + + )} + + {!!document.path && ( + + {document.path} + + )} + {matchFieldLabels.length > 0 ? ( + + {t("knowledgeToolResult.matchedIn", { + fields: matchFieldLabels.join(", "), + defaultValue: `Matched in ${matchFieldLabels.join(", ")}`, + })} + + ) : null} + {!!document.snippet && ( + + {document.snippet} + + )} + + ); + })} + + ); +} + +function KnowledgeToolResultRelationRows({ + relations = [], + styles, + max = 4, +}: { + relations?: NonNullable; + styles: ReturnType; + max?: number; +}) { + const { t } = useTranslation(); + const colors = useColors(); + if (relations.length === 0) return null; + const handleOpenDocument = (document: KnowledgeToolResultDisplay["documents"][number]) => { + if (requestKnowledgeDocumentOpen(document, "ai_relation")) return; + Alert.alert( + t("knowledgeToolResult.openDocument", "打开文档"), + t("knowledgeToolResult.openUnavailable", "请先打开这本书的知识库页,再查看这个文档。"), + ); + }; + + return ( + + + {t("knowledgeToolResult.relations", "关联路径")} + + {relations.slice(0, max).map((relation) => { + const direction = + relation.direction === "outgoing" + ? t("knowledgeToolResult.relationOutgoing", "链接到") + : t("knowledgeToolResult.relationBacklink", "被引用自"); + const relationLabel = relation.label || relation.relation; + + return ( + + + + {direction} + + + + + + {relation.document.title} + + {!!relationLabel && ( + + {relationLabel} + + )} + + {!!relation.document.path && ( + + {relation.document.path} + + )} + {!!relation.document.id && ( + handleOpenDocument(relation.document)} + activeOpacity={0.75} + accessibilityLabel={t("knowledgeToolResult.openDocument", "打开文档")} + > + + + {t("knowledgeToolResult.open", "打开")} + + + )} + + + ); + })} + {relations.length > max ? ( + + {t("knowledgeToolResult.moreRelations", { count: relations.length - max })} + + ) : null} + + ); +} + +function KnowledgeToolResultCard({ display }: { display: KnowledgeToolResultDisplay }) { + const { t } = useTranslation(); + const colors = useColors(); + const s = makeToolStyles(colors); + const toolLabel = + display.toolName && TOOL_LABEL_KEYS[display.toolName] + ? t(TOOL_LABEL_KEYS[display.toolName]) + : display.toolName; + const title = + display.kind === "failure" + ? t("knowledgeToolResult.failureTitle", { + tool: toolLabel || t("knowledgeToolResult.tool", "知识库工具"), + }) + : display.kind === "search" + ? t("knowledgeToolResult.searchTitle", "知识库检索结果") + : display.kind === "document" + ? t("knowledgeToolResult.documentTitle", "已读取知识文档") + : display.kind === "bookKnowledge" + ? t("knowledgeToolResult.bookKnowledgeTitle", "已读取本书知识") + : t("knowledgeToolResult.summaryTitle", "知识记忆已更新"); + const countText = + display.kind === "failure" + ? [ + display.status ? t("knowledgeToolResult.status", { status: display.status }) : undefined, + display.documentId + ? t("knowledgeToolResult.documentId", { id: display.documentId }) + : undefined, + ] + .filter(Boolean) + .join(" · ") + : display.kind === "summary" + ? [ + display.status + ? t("knowledgeToolResult.status", { status: display.status }) + : undefined, + display.persisted !== undefined + ? display.persisted + ? t("knowledgeToolResult.persisted", "已持久化") + : t("knowledgeToolResult.notPersisted", "未持久化") + : undefined, + ] + .filter(Boolean) + .join(" · ") + : display.kind === "document" + ? display.documentId + ? t("knowledgeToolResult.documentId", { id: display.documentId }) + : "" + : t("knowledgeToolResult.count", { + total: display.total ?? display.documents.length, + showing: display.showing ?? display.documents.length, + }); + const writeSafetyLabel = t( + `knowledgeToolResult.writeSafety.${display.writeSafety.state}.label`, + display.writeSafety.label, + ); + const writeSafetyDescription = t( + `knowledgeToolResult.writeSafety.${display.writeSafety.state}.description`, + display.writeSafety.description, + ); + + return ( + + + + + {title} + + {!!countText && ( + + {countText} + + )} + + {display.kind === "summary" && display.sourceChars ? ( + + + {t("knowledgeToolResult.sourceChars", { count: display.sourceChars })} + + + ) : null} + + + + + + {writeSafetyLabel} + + + {writeSafetyDescription} + + + {display.kind === "failure" ? ( + <> + + + {display.error || t("knowledgeToolResult.failureUnknown", "工具调用失败")} + + {!!display.reason && ( + + {t("knowledgeToolResult.reason", { reason: display.reason })} + + )} + + {t( + "knowledgeToolResult.failureSafeHint", + display.safeNoWriteHint || "失败的工具不会写入知识库或修改文档。", + )} + + + {display.documents.length > 0 ? ( + + ) : null} + + ) : display.kind === "summary" ? ( + <> + {display.documents.length > 0 ? ( + + ) : display.documentId ? ( + + {t("knowledgeToolResult.documentId", { id: display.documentId })} + + ) : null} + {display.reason ? ( + + {t("knowledgeToolResult.reason", { reason: display.reason })} + + ) : null} + {display.summaryPreview ? ( + + {display.summaryPreview} + + ) : null} + + ) : display.documents.length === 0 ? ( + + {t("knowledgeToolResult.empty", "没有匹配的知识文档")} + + ) : ( + <> + + + + )} + {display.documents.length > 5 ? ( + + {t("knowledgeToolResult.more", { count: display.documents.length - 5 })} + + ) : null} + + + ); +} + function ToolCallPartView({ part }: { part: ToolCallPart }) { - const hasError = part.status === "error" || Boolean(part.error); + const toolResultError = useMemo(() => getToolResultError(part.result), [part.result]); + const hasError = part.status === "error" || Boolean(part.error) || Boolean(toolResultError); + const proposal = useMemo(() => getKnowledgeWriteProposal(part.result), [part.result]); + const errorMessage = part.error || toolResultError || ""; + const knowledgeResult = useMemo( + () => getKnowledgeToolResultDisplay(part.name, part.result, { error: errorMessage }), + [errorMessage, part.name, part.result], + ); - const [isOpen, setIsOpen] = useState(hasError); + const [isOpen, setIsOpen] = useState(hasError || Boolean(proposal) || Boolean(knowledgeResult)); + const [proposalApplyState, setProposalApplyState] = useState("idle"); + const [proposalApplyResult, setProposalApplyResult] = + useState(null); + const [proposalApplyError, setProposalApplyError] = useState(null); const { t } = useTranslation(); const colors = useColors(); const s = makeToolStyles(colors); useEffect(() => { - if (hasError) setIsOpen(true); - }, [hasError]); + if (hasError || proposal || knowledgeResult) setIsOpen(true); + setProposalApplyState("idle"); + setProposalApplyResult(null); + setProposalApplyError(null); + }, [hasError, proposal, knowledgeResult]); + + const handleApplyProposal = async () => { + if (!proposal || proposalApplyState === "applying" || proposalApplyState === "applied") return; + setProposalApplyState("applying"); + setProposalApplyError(null); + try { + const result = await applyKnowledgeWriteProposal(proposal); + if (proposal.action !== "link") { + queueKnowledgeProposalSummaryMaintenance(result.documentId); + } + setProposalApplyResult(result); + setProposalApplyState("applied"); + } catch (error) { + const details = getKnowledgeProposalApplyErrorDetails(error); + setProposalApplyError( + details + ? t(details.i18nKey, { defaultValue: details.message }) + : error instanceof Error + ? error.message + : t("knowledgeProposal.applyFailed", "应用失败"), + ); + setProposalApplyState("failed"); + console.error("[KnowledgeProposal] Failed to apply proposal:", error); + } + }; const getStatusIcon = () => { + if (hasError) return ; + switch (part.status) { case "pending": return ; @@ -191,12 +735,6 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { const label = TOOL_LABEL_KEYS[part.name] ? t(TOOL_LABEL_KEYS[part.name]) : part.name; const queryText = part.args.query ? String(part.args.query) : ""; - const errorMessage = - part.error || - (part.result && typeof part.result === "object" - ? String((part.result as Record).error || "") - : ""); - return ( setIsOpen(!isOpen)} activeOpacity={0.7}> @@ -210,6 +748,22 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {t("streaming.toolFailed", "调用失败")} ) : null} + {proposal && !hasError ? ( + + + {proposalApplyState === "applied" + ? t("knowledgeProposal.savedBadge", "已应用") + : proposalApplyState === "failed" + ? t("knowledgeProposal.failedBadge", "应用失败") + : t("knowledgeProposal.pendingBadge", "待确认")} + + + ) : null} {queryText ? ( {queryText.slice(0, 30)} @@ -222,6 +776,21 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {isOpen && ( + {hasError && knowledgeResult?.kind !== "failure" ? ( + + {t("streaming.toolFailedDetail", "工具调用失败")} + + {errorMessage || t("streaming.toolFailed", "调用失败")} + + + {t( + "streaming.toolFailedHint", + "请检查参数和结果详情。失败的工具不会写入知识库或修改数据。", + )} + + + ) : null} + {Object.keys(part.args).length > 0 && ( {t("common.params", "参数")} @@ -240,21 +809,27 @@ 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)} - - - - - )} - {hasError && ( - - {t("streaming.toolFailedDetail", "工具调用失败")} - {errorMessage || t("streaming.toolFailed", "调用失败")} + {proposal ? ( + + ) : knowledgeResult ? ( + + ) : ( + + + + {typeof part.result === "string" && part.result.length > 500 + ? `${part.result.slice(0, 500)}...` + : JSON.stringify(part.result, null, 2)} + + + + )} )} @@ -263,6 +838,274 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { ); } +function KnowledgeProposalCard({ + proposal, + applyState, + applyResult, + applyError, + onApply, +}: { + proposal: KnowledgeWriteProposal; + applyState: KnowledgeProposalApplyState; + applyResult: KnowledgeProposalApplyResult | null; + applyError: string | null; + onApply: () => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const s = makeToolStyles(colors); + const [cardTemplates, setCardTemplates] = useState( + () => knowledgeCardTemplatesCache ?? [], + ); + useEffect(() => { + let mounted = true; + const refreshTemplates = () => { + void loadKnowledgeCardTemplatesForPreview().then((templates) => { + if (mounted) setCardTemplates(templates); + }); + }; + const invalidateTemplates = () => { + clearKnowledgeCardTemplatesForPreview(); + refreshTemplates(); + }; + + refreshTemplates(); + const offTemplateChange = eventBus.on("knowledge:card-templates-changed", invalidateTemplates); + const offSyncCompleted = eventBus.on("sync:completed", invalidateTemplates); + + return () => { + mounted = false; + offTemplateChange(); + offSyncCompleted(); + }; + }, []); + const preview = useMemo( + () => createKnowledgeWriteProposalPreview(proposal, { cardTemplates }), + [cardTemplates, proposal], + ); + const actionLabel = + preview.action === "create" + ? t("knowledgeProposal.create", "创建知识文档") + : preview.action === "update" + ? t("knowledgeProposal.update", "更新知识文档") + : t("knowledgeProposal.link", "建立知识关联"); + const typeLabel = preview.documentType + ? t(KNOWLEDGE_DOCUMENT_TYPE_KEYS[preview.documentType], { + defaultValue: preview.documentType, + }) + : t( + preview.action === "link" + ? "knowledgeProposal.types.knowledgeLink" + : "knowledgeProposal.types.knowledgeDocument", + preview.action === "link" ? "知识关联" : "知识文档", + ); + const changedFieldLabels = formatKnowledgeChangedFields(preview.changedFields, t); + const proposalSafetyLabel = t( + `knowledgeProposal.writeSafety.${preview.writeSafety.state}.label`, + preview.writeSafety.label, + ); + const proposalSafetyDescription = t( + `knowledgeProposal.writeSafety.${preview.writeSafety.state}.description`, + preview.writeSafety.description, + ); + const openTarget = useMemo(() => { + if (applyState !== "applied" || !applyResult?.documentId) return null; + if (proposal.action === "create") { + return { + id: applyResult.documentId, + bookId: proposal.draft.bookId, + title: proposal.draft.title, + path: preview.targetPath ?? preview.visiblePath, + }; + } + if (proposal.action === "update") { + return { + id: applyResult.documentId, + bookId: proposal.current?.bookId, + title: proposal.patch.title ?? proposal.current?.title, + path: preview.targetPath ?? proposal.current?.path ?? preview.visiblePath, + }; + } + return { + id: applyResult.documentId, + bookId: proposal.source?.bookId, + title: proposal.source?.title, + path: proposal.source?.path ?? preview.currentPath, + }; + }, [applyResult, applyState, preview, proposal]); + const handleOpenAppliedDocument = () => { + if (!openTarget) return; + if (requestKnowledgeDocumentOpen(openTarget, "ai_proposal")) return; + Alert.alert( + t("knowledgeToolResult.openDocument", "打开文档"), + t("knowledgeToolResult.openUnavailable", "请先打开这本书的知识库页,再查看这个文档。"), + ); + }; + const previewContent = preview.contentPreview + ? preview.contentPreview.length > 520 + ? `${preview.contentPreview.slice(0, 520)}...` + : preview.contentPreview + : ""; + const previewMarkdownStyles = useMemo( + () => ({ + body: s.proposalPreviewMarkdownBody, + paragraph: s.proposalPreviewMarkdownParagraph, + heading1: s.proposalPreviewMarkdownHeading, + heading2: s.proposalPreviewMarkdownHeading, + heading3: s.proposalPreviewMarkdownHeading, + strong: s.proposalPreviewMarkdownStrong, + em: s.proposalPreviewMarkdownEm, + link: s.proposalPreviewMarkdownLink, + blockquote: s.proposalPreviewMarkdownQuote, + bullet_list: s.proposalPreviewMarkdownList, + ordered_list: s.proposalPreviewMarkdownList, + list_item: s.proposalPreviewMarkdownListItem, + code_inline: s.proposalPreviewMarkdownCode, + code_block: s.proposalPreviewMarkdownCodeBlock, + fence: s.proposalPreviewMarkdownCodeBlock, + }), + [s], + ); + + return ( + + + + {actionLabel} + + {preview.title} + + {preview.visiblePath ? ( + + + + {preview.visiblePath} + + + ) : null} + + + + {typeLabel} + + + + + + + {proposalSafetyLabel} + {proposalSafetyDescription} + + + {preview.tags.length > 0 ? ( + + {preview.tags.slice(0, 6).map((tag) => ( + + {tag} + + ))} + {preview.tags.length > 6 ? ( + + +{preview.tags.length - 6} + + ) : null} + + ) : null} + + {changedFieldLabels.length > 0 ? ( + + {t("knowledgeProposal.changes", "变更")} + {changedFieldLabels.join(", ")} + + ) : null} + + {preview.visiblePath ? ( + + {t("knowledgeProposal.location", "位置")} + {preview.hasPathChange ? ( + + + {preview.currentPath} + + + + → {preview.targetPath} + + + ) : ( + + {preview.visiblePath} + + )} + + ) : null} + + {preview.contentPreview ? ( + + + {t("knowledgeProposal.contentPreview", "内容预览")} + + + + + + ) : null} + + {applyState === "failed" ? ( + + + {t("knowledgeProposal.applyFailed", "应用知识库提案失败")} + + {applyError ? {applyError} : null} + + {t( + "knowledgeProposal.applyFailedSafeHint", + "没有写入任何内容。请刷新或检查文档后再试一次。", + )} + + + ) : null} + + + {openTarget ? ( + + + + {t("knowledgeToolResult.open", "打开")} + + + ) : null} + + + {applyState === "applying" + ? t("knowledgeProposal.applying", "应用中...") + : applyState === "applied" + ? t("knowledgeProposal.applied", "已应用") + : applyState === "failed" + ? t("knowledgeProposal.retry", "重试") + : t("knowledgeProposal.apply", "应用到知识库")} + + + + + + ); +} + function AbortedPartView({ part }: { part: AbortedPart }) { const colors = useColors(); return ( @@ -390,6 +1233,23 @@ const makeToolStyles = (colors: ThemeColors) => color: colors.destructive, fontWeight: fw.medium, }, + proposalBadge: { + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.1), + paddingHorizontal: 6, + paddingVertical: 2, + }, + proposalFailedBadge: { + backgroundColor: withOpacity(colors.destructive, 0.1), + }, + proposalBadgeText: { + fontSize: fs.xs, + color: colors.primary, + fontWeight: fw.medium, + }, + proposalFailedBadgeText: { + color: colors.destructive, + }, chevron: {}, chevronOpen: { transform: [{ rotate: "180deg" }] }, body: { @@ -427,6 +1287,582 @@ const makeToolStyles = (colors: ThemeColors) => lineHeight: 16, }, codeKey: { color: colors.mutedForeground }, + knowledgeResultCard: { + overflow: "hidden", + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.card, + borderRadius: radius.md, + }, + knowledgeResultFailure: { + borderColor: withOpacity(colors.destructive, 0.34), + }, + knowledgeResultHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.38), + paddingHorizontal: 10, + paddingVertical: 9, + }, + knowledgeResultFailureHeader: { + borderBottomColor: withOpacity(colors.destructive, 0.22), + backgroundColor: withOpacity(colors.destructive, 0.06), + }, + knowledgeResultTitleBlock: { + flex: 1, + minWidth: 0, + gap: 3, + }, + knowledgeResultTitle: { + fontSize: fs.sm, + lineHeight: 18, + fontWeight: fw.semibold, + color: colors.foreground, + }, + knowledgeResultFailureTitle: { + color: colors.destructive, + }, + knowledgeResultMeta: { + fontSize: fs.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeResultBadge: { + maxWidth: 120, + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 7, + paddingVertical: 4, + }, + knowledgeResultBadgeText: { + fontSize: fs.xs, + color: colors.mutedForeground, + fontWeight: fw.medium, + }, + knowledgeResultBody: { + gap: 8, + padding: 10, + }, + knowledgeResultSafety: { + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.24), + backgroundColor: withOpacity(colors.primary, 0.06), + borderRadius: radius.sm, + paddingHorizontal: 10, + paddingVertical: 8, + gap: 3, + }, + knowledgeResultFailureSafety: { + borderColor: withOpacity(colors.destructive, 0.24), + backgroundColor: withOpacity(colors.destructive, 0.08), + }, + knowledgeResultSafetyLabel: { + fontSize: fs.xs, + lineHeight: 16, + fontWeight: fw.semibold, + color: colors.foreground, + }, + knowledgeResultFailureSafetyLabel: { + color: colors.destructive, + }, + knowledgeResultSafetyText: { + fontSize: fs.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeResultFailureSafetyText: { + color: withOpacity(colors.destructive, 0.76), + }, + knowledgeResultFailureBody: { + borderWidth: 0.5, + borderColor: withOpacity(colors.destructive, 0.24), + backgroundColor: withOpacity(colors.destructive, 0.08), + borderRadius: radius.sm, + paddingHorizontal: 10, + paddingVertical: 10, + gap: 6, + }, + knowledgeResultFailureText: { + fontSize: fs.xs, + lineHeight: 17, + fontWeight: fw.semibold, + color: colors.destructive, + }, + knowledgeResultFailureMeta: { + fontSize: fs.xs, + lineHeight: 16, + color: withOpacity(colors.destructive, 0.86), + }, + knowledgeResultFailureHint: { + fontSize: fs.xs, + lineHeight: 16, + color: withOpacity(colors.destructive, 0.72), + }, + knowledgeResultItem: { + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.28), + borderRadius: radius.sm, + paddingHorizontal: 9, + paddingVertical: 8, + }, + knowledgeResultItemHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 8, + }, + knowledgeResultItemTitle: { + flex: 1, + minWidth: 0, + fontSize: fs.sm, + lineHeight: 18, + fontWeight: fw.semibold, + color: colors.foreground, + }, + knowledgeResultItemActions: { + flexDirection: "row", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + knowledgeResultTypeBadge: { + maxWidth: 96, + borderRadius: radius.sm, + backgroundColor: colors.card, + paddingHorizontal: 6, + paddingVertical: 3, + }, + knowledgeResultTypeText: { + fontSize: fs.xs, + color: colors.mutedForeground, + }, + knowledgeResultOpenButton: { + minHeight: 24, + flexDirection: "row", + alignItems: "center", + gap: 4, + borderWidth: 0.5, + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: colors.card, + paddingHorizontal: 6, + paddingVertical: 3, + }, + knowledgeResultOpenText: { + fontSize: fs.xs, + lineHeight: 14, + fontWeight: fw.medium, + color: colors.primary, + }, + knowledgeResultPath: { + marginTop: 4, + fontSize: fs.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeResultMatchText: { + marginTop: 5, + fontSize: fs.xs, + lineHeight: 15, + color: colors.mutedForeground, + }, + knowledgeResultSnippet: { + marginTop: 7, + fontSize: fs.xs, + lineHeight: 17, + color: colors.foreground, + }, + knowledgeResultRelations: { + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.18), + borderRadius: radius.sm, + paddingHorizontal: 9, + paddingVertical: 8, + gap: 7, + }, + knowledgeResultRelationsTitle: { + fontSize: fs.xs, + lineHeight: 16, + fontWeight: fw.medium, + color: colors.mutedForeground, + }, + knowledgeResultRelationItem: { + flexDirection: "row", + alignItems: "flex-start", + gap: 8, + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.card, 0.72), + paddingHorizontal: 8, + paddingVertical: 7, + }, + knowledgeResultRelationBadge: { + maxWidth: 76, + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.1), + paddingHorizontal: 6, + paddingVertical: 3, + }, + knowledgeResultRelationBadgeText: { + fontSize: fs.xs, + lineHeight: 14, + fontWeight: fw.medium, + color: colors.primary, + }, + knowledgeResultRelationBody: { + flex: 1, + minWidth: 0, + gap: 3, + }, + knowledgeResultRelationHeader: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + knowledgeResultRelationTitle: { + flex: 1, + minWidth: 0, + fontSize: fs.xs, + lineHeight: 16, + fontWeight: fw.semibold, + color: colors.foreground, + }, + knowledgeResultRelationMeta: { + maxWidth: 84, + fontSize: fs.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeResultRelationPath: { + fontSize: fs.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeResultRelationOpenButton: { + alignSelf: "flex-start", + minHeight: 24, + flexDirection: "row", + alignItems: "center", + gap: 4, + borderWidth: 0.5, + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: colors.card, + paddingHorizontal: 6, + paddingVertical: 3, + }, + knowledgeResultEmpty: { + borderWidth: 0.5, + borderStyle: "dashed", + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.muted, 0.28), + paddingHorizontal: 10, + paddingVertical: 10, + fontSize: fs.xs, + lineHeight: 17, + color: colors.mutedForeground, + textAlign: "center", + }, + knowledgeResultMore: { + fontSize: fs.xs, + 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, + }, + proposalHeaderPath: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + proposalHeaderPathDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: withOpacity(colors.primary, 0.58), + }, + proposalHeaderPathText: { + flex: 1, + fontSize: fs.xs, + lineHeight: 16, + fontFamily: "Menlo", + color: colors.mutedForeground, + }, + 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, + }, + proposalSafetyBlock: { + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.24), + backgroundColor: withOpacity(colors.primary, 0.06), + borderRadius: radius.sm, + paddingHorizontal: 10, + paddingVertical: 8, + gap: 3, + }, + proposalSafetyLabel: { + fontSize: fs.xs, + lineHeight: 16, + fontWeight: fw.semibold, + color: colors.foreground, + }, + proposalSafetyText: { + fontSize: fs.xs, + lineHeight: 16, + 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, + }, + proposalPathBox: { + gap: 3, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.35), + borderRadius: radius.sm, + padding: 8, + }, + proposalPathDivider: { + height: 0.5, + backgroundColor: colors.border, + marginVertical: 2, + }, + proposalPathBoxText: { + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.35), + borderRadius: radius.sm, + padding: 8, + fontSize: fs.xs, + lineHeight: 17, + fontFamily: "Menlo", + color: colors.foreground, + }, + proposalPathMutedText: { + fontSize: fs.xs, + lineHeight: 17, + fontFamily: "Menlo", + color: colors.mutedForeground, + }, + proposalPathText: { + fontSize: fs.xs, + lineHeight: 17, + fontFamily: "Menlo", + color: colors.primary, + fontWeight: fw.medium, + }, + proposalPreviewBlock: { + gap: 5, + }, + proposalPreviewBox: { + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.45), + borderRadius: radius.sm, + padding: 8, + maxHeight: 158, + overflow: "hidden", + }, + proposalPreviewMarkdownBody: { + fontSize: fs.xs, + lineHeight: 17, + color: colors.foreground, + }, + proposalPreviewMarkdownParagraph: { + marginTop: 0, + marginBottom: 6, + fontSize: fs.xs, + lineHeight: 17, + color: colors.foreground, + }, + proposalPreviewMarkdownHeading: { + marginTop: 0, + marginBottom: 6, + fontSize: fs.sm, + lineHeight: 18, + color: colors.foreground, + fontWeight: fw.semibold, + }, + proposalPreviewMarkdownStrong: { + fontWeight: fw.bold, + }, + proposalPreviewMarkdownEm: { + fontStyle: "italic", + }, + proposalPreviewMarkdownLink: { + color: colors.primary, + textDecorationLine: "none", + }, + proposalPreviewMarkdownQuote: { + borderLeftWidth: 2, + borderLeftColor: withOpacity(colors.primary, 0.35), + marginVertical: 4, + paddingLeft: 8, + }, + proposalPreviewMarkdownList: { + marginVertical: 3, + }, + proposalPreviewMarkdownListItem: { + marginBottom: 3, + flexDirection: "row", + }, + proposalPreviewMarkdownCode: { + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 4, + paddingVertical: 1, + color: colors.foreground, + fontFamily: "Menlo", + fontSize: fs.xs, + }, + proposalPreviewMarkdownCodeBlock: { + borderRadius: radius.sm, + backgroundColor: colors.muted, + padding: 8, + color: colors.foreground, + fontFamily: "Menlo", + fontSize: fs.xs, + lineHeight: 16, + marginVertical: 4, + }, + proposalApplyErrorBox: { + gap: 5, + borderWidth: 0.5, + borderColor: withOpacity(colors.destructive, 0.28), + backgroundColor: withOpacity(colors.destructive, 0.07), + borderRadius: radius.sm, + paddingHorizontal: 10, + paddingVertical: 9, + }, + proposalApplyErrorTitle: { + fontSize: fs.xs, + lineHeight: 16, + fontWeight: fw.semibold, + color: colors.destructive, + }, + proposalApplyErrorText: { + fontSize: fs.xs, + lineHeight: 17, + color: withOpacity(colors.destructive, 0.88), + }, + proposalApplyErrorHint: { + fontSize: fs.xs, + lineHeight: 17, + color: withOpacity(colors.destructive, 0.72), + }, + proposalActionRow: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: 8, + }, + proposalOpenButton: { + minHeight: 34, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + borderWidth: 0.5, + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: colors.card, + paddingHorizontal: 10, + paddingVertical: 8, + }, + proposalOpenText: { + fontSize: fs.xs, + fontWeight: fw.semibold, + color: colors.primary, + }, + 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, @@ -439,6 +1875,12 @@ const makeToolStyles = (colors: ThemeColors) => color: colors.destructive, lineHeight: 16, }, + errorHintText: { + marginTop: 6, + fontSize: fs.xs, + color: withOpacity(colors.destructive, 0.78), + lineHeight: 16, + }, errorTitle: { marginBottom: 4, fontSize: fs.xs, 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..71815867 --- /dev/null +++ b/packages/app-expo/src/components/knowledge/MobileKnowledgeEditor.tsx @@ -0,0 +1,4161 @@ +import { + BoldIcon, + BookOpenIcon, + BrainIcon, + CodeIcon, + EditIcon, + HashIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + ImagePlusIcon, + ItalicIcon, + LightbulbIcon, + Link2Icon, + ListIcon, + ListOrderedIcon, + ListTodoIcon, + MessageCirclePlusIcon, + MinusIcon, + OctagonXIcon, + PlusIcon, + QuoteIcon, + Redo2Icon, + ScrollTextIcon, + SparklesIcon, + StrikethroughIcon, + Trash2Icon, + Undo2Icon, +} from "@/components/ui/Icon"; +import { RichTextEditor } from "@/components/ui/RichTextEditor"; +import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; +import { + disableKnowledgeCardTemplate, + getKnowledgeCardTemplates, + upsertKnowledgeCardTemplate, +} from "@readany/core/db/database"; +import { + KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT, + type KnowledgeEditorFeature, + type KnowledgeEditorSurface, + type KnowledgeEditorTier, + type ReadAnyCardTemplateField, + builtInReadAnyCards, + clampKnowledgeEditorBridgeHeight, + clearKnowledgeEditorDraft, + createCustomReadAnyCardTemplate, + createDefaultReadAnyCardAttrs, + createKnowledgeEditorDraftKey, + createReadAnyCardAttrsFromTemplate, + getKnowledgeEditorFeatureForCardType, + getKnowledgeEditorProfile, + getKnowledgeEditorSurfaceProfile, + getReadAnyCardTemplateDescription, + getReadAnyCardTemplateFields, + getReadAnyCardTemplateInsertLabel, + hasKnowledgeEditorFeature, + isKnowledgeEditorBridgeJsonValue, + isKnowledgeEditorDraftRestorable, + knowledgeEditorDraftFingerprint, + loadKnowledgeEditorDraft, + markdownToBasicTiptap, + normalizeReadAnyCardTemplateFields, + normalizeTiptapDocument, + parseKnowledgeEditorBridgeMessage, + renderKnowledgeJsonToMarkdown, + saveKnowledgeEditorDraft, + updateCustomReadAnyCardTemplate, +} from "@readany/core/knowledge"; +import type { + KnowledgeEditorBridgeSelectionState, + KnowledgeEditorDraft, +} from "@readany/core/knowledge"; +import type { JSONValue, KnowledgeCardTemplate } from "@readany/core/types"; +import { generateId } from "@readany/core/utils"; +import { eventBus } from "@readany/core/utils/event-bus"; +import { Asset } from "expo-asset"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +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; +} + +function normalizeMobileKnowledgeEditorValue( + value: MobileKnowledgeEditorValue, + cardTemplates: KnowledgeCardTemplate[] = [], +): MobileKnowledgeEditorValue { + const contentJson = normalizeTiptapDocument(value.contentJson, { + cardTemplates, + }) as unknown as JSONValue; + return { + ...value, + contentJson, + contentMd: renderKnowledgeJsonToMarkdown(contentJson, { cardTemplates }), + }; +} + +export interface MobileKnowledgeEditorOutlineTarget { + index: number; + requestId: number; +} + +export interface MobileKnowledgeInternalLinkTarget { + id: string; + title: string; + path?: string; + targetPath?: string; + typeLabel?: string; +} + +export interface MobileKnowledgeImageInsertAttrs { + src: string; + alt?: string; + title?: string; + attachmentId?: string; + fileName?: string; +} + +export interface MobileKnowledgeSourceReferenceRequest { + requestId: number; + label: string; + sourceTitle?: string; + sourceId?: string; + cfi?: string; +} + +const customCardFieldTypes = [ + "text", + "multiline", + "number", + "checkbox", + "select", + "multiselect", +] as const satisfies ReadAnyCardTemplateField["type"][]; + +const customCardFieldWidths = ["", "full", "half", "third"] as const satisfies readonly ( + | "" + | NonNullable +)[]; + +const customCardFieldConditionOperators = [ + "equals", + "notEquals", + "contains", + "notContains", + "empty", + "notEmpty", +] as const satisfies NonNullable["operator"][]; + +type TranslationFn = ReturnType["t"]; + +function isChoiceTemplateField(field: ReadAnyCardTemplateField) { + return field.type === "select" || field.type === "multiselect"; +} + +function defaultTemplateFieldOptionLabel(t: TranslationFn, count: number): string { + return t("notes.knowledgeCustomCardFieldOptionDefault", { + count, + defaultValue: `Option ${count}`, + }); +} + +function createDefaultTemplateFieldOptions(t: TranslationFn) { + return [ + { label: defaultTemplateFieldOptionLabel(t, 1), value: "option_1" }, + { label: defaultTemplateFieldOptionLabel(t, 2), value: "option_2" }, + ]; +} + +function formatTemplateFieldOptionsText(field: ReadAnyCardTemplateField): string { + return (field.options ?? []) + .map((option) => + option.label === option.value ? option.value : `${option.label} | ${option.value}`, + ) + .join("\n"); +} + +function parseTemplateFieldOptionsText( + input: string, + t: TranslationFn, +): ReadAnyCardTemplateField["options"] { + return input + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [labelPart, valuePart] = line.split("|").map((part) => part.trim()); + const label = labelPart || defaultTemplateFieldOptionLabel(t, index + 1); + const value = + valuePart || + label + .toLowerCase() + .replace(/[^a-z0-9_-\s]/g, "") + .replace(/[\s-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") || + `option_${index + 1}`; + return { label, value }; + }); +} + +function getTemplateFieldDefaultString(field: ReadAnyCardTemplateField): string { + if (field.defaultValue === undefined || field.defaultValue === null) return ""; + return String(field.defaultValue); +} + +function getTemplateConditionValueString( + condition: ReadAnyCardTemplateField["visibleWhen"] | undefined, +): string { + const value = condition?.value; + if (value === undefined || value === null) return ""; + if (Array.isArray(value)) return value.length > 0 ? String(value[0]) : ""; + return String(value); +} + +function getTemplateFieldConditionValueString(field: ReadAnyCardTemplateField): string { + return getTemplateConditionValueString(field.visibleWhen); +} + +function parseTemplateFieldConditionValue( + sourceField: ReadAnyCardTemplateField | undefined, + value: string, +) { + if (sourceField?.type === "checkbox") return value === "true"; + if (sourceField?.type === "number") { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : value; + } + return value; +} + +function getConditionOperatorLabel( + operator: (typeof customCardFieldConditionOperators)[number], +): string { + if (operator === "equals") return "等于"; + if (operator === "notEquals") return "不等于"; + if (operator === "contains") return "包含"; + if (operator === "notContains") return "不包含"; + if (operator === "empty") return "为空"; + return "不为空"; +} + +interface MobileKnowledgeEditorProps { + documentId?: string; + value: MobileKnowledgeEditorValue; + onChange: (value: MobileKnowledgeEditorValue) => void; + placeholder?: string; + autoFocus?: boolean; + readOnly?: boolean; + layout?: "embedded" | "document"; + tier?: KnowledgeEditorTier; + surface?: KnowledgeEditorSurface; + isSaved?: boolean; + outlineTarget?: MobileKnowledgeEditorOutlineTarget | null; + internalLinkTargets?: MobileKnowledgeInternalLinkTarget[]; + sourceReferenceRequest?: MobileKnowledgeSourceReferenceRequest | null; + onPickLocalImage?: () => Promise; +} + +type EditorIssueKind = "asset" | "timeout" | "bridge" | "webview" | "process"; + +interface EditorIssue { + kind: EditorIssueKind; + code?: string; + message: string; +} + +type EditorCommand = + | { + type: "init"; + contentJson: JSONValue; + theme: EditorTheme; + placeholder?: string; + cardBodyPlaceholder?: string; + cardConvertToTextLabel?: string; + imageUnavailableTitle?: string; + imageUnavailableHint?: string; + cardTemplates?: KnowledgeCardTemplate[]; + readOnly?: boolean; + } + | { type: "setContent"; contentJson: JSONValue; cardTemplates?: KnowledgeCardTemplate[] } + | { type: "setCardTemplates"; cardTemplates: KnowledgeCardTemplate[] } + | { type: "focus"; position?: "start" | "end" } + | { type: "blur" } + | { type: "setEditable"; editable: boolean } + | { type: "setTheme"; theme: EditorTheme } + | { type: "requestContent"; requestId: string } + | { type: "runCommand"; command: string; attrs?: Record }; + +interface EditorTheme { + background: string; + foreground: string; + card: string; + border: string; + muted: string; + mutedForeground: string; + primary: string; + destructive: string; +} + +interface InsertableCardItem { + key: string; + cardType: string; + insertLabel: string; + description?: string; + template?: KnowledgeCardTemplate; + createAttrs: () => Record; +} + +const EDITOR_READY_TIMEOUT_MS = 8000; +const DRAFT_SAVE_DELAY_MS = 650; +const cardIconMap: Record = { + bookQuote: QuoteIcon, + callout: LightbulbIcon, + bookMetadata: BookOpenIcon, + aiSummary: SparklesIcon, + aiToolFailure: OctagonXIcon, + qa: MessageCirclePlusIcon, + review: ScrollTextIcon, + mindmap: BrainIcon, + mermaid: BrainIcon, + relatedNotes: BrainIcon, +}; + +function fingerprintJson(value: JSONValue): string { + return knowledgeEditorDraftFingerprint(value); +} + +export function MobileKnowledgeEditor({ + documentId, + value, + onChange, + placeholder, + autoFocus = false, + readOnly = false, + layout = "embedded", + tier = "knowledge_doc", + surface, + isSaved, + outlineTarget, + internalLinkTargets = [], + sourceReferenceRequest, + onPickLocalImage, +}: MobileKnowledgeEditorProps) { + const { t } = useTranslation(); + const colors = useColors(); + const insets = useSafeAreaInsets(); + const styles = makeStyles(colors); + const isDocumentLayout = layout === "document"; + const [cardTemplates, setCardTemplates] = useState([]); + const templateLoaderMountedRef = useRef(false); + const normalizedContentJson = useMemo( + () => + normalizeTiptapDocument(value.contentJson, { + cardTemplates, + }) as unknown as JSONValue, + [cardTemplates, value.contentJson], + ); + const normalizedContentMd = useMemo( + () => renderKnowledgeJsonToMarkdown(normalizedContentJson, { cardTemplates }), + [cardTemplates, normalizedContentJson], + ); + const normalizedValue = useMemo( + () => ({ + contentJson: normalizedContentJson, + contentMd: normalizedContentMd, + plainText: value.plainText, + }), + [normalizedContentJson, normalizedContentMd, value.plainText], + ); + const webViewRef = useRef(null); + const latestValueRef = useRef(normalizedValue); + const localFingerprintRef = useRef(fingerprintJson(normalizedValue.contentJson)); + const baseFingerprintRef = useRef(fingerprintJson(normalizedValue.contentJson)); + const draftSaveTimerRef = useRef | null>(null); + const lastWrittenDraftFingerprintRef = useRef(null); + const handledSourceReferenceRequestIdRef = useRef(null); + const [htmlUri, setHtmlUri] = useState(null); + const [isBridgeReady, setIsBridgeReady] = useState(false); + const [isEditorReady, setIsEditorReady] = useState(false); + const [isEditorFocused, setIsEditorFocused] = useState(false); + const [editorReloadKey, setEditorReloadKey] = useState(0); + const [editorHeight, setEditorHeight] = useState(KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT); + const [editorIssue, setEditorIssue] = useState(null); + const [useMarkdownFallback, setUseMarkdownFallback] = useState(false); + const [pendingDraft, setPendingDraft] = useState(null); + const [selection, setSelection] = useState({ + marks: {}, + linkHref: null, + headingLevel: null, + canUndo: false, + canRedo: false, + }); + const [showLinkModal, setShowLinkModal] = useState(false); + const [showInternalLinkModal, setShowInternalLinkModal] = useState(false); + const [showBlockInsertMenu, setShowBlockInsertMenu] = useState(false); + const [showCardMenu, setShowCardMenu] = useState(false); + const [showImageModal, setShowImageModal] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [internalLinkQuery, setInternalLinkQuery] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [imageAlt, setImageAlt] = useState(""); + const [isPickingLocalImage, setIsPickingLocalImage] = useState(false); + const [isTemplateFormOpen, setIsTemplateFormOpen] = useState(false); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [templateName, setTemplateName] = useState(""); + const [templateDescription, setTemplateDescription] = useState(""); + const [templateMarkdown, setTemplateMarkdown] = useState(""); + const [templateFields, setTemplateFields] = useState([]); + const [isSavingTemplate, setIsSavingTemplate] = useState(false); + const [templateSaveError, setTemplateSaveError] = useState(null); + const errorMessage = editorIssue?.message ?? null; + const editorProfile = useMemo( + () => (surface ? getKnowledgeEditorSurfaceProfile(surface) : getKnowledgeEditorProfile(tier)), + [surface, tier], + ); + const canUse = useCallback( + (feature: KnowledgeEditorFeature) => hasKnowledgeEditorFeature(editorProfile, feature), + [editorProfile], + ); + const canInsertCard = useCallback( + (cardType: string) => { + const feature = getKnowledgeEditorFeatureForCardType(cardType); + return canUse("readAnyCards") || (feature ? canUse(feature) : false); + }, + [canUse], + ); + const allowedCards = useMemo( + () => [ + ...builtInReadAnyCards + .filter((card) => canInsertCard(card.cardType)) + .map((card) => ({ + key: `built-in:${card.cardType}`, + cardType: card.cardType, + insertLabel: t(`notes.knowledgeCards.${card.cardType}`, { + defaultValue: card.insertLabel, + }), + description: t(`notes.knowledgeCardDescriptions.${card.cardType}`, { + defaultValue: "", + }), + createAttrs: () => + createDefaultReadAnyCardAttrs(card.cardType, { + title: t(`notes.knowledgeCards.${card.cardType}`, { + defaultValue: card.insertLabel, + }), + version: card.version, + }) as Record, + })), + ...cardTemplates + .map((template) => { + const attrs = createReadAnyCardAttrsFromTemplate(template); + const cardType = attrs.cardType ?? `custom:${template.id}`; + return { template, cardType }; + }) + .filter(({ template, cardType }) => template.enabled !== false && canInsertCard(cardType)) + .map(({ template, cardType }) => ({ + key: `template:${template.id}`, + cardType, + insertLabel: getReadAnyCardTemplateInsertLabel(template), + description: getReadAnyCardTemplateDescription(template), + template, + createAttrs: () => + createReadAnyCardAttrsFromTemplate(template) as Record, + })), + ], + [canInsertCard, cardTemplates, t], + ); + const visibleInternalLinkTargets = useMemo(() => { + const query = internalLinkQuery.trim().toLowerCase(); + const source = query + ? internalLinkTargets.filter((target) => + [target.title, target.path ?? "", target.typeLabel ?? "", target.id] + .join(" ") + .toLowerCase() + .includes(query), + ) + : internalLinkTargets; + return source.slice(0, 10); + }, [internalLinkQuery, internalLinkTargets]); + + const theme = useMemo( + () => ({ + background: colors.background, + foreground: colors.foreground, + card: colors.card, + border: colors.border, + muted: colors.muted, + mutedForeground: colors.mutedForeground, + primary: colors.primary, + destructive: colors.destructive, + }), + [colors], + ); + + const valueFingerprint = useMemo( + () => fingerprintJson(normalizedValue.contentJson), + [normalizedValue.contentJson], + ); + const draftKey = useMemo( + () => (documentId ? createKnowledgeEditorDraftKey(documentId, "mobile") : null), + [documentId], + ); + const webViewInstanceKey = useMemo( + () => `${documentId ?? "knowledge-editor"}-${editorReloadKey}`, + [documentId, editorReloadKey], + ); + const previousDraftKeyRef = useRef(draftKey); + const readyAttemptRef = useRef(webViewInstanceKey); + + useEffect(() => { + latestValueRef.current = normalizedValue; + }, [normalizedValue]); + + useEffect(() => { + if (readOnly || fingerprintJson(value.contentJson) === valueFingerprint) return; + localFingerprintRef.current = valueFingerprint; + onChange(normalizedValue); + }, [normalizedValue, onChange, readOnly, value.contentJson, valueFingerprint]); + + const reloadCardTemplates = useCallback(async () => { + try { + const templates = await getKnowledgeCardTemplates({ includeDisabled: true }); + if (!templateLoaderMountedRef.current) return; + setCardTemplates(templates.filter((template) => !template.builtIn)); + } catch (error) { + console.warn("[MobileKnowledgeEditor] Failed to load card templates:", error); + } + }, []); + + useEffect(() => { + templateLoaderMountedRef.current = true; + void reloadCardTemplates(); + const offTemplateChange = eventBus.on("knowledge:card-templates-changed", () => { + void reloadCardTemplates(); + }); + const offSyncCompleted = eventBus.on("sync:completed", () => { + void reloadCardTemplates(); + }); + return () => { + templateLoaderMountedRef.current = false; + offTemplateChange(); + offSyncCompleted(); + }; + }, [reloadCardTemplates]); + + useEffect(() => { + return () => { + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + }; + }, []); + + useEffect(() => { + if (previousDraftKeyRef.current === draftKey) return; + previousDraftKeyRef.current = draftKey; + baseFingerprintRef.current = valueFingerprint; + localFingerprintRef.current = valueFingerprint; + lastWrittenDraftFingerprintRef.current = null; + setPendingDraft(null); + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + }, [draftKey, valueFingerprint]); + + useEffect(() => { + let mounted = true; + if (!draftKey || readOnly) { + setPendingDraft(null); + return; + } + + const initialFingerprint = baseFingerprintRef.current; + const loadDraft = async () => { + const draft = await loadKnowledgeEditorDraft(draftKey); + if (!mounted) return; + if (isKnowledgeEditorDraftRestorable(draft, initialFingerprint)) { + setPendingDraft(draft); + } else if (draft) { + void clearKnowledgeEditorDraft(draftKey); + } + }; + + void loadDraft(); + return () => { + mounted = false; + }; + }, [draftKey, readOnly]); + + useEffect(() => { + if (!draftKey || !isSaved) return; + if (lastWrittenDraftFingerprintRef.current !== valueFingerprint) return; + + lastWrittenDraftFingerprintRef.current = null; + baseFingerprintRef.current = valueFingerprint; + setPendingDraft((draft) => (draft?.contentFingerprint === valueFingerprint ? null : draft)); + void clearKnowledgeEditorDraft(draftKey); + }, [draftKey, isSaved, valueFingerprint]); + + 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; + setEditorIssue({ + kind: "asset", + message: t("notes.knowledgeEditorLoadFailed", "编辑器加载失败"), + }); + setUseMarkdownFallback(true); + } + }; + void loadAsset(); + return () => { + mounted = false; + }; + }, [t]); + + useEffect(() => { + if (!htmlUri || isEditorReady || useMarkdownFallback) return; + readyAttemptRef.current = webViewInstanceKey; + const attemptKey = webViewInstanceKey; + const timeout = setTimeout(() => { + if (readyAttemptRef.current !== attemptKey) return; + setEditorIssue((current) => + current + ? current + : { + kind: "timeout", + code: "editor_ready_timeout", + message: t( + "notes.knowledgeEditorTimeout", + "编辑器启动超时,可以重试或使用备用编辑器", + ), + }, + ); + }, EDITOR_READY_TIMEOUT_MS); + + return () => clearTimeout(timeout); + }, [htmlUri, isEditorReady, t, useMarkdownFallback, webViewInstanceKey]); + + const scheduleDraftSave = useCallback( + (nextValue: MobileKnowledgeEditorValue) => { + if (readOnly || !draftKey) return; + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + + const nextFingerprint = fingerprintJson(nextValue.contentJson); + if (nextFingerprint === baseFingerprintRef.current) { + lastWrittenDraftFingerprintRef.current = null; + void clearKnowledgeEditorDraft(draftKey); + return; + } + + draftSaveTimerRef.current = setTimeout(() => { + void saveKnowledgeEditorDraft(draftKey, nextValue, { + baseFingerprint: baseFingerprintRef.current, + }) + .then((draft) => { + lastWrittenDraftFingerprintRef.current = draft.contentFingerprint; + }) + .catch((error) => { + console.warn("[MobileKnowledgeEditor] Failed to save editor draft:", error); + }); + }, DRAFT_SAVE_DELAY_MS); + }, + [draftKey, readOnly], + ); + + const retryEditor = useCallback(() => { + setEditorIssue(null); + setUseMarkdownFallback(false); + setIsBridgeReady(false); + setIsEditorReady(false); + setIsEditorFocused(false); + setEditorReloadKey((key) => key + 1); + webViewRef.current?.reload(); + }, []); + + 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, + cardBodyPlaceholder: t("notes.knowledgeCardBodyPlaceholder", "直接在卡片里书写..."), + cardConvertToTextLabel: t("notes.knowledgeCardConvertToText", "转成普通正文"), + imageUnavailableTitle: t( + "notes.knowledgeAttachmentUnavailable", + "图片附件暂时无法在这台设备显示。", + ), + imageUnavailableHint: t( + "notes.knowledgeAttachmentUnavailableHint", + "重新同步,或让原设备保持在线后再试。", + ), + cardTemplates, + readOnly, + theme, + }); + if (autoFocus && !readOnly) { + injectCommand({ type: "focus", position: "end" }); + } + }, [autoFocus, cardTemplates, injectCommand, placeholder, readOnly, t, theme]); + + useEffect(() => { + if (!isBridgeReady || !isEditorReady) return; + injectCommand({ type: "setCardTemplates", cardTemplates }); + }, [cardTemplates, injectCommand, isBridgeReady, isEditorReady]); + + useEffect(() => { + if (!isBridgeReady) return; + injectCommand({ type: "setTheme", theme }); + }, [injectCommand, isBridgeReady, theme]); + + useEffect(() => { + if (!isBridgeReady || !isEditorReady) return; + injectCommand({ type: "setEditable", editable: !readOnly }); + if (readOnly) { + injectCommand({ type: "blur" }); + setShowBlockInsertMenu(false); + setShowCardMenu(false); + setShowImageModal(false); + setShowInternalLinkModal(false); + setShowLinkModal(false); + setIsTemplateFormOpen(false); + } + }, [injectCommand, isBridgeReady, isEditorReady, readOnly]); + + useEffect(() => { + if (!isBridgeReady || !isEditorReady) return; + if (valueFingerprint === localFingerprintRef.current) return; + localFingerprintRef.current = valueFingerprint; + injectCommand({ + type: "setContent", + contentJson: normalizedValue.contentJson, + cardTemplates, + }); + }, [ + cardTemplates, + injectCommand, + isBridgeReady, + isEditorReady, + normalizedValue.contentJson, + valueFingerprint, + ]); + + useEffect(() => { + if (!outlineTarget || !isBridgeReady || !isEditorReady || useMarkdownFallback) return; + injectCommand({ + type: "runCommand", + command: "scrollToOutline", + attrs: { index: outlineTarget.index }, + }); + }, [injectCommand, isBridgeReady, isEditorReady, outlineTarget, useMarkdownFallback]); + + useEffect(() => { + if ( + readOnly || + !sourceReferenceRequest || + !isBridgeReady || + !isEditorReady || + useMarkdownFallback + ) { + return; + } + if (handledSourceReferenceRequestIdRef.current === sourceReferenceRequest.requestId) return; + const label = sourceReferenceRequest.label.trim(); + if (!label) return; + handledSourceReferenceRequestIdRef.current = sourceReferenceRequest.requestId; + injectCommand({ + type: "runCommand", + command: "insertSourceReference", + attrs: { + label, + sourceTitle: sourceReferenceRequest.sourceTitle?.trim() || label, + sourceId: sourceReferenceRequest.sourceId?.trim() || null, + cfi: sourceReferenceRequest.cfi?.trim() || null, + }, + }); + }, [ + injectCommand, + isBridgeReady, + isEditorReady, + readOnly, + sourceReferenceRequest, + useMarkdownFallback, + ]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + const { message, error } = parseKnowledgeEditorBridgeMessage(event.nativeEvent.data); + if (!message) { + if (error) { + console.error("[MobileKnowledgeEditor] Invalid bridge message:", { + error, + data: event.nativeEvent.data.slice(0, 160), + }); + setEditorIssue({ + kind: "bridge", + code: `bridge_${error}`, + message: t( + "notes.knowledgeEditorBridgeError", + "编辑器通信异常,可以重试或使用备用编辑器", + ), + }); + } + return; + } + + switch (message.type) { + case "loaded": + setIsBridgeReady(true); + setEditorIssue(null); + sendInit(); + break; + case "ready": + setIsEditorReady(true); + setEditorIssue(null); + break; + case "heightChanged": + if (typeof message.height === "number" && Number.isFinite(message.height)) { + setEditorHeight(clampKnowledgeEditorBridgeHeight(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 (readOnly) return; + if (!isKnowledgeEditorBridgeJsonValue(message.contentJson)) { + console.error("[MobileKnowledgeEditor] Invalid contentChanged payload:", message); + setEditorIssue({ + kind: "bridge", + code: "bridge_invalid_content", + message: t( + "notes.knowledgeEditorBridgeError", + "编辑器通信异常,可以重试或使用备用编辑器", + ), + }); + return; + } + const contentJson = normalizeTiptapDocument(message.contentJson, { + cardTemplates, + }) as unknown as JSONValue; + localFingerprintRef.current = fingerprintJson(contentJson); + const nextValue = { + contentJson, + contentMd: renderKnowledgeJsonToMarkdown(contentJson, { cardTemplates }), + plainText: typeof message.plainText === "string" ? message.plainText : "", + }; + scheduleDraftSave(nextValue); + onChange(nextValue); + break; + } + case "focusChanged": + setIsEditorFocused(message.focused === true); + break; + case "error": + console.error("[MobileKnowledgeEditor] WebView error:", message); + setEditorIssue({ + kind: "bridge", + code: message.code, + message: + message.message || + t("notes.knowledgeEditorBridgeError", "编辑器通信异常,可以重试或使用备用编辑器"), + }); + break; + } + }, + [cardTemplates, onChange, readOnly, scheduleDraftSave, sendInit, t], + ); + + const runCommand = useCallback( + (command: string, attrs?: Record) => { + if (readOnly) return; + injectCommand({ type: "runCommand", command, attrs }); + }, + [injectCommand, readOnly], + ); + + const restorePendingDraft = useCallback(() => { + if (readOnly || !pendingDraft) return; + const nextValue = normalizeMobileKnowledgeEditorValue(pendingDraft.value, cardTemplates); + setPendingDraft(null); + const nextFingerprint = fingerprintJson(nextValue.contentJson); + lastWrittenDraftFingerprintRef.current = nextFingerprint; + onChange(nextValue); + + if (isBridgeReady && isEditorReady) { + localFingerprintRef.current = nextFingerprint; + injectCommand({ type: "setContent", contentJson: nextValue.contentJson }); + } + }, [ + cardTemplates, + injectCommand, + isBridgeReady, + isEditorReady, + onChange, + pendingDraft, + readOnly, + ]); + + const discardPendingDraft = useCallback(() => { + if (!draftKey) return; + setPendingDraft(null); + lastWrittenDraftFingerprintRef.current = null; + void clearKnowledgeEditorDraft(draftKey); + }, [draftKey]); + + const openLinkModal = useCallback(() => { + if (readOnly || !canUse("link")) return; + setLinkUrl(selection.linkHref ?? ""); + setShowLinkModal(true); + }, [canUse, readOnly, selection.linkHref]); + + const applyLink = useCallback(() => { + const href = linkUrl.trim(); + runCommand(href ? "setLink" : "unsetLink", href ? { href } : undefined); + setShowLinkModal(false); + setLinkUrl(""); + }, [linkUrl, runCommand]); + + const insertInternalLink = useCallback( + (target?: MobileKnowledgeInternalLinkTarget) => { + if (readOnly || !canUse("internalLink")) return; + const label = (target?.title ?? internalLinkQuery).trim(); + if (!label) return; + runCommand("insertInternalLink", { + label, + title: label, + ...(target?.id ? { documentId: target.id } : {}), + ...(target?.targetPath ? { targetPath: target.targetPath } : {}), + }); + setShowInternalLinkModal(false); + setInternalLinkQuery(""); + }, + [canUse, internalLinkQuery, readOnly, runCommand], + ); + + const openImageModal = useCallback(() => { + if (readOnly || !canUse("image")) return; + setImageUrl(""); + setImageAlt(""); + setShowBlockInsertMenu(false); + setShowImageModal(true); + }, [canUse, readOnly]); + + const insertImageAttrs = useCallback( + (attrs: MobileKnowledgeImageInsertAttrs) => { + const src = attrs.src.trim(); + if (!src) return; + runCommand("insertImage", { + src, + alt: attrs.alt?.trim() ?? "", + title: attrs.title?.trim() ?? "", + attachmentId: attrs.attachmentId?.trim() ?? "", + fileName: attrs.fileName?.trim() ?? "", + }); + setShowImageModal(false); + setImageUrl(""); + setImageAlt(""); + }, + [runCommand], + ); + + const applyImage = useCallback(() => { + const src = imageUrl.trim(); + if (!src) return; + insertImageAttrs({ src, alt: imageAlt }); + }, [imageAlt, imageUrl, insertImageAttrs]); + + const pickLocalImage = useCallback(async () => { + if (readOnly || !onPickLocalImage || isPickingLocalImage) return; + setIsPickingLocalImage(true); + try { + const attrs = await onPickLocalImage(); + if (attrs) insertImageAttrs(attrs); + } finally { + setIsPickingLocalImage(false); + } + }, [insertImageAttrs, isPickingLocalImage, onPickLocalImage, readOnly]); + + const insertCard = useCallback( + (card: InsertableCardItem) => { + if (!canInsertCard(card.cardType)) return; + runCommand("insertCard", card.createAttrs()); + setShowCardMenu(false); + setShowBlockInsertMenu(false); + }, + [canInsertCard, runCommand], + ); + + const resetTemplateForm = useCallback(() => { + setEditingTemplateId(null); + setTemplateName(""); + setTemplateDescription(""); + setTemplateMarkdown(""); + setTemplateFields([]); + setTemplateSaveError(null); + }, []); + + const addTemplateField = useCallback(() => { + setTemplateFields((current) => [ + ...current, + { + key: `field_${current.length + 1}`, + label: t("notes.knowledgeCustomCardFieldNew", `字段 ${current.length + 1}`, { + count: current.length + 1, + }), + type: "text", + }, + ]); + setTemplateSaveError(null); + }, [t]); + + const updateTemplateField = useCallback( + (index: number, patch: Partial) => { + setTemplateFields((current) => + current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)), + ); + setTemplateSaveError(null); + }, + [], + ); + + const updateTemplateGroupVisibleWhen = useCallback( + ( + group: string | undefined, + visibleWhen: ReadAnyCardTemplateField["groupVisibleWhen"] | undefined, + ) => { + const groupName = group?.trim(); + if (!groupName) return; + setTemplateFields((current) => + current.map((field) => + field.group?.trim() === groupName ? { ...field, groupVisibleWhen: visibleWhen } : field, + ), + ); + setTemplateSaveError(null); + }, + [], + ); + + const removeTemplateField = useCallback((index: number) => { + setTemplateFields((current) => current.filter((_, fieldIndex) => fieldIndex !== index)); + setTemplateSaveError(null); + }, []); + + const openNewTemplateForm = useCallback(() => { + if (readOnly) return; + resetTemplateForm(); + setIsTemplateFormOpen(true); + }, [readOnly, resetTemplateForm]); + + const openTemplateEditForm = useCallback( + (template: KnowledgeCardTemplate) => { + if (readOnly) return; + const attrs = createReadAnyCardAttrsFromTemplate(template); + setEditingTemplateId(template.id); + setTemplateName(getReadAnyCardTemplateInsertLabel(template)); + setTemplateDescription(getReadAnyCardTemplateDescription(template) ?? ""); + setTemplateMarkdown((attrs.markdown ?? attrs.text ?? "") as string); + setTemplateFields(getReadAnyCardTemplateFields(template)); + setTemplateSaveError(null); + setIsTemplateFormOpen(true); + }, + [readOnly], + ); + + const saveTemplate = useCallback(async () => { + if (readOnly || !canUse("readAnyCards") || isSavingTemplate) return; + const name = templateName.trim(); + if (!name) return; + + setIsSavingTemplate(true); + setTemplateSaveError(null); + try { + const normalizedTemplateFields = normalizeReadAnyCardTemplateFields(templateFields); + const editingTemplate = editingTemplateId + ? cardTemplates.find((template) => template.id === editingTemplateId) + : null; + if (editingTemplateId && !editingTemplate) { + throw new Error(t("notes.knowledgeCustomCardMissing", "这个自定义卡片模板已经不存在")); + } + const template = editingTemplate + ? updateCustomReadAnyCardTemplate({ + template: editingTemplate, + name, + description: templateDescription, + markdown: templateMarkdown, + fields: normalizedTemplateFields, + }) + : createCustomReadAnyCardTemplate({ + id: `card-template-${generateId()}`, + name, + description: templateDescription, + markdown: templateMarkdown, + fields: normalizedTemplateFields, + }); + + await upsertKnowledgeCardTemplate(template); + setCardTemplates((current) => + [...current.filter((item) => item.id !== template.id), template].sort((a, b) => + a.name.localeCompare(b.name), + ), + ); + if (!editingTemplate) { + runCommand( + "insertCard", + createReadAnyCardAttrsFromTemplate(template) as Record, + ); + setShowCardMenu(false); + setShowBlockInsertMenu(false); + } + resetTemplateForm(); + setIsTemplateFormOpen(false); + } catch (error) { + console.warn("[MobileKnowledgeEditor] Failed to save card template:", error); + setTemplateSaveError( + error instanceof Error + ? error.message + : t("notes.knowledgeCustomCardCreateFailed", "保存自定义卡片失败"), + ); + } finally { + setIsSavingTemplate(false); + } + }, [ + canUse, + cardTemplates, + editingTemplateId, + isSavingTemplate, + readOnly, + resetTemplateForm, + runCommand, + t, + templateDescription, + templateFields, + templateMarkdown, + templateName, + ]); + const disableTemplate = useCallback( + (template: KnowledgeCardTemplate) => { + if (readOnly) return; + Alert.alert( + t("notes.knowledgeCustomCardDisable", "移除自定义卡片"), + t( + "notes.knowledgeCustomCardDisableConfirm", + `从插入菜单移除「${template.name}」?已经插入到文档里的卡片不会变化。`, + { name: template.name }, + ), + [ + { text: t("common.cancel", "取消"), style: "cancel" }, + { + text: t("common.confirm", "确认"), + style: "destructive", + onPress: () => { + void (async () => { + try { + await disableKnowledgeCardTemplate(template.id); + setCardTemplates((current) => + current.map((item) => + item.id === template.id ? { ...item, enabled: false } : item, + ), + ); + if (editingTemplateId === template.id) { + resetTemplateForm(); + setIsTemplateFormOpen(false); + } + } catch (error) { + console.warn("[MobileKnowledgeEditor] Failed to disable card template:", error); + setTemplateSaveError( + error instanceof Error + ? error.message + : t("notes.knowledgeCustomCardDisableFailed", "移除自定义卡片失败"), + ); + } + })(); + }, + }, + ], + ); + }, + [editingTemplateId, readOnly, resetTemplateForm, t], + ); + + const handleFallbackChange = useCallback( + (markdown: string) => { + if (readOnly) return; + const contentJson = markdownToBasicTiptap(markdown, { + cardTemplates, + }) as unknown as JSONValue; + const nextValue = { + contentJson, + contentMd: markdown, + plainText: markdown, + }; + scheduleDraftSave(nextValue); + onChange(nextValue); + }, + [cardTemplates, onChange, readOnly, scheduleDraftSave], + ); + + 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("internalLink") ? ( + { + setInternalLinkQuery(""); + setShowInternalLinkModal(true); + }} + disabled={!isEditorReady} + styles={styles} + > + + + ) : null} + + ), + }, + canUse("bulletList") || + canUse("orderedList") || + canUse("taskList") || + 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("taskList") ? ( + runCommand("taskList")} + isActive={selection.marks.taskList} + 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, + allowedCards.length > 0 + ? { + key: "cards", + node: ( + + {canUse("image") ? ( + + + + ) : null} + setShowCardMenu(true)} + disabled={!isEditorReady} + styles={styles} + > + + + + ), + } + : canUse("image") + ? { + key: "media", + node: ( + + + + ), + } + : null, + ]; + const toolbarGroups = readOnly + ? [] + : toolbarGroupCandidates.filter( + (group): group is { key: string; node: React.ReactNode } => group !== null, + ); + const hasBlockInsertItems = + canUse("heading1") || + canUse("heading2") || + canUse("heading3") || + canUse("bulletList") || + canUse("orderedList") || + canUse("taskList") || + canUse("blockquote") || + canUse("codeBlock") || + canUse("horizontalRule") || + canUse("image") || + allowedCards.length > 0; + + if (useMarkdownFallback) { + return ( + + {!readOnly && pendingDraft ? ( + + + + {t("notes.knowledgeEditorDraftFound", "发现未恢复的草稿")} + + + {t("notes.knowledgeEditorDraftHint", "可以恢复上次未保存的编辑内容。")} + + + + + + {t("notes.knowledgeEditorDraftDiscard", "丢弃")} + + + + + {t("notes.knowledgeEditorDraftRestore", "恢复")} + + + + + ) : null} + {editorIssue ? ( + + ) : null} + {readOnly ? ( + + + {normalizedValue.plainText || normalizedValue.contentMd} + + + ) : ( + + )} + + ); + } + + return ( + + {toolbarGroups.length > 0 || (!readOnly && hasBlockInsertItems) ? ( + + {!readOnly && hasBlockInsertItems ? ( + <> + setShowBlockInsertMenu(true)} + disabled={!isEditorReady} + styles={styles} + > + + + + + ) : null} + {toolbarGroups.map((group, index) => ( + + {index > 0 ? : null} + {group.node} + + ))} + + ) : null} + + {!readOnly && pendingDraft ? ( + + + + {t("notes.knowledgeEditorDraftFound", "发现未恢复的草稿")} + + + {t("notes.knowledgeEditorDraftHint", "可以恢复上次未保存的编辑内容。")} + + + + + + {t("notes.knowledgeEditorDraftDiscard", "丢弃")} + + + + + {t("notes.knowledgeEditorDraftRestore", "恢复")} + + + + + ) : null} + + + {!htmlUri ? ( + + + + {t("notes.knowledgeEditorLoading", "正在准备编辑器...")} + + + ) : ( + <> + { + console.error("[MobileKnowledgeEditor] WebView load error:", event.nativeEvent); + setEditorIssue({ + kind: "webview", + code: event.nativeEvent.code ? String(event.nativeEvent.code) : undefined, + message: t("notes.knowledgeEditorLoadFailed", "编辑器加载失败"), + }); + setIsEditorFocused(false); + setUseMarkdownFallback(true); + }} + onContentProcessDidTerminate={() => { + setEditorIssue({ + kind: "process", + code: "content_process_terminated", + message: t("notes.knowledgeEditorReloading", "编辑器正在恢复..."), + }); + setIsBridgeReady(false); + setIsEditorReady(false); + setIsEditorFocused(false); + setEditorReloadKey((key) => key + 1); + }} + /> + {!isEditorReady && ( + + + {errorMessage ? ( + <> + {errorMessage} + {editorIssue?.code ? ( + + {t("notes.knowledgeEditorErrorCode", { + code: editorIssue.code, + defaultValue: `Code: ${editorIssue.code}`, + })} + + ) : null} + + setUseMarkdownFallback(true)} + > + + {t("notes.knowledgeEditorFallback", "使用备用编辑器")} + + + + + {t("notes.knowledgeEditorRetry", "重试")} + + + + + ) : null} + + )} + + )} + + + {editorIssue && isEditorReady ? ( + + ) : null} + + setShowBlockInsertMenu(false)} + > + + setShowBlockInsertMenu(false)} + /> + + + + {t("notes.knowledgeInsertBlock", "插入块")} + + {t("notes.knowledgeInsertBlockHint", "插入标题、列表、引用、图片或知识卡片。")} + + + + {canUse("heading1") ? ( + } + title={t("editor.heading1", "一级标题")} + hint={t("notes.knowledgeInsertHeadingHint", "开始一个章节")} + styles={styles} + onPress={() => { + runCommand("heading", { level: 1 }); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("heading2") ? ( + } + title={t("editor.heading2", "二级标题")} + hint={t("notes.knowledgeInsertSubheadingHint", "拆出一个小节")} + styles={styles} + onPress={() => { + runCommand("heading", { level: 2 }); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("heading3") ? ( + } + title={t("editor.heading3", "三级标题")} + hint={t("notes.knowledgeInsertMinorHeadingHint", "添加更小的小节")} + styles={styles} + onPress={() => { + runCommand("heading", { level: 3 }); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("bulletList") ? ( + } + title={t("editor.bulletList", "无序列表")} + hint={t("notes.knowledgeInsertListHint", "整理要点")} + styles={styles} + onPress={() => { + runCommand("bulletList"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("orderedList") ? ( + } + title={t("editor.orderedList", "有序列表")} + hint={t("notes.knowledgeInsertOrderedListHint", "书写步骤或顺序")} + styles={styles} + onPress={() => { + runCommand("orderedList"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("taskList") ? ( + } + title={t("editor.taskList", "任务列表")} + hint={t("notes.knowledgeInsertTaskHint", "记录后续阅读动作")} + styles={styles} + onPress={() => { + runCommand("taskList"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("blockquote") ? ( + } + title={t("editor.blockquote", "引用")} + hint={t("notes.knowledgeInsertQuoteHint", "突出想法或引文")} + styles={styles} + onPress={() => { + runCommand("blockquote"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("codeBlock") ? ( + } + title={t("editor.codeBlock", "代码块")} + hint={t("notes.knowledgeInsertCodeBlockHint", "记录代码、提示词或结构化片段")} + styles={styles} + onPress={() => { + runCommand("codeBlock"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("horizontalRule") ? ( + } + title={t("editor.horizontalRule", "分割线")} + hint={t("notes.knowledgeInsertDividerHint", "分隔两个段落")} + styles={styles} + onPress={() => { + runCommand("horizontalRule"); + setShowBlockInsertMenu(false); + }} + /> + ) : null} + {canUse("image") ? ( + } + title={t("notes.knowledgeInsertImage", "插入图片")} + hint={t("notes.knowledgeInsertImageHint", "添加可同步的图片附件")} + styles={styles} + onPress={openImageModal} + /> + ) : null} + {allowedCards.length > 0 ? ( + <> + {allowedCards.slice(0, 4).map((card) => { + const Icon = cardIconMap[card.cardType] ?? SparklesIcon; + return ( + } + title={card.insertLabel} + hint={card.description || t("notes.knowledgeInsertCard", "插入卡片")} + styles={styles} + onPress={() => insertCard(card)} + /> + ); + })} + {allowedCards.length > 4 ? ( + } + title={t("notes.knowledgeCardPickerTitle", "插入知识卡片")} + hint={t( + "notes.knowledgeCardPickerHint", + "选择一种结构,插入后会随知识文档同步和导出。", + )} + styles={styles} + onPress={() => { + setShowBlockInsertMenu(false); + setShowCardMenu(true); + }} + /> + ) : null} + + ) : null} + + + + + + setShowLinkModal(false)} + > + + { + setShowLinkModal(false); + setLinkUrl(""); + }} + /> + + + + + {t("common.insertLink", "插入链接")} + {t("common.enterLinkUrl", "输入链接地址")} + + + + { + 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", "确定")} + + + + + + + + setShowInternalLinkModal(false)} + > + + { + setShowInternalLinkModal(false); + setInternalLinkQuery(""); + }} + /> + + + + + + {t("notes.knowledgeInsertInternalLink", "插入内部链接")} + + + {t("notes.knowledgeInternalLinkHint", "连接到当前知识库里的另一篇文档")} + + + insertInternalLink(visibleInternalLinkTargets[0])} + /> + + {visibleInternalLinkTargets.map((target) => ( + insertInternalLink(target)} + > + + + + + + {target.title} + + + {[target.typeLabel, target.path].filter(Boolean).join(" · ")} + + + + ))} + {internalLinkQuery.trim() ? ( + insertInternalLink()} + > + + + + + + {t("notes.knowledgeInsertLooseInternalLink", { + title: internalLinkQuery.trim(), + })} + + + {t("notes.knowledgeInternalLinkLooseHint", "作为 Obsidian 风格链接保留")} + + + + ) : null} + + + { + setShowInternalLinkModal(false); + setInternalLinkQuery(""); + }} + activeOpacity={0.75} + > + {t("common.cancel", "取消")} + + + + + + + + setShowImageModal(false)} + > + + { + setShowImageModal(false); + setImageUrl(""); + setImageAlt(""); + }} + /> + + + + + + {t("notes.knowledgeInsertImage", "插入图片")} + + + {t("notes.knowledgeImageUrlPlaceholder", "图片 URL")} + + + {onPickLocalImage ? ( + + + + + + {isPickingLocalImage + ? t("common.loading", "加载中") + : t("notes.knowledgeInsertLocalImage", "选择本地图片")} + + + ) : null} + + + + { + setShowImageModal(false); + setImageUrl(""); + setImageAlt(""); + }} + activeOpacity={0.75} + > + {t("common.cancel", "取消")} + + + {t("common.confirm", "确定")} + + + + + + + + setShowCardMenu(false)} + > + + { + setShowCardMenu(false); + setTemplateSaveError(null); + }} + /> + + + + + + {t("notes.knowledgeCardPickerTitle", "插入知识卡片")} + + + {t( + "notes.knowledgeCardPickerHint", + "选择一种结构,插入后会随知识文档同步和导出。", + )} + + + + {allowedCards.map((card) => { + const Icon = cardIconMap[card.cardType] ?? SparklesIcon; + return ( + + insertCard(card)} + > + + + + + {card.insertLabel} + {card.description ? ( + + {card.description} + + ) : null} + + + {card.template ? ( + + { + if (card.template) openTemplateEditForm(card.template); + }} + accessibilityLabel={t( + "notes.knowledgeCustomCardEdit", + "编辑自定义卡片", + )} + > + + + { + if (card.template) disableTemplate(card.template); + }} + accessibilityLabel={t( + "notes.knowledgeCustomCardDisable", + "移除自定义卡片", + )} + > + + + + ) : null} + + ); + })} + {canUse("readAnyCards") ? ( + + {isTemplateFormOpen ? ( + + + + {editingTemplateId + ? t("notes.knowledgeCustomCardEdit", "编辑自定义卡片") + : t("notes.knowledgeCustomCardNew", "新建自定义卡片")} + + + {editingTemplateId + ? t( + "notes.knowledgeCustomCardEditHint", + "只影响之后插入的卡片,文档里已有的卡片保持不变。", + ) + : t("notes.knowledgeCustomCardNewHint", "创建一个可同步复用的结构。")} + + + + {t("notes.knowledgeCustomCardName", "卡片名称")} + + { + setTemplateName(text); + setTemplateSaveError(null); + }} + placeholder={t( + "notes.knowledgeCustomCardNamePlaceholder", + "概念、时间线、阅读问题...", + )} + placeholderTextColor={colors.mutedForeground} + style={styles.linkInput} + /> + + {t("notes.knowledgeCustomCardDescription", "描述")} + + + + {t("notes.knowledgeCustomCardDefaultBody", "默认正文")} + + + + + + + {t("notes.knowledgeCustomCardFields", "字段")} + + + {t( + "notes.knowledgeCustomCardFieldsHint", + "把卡片数据变成可编辑字段,而不是只编辑原始 JSON。", + )} + + + + + + {t("notes.knowledgeCustomCardAddField", "添加")} + + + + {templateFields.length > 0 ? ( + + {templateFields.map((field, index) => { + const conditionSourceFields = templateFields.filter( + (candidate, candidateIndex) => + candidateIndex !== index && candidate.key !== field.key, + ); + const conditionSourceField = conditionSourceFields.find( + (candidate) => candidate.key === field.visibleWhen?.fieldKey, + ); + const conditionOperator = field.visibleWhen?.operator ?? "equals"; + const conditionNeedsValue = + conditionOperator !== "empty" && conditionOperator !== "notEmpty"; + const fieldGroupName = field.group?.trim() || ""; + const groupConditionSourceFields = fieldGroupName + ? templateFields.filter( + (candidate, candidateIndex) => + candidateIndex !== index && candidate.key !== field.key, + ) + : []; + const isFirstGroupField = fieldGroupName + ? templateFields.findIndex( + (candidate) => candidate.group?.trim() === fieldGroupName, + ) === index + : false; + const groupVisibleWhen = fieldGroupName + ? templateFields.find( + (candidate) => + candidate.group?.trim() === fieldGroupName && + candidate.groupVisibleWhen, + )?.groupVisibleWhen + : undefined; + const groupConditionSourceField = groupConditionSourceFields.find( + (candidate) => candidate.key === groupVisibleWhen?.fieldKey, + ); + const groupConditionOperator = + groupVisibleWhen?.operator ?? "equals"; + const groupConditionNeedsValue = + groupConditionOperator !== "empty" && + groupConditionOperator !== "notEmpty"; + + return ( + + + {t("notes.knowledgeCustomCardFieldLabel", "名称")} + + + updateTemplateField(index, { label: text }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldLabelPlaceholder", + "问题、证据、置信度...", + )} + placeholderTextColor={colors.mutedForeground} + style={styles.linkInput} + /> + + {t("notes.knowledgeCustomCardFieldKey", "数据键")} + + + updateTemplateField(index, { key: text }) + } + autoCapitalize="none" + autoCorrect={false} + placeholder="field_key" + placeholderTextColor={colors.mutedForeground} + style={[styles.linkInput, styles.cardTemplateKeyInput]} + /> + + {t("notes.knowledgeCustomCardFieldGroup", "分组")} + + + updateTemplateField(index, { + group: text.trim() || undefined, + groupVisibleWhen: text.trim() + ? field.groupVisibleWhen + : undefined, + }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldGroupPlaceholder", + "核心、证据、后续...", + )} + placeholderTextColor={colors.mutedForeground} + style={styles.linkInput} + /> + {fieldGroupName && isFirstGroupField ? ( + + + {t( + "notes.knowledgeCustomCardGroupVisibleWhen", + "分组显示条件", + )} + + + + updateTemplateGroupVisibleWhen( + fieldGroupName, + undefined, + ) + } + > + + {t( + "notes.knowledgeCustomCardFieldAlwaysVisible", + "始终显示", + )} + + + {groupConditionSourceFields.map((candidate) => { + const isActive = + groupVisibleWhen?.fieldKey === candidate.key; + return ( + + updateTemplateGroupVisibleWhen(fieldGroupName, { + fieldKey: candidate.key, + operator: + groupVisibleWhen?.operator ?? "equals", + value: groupVisibleWhen?.value ?? "", + }) + } + > + + {candidate.label} + + + ); + })} + + {groupVisibleWhen ? ( + <> + + {t( + "notes.knowledgeCustomCardFieldConditionOperator", + "规则", + )} + + + {customCardFieldConditionOperators.map((operator) => { + const isActive = + groupConditionOperator === operator; + return ( + + updateTemplateGroupVisibleWhen( + fieldGroupName, + { + fieldKey: + groupVisibleWhen?.fieldKey ?? "", + operator, + value: + operator === "empty" || + operator === "notEmpty" + ? undefined + : (groupVisibleWhen?.value ?? ""), + }, + ) + } + > + + {getConditionOperatorLabel(operator)} + + + ); + })} + + {groupConditionNeedsValue ? ( + <> + + {t( + "notes.knowledgeCustomCardFieldConditionValue", + "条件值", + )} + + {groupConditionSourceField?.type === "checkbox" ? ( + + {["true", "false"].map((value) => { + const isActive = + getTemplateConditionValueString( + groupVisibleWhen, + ) === value; + return ( + + updateTemplateGroupVisibleWhen( + fieldGroupName, + { + fieldKey: + groupVisibleWhen?.fieldKey ?? "", + operator: groupConditionOperator, + value: value === "true", + }, + ) + } + > + + {value === "true" + ? t("common.yes", "是") + : t("common.no", "否")} + + + ); + })} + + ) : isChoiceTemplateField( + groupConditionSourceField ?? + ({ + type: "text", + } as ReadAnyCardTemplateField), + ) ? ( + + {(groupConditionSourceField?.options ?? []).map( + (option) => { + const isActive = + getTemplateConditionValueString( + groupVisibleWhen, + ) === option.value; + return ( + + updateTemplateGroupVisibleWhen( + fieldGroupName, + { + fieldKey: + groupVisibleWhen?.fieldKey ?? + "", + operator: groupConditionOperator, + value: option.value, + }, + ) + } + > + + {option.label} + + + ); + }, + )} + + ) : ( + + updateTemplateGroupVisibleWhen( + fieldGroupName, + { + fieldKey: + groupVisibleWhen?.fieldKey ?? "", + operator: groupConditionOperator, + value: parseTemplateFieldConditionValue( + groupConditionSourceField, + text, + ), + }, + ) + } + placeholder={t( + "notes.knowledgeCustomCardFieldConditionValuePlaceholder", + "期望值", + )} + placeholderTextColor={colors.mutedForeground} + keyboardType={ + groupConditionSourceField?.type === "number" + ? "numeric" + : "default" + } + style={styles.linkInput} + /> + )} + + ) : ( + + {t( + "notes.knowledgeCustomCardFieldConditionNoValue", + "这个规则不需要填写条件值。", + )} + + )} + + ) : ( + + {t( + "notes.knowledgeCustomCardGroupVisibleHint", + "同名分组里的字段会共用这个显示规则。", + )} + + )} + + ) : null} + + {t("notes.knowledgeCustomCardFieldType", "类型")} + + + {customCardFieldTypes.map((type) => { + const label = + type === "text" + ? t("notes.knowledgeCustomCardFieldTypeText", "文本") + : type === "multiline" + ? t( + "notes.knowledgeCustomCardFieldTypeMultiline", + "长文本", + ) + : type === "number" + ? t( + "notes.knowledgeCustomCardFieldTypeNumber", + "数字", + ) + : type === "checkbox" + ? t( + "notes.knowledgeCustomCardFieldTypeCheckbox", + "复选框", + ) + : type === "select" + ? t( + "notes.knowledgeCustomCardFieldTypeSelect", + "单选", + ) + : t( + "notes.knowledgeCustomCardFieldTypeMultiselect", + "多选", + ); + const isActive = field.type === type; + return ( + + updateTemplateField(index, { + type, + options: + type === "select" || type === "multiselect" + ? field.options?.length + ? field.options + : createDefaultTemplateFieldOptions(t) + : undefined, + defaultValue: + type === "multiselect" + ? [] + : type === "checkbox" + ? undefined + : typeof field.defaultValue === "boolean" + ? undefined + : field.defaultValue, + }) + } + > + + {label} + + + ); + })} + + + {t("notes.knowledgeCustomCardFieldLayout", "布局")} + + + {customCardFieldWidths.map((width) => { + const isActive = (field.width ?? "") === width; + const label = + width === "" + ? t("notes.knowledgeCustomCardFieldWidthAuto", "自动") + : width === "full" + ? t("notes.knowledgeCustomCardFieldWidthFull", "满宽") + : width === "half" + ? t( + "notes.knowledgeCustomCardFieldWidthHalf", + "半宽", + ) + : t( + "notes.knowledgeCustomCardFieldWidthThird", + "三分之一", + ); + return ( + + updateTemplateField(index, { + width: width || undefined, + }) + } + > + + {label} + + + ); + })} + + + {t("notes.knowledgeCustomCardFieldPlaceholder", "占位提示")} + + + updateTemplateField(index, { + placeholder: text || undefined, + }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldPlaceholderPlaceholder", + "空值时显示", + )} + placeholderTextColor={colors.mutedForeground} + style={styles.linkInput} + /> + + {t("notes.knowledgeCustomCardFieldHelpText", "说明")} + + + updateTemplateField(index, { + helpText: text || undefined, + }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldHelpTextPlaceholder", + "显示在字段下方的短提示", + )} + placeholderTextColor={colors.mutedForeground} + style={styles.linkInput} + /> + + updateTemplateField(index, { + required: field.required ? undefined : true, + }) + } + > + + {field.required + ? t("notes.knowledgeCustomCardFieldRequiredOn", "必填") + : t("notes.knowledgeCustomCardFieldRequired", "设为必填")} + + + {isChoiceTemplateField(field) ? ( + <> + + {t("notes.knowledgeCustomCardFieldOptions", "选项")} + + + updateTemplateField(index, { + options: parseTemplateFieldOptionsText(text, t), + }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldOptionsPlaceholder", + "重要 | important\n稍后 | later", + )} + placeholderTextColor={colors.mutedForeground} + multiline + textAlignVertical="top" + style={[ + styles.linkInput, + styles.cardTemplateOptionsInput, + ]} + /> + + {t( + "notes.knowledgeCustomCardFieldOptionsHint", + "每行一个选项。需要稳定值时使用 名称 | value。", + )} + + + ) : null} + + + {t("notes.knowledgeCustomCardFieldVisibleWhen", "显示条件")} + + + + updateTemplateField(index, { visibleWhen: undefined }) + } + > + + {t( + "notes.knowledgeCustomCardFieldAlwaysVisible", + "始终显示", + )} + + + {conditionSourceFields.map((candidate) => { + const isActive = + field.visibleWhen?.fieldKey === candidate.key; + return ( + + updateTemplateField(index, { + visibleWhen: { + fieldKey: candidate.key, + operator: + field.visibleWhen?.operator ?? "equals", + value: field.visibleWhen?.value ?? "", + }, + }) + } + > + + {candidate.label} + + + ); + })} + + {field.visibleWhen ? ( + <> + + {t( + "notes.knowledgeCustomCardFieldConditionOperator", + "规则", + )} + + + {customCardFieldConditionOperators.map((operator) => { + const isActive = conditionOperator === operator; + return ( + + updateTemplateField(index, { + visibleWhen: { + fieldKey: field.visibleWhen?.fieldKey ?? "", + operator, + value: + operator === "empty" || + operator === "notEmpty" + ? undefined + : (field.visibleWhen?.value ?? ""), + }, + }) + } + > + + {getConditionOperatorLabel(operator)} + + + ); + })} + + {conditionNeedsValue ? ( + <> + + {t( + "notes.knowledgeCustomCardFieldConditionValue", + "条件值", + )} + + {conditionSourceField?.type === "checkbox" ? ( + + {["true", "false"].map((value) => { + const isActive = + getTemplateFieldConditionValueString( + field, + ) === value; + return ( + + updateTemplateField(index, { + visibleWhen: { + fieldKey: + field.visibleWhen?.fieldKey ?? "", + operator: conditionOperator, + value: value === "true", + }, + }) + } + > + + {value === "true" + ? t("common.yes", "是") + : t("common.no", "否")} + + + ); + })} + + ) : isChoiceTemplateField( + conditionSourceField ?? + ({ + type: "text", + } as ReadAnyCardTemplateField), + ) ? ( + + {(conditionSourceField?.options ?? []).map( + (option) => { + const isActive = + getTemplateFieldConditionValueString( + field, + ) === option.value; + return ( + + updateTemplateField(index, { + visibleWhen: { + fieldKey: + field.visibleWhen?.fieldKey ?? "", + operator: conditionOperator, + value: option.value, + }, + }) + } + > + + {option.label} + + + ); + }, + )} + + ) : ( + + updateTemplateField(index, { + visibleWhen: { + fieldKey: field.visibleWhen?.fieldKey ?? "", + operator: conditionOperator, + value: parseTemplateFieldConditionValue( + conditionSourceField, + text, + ), + }, + }) + } + placeholder={t( + "notes.knowledgeCustomCardFieldConditionValuePlaceholder", + "期望值", + )} + placeholderTextColor={colors.mutedForeground} + keyboardType={ + conditionSourceField?.type === "number" + ? "numeric" + : "default" + } + style={styles.linkInput} + /> + )} + + ) : ( + + {t( + "notes.knowledgeCustomCardFieldConditionNoValue", + "这个规则不需要填写条件值。", + )} + + )} + + ) : ( + + {t( + "notes.knowledgeCustomCardFieldAlwaysVisibleHint", + "未设置条件时,这个字段会一直显示。", + )} + + )} + + + {t("notes.knowledgeCustomCardFieldDefault", "默认值")} + + + updateTemplateField(index, { + defaultValue: + field.type === "multiselect" + ? text + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : text + ? text + : undefined, + }) + } + placeholder={ + field.type === "checkbox" + ? "true / false" + : field.type === "select" + ? "option_1" + : field.type === "multiselect" + ? "option_1, option_2" + : t( + "notes.knowledgeCustomCardFieldDefaultPlaceholder", + "可选", + ) + } + placeholderTextColor={colors.mutedForeground} + keyboardType={field.type === "number" ? "numeric" : "default"} + style={styles.linkInput} + /> + removeTemplateField(index)} + activeOpacity={0.75} + > + + + {t("notes.knowledgeCustomCardRemoveField", "移除字段")} + + + + ); + })} + + ) : ( + + + {t( + "notes.knowledgeCustomCardNoFields", + "还没有字段。卡片仍可使用默认正文和高级 JSON。", + )} + + + )} + + {templateSaveError ? ( + {templateSaveError} + ) : null} + + { + resetTemplateForm(); + setIsTemplateFormOpen(false); + }} + activeOpacity={0.75} + > + {t("common.cancel", "取消")} + + + + {isSavingTemplate + ? t("common.saving", "保存中...") + : editingTemplateId + ? t("notes.knowledgeCustomCardSave", "保存卡片") + : t("notes.knowledgeCustomCardCreate", "创建并插入")} + + + + + ) : ( + + + + + + + {t("notes.knowledgeCustomCardNew", "新建自定义卡片")} + + + {t("notes.knowledgeCustomCardNewHint", "创建一个可同步复用的结构。")} + + + + )} + + ) : null} + + + + + + + ); +} + +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 ; +} + +function EditorIssueBanner({ + issue, + fallbackActive, + styles, +}: { + issue: EditorIssue; + fallbackActive?: boolean; + styles: ReturnType; +}) { + const { t } = useTranslation(); + + return ( + + + {fallbackActive + ? t("notes.knowledgeEditorFallbackActive", "已切换到备用编辑器") + : t("notes.knowledgeEditorError", "编辑器出错了")} + + {issue.message} + {issue.code ? ( + + {t("notes.knowledgeEditorErrorCode", { + code: issue.code, + defaultValue: `Code: ${issue.code}`, + })} + + ) : null} + {fallbackActive ? ( + + {t( + "notes.knowledgeEditorFallbackHint", + "备用编辑器会保留 Markdown 内容;恢复后可以重试所见所得编辑器。", + )} + + ) : null} + + ); +} + +function BlockSheetOption({ + icon, + title, + hint, + onPress, + styles, +}: { + icon: React.ReactNode; + title: string; + hint?: string; + onPress: () => void; + styles: ReturnType; +}) { + return ( + + {icon} + + + {title} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +const makeStyles = (colors: ReturnType) => + StyleSheet.create({ + container: { + minHeight: KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT, + overflow: "hidden", + borderRadius: radius.lg, + backgroundColor: colors.background, + }, + documentContainer: { + flex: 1, + minHeight: 0, + borderRadius: 0, + }, + fallbackWrap: { + minHeight: 360, + gap: 8, + }, + fallbackWrapDocument: { + flex: 1, + minHeight: 0, + }, + readOnlyFallback: { + flex: 1, + minHeight: KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.lg, + backgroundColor: colors.background, + }, + readOnlyFallbackContent: { + paddingHorizontal: 16, + paddingVertical: 14, + }, + readOnlyFallbackText: { + color: colors.foreground, + fontSize: fontSize.sm, + lineHeight: 22, + }, + 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: KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + backgroundColor: colors.background, + }, + webViewFrameFocused: { + borderTopColor: withOpacity(colors.primary, 0.42), + }, + webViewFrameDocument: { + flex: 1, + minHeight: 0, + }, + webView: { + flex: 1, + backgroundColor: colors.background, + }, + loadingWrap: { + flex: 1, + minHeight: KNOWLEDGE_MOBILE_EDITOR_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + loadingText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + readyOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + gap: 10, + paddingHorizontal: 22, + backgroundColor: withOpacity(colors.background, 0.72), + }, + readyOverlayText: { + color: colors.foreground, + fontSize: fontSize.xs, + lineHeight: 18, + textAlign: "center", + }, + readyOverlayCode: { + color: colors.mutedForeground, + fontSize: 11, + lineHeight: 15, + textAlign: "center", + }, + readyOverlayActions: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + readyGhostButton: { + minHeight: 34, + justifyContent: "center", + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.md, + backgroundColor: colors.card, + paddingHorizontal: 12, + }, + readyGhostText: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + }, + readyPrimaryButton: { + minHeight: 34, + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: colors.primary, + paddingHorizontal: 13, + }, + readyPrimaryText: { + color: colors.primaryForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + errorText: { + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: fontSize.xs, + lineHeight: 17, + color: colors.destructive, + backgroundColor: withOpacity(colors.destructive, 0.08), + }, + issueBanner: { + gap: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + backgroundColor: withOpacity(colors.destructive, 0.06), + paddingHorizontal: 12, + paddingVertical: 9, + }, + issueTitle: { + color: colors.foreground, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + lineHeight: 16, + }, + issueText: { + color: colors.destructive, + fontSize: fontSize.xs, + lineHeight: 17, + }, + issueCode: { + color: colors.mutedForeground, + fontSize: 11, + lineHeight: 15, + }, + issueHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 17, + }, + draftBanner: { + flexDirection: "row", + alignItems: "center", + gap: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + backgroundColor: withOpacity(colors.primary, 0.08), + paddingHorizontal: 12, + paddingVertical: 10, + }, + draftBannerTextBlock: { + minWidth: 0, + flex: 1, + gap: 2, + }, + draftBannerTitle: { + color: colors.foreground, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + draftBannerHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 16, + }, + draftBannerActions: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + draftGhostButton: { + minHeight: 32, + justifyContent: "center", + borderRadius: radius.md, + paddingHorizontal: 9, + }, + draftGhostText: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + }, + draftPrimaryButton: { + minHeight: 32, + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: colors.primary, + paddingHorizontal: 10, + }, + draftPrimaryText: { + color: colors.primaryForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + inputSheet: { + gap: 14, + paddingBottom: 14, + }, + linkInput: { + minHeight: 44, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.md, + backgroundColor: colors.background, + paddingHorizontal: 12, + color: colors.foreground, + fontSize: fontSize.sm, + }, + localImageButton: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.primary, 0.3), + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.07), + paddingHorizontal: 11, + }, + localImageButtonIcon: { + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.1), + }, + localImageButtonText: { + minWidth: 0, + flex: 1, + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + internalLinkResultScroll: { + flexGrow: 0, + maxHeight: 260, + }, + internalLinkResultList: { + gap: 7, + paddingBottom: 2, + }, + internalLinkResult: { + minHeight: 52, + flexDirection: "row", + alignItems: "center", + gap: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.md, + backgroundColor: colors.background, + paddingHorizontal: 10, + paddingVertical: 8, + }, + internalLinkLooseResult: { + borderStyle: "dashed", + backgroundColor: withOpacity(colors.primary, 0.05), + }, + internalLinkResultIcon: { + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.1), + }, + internalLinkResultText: { + minWidth: 0, + flex: 1, + }, + internalLinkResultTitle: { + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + lineHeight: 18, + }, + internalLinkResultMeta: { + marginTop: 2, + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 16, + }, + 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, + }, + disabledButton: { + opacity: 0.48, + }, + linkPrimaryText: { + color: colors.primaryForeground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + cardSheetOverlay: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: withOpacity("#000000", 0.4), + }, + cardSheet: { + maxHeight: "76%", + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + borderWidth: StyleSheet.hairlineWidth, + borderBottomWidth: 0, + borderColor: colors.border, + backgroundColor: colors.card, + paddingHorizontal: 16, + paddingTop: 10, + shadowColor: "#000000", + shadowOpacity: 0.18, + shadowRadius: 24, + shadowOffset: { width: 0, height: -10 }, + elevation: 12, + }, + cardSheetHandle: { + alignSelf: "center", + width: 34, + height: 4, + borderRadius: radius.full, + backgroundColor: withOpacity(colors.mutedForeground, 0.28), + }, + cardSheetHeader: { + gap: 5, + paddingTop: 14, + paddingBottom: 12, + }, + cardSheetTitle: { + color: colors.foreground, + fontSize: fontSize.lg, + fontWeight: fontWeight.semibold, + letterSpacing: 0, + }, + cardSheetHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 18, + }, + cardOptionScroll: { + flexGrow: 0, + }, + cardOptionList: { + gap: 8, + paddingBottom: 2, + }, + cardTemplateSection: { + gap: 8, + marginTop: 2, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + paddingTop: 10, + }, + cardTemplateForm: { + gap: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.lg, + backgroundColor: withOpacity(colors.muted, 0.28), + padding: 10, + }, + cardTemplateFormHeader: { + gap: 3, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.border, 0.75), + borderRadius: radius.md, + backgroundColor: withOpacity(colors.background, 0.74), + paddingHorizontal: 10, + paddingVertical: 8, + }, + cardTemplateFormTitle: { + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + cardTemplateFormHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 17, + }, + cardTemplateLabel: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + }, + cardTemplateBodyInput: { + minHeight: 96, + paddingTop: 10, + paddingBottom: 10, + lineHeight: 19, + }, + cardTemplateError: { + color: colors.destructive, + fontSize: fontSize.xs, + lineHeight: 17, + }, + cardTemplateFieldSection: { + gap: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.border, 0.8), + borderRadius: radius.md, + backgroundColor: withOpacity(colors.background, 0.72), + padding: 9, + }, + cardTemplateFieldHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + }, + cardTemplateFieldHeaderText: { + flex: 1, + gap: 2, + }, + cardTemplateFieldTitle: { + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + cardTemplateFieldHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 16, + }, + cardTemplateAddFieldButton: { + minHeight: 30, + flexDirection: "row", + alignItems: "center", + gap: 5, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.primary, 0.28), + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.08), + paddingHorizontal: 9, + }, + cardTemplateAddFieldText: { + color: colors.primary, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + cardTemplateFieldList: { + gap: 8, + }, + cardTemplateFieldCard: { + gap: 7, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.border, 0.75), + borderRadius: radius.md, + backgroundColor: withOpacity(colors.muted, 0.18), + padding: 9, + }, + cardTemplateKeyInput: { + fontFamily: Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", + }), + }, + cardTemplateTypeGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + cardTemplateTypeButton: { + minHeight: 30, + minWidth: 74, + alignItems: "center", + justifyContent: "center", + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: colors.background, + paddingHorizontal: 9, + }, + cardTemplateTypeButtonActive: { + borderColor: withOpacity(colors.primary, 0.42), + backgroundColor: withOpacity(colors.primary, 0.1), + }, + cardTemplateTypeText: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + }, + cardTemplateTypeTextActive: { + color: colors.primary, + fontWeight: fontWeight.semibold, + }, + cardTemplateRequiredButton: { + minHeight: 32, + alignItems: "center", + justifyContent: "center", + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.sm, + backgroundColor: colors.background, + paddingHorizontal: 10, + }, + cardTemplateRequiredButtonActive: { + borderColor: withOpacity(colors.primary, 0.42), + backgroundColor: withOpacity(colors.primary, 0.1), + }, + cardTemplateRequiredText: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + }, + cardTemplateRequiredTextActive: { + color: colors.primary, + fontWeight: fontWeight.semibold, + }, + cardTemplateOptionsInput: { + minHeight: 82, + paddingTop: 10, + paddingBottom: 10, + lineHeight: 18, + fontFamily: Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", + }), + }, + cardTemplateConditionBox: { + gap: 7, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.border, 0.75), + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.background, 0.58), + padding: 8, + }, + cardTemplateRemoveFieldButton: { + minHeight: 30, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + borderWidth: StyleSheet.hairlineWidth, + borderColor: withOpacity(colors.destructive, 0.28), + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.destructive, 0.07), + }, + cardTemplateRemoveFieldText: { + color: colors.destructive, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + cardTemplateNoFields: { + borderWidth: StyleSheet.hairlineWidth, + borderStyle: "dashed", + borderColor: withOpacity(colors.border, 0.8), + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.muted, 0.16), + paddingHorizontal: 9, + paddingVertical: 8, + }, + cardTemplateNoFieldsText: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 16, + }, + customCardOption: { + borderStyle: "dashed", + backgroundColor: withOpacity(colors.primary, 0.05), + }, + cardOption: { + minHeight: 66, + flexDirection: "row", + alignItems: "center", + gap: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.lg, + backgroundColor: colors.background, + paddingHorizontal: 12, + paddingVertical: 10, + }, + cardOptionMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + cardOptionIcon: { + width: 38, + height: 38, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.1), + }, + cardOptionText: { + minWidth: 0, + flex: 1, + gap: 3, + }, + cardOptionTitle: { + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, + cardOptionDescription: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 17, + }, + cardTemplateActions: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + cardTemplateEditButton: { + width: 34, + height: 34, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.09), + }, + cardTemplateRemoveButton: { + width: 34, + height: 34, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.destructive, 0.08), + }, + blockOption: { + minHeight: 58, + flexDirection: "row", + alignItems: "center", + gap: 11, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: radius.md, + backgroundColor: colors.background, + paddingHorizontal: 11, + paddingVertical: 9, + }, + blockOptionIcon: { + width: 34, + height: 34, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: withOpacity(colors.primary, 0.1), + }, + blockOptionText: { + minWidth: 0, + flex: 1, + gap: 2, + }, + blockOptionTitle: { + color: colors.foreground, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + lineHeight: 18, + }, + blockOptionHint: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 16, + }, + }); diff --git a/packages/app-expo/src/components/reader/SelectionPopover.tsx b/packages/app-expo/src/components/reader/SelectionPopover.tsx index 8f5b3052..23d4c172 100644 --- a/packages/app-expo/src/components/reader/SelectionPopover.tsx +++ b/packages/app-expo/src/components/reader/SelectionPopover.tsx @@ -299,6 +299,7 @@ export function SelectionPopover({ ( )); +export const ImagePlusIcon = icon(() => ( + <> + + + + + + +)); + export const SearchIcon = icon(() => ( <> @@ -612,6 +622,16 @@ export const ListOrderedIcon = icon(() => ( )); +export const ListTodoIcon = icon(() => ( + <> + + + + + + +)); + export const CodeIcon = icon(() => ( <> diff --git a/packages/app-expo/src/components/ui/RichTextEditor.tsx b/packages/app-expo/src/components/ui/RichTextEditor.tsx index 3cb1493a..066f21a1 100644 --- a/packages/app-expo/src/components/ui/RichTextEditor.tsx +++ b/packages/app-expo/src/components/ui/RichTextEditor.tsx @@ -16,7 +16,15 @@ import { XIcon, } from "@/components/ui/Icon"; import { radius, useColors } from "@/styles/theme"; -import { useCallback, useRef, useState } from "react"; +import { + type KnowledgeEditorFeature, + type KnowledgeEditorSurface, + type KnowledgeEditorTier, + getKnowledgeEditorProfile, + getKnowledgeEditorSurfaceProfile, + hasKnowledgeEditorFeature, +} from "@readany/core/knowledge"; +import { Fragment, useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Modal, @@ -33,6 +41,8 @@ interface RichTextEditorProps { onChange?: (markdown: string) => void; placeholder?: string; autoFocus?: boolean; + tier?: KnowledgeEditorTier; + surface?: KnowledgeEditorSurface; } export function RichTextEditor({ @@ -40,6 +50,8 @@ export function RichTextEditor({ onChange, placeholder, autoFocus = false, + tier = "inline_note", + surface, }: RichTextEditorProps) { const colors = useColors(); const { t } = useTranslation(); @@ -50,6 +62,14 @@ export function RichTextEditor({ const [linkText, setLinkText] = useState(""); const [previewMode, setPreviewMode] = useState(false); const inputRef = useRef(null); + const editorProfile = useMemo( + () => (surface ? getKnowledgeEditorSurfaceProfile(surface) : getKnowledgeEditorProfile(tier)), + [surface, 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 +163,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 +261,6 @@ export function RichTextEditor({ setPreviewMode(!previewMode)} isActive={previewMode} - colors={colors} styles={styles} > {previewMode ? ( @@ -167,57 +271,13 @@ export function RichTextEditor({ - {!previewMode && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} + {!previewMode && + toolbarGroups.map((group) => ( + + + {group.node} + + ))} {previewMode ? ( @@ -232,7 +292,7 @@ export function RichTextEditor({ ) : ( - {placeholder} + {defaultPlaceholder} )} @@ -248,7 +308,7 @@ export function RichTextEditor({ value={value} onChangeText={handleChange} onSelectionChange={handleSelectionChange} - placeholder={placeholder} + placeholder={defaultPlaceholder} placeholderTextColor={colors.mutedForeground} autoFocus={autoFocus} multiline @@ -313,18 +373,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/lib/knowledge/attachment-assets-mobile.ts b/packages/app-expo/src/lib/knowledge/attachment-assets-mobile.ts new file mode 100644 index 00000000..85f4252f --- /dev/null +++ b/packages/app-expo/src/lib/knowledge/attachment-assets-mobile.ts @@ -0,0 +1,87 @@ +import { insertKnowledgeAttachment } from "@readany/core/db/database"; +import { + basenameFromPath, + createKnowledgeAttachmentHash, + inferKnowledgeAttachmentKind, + inferKnowledgeAttachmentMimeType, + sanitizeKnowledgeAttachmentFileName, +} from "@readany/core/knowledge"; +import { getPlatformService } from "@readany/core/services"; +import type { KnowledgeAttachment } from "@readany/core/types"; +import { generateId } from "@readany/core/utils"; + +export interface MobileKnowledgeImageAttachmentInsert { + attachment: KnowledgeAttachment; + attrs: { + src: string; + alt: string; + title: string; + attachmentId: string; + fileName: string; + }; +} + +function fileNameWithAttachmentId(attachmentId: string, fileName: string): string { + return sanitizeKnowledgeAttachmentFileName(`${attachmentId}-${fileName}`); +} + +export async function pickAndPersistMobileKnowledgeImageAttachment( + documentId: string, +): Promise { + const platform = getPlatformService(); + const picked = await platform.pickFile({ + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp"], + }, + ], + }); + const sourcePath = Array.isArray(picked) ? picked[0] : picked; + if (!sourcePath) return null; + + const rawFileName = basenameFromPath(sourcePath, "image"); + const fileName = sanitizeKnowledgeAttachmentFileName(rawFileName, "image"); + const mimeType = inferKnowledgeAttachmentMimeType(fileName); + const kind = inferKnowledgeAttachmentKind(fileName, mimeType); + if (kind !== "image") { + throw new Error(`Unsupported image attachment type: ${fileName}`); + } + + const data = await platform.readFile(sourcePath); + const attachmentId = generateId(); + const dataDir = await platform.getDataDir(); + const attachmentDir = await platform.joinPath(dataDir, "knowledge", "attachments"); + await platform.mkdir(attachmentDir); + + const storedFileName = fileNameWithAttachmentId(attachmentId, fileName); + const localPath = await platform.joinPath(attachmentDir, storedFileName); + await platform.writeFile(localPath, data); + + const now = Date.now(); + const attachment: KnowledgeAttachment = { + id: attachmentId, + documentId, + kind: "image", + fileName, + mimeType, + localPath, + remotePath: `/readany/data/knowledge/attachments/${storedFileName}`, + size: data.byteLength, + hash: createKnowledgeAttachmentHash(data), + createdAt: now, + updatedAt: now, + }; + await insertKnowledgeAttachment(attachment); + + return { + attachment, + attrs: { + src: platform.convertFileSrc(localPath), + alt: fileName, + title: fileName, + attachmentId, + fileName, + }, + }; +} diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts index 1935cf27..28c2bf6e 100644 --- a/packages/app-expo/src/lib/platform/expo-platform-service.ts +++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts @@ -17,6 +17,7 @@ import type { IDatabase, IPlatformService, IWebSocket, + PickedFile, WebSocketOptions, } from "@readany/core/services"; import * as Clipboard from "expo-clipboard"; @@ -112,7 +113,7 @@ export class ExpoPlatformService implements IPlatformService { // ---- File picker (expo-document-picker) ---- - async pickFile(options?: FilePickerOptions): Promise { + async pickFiles(options?: FilePickerOptions): Promise { try { // Convert extension-based filters to MIME types for expo-document-picker const mimeTypes: string[] = []; @@ -137,15 +138,25 @@ export class ExpoPlatformService implements IPlatformService { return null; } - if (options?.multiple) { - return result.assets.map((a) => a.uri); - } - return result.assets[0].uri; + const files = result.assets.map((asset) => ({ + path: asset.uri, + name: asset.name, + mimeType: asset.mimeType, + size: asset.size, + })); + return files.length > 0 ? files : null; } catch { return null; } } + async pickFile(options?: FilePickerOptions): Promise { + const files = await this.pickFiles(options); + if (!files || files.length === 0) return null; + if (options?.multiple) return files.map((file) => file.path); + return files[0].path; + } + // ---- Database (expo-sqlite) ---- async loadDatabase(path: string): Promise { @@ -721,6 +732,11 @@ export class ExpoPlatformService implements IPlatformService { return null; } + async openExternalUrl(url: string): Promise { + const { Linking } = await import("react-native"); + await Linking.openURL(url); + } + // ---- LAN Sync ---- async isOnWifi(): Promise { @@ -759,6 +775,7 @@ export class ExpoPlatformService implements IPlatformService { let BufferMod: any; try { TcpSocket = (await import("react-native-tcp-socket")).default; + // biome-ignore lint/style/useNodejsImportProtocol: React Native needs the buffer polyfill package, not the Node builtin. BufferMod = (await import("buffer")).Buffer; } catch (e) { throw new Error(`Native TCP Socket unavailable: ${e instanceof Error ? e.message : e}`); @@ -850,6 +867,8 @@ function extensionToMime(ext: string): string { fb2: "application/x-fictionbook+xml", fbz: "application/x-zip-compressed-fb2", txt: "text/plain", + md: "text/markdown", + markdown: "text/markdown", umd: "application/octet-stream", zip: "application/zip", }; diff --git a/packages/app-expo/src/screens/NotesView.tsx b/packages/app-expo/src/screens/NotesView.tsx index e923fcf4..85239a49 100644 --- a/packages/app-expo/src/screens/NotesView.tsx +++ b/packages/app-expo/src/screens/NotesView.tsx @@ -1,38 +1,133 @@ +import { MarkdownRenderer } from "@/components/chat/MarkdownRenderer"; +import { + MobileKnowledgeEditor, + type MobileKnowledgeEditorOutlineTarget, + type MobileKnowledgeEditorValue, + type MobileKnowledgeImageInsertAttrs, + type MobileKnowledgeInternalLinkTarget, + type MobileKnowledgeSourceReferenceRequest, +} from "@/components/knowledge/MobileKnowledgeEditor"; import { BookOpenIcon, + BrainIcon, + CheckCheckIcon, ChevronLeftIcon, + ChevronRightIcon, + EditIcon, + FolderIcon, + FolderInputIcon, + FolderPlusIcon, HighlighterIcon, + ListIcon, + LoaderIcon, + MoreVerticalIcon, NotebookPenIcon, + PlusIcon, + ScrollTextIcon, SearchIcon, ShareIcon, + SparklesIcon, + Trash2Icon, XIcon, } from "@/components/ui/Icon"; import { SyncButton } from "@/components/ui/SyncButton"; +import { useKeyboardInsets } from "@/hooks/use-keyboard-insets"; +import { resolveActiveAIConfig } from "@/lib/ai/resolve-active-ai-config"; +import { pickAndPersistMobileKnowledgeImageAttachment } from "@/lib/knowledge/attachment-assets-mobile"; import { openMobileBook } from "@/lib/library/open-mobile-book"; import type { RootStackParamList } from "@/navigation/RootNavigator"; -import { useAnnotationStore, useLibraryStore } from "@/stores"; +import { useAnnotationStore, useLibraryStore, useSettingsStore } 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 { AnnotationExporter, type ExportFormat } from "@readany/core/export"; +import { + maybeCompressAndPersistKnowledgeSummary, + maybeCompressKnowledgeDocumentsById, +} from "@readany/core/ai"; +import { + type HighlightWithBook, + type KnowledgeBacklink, + createKnowledgeDocument, + deleteKnowledgeDocument, + ensureBookHomeDocument, + ensureHighlightNoteKnowledgeDocuments, + ensureNoteKnowledgeDocuments, + getKnowledgeAttachments, + getKnowledgeBacklinks, + getKnowledgeCardTemplates, + getKnowledgeDocument, + getKnowledgeDocuments, + getKnowledgeLinks, + updateKnowledgeDocument, +} from "@readany/core/db/database"; +import { + AnnotationExporter, + type ExportFormat, + type KnowledgeExportFormat, + type KnowledgeImportWriteProposal, + createKnowledgeMarkdownImportPlan, + knowledgeExporter, + scopeKnowledgeExportInputToDocumentSubtree, +} from "@readany/core/export"; +import { + type KnowledgeDocumentOutlineItem, + type KnowledgeDocumentTreeNode, + buildKnowledgeDocumentTree, + canonicalizeKnowledgeAttachmentImageSources, + createKnowledgeDocumentMoveTargets, + createKnowledgeExcerpt, + createKnowledgeFolderDisplaySections, + createKnowledgeRootDisplaySections, + createKnowledgeSummarySourceFingerprint, + extractKnowledgeDocumentOutline, + filterKnowledgeDocumentTreeNodesForSearch, + flattenKnowledgeDocumentTree, + getKnowledgeEditorSurfaceForDocumentType, + getKnowledgeDocumentCreateParentId, + getKnowledgeDocumentOpenMode, + getKnowledgeDocumentWorkspaceMode, + ensureKnowledgeSourceLink, + knowledgeDocumentFingerprint, + markdownToBasicTiptap, + normalizeTiptapDocument, + orderKnowledgeDocuments, + renderKnowledgeJsonToMarkdown, + resolveKnowledgeAttachmentImageSources, + resolveKnowledgeDocumentPath, + syncKnowledgeInternalDocumentLinks, + validateKnowledgeDocumentParent, + validateKnowledgeDocumentSiblingTitle, +} from "@readany/core/knowledge"; +import { applyKnowledgeWriteProposal } from "@readany/core/knowledge/proposals"; import { sortAnnotationsByPosition } from "@readany/core/reader"; -import type { Highlight } from "@readany/core/types"; +import { getPlatformService, type PickedFile } from "@readany/core/services"; +import type { + Book, + Highlight, + KnowledgeAttachment, + KnowledgeDocument, + KnowledgeDocumentType, + KnowledgeLink, +} 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. + * NotesView — mobile notes plus book-centered knowledge vault. + * Knowledge documents keep an Obsidian-like folder hierarchy and open into a + * focused WYSIWYG editor surface. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, FlatList, Image, + KeyboardAvoidingView, Modal, + Platform, Pressable, ScrollView, + StyleSheet, Text, TextInput, TouchableOpacity, @@ -50,7 +145,214 @@ 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"; +type MobileKnowledgeWorkspaceMode = "vault" | "document"; +type CreatableKnowledgeDocumentType = Extract< + KnowledgeDocumentType, + "folder" | "standalone_note" | "review" | "summary" +>; + +const KNOWLEDGE_SUMMARY_AUTOSAVE_MAINTENANCE_DELAY_MS = 45_000; + +interface KnowledgeMarkdownImportReviewItem { + path: string; + sourcePath: string; + sourceName?: string; + proposal: KnowledgeImportWriteProposal; + warnings: string[]; +} + +interface KnowledgeMarkdownImportReview { + items: KnowledgeMarkdownImportReviewItem[]; +} + +function createEmptyKnowledgeValue(): MobileKnowledgeEditorValue { + return { + contentJson: { type: "doc", content: [] }, + contentMd: "", + plainText: "", + }; +} + +function normalizeKnowledgeTags(tags: readonly string[]): string[] { + return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))].sort((a, b) => + a.localeCompare(b), + ); +} + +function mobileFileName(path: string): string { + const fileName = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() || path; + try { + return decodeURIComponent(fileName); + } catch { + return fileName; + } +} + +function normalizePickedFiles(selected: string | string[] | null): PickedFile[] { + if (!selected) return []; + const paths = Array.isArray(selected) ? selected : [selected]; + return paths.map((path) => ({ + path, + name: mobileFileName(path), + })); +} + +function knowledgeMarkdownImportWarningLabel(warning: string, t: TFunction): string { + if (warning === "frontmatter_not_readany") { + return t("notes.knowledgeMarkdownImportWarningFrontmatterNotReadAny", "普通 Markdown"); + } + if (warning === "created_folder_from_import_path") { + return t("notes.knowledgeMarkdownImportWarningCreatedFolder", "将创建路径文件夹"); + } + if (warning === "duplicate_sibling_title") { + return t("notes.knowledgeMarkdownImportWarningDuplicateTitle", "目标文件夹已有同名文档"); + } + return t("notes.knowledgeMarkdownImportWarningFallback", { warning }); +} + +function canDeleteKnowledgeDocument(document: KnowledgeDocument): boolean { + if (document.type === "book_home") return false; + if (document.sourceKind === "highlight" || document.sourceKind === "note") return false; + return true; +} + +function knowledgeDocumentCreateTitle( + type: CreatableKnowledgeDocumentType, + count: number, + t: TFunction, +): string { + if (type === "folder") return t("notes.knowledgeNewFolderTitle", { count }); + if (type === "review") return t("notes.knowledgeNewReviewTitle", { count }); + if (type === "summary") return t("notes.knowledgeNewSummaryTitle", { count }); + return t("notes.knowledgeNewNoteTitle", { count }); +} + +function sameKnowledgeParent(left?: string | null, right?: string | null): boolean { + return (left || undefined) === (right || undefined); +} + +function createUniqueKnowledgeDocumentCreateTitle(input: { + type: CreatableKnowledgeDocumentType; + bookId?: string; + parentId?: string; + documents: readonly KnowledgeDocument[]; + t: TFunction; +}): string { + const baseCount = + input.documents.filter( + (document) => + document.type === input.type && sameKnowledgeParent(document.parentId, input.parentId), + ).length + 1; + + for (let offset = 0; offset < 1000; offset += 1) { + const title = knowledgeDocumentCreateTitle(input.type, baseCount + offset, input.t); + const validation = validateKnowledgeDocumentSiblingTitle({ + bookId: input.bookId, + parentId: input.parentId, + title, + documents: input.documents, + }); + if (validation.ok) return title; + } + + return knowledgeDocumentCreateTitle(input.type, baseCount, input.t); +} + +function isEmptyTiptapDocument(contentJson: KnowledgeDocument["contentJson"]): boolean { + const doc = normalizeTiptapDocument(contentJson); + return !doc.content || doc.content.length === 0; +} + +function createKnowledgeContentJson(document: KnowledgeDocument): KnowledgeDocument["contentJson"] { + if (document.contentMd.trim() && isEmptyTiptapDocument(document.contentJson)) { + return markdownToBasicTiptap(document.contentMd) as unknown as KnowledgeDocument["contentJson"]; + } + return normalizeTiptapDocument( + document.contentJson, + ) as unknown as KnowledgeDocument["contentJson"]; +} + +function createKnowledgeValue( + document: KnowledgeDocument, + contentJsonOverride?: KnowledgeDocument["contentJson"], +): MobileKnowledgeEditorValue { + const contentJson = contentJsonOverride ?? createKnowledgeContentJson(document); + const contentMd = document.contentMd || renderKnowledgeJsonToMarkdown(contentJson); + return { + contentJson, + contentMd, + plainText: contentMd + .replace(/[#>*_`~\-[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(), + }; +} + +function resolveKnowledgeAttachmentDisplaySrc(attachment: KnowledgeAttachment): string | undefined { + if (!attachment.localPath) return undefined; + try { + return getPlatformService().convertFileSrc(attachment.localPath); + } catch (error) { + console.warn("[Notes] Failed to resolve knowledge attachment image source:", error); + return attachment.localPath; + } +} + +async function createResolvedKnowledgeValue( + document: KnowledgeDocument, +): Promise { + let attachments: KnowledgeAttachment[] = []; + try { + attachments = await getKnowledgeAttachments(document.id); + } catch (error) { + console.warn("[Notes] Failed to load knowledge attachments:", error); + return createKnowledgeValue(document); + } + if (attachments.length === 0) return createKnowledgeValue(document); + + const displaySrcByAttachmentId = new Map(); + for (const attachment of attachments) { + if (attachment.kind !== "image") continue; + const displaySrc = resolveKnowledgeAttachmentDisplaySrc(attachment); + if (displaySrc) displaySrcByAttachmentId.set(attachment.id, displaySrc); + } + + if (displaySrcByAttachmentId.size === 0) return createKnowledgeValue(document); + + const contentJson = createKnowledgeContentJson(document); + const resolvedContentJson = resolveKnowledgeAttachmentImageSources(contentJson, (attachmentId) => + displaySrcByAttachmentId.get(attachmentId), + ) as KnowledgeDocument["contentJson"]; + + return createKnowledgeValue(document, resolvedContentJson); +} + +async function collectBookKnowledgeExportInput( + bookId: string, + liveDocument: KnowledgeDocument, + book: Book, +) { + const documents = await getKnowledgeDocuments({ bookId, limit: 500 }); + const documentMap = new Map(documents.map((document) => [document.id, document])); + documentMap.set(liveDocument.id, liveDocument); + const homeDocumentId = documents.find((document) => document.type === "book_home")?.id; + const mergedDocuments = orderKnowledgeDocuments(Array.from(documentMap.values()), homeDocumentId); + + const [linksByDocument, attachmentsByDocument, cardTemplates] = await Promise.all([ + Promise.all(mergedDocuments.map((document) => getKnowledgeLinks(document.id))), + Promise.all(mergedDocuments.map((document) => getKnowledgeAttachments(document.id))), + getKnowledgeCardTemplates({ includeDisabled: true }), + ]); + + return { + documents: mergedDocuments, + books: [book], + links: linksByDocument.flat(), + attachments: attachmentsByDocument.flat(), + cardTemplates, + }; +} export function NotesView({ initialBookId, @@ -82,10 +384,62 @@ 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 [knowledgeDocuments, setKnowledgeDocuments] = useState([]); + const [selectedKnowledgeDocumentId, setSelectedKnowledgeDocumentId] = useState( + null, + ); + const [isKnowledgeVaultRootOpen, setIsKnowledgeVaultRootOpen] = useState(false); + const [knowledgeTitle, setKnowledgeTitle] = useState(""); + const [knowledgeTags, setKnowledgeTags] = useState([]); + const [knowledgeValue, setKnowledgeValue] = useState(() => + createEmptyKnowledgeValue(), + ); + const [knowledgeSourceReferenceRequest, setKnowledgeSourceReferenceRequest] = + useState(null); + const [savedKnowledgeFingerprint, setSavedKnowledgeFingerprint] = useState(() => + knowledgeDocumentFingerprint("", createEmptyKnowledgeValue()), + ); + const [knowledgeLinks, setKnowledgeLinks] = useState([]); + const [knowledgeBacklinks, setKnowledgeBacklinks] = useState([]); + const [isKnowledgeRelationsLoading, setIsKnowledgeRelationsLoading] = useState(false); + const [isKnowledgeLoading, setIsKnowledgeLoading] = useState(false); + const [isKnowledgeSaving, setIsKnowledgeSaving] = useState(false); + const [isKnowledgeSummaryCompressing, setIsKnowledgeSummaryCompressing] = useState(false); + const [isKnowledgeDocumentCreating, setIsKnowledgeDocumentCreating] = useState(false); + const [isKnowledgeFolderExporting, setIsKnowledgeFolderExporting] = useState(false); + const [isKnowledgeMarkdownImporting, setIsKnowledgeMarkdownImporting] = useState(false); + const [isKnowledgeMarkdownImportApplying, setIsKnowledgeMarkdownImportApplying] = useState(false); + const [knowledgeMarkdownImportReview, setKnowledgeMarkdownImportReview] = + useState(null); + const knowledgeSaveVersionRef = useRef(0); + const knowledgeSourceReferenceRequestIdRef = useRef(0); + const knowledgeSummaryMaintenanceTimersRef = useRef>>( + new Map(), + ); + const knowledgeSummaryMaintenanceFingerprintsRef = useRef>(new Map()); + const currentKnowledgeFingerprint = useMemo( + () => knowledgeDocumentFingerprint(knowledgeTitle, knowledgeValue, knowledgeTags), + [knowledgeTitle, knowledgeTags, knowledgeValue], + ); + const knowledgeDocumentIds = useMemo( + () => knowledgeDocuments.map((document) => document.id), + [knowledgeDocuments], + ); + + useEffect(() => { + const timers = knowledgeSummaryMaintenanceTimersRef.current; + return () => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, []); useFocusEffect( useCallback(() => { @@ -98,6 +452,7 @@ export function NotesView({ setSelectedBookId(null); setEditingId(null); setSearchQuery(""); + setIsKnowledgeVaultRootOpen(false); }; }, [loadAllHighlightsWithBooks, loadStats]), ); @@ -121,7 +476,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 +492,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 +528,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 +537,20 @@ 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 ?? ""; + const activeKnowledgeDocumentId = knowledgeHome?.id ?? null; + + useEffect(() => { + if (!selectedBookId) return; + if (bookNotebooks.some((book) => book.bookId === selectedBookId)) return; + + setSelectedBookId(null); + setDetailTab("knowledge"); + setSearchQuery(""); + setEditingId(null); + setIsKnowledgeVaultRootOpen(false); + }, [bookNotebooks, selectedBookId]); const { notesList, highlightsList } = useMemo(() => { if (!selectedBook) return { notesList: [], highlightsList: [] }; @@ -188,7 +571,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,99 +597,1420 @@ export function NotesView({ [nav, t], ); - const handleDeleteNote = useCallback( - (highlight: HighlightWithBook) => { - Alert.alert(t("common.confirm", "确认"), t("notes.deleteNoteConfirm", "确定删除此笔记?"), [ - { text: t("common.cancel", "取消"), style: "cancel" }, - { - text: t("common.delete", "删除"), - style: "destructive", - onPress: () => { - updateHighlight(highlight.id, { note: undefined }); - }, - }, + useEffect(() => { + let cancelled = false; + + async function loadKnowledgeHome() { + knowledgeSaveVersionRef.current += 1; + + if (!selectedKnowledgeBookId) { + const emptyValue = createEmptyKnowledgeValue(); + setKnowledgeHome(null); + setKnowledgeDocuments([]); + setSelectedKnowledgeDocumentId(null); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeTitle(""); + setKnowledgeTags([]); + setKnowledgeValue(emptyValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint("", emptyValue)); + setIsKnowledgeSaving(false); + setKnowledgeLinks([]); + setKnowledgeBacklinks([]); + return; + } + + setIsKnowledgeLoading(true); + setIsKnowledgeSaving(false); + try { + const homeDocument = await ensureBookHomeDocument( + selectedKnowledgeBookId, + selectedKnowledgeBookTitle, + ); + await Promise.all([ + ensureHighlightNoteKnowledgeDocuments(selectedKnowledgeBookId), + ensureNoteKnowledgeDocuments(selectedKnowledgeBookId), + ]); + const bookDocuments = await getKnowledgeDocuments({ + bookId: selectedKnowledgeBookId, + limit: 200, + }); + if (cancelled) return; + const nextDocuments = orderKnowledgeDocuments( + [homeDocument, ...bookDocuments], + homeDocument.id, + ); + const activeDocument = nextDocuments[0] ?? homeDocument; + const nextValue = await createResolvedKnowledgeValue(activeDocument); + setKnowledgeDocuments(nextDocuments); + setSelectedKnowledgeDocumentId(activeDocument.id); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeHome(activeDocument); + setKnowledgeTitle(activeDocument.title); + setKnowledgeTags(normalizeKnowledgeTags(activeDocument.tags)); + setKnowledgeValue(nextValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint(activeDocument.title, nextValue, activeDocument.tags), + ); + } 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(() => { + let cancelled = false; + + async function loadKnowledgeRelations() { + if (!activeKnowledgeDocumentId) { + setKnowledgeLinks([]); + setKnowledgeBacklinks([]); + setIsKnowledgeRelationsLoading(false); + return; + } + + setIsKnowledgeRelationsLoading(true); + try { + const [links, backlinks] = await Promise.all([ + getKnowledgeLinks(activeKnowledgeDocumentId), + getKnowledgeBacklinks(activeKnowledgeDocumentId), + ]); + if (cancelled) return; + setKnowledgeLinks(links); + setKnowledgeBacklinks(backlinks); + } catch (error) { + if (cancelled) return; + console.error("[Notes] Failed to load knowledge relations:", error); + setKnowledgeLinks([]); + setKnowledgeBacklinks([]); + } finally { + if (!cancelled) setIsKnowledgeRelationsLoading(false); + } + } + + void loadKnowledgeRelations(); + + return () => { + cancelled = true; + }; + }, [activeKnowledgeDocumentId]); + + useEffect(() => { + if (!knowledgeHome || currentKnowledgeFingerprint === savedKnowledgeFingerprint) return; + + const saveVersion = knowledgeSaveVersionRef.current + 1; + knowledgeSaveVersionRef.current = saveVersion; + const normalizedTitle = knowledgeTitle.trim() || knowledgeHome.title; + const normalizedTags = normalizeKnowledgeTags(knowledgeTags); + const valueToSave = knowledgeValue; + const nextExcerpt = createKnowledgeExcerpt(valueToSave.contentMd); + const contentJsonForStorage = canonicalizeKnowledgeAttachmentImageSources( + valueToSave.contentJson, + ) as KnowledgeDocument["contentJson"]; + const titleValidation = validateKnowledgeDocumentSiblingTitle({ + documentId: knowledgeHome.id, + bookId: knowledgeHome.bookId, + parentId: knowledgeHome.parentId, + title: normalizedTitle, + documents: knowledgeDocuments, + }); + if (!titleValidation.ok) { + setIsKnowledgeSaving(false); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentTitleDuplicate", "同一文件夹里已经有同名文档"), + ); + return; + } + + const timeout = setTimeout(async () => { + if (knowledgeSaveVersionRef.current !== saveVersion) return; + setIsKnowledgeSaving(true); + try { + await updateKnowledgeDocument(knowledgeHome.id, { + title: normalizedTitle, + contentMd: valueToSave.contentMd, + contentJson: contentJsonForStorage, + excerpt: nextExcerpt, + tags: normalizedTags, + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return; + const linkSync = await syncKnowledgeInternalDocumentLinks({ + documentId: knowledgeHome.id, + contentJson: contentJsonForStorage, + validDocumentIds: knowledgeDocumentIds, + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return; + if (linkSync.added > 0 || linkSync.deleted > 0) { + const [links, backlinks] = await Promise.all([ + getKnowledgeLinks(knowledgeHome.id), + getKnowledgeBacklinks(knowledgeHome.id), + ]); + if (knowledgeSaveVersionRef.current !== saveVersion) return; + setKnowledgeLinks(links); + setKnowledgeBacklinks(backlinks); + } + const updatedDocument: KnowledgeDocument = { + ...knowledgeHome, + title: normalizedTitle, + contentMd: valueToSave.contentMd, + contentJson: contentJsonForStorage, + excerpt: nextExcerpt, + tags: normalizedTags, + 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); + if (normalizedTags.join("\u0000") !== knowledgeTags.join("\u0000")) { + setKnowledgeTags(normalizedTags); + } + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint(normalizedTitle, valueToSave, normalizedTags), + ); + } 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, + knowledgeDocumentIds, + knowledgeDocuments, + knowledgeTitle, + knowledgeTags, + knowledgeValue, + currentKnowledgeFingerprint, + savedKnowledgeFingerprint, + t, + ]); + + const saveActiveKnowledgeDocumentNow = useCallback(async (): Promise => { + if (!knowledgeHome || currentKnowledgeFingerprint === savedKnowledgeFingerprint) return true; + + const saveVersion = knowledgeSaveVersionRef.current + 1; + knowledgeSaveVersionRef.current = saveVersion; + const normalizedTitle = knowledgeTitle.trim() || knowledgeHome.title; + const normalizedTags = normalizeKnowledgeTags(knowledgeTags); + const nextExcerpt = createKnowledgeExcerpt(knowledgeValue.contentMd); + const contentJsonForStorage = canonicalizeKnowledgeAttachmentImageSources( + knowledgeValue.contentJson, + ) as KnowledgeDocument["contentJson"]; + const titleValidation = validateKnowledgeDocumentSiblingTitle({ + documentId: knowledgeHome.id, + bookId: knowledgeHome.bookId, + parentId: knowledgeHome.parentId, + title: normalizedTitle, + documents: knowledgeDocuments, + }); + if (!titleValidation.ok) { + setIsKnowledgeSaving(false); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentTitleDuplicate", "同一文件夹里已经有同名文档"), + ); + return false; + } + + setIsKnowledgeSaving(true); + try { + await updateKnowledgeDocument(knowledgeHome.id, { + title: normalizedTitle, + contentMd: knowledgeValue.contentMd, + contentJson: contentJsonForStorage, + excerpt: nextExcerpt, + tags: normalizedTags, + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return false; + const linkSync = await syncKnowledgeInternalDocumentLinks({ + documentId: knowledgeHome.id, + contentJson: contentJsonForStorage, + validDocumentIds: knowledgeDocumentIds, + }); + if (knowledgeSaveVersionRef.current !== saveVersion) return false; + if (linkSync.added > 0 || linkSync.deleted > 0) { + const [links, backlinks] = await Promise.all([ + getKnowledgeLinks(knowledgeHome.id), + getKnowledgeBacklinks(knowledgeHome.id), + ]); + if (knowledgeSaveVersionRef.current !== saveVersion) return false; + setKnowledgeLinks(links); + setKnowledgeBacklinks(backlinks); + } + const updatedDocument: KnowledgeDocument = { + ...knowledgeHome, + title: normalizedTitle, + contentMd: knowledgeValue.contentMd, + contentJson: contentJsonForStorage, + excerpt: nextExcerpt, + tags: normalizedTags, + 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); + if (normalizedTags.join("\u0000") !== knowledgeTags.join("\u0000")) { + setKnowledgeTags(normalizedTags); + } + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint(normalizedTitle, knowledgeValue, normalizedTags), + ); + return true; + } catch (error) { + if (knowledgeSaveVersionRef.current === saveVersion) { + console.error("[Notes] Failed to save knowledge document:", error); + Alert.alert(t("common.error", "错误"), t("notes.knowledgeSaveFailed", "知识主页保存失败")); + } + return false; + } finally { + if (knowledgeSaveVersionRef.current === saveVersion) { + setIsKnowledgeSaving(false); + } + } + }, [ + knowledgeHome, + knowledgeDocumentIds, + knowledgeDocuments, + knowledgeTitle, + knowledgeTags, + knowledgeValue, + currentKnowledgeFingerprint, + savedKnowledgeFingerprint, + t, + ]); + + const openKnowledgeDocument = useCallback( + async (document: KnowledgeDocument): Promise => { + if (document.id === knowledgeHome?.id) { + setSelectedKnowledgeDocumentId(document.id); + setIsKnowledgeVaultRootOpen(false); + return true; + } + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return false; + + knowledgeSaveVersionRef.current += 1; + const nextValue = await createResolvedKnowledgeValue(document); + setSelectedKnowledgeDocumentId(document.id); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeHome(document); + setKnowledgeTitle(document.title); + setKnowledgeTags(normalizeKnowledgeTags(document.tags)); + setKnowledgeValue(nextValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint(document.title, nextValue, document.tags), + ); + setIsKnowledgeSaving(false); + return true; + }, + [knowledgeHome?.id, saveActiveKnowledgeDocumentNow], + ); + + const openKnowledgeVaultRoot = useCallback(async () => { + if (isKnowledgeVaultRootOpen) return true; + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return false; + setSelectedKnowledgeDocumentId(null); + setIsKnowledgeVaultRootOpen(true); + setKnowledgeSourceReferenceRequest(null); + return true; + }, [isKnowledgeVaultRootOpen, saveActiveKnowledgeDocumentNow]); + + const refreshSelectedKnowledgeDocuments = useCallback( + async ( + preferredDocumentId?: string | null, + options: { keepVaultRootOpen?: boolean } = {}, + ) => { + if (!selectedKnowledgeBookId) return; + + const homeDocument = await ensureBookHomeDocument( + selectedKnowledgeBookId, + selectedKnowledgeBookTitle, + ); + await Promise.all([ + ensureHighlightNoteKnowledgeDocuments(selectedKnowledgeBookId), + ensureNoteKnowledgeDocuments(selectedKnowledgeBookId), ]); + const bookDocuments = await getKnowledgeDocuments({ + bookId: selectedKnowledgeBookId, + limit: 200, + }); + const documentsById = new Map(); + for (const document of [homeDocument, ...bookDocuments]) { + documentsById.set(document.id, document); + } + const nextDocuments = orderKnowledgeDocuments( + Array.from(documentsById.values()), + homeDocument.id, + ); + const nextActiveDocument = + nextDocuments.find((document) => document.id === preferredDocumentId) ?? + nextDocuments.find((document) => document.id === knowledgeHome?.id) ?? + nextDocuments[0] ?? + null; + + knowledgeSaveVersionRef.current += 1; + setKnowledgeDocuments(nextDocuments); + + if (!nextActiveDocument) { + const emptyValue = createEmptyKnowledgeValue(); + setSelectedKnowledgeDocumentId(null); + setIsKnowledgeVaultRootOpen(Boolean(options.keepVaultRootOpen)); + setKnowledgeHome(null); + setKnowledgeTitle(""); + setKnowledgeTags([]); + setKnowledgeValue(emptyValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint("", emptyValue)); + setIsKnowledgeSaving(false); + return; + } + + const nextValue = await createResolvedKnowledgeValue(nextActiveDocument); + setSelectedKnowledgeDocumentId(options.keepVaultRootOpen ? null : nextActiveDocument.id); + setIsKnowledgeVaultRootOpen(Boolean(options.keepVaultRootOpen)); + setKnowledgeHome(nextActiveDocument); + setKnowledgeTitle(nextActiveDocument.title); + setKnowledgeTags(normalizeKnowledgeTags(nextActiveDocument.tags)); + setKnowledgeValue(nextValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint(nextActiveDocument.title, nextValue, nextActiveDocument.tags), + ); + setIsKnowledgeSaving(false); }, - [updateHighlight, t], + [knowledgeHome?.id, selectedKnowledgeBookId, selectedKnowledgeBookTitle], ); - const handleDeleteHighlight = useCallback( - (highlight: HighlightWithBook) => { + useEffect(() => { + return eventBus.on("knowledge:changed", (event) => { + if (!selectedKnowledgeBookId) return; + if (event.bookId && event.bookId !== selectedKnowledgeBookId) return; + + void (async () => { + try { + const preferredDocumentId = + event.action === "create" ? event.documentId : selectedKnowledgeDocumentId; + await refreshSelectedKnowledgeDocuments(preferredDocumentId); + if (event.action === "link" && event.documentId === activeKnowledgeDocumentId) { + const [links, backlinks] = await Promise.all([ + getKnowledgeLinks(activeKnowledgeDocumentId), + getKnowledgeBacklinks(activeKnowledgeDocumentId), + ]); + setKnowledgeLinks(links); + setKnowledgeBacklinks(backlinks); + } + } catch (error) { + console.error("[Notes] Failed to refresh knowledge after proposal apply:", error); + } + })(); + }); + }, [ + activeKnowledgeDocumentId, + refreshSelectedKnowledgeDocuments, + selectedKnowledgeBookId, + selectedKnowledgeDocumentId, + ]); + + useEffect(() => { + return eventBus.on("knowledge:open-document", (event) => { + if (!selectedKnowledgeBookId) { + event.respond?.(false); + return; + } + if (event.bookId && event.bookId !== selectedKnowledgeBookId) { + event.respond?.(false); + return; + } + const localDocument = knowledgeDocuments.find((document) => document.id === event.documentId); + if (!localDocument && event.bookId !== selectedKnowledgeBookId) { + event.respond?.(false); + return; + } + event.respond?.(true); + + void (async () => { + try { + const targetDocument = localDocument ?? (await getKnowledgeDocument(event.documentId)); + if (!targetDocument || targetDocument.bookId !== selectedKnowledgeBookId) { + return; + } + + setDetailTab("knowledge"); + if (localDocument) { + await openKnowledgeDocument(localDocument); + } else { + await refreshSelectedKnowledgeDocuments(event.documentId); + } + } catch (error) { + console.error("[Notes] Failed to open knowledge document from event:", error); + } + })(); + }); + }, [ + knowledgeDocuments, + openKnowledgeDocument, + refreshSelectedKnowledgeDocuments, + selectedKnowledgeBookId, + ]); + + useEffect(() => { + return eventBus.on("sync:completed", () => { + if (!selectedKnowledgeBookId) return; + + void (async () => { + try { + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + await refreshSelectedKnowledgeDocuments(selectedKnowledgeDocumentId, { + keepVaultRootOpen: isKnowledgeVaultRootOpen, + }); + } catch (error) { + console.error("[Notes] Failed to refresh knowledge after sync:", error); + } + })(); + }); + }, [ + isKnowledgeVaultRootOpen, + refreshSelectedKnowledgeDocuments, + saveActiveKnowledgeDocumentNow, + selectedKnowledgeBookId, + selectedKnowledgeDocumentId, + ]); + + const handleCreateKnowledgeDocument = useCallback( + async (type: CreatableKnowledgeDocumentType = "standalone_note", parentId?: string) => { + if (!selectedKnowledgeBookId || isKnowledgeDocumentCreating) return; + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + setIsKnowledgeDocumentCreating(true); + try { + const emptyValue = createEmptyKnowledgeValue(); + const title = createUniqueKnowledgeDocumentCreateTitle({ + type, + bookId: selectedKnowledgeBookId, + parentId, + documents: knowledgeDocuments, + t, + }); + const document = await createKnowledgeDocument({ + bookId: selectedKnowledgeBookId, + type, + title, + contentJson: emptyValue.contentJson, + contentMd: "", + excerpt: undefined, + tags: [], + sourceKind: "book", + sourceId: selectedKnowledgeBookId, + parentId, + }); + const nextValue = createKnowledgeValue(document); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + [document, ...documents], + documents.find((item) => item.type === "book_home")?.id, + ), + ); + setSelectedKnowledgeDocumentId(document.id); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeHome(document); + setKnowledgeTitle(document.title); + setKnowledgeTags([]); + setKnowledgeValue(nextValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint(document.title, nextValue, [])); + } catch (error) { + console.error("[Notes] Failed to create knowledge document:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentCreateFailed", "知识文档创建失败"), + ); + } finally { + setIsKnowledgeDocumentCreating(false); + } + }, + [ + selectedKnowledgeBookId, + isKnowledgeDocumentCreating, + saveActiveKnowledgeDocumentNow, + t, + knowledgeDocuments, + ], + ); + + const handleDeleteKnowledgeDocument = useCallback( + (document: KnowledgeDocument) => { + if (!canDeleteKnowledgeDocument(document)) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentDeleteBlocked", "这个文档暂不支持直接删除"), + ); + return; + } + if ( + document.type === "folder" && + knowledgeDocuments.some((item) => item.parentId === document.id) + ) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeFolderDeleteBlocked", "请先移动或删除这个文件夹里的文档"), + ); + return; + } + Alert.alert( - t("common.confirm", "确认"), - t("notes.deleteHighlightConfirm", "确定删除此高亮?"), + t("notes.knowledgeDeleteDocument", "删除文档"), + t("notes.knowledgeDocumentDeleteConfirm", { title: document.title }), [ { text: t("common.cancel", "取消"), style: "cancel" }, { text: t("common.delete", "删除"), style: "destructive", onPress: () => { - removeHighlight(highlight.id); + void (async () => { + const isDeletingActiveDocument = document.id === knowledgeHome?.id; + if (isDeletingActiveDocument) { + knowledgeSaveVersionRef.current += 1; + } + + try { + await deleteKnowledgeDocument(document.id); + const remainingDocuments = orderKnowledgeDocuments( + knowledgeDocuments.filter((item) => item.id !== document.id), + knowledgeDocuments.find((item) => item.type === "book_home")?.id, + ); + setKnowledgeDocuments(remainingDocuments); + + if (isDeletingActiveDocument) { + const nextDocument = + remainingDocuments.find((item) => item.type === "book_home") ?? + remainingDocuments[0] ?? + null; + if (nextDocument) { + const nextValue = await createResolvedKnowledgeValue(nextDocument); + setSelectedKnowledgeDocumentId(nextDocument.id); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeHome(nextDocument); + setKnowledgeTitle(nextDocument.title); + setKnowledgeTags(normalizeKnowledgeTags(nextDocument.tags)); + setKnowledgeValue(nextValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint( + nextDocument.title, + nextValue, + nextDocument.tags, + ), + ); + } else { + const emptyValue = createEmptyKnowledgeValue(); + setSelectedKnowledgeDocumentId(null); + setIsKnowledgeVaultRootOpen(false); + setKnowledgeHome(null); + setKnowledgeTitle(""); + setKnowledgeTags([]); + setKnowledgeValue(emptyValue); + setKnowledgeSourceReferenceRequest(null); + setSavedKnowledgeFingerprint(knowledgeDocumentFingerprint("", emptyValue)); + } + setIsKnowledgeSaving(false); + } else if (selectedKnowledgeDocumentId === document.id) { + setSelectedKnowledgeDocumentId(knowledgeHome?.id ?? null); + } + } catch (error) { + console.error("[Notes] Failed to delete knowledge document:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentDeleteFailed", "知识文档删除失败"), + ); + } + })(); }, }, ], ); }, - [removeHighlight, t], + [knowledgeDocuments, knowledgeHome?.id, selectedKnowledgeDocumentId, t], ); - const startEditNote = useCallback((highlight: HighlightWithBook) => { - setEditingId(highlight.id); - setEditNote(highlight.note || ""); - }, []); + const handleMoveKnowledgeDocument = useCallback( + async (document: KnowledgeDocument, parentId?: string | null) => { + const validation = validateKnowledgeDocumentParent(document.id, parentId, knowledgeDocuments); + if (!validation.ok) { + if (validation.reason !== "same_parent") { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentMoveInvalid", "不能移动到这个位置"), + ); + } + return; + } + const nextParentId = parentId || undefined; + const nextTitle = + knowledgeHome?.id === document.id + ? knowledgeTitle.trim() || document.title + : document.title; + const titleValidation = validateKnowledgeDocumentSiblingTitle({ + documentId: document.id, + bookId: document.bookId, + parentId: nextParentId, + title: nextTitle, + documents: knowledgeDocuments, + }); + if (!titleValidation.ok) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentTitleDuplicate", "同一文件夹里已经有同名文档"), + ); + return; + } - const saveNote = useCallback( - (id: string) => { - updateHighlight(id, { note: editNote || undefined }); - setEditingId(null); - setEditNote(""); + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + try { + await updateKnowledgeDocument(document.id, { parentId: nextParentId }); + const isMovingActiveDocument = knowledgeHome?.id === document.id; + const updatedAt = Date.now(); + const updatedDocument: KnowledgeDocument = isMovingActiveDocument + ? { + ...knowledgeHome, + title: nextTitle, + contentMd: knowledgeValue.contentMd, + contentJson: canonicalizeKnowledgeAttachmentImageSources( + knowledgeValue.contentJson, + ) as KnowledgeDocument["contentJson"], + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + tags: normalizeKnowledgeTags(knowledgeTags), + parentId: nextParentId, + updatedAt, + } + : { + ...document, + parentId: nextParentId, + updatedAt, + }; + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((item) => (item.id === document.id ? updatedDocument : item)), + documents.find((item) => item.type === "book_home")?.id, + ), + ); + if (isMovingActiveDocument) { + setKnowledgeHome(updatedDocument); + } + } catch (error) { + console.error("[Notes] Failed to move knowledge document:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentMoveFailed", "知识文档移动失败"), + ); + } }, - [updateHighlight, editNote], + [ + knowledgeDocuments, + knowledgeHome, + knowledgeTags, + knowledgeTitle, + knowledgeValue.contentJson, + knowledgeValue.contentMd, + saveActiveKnowledgeDocumentNow, + t, + ], ); - const cancelEdit = useCallback(() => { - setEditingId(null); - setEditNote(""); - }, []); + const handleRenameKnowledgeDocument = useCallback( + async (document: KnowledgeDocument, title: string) => { + const normalizedTitle = title.trim(); + if (!normalizedTitle || normalizedTitle === document.title) return; + const titleValidation = validateKnowledgeDocumentSiblingTitle({ + documentId: document.id, + bookId: document.bookId, + parentId: document.parentId, + title: normalizedTitle, + documents: knowledgeDocuments, + }); + if (!titleValidation.ok) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentTitleDuplicate", "同一文件夹里已经有同名文档"), + ); + return; + } - const handleExport = useCallback( - async (format: ExportFormat) => { - setShowExportMenu(false); - if (!selectedBook) return; + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; - const book = books.find((b) => b.id === selectedBook.bookId); - if (!book) return; + try { + await updateKnowledgeDocument(document.id, { title: normalizedTitle }); + const updatedAt = Date.now(); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((item) => + item.id === document.id ? { ...item, title: normalizedTitle, updatedAt } : item, + ), + documents.find((item) => item.type === "book_home")?.id, + ), + ); - const exporter = new AnnotationExporter(); - const content = exporter.export(selectedBook.highlights as Highlight[], [], book, { format }); + if (knowledgeHome?.id === document.id) { + setKnowledgeHome((current) => + current ? { ...current, title: normalizedTitle, updatedAt } : current, + ); + setKnowledgeTitle(normalizedTitle); + setSavedKnowledgeFingerprint( + knowledgeDocumentFingerprint( + normalizedTitle, + knowledgeValue, + normalizeKnowledgeTags(knowledgeTags), + ), + ); + } + } catch (error) { + console.error("[Notes] Failed to rename knowledge document:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeDocumentRenameFailed", "知识文档重命名失败"), + ); + } + }, + [ + knowledgeDocuments, + knowledgeHome?.id, + knowledgeTags, + knowledgeValue, + saveActiveKnowledgeDocumentNow, + t, + ], + ); + const handlePickKnowledgeImageAttachment = useCallback( + async (document: KnowledgeDocument): Promise => { try { - if (format === "notion") { - await exporter.copyToClipboard(content); - Alert.alert(t("common.success", "成功"), t("notes.copiedToClipboard", "已复制到剪贴板")); - } else { - const ext = format === "json" ? "json" : "md"; - await exporter.downloadAsFile(content, `${selectedBook.title}-${format}.${ext}`, format); - } - } catch (err) { - console.error("Export failed:", err); - Alert.alert(t("common.error", "错误"), t("notes.exportFailed", "导出失败")); + const result = await pickAndPersistMobileKnowledgeImageAttachment(document.id); + return result?.attrs ?? null; + } catch (error) { + console.error("[Notes] Failed to add knowledge image attachment:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeAttachmentAddFailed", "图片附件添加失败"), + ); + return null; } }, - [selectedBook, books, t], + [t], ); - const totalHighlights = stats?.totalHighlights ?? 0; - const totalNotes = stats?.highlightsWithNotes ?? 0; - const totalBooks = stats?.totalBooks ?? 0; + const handleInsertKnowledgeSourceReference = useCallback( + async (highlight: HighlightWithBook) => { + if (!knowledgeHome || isKnowledgeVaultRootOpen || knowledgeHome.type === "folder") { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeSourceReferenceUnavailable", "请先打开一个知识文档"), + ); + return; + } - // Loading - if (isLoading) { - return ( - - - - {t("common.loading", "加载中...")} + const label = highlight.chapterTitle?.trim() || t("notes.knowledgeSourceHighlight", "高亮"); + try { + await ensureKnowledgeSourceLink({ + documentId: knowledgeHome.id, + toKind: "highlight", + toId: highlight.id, + label, + cfi: highlight.cfi, + }); + setKnowledgeLinks(await getKnowledgeLinks(knowledgeHome.id)); + knowledgeSourceReferenceRequestIdRef.current += 1; + setKnowledgeSourceReferenceRequest({ + requestId: knowledgeSourceReferenceRequestIdRef.current, + label, + sourceTitle: label, + cfi: highlight.cfi, + }); + } catch (error) { + console.error("[Notes] Failed to insert knowledge source reference:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeSourceReferenceInsertFailed", "来源引用插入失败"), + ); + } + }, + [isKnowledgeVaultRootOpen, knowledgeHome, t], + ); + + const applyBackgroundKnowledgeSummaryUpdate = useCallback((document: KnowledgeDocument) => { + const summaryPatch = { + summaryMd: document.summaryMd, + summarySourceFingerprint: document.summarySourceFingerprint, + summarySourceUpdatedAt: document.summarySourceUpdatedAt, + summaryUpdatedAt: document.summaryUpdatedAt, + updatedAt: document.updatedAt, + }; + + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((item) => + item.id === document.id ? { ...item, ...summaryPatch } : item, + ), + documents.find((item) => item.type === "book_home")?.id, + ), + ); + setKnowledgeHome((current) => + current?.id === document.id ? { ...current, ...summaryPatch } : current, + ); + }, []); + + const queueKnowledgeSummaryMaintenance = useCallback(( + documentIds: string[], + options: { + delayMs?: number; + sourceFingerprints?: Map; + } = {}, + ) => { + const uniqueDocumentIds = [...new Set(documentIds.filter(Boolean))]; + if (uniqueDocumentIds.length === 0) return; + + const delayMs = Math.max(0, options.delayMs ?? 0); + for (const documentId of uniqueDocumentIds) { + const nextFingerprint = options.sourceFingerprints?.get(documentId); + if ( + nextFingerprint && + knowledgeSummaryMaintenanceFingerprintsRef.current.get(documentId) === nextFingerprint + ) { + continue; + } + if (nextFingerprint) { + knowledgeSummaryMaintenanceFingerprintsRef.current.set(documentId, nextFingerprint); + } + + const existingTimer = knowledgeSummaryMaintenanceTimersRef.current.get(documentId); + if (existingTimer) clearTimeout(existingTimer); + + const timer = setTimeout(() => { + knowledgeSummaryMaintenanceTimersRef.current.delete(documentId); + void (async () => { + const resolvedAIConfig = await resolveActiveAIConfig(useSettingsStore.getState()); + if (!resolvedAIConfig) { + knowledgeSummaryMaintenanceFingerprintsRef.current.delete(documentId); + return; + } + const results = await maybeCompressKnowledgeDocumentsById([documentId], resolvedAIConfig); + const result = results[0]; + if (result?.status === "failed" || result?.status === "missing") { + knowledgeSummaryMaintenanceFingerprintsRef.current.delete(documentId); + return; + } + if (result?.persisted) { + const refreshedDocument = await getKnowledgeDocument(documentId); + if (refreshedDocument) applyBackgroundKnowledgeSummaryUpdate(refreshedDocument); + } + })().catch((error) => { + knowledgeSummaryMaintenanceFingerprintsRef.current.delete(documentId); + console.warn("[Notes] Background knowledge summary maintenance failed:", error); + }); + }, delayMs); + knowledgeSummaryMaintenanceTimersRef.current.set(documentId, timer); + } + }, [applyBackgroundKnowledgeSummaryUpdate]); + + useEffect(() => { + if (!knowledgeHome || knowledgeHome.type === "folder") return; + if (currentKnowledgeFingerprint !== savedKnowledgeFingerprint) return; + + const sourceFingerprint = createKnowledgeSummarySourceFingerprint(knowledgeHome); + if (knowledgeHome.summarySourceFingerprint === sourceFingerprint) return; + + queueKnowledgeSummaryMaintenance([knowledgeHome.id], { + delayMs: KNOWLEDGE_SUMMARY_AUTOSAVE_MAINTENANCE_DELAY_MS, + sourceFingerprints: new Map([[knowledgeHome.id, sourceFingerprint]]), + }); + }, [ + currentKnowledgeFingerprint, + knowledgeHome, + queueKnowledgeSummaryMaintenance, + savedKnowledgeFingerprint, + ]); + + const handleCompressKnowledgeSummary = useCallback(async () => { + if (!knowledgeHome || isKnowledgeSummaryCompressing) return; + + const resolvedAIConfig = await resolveActiveAIConfig(useSettingsStore.getState()); + if (!resolvedAIConfig) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeSummaryAIConfigMissing", "请先配置可用的 AI 模型,再压缩知识记忆"), + ); + return; + } + + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + const normalizedTags = normalizeKnowledgeTags(knowledgeTags); + const liveDocument: KnowledgeDocument = { + ...knowledgeHome, + title: knowledgeTitle.trim() || knowledgeHome.title, + contentJson: knowledgeValue.contentJson, + contentMd: knowledgeValue.contentMd, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + tags: normalizedTags, + updatedAt: Date.now(), + }; + + setIsKnowledgeSummaryCompressing(true); + try { + const result = await maybeCompressAndPersistKnowledgeSummary(liveDocument, resolvedAIConfig); + + if (result.status === "failed") { + Alert.alert( + t("common.error", "错误"), + [t("notes.knowledgeSummaryFailed", "压缩记忆更新失败"), result.error] + .filter(Boolean) + .join("\n"), + ); + return; + } + + if (result.status === "skipped") { + const message = + result.plan.reason === "empty" + ? t("notes.knowledgeSummaryEmpty", "这个文档还没有可摘要的内容") + : result.plan.reason === "below_threshold" + ? t("notes.knowledgeSummaryTooShort", "这个文档还比较短,暂不需要压缩记忆") + : t("notes.knowledgeSummaryUpToDate", "压缩记忆已是最新"); + Alert.alert(t("notes.knowledgeSummaryMemory", "AI 记忆"), message); + return; + } + + const refreshedDocument = await getKnowledgeDocument(liveDocument.id); + const updatedDocument: KnowledgeDocument = + refreshedDocument ?? + ({ + ...liveDocument, + summaryMd: result.state?.summaryMd, + summarySourceFingerprint: result.state?.sourceFingerprint, + summarySourceUpdatedAt: result.state?.sourceUpdatedAt, + summaryUpdatedAt: result.state?.compressedAt, + updatedAt: Date.now(), + } satisfies KnowledgeDocument); + + setKnowledgeHome(updatedDocument); + setKnowledgeDocuments((documents) => + orderKnowledgeDocuments( + documents.map((document) => + document.id === updatedDocument.id ? updatedDocument : document, + ), + documents.find((document) => document.type === "book_home")?.id, + ), + ); + Alert.alert( + t("common.success", "成功"), + t("notes.knowledgeSummaryCompressed", "压缩记忆已更新"), + ); + } catch (error) { + console.error("[Notes] Failed to compress knowledge summary:", error); + Alert.alert(t("common.error", "错误"), t("notes.knowledgeSummaryFailed", "压缩记忆更新失败")); + } finally { + setIsKnowledgeSummaryCompressing(false); + } + }, [ + isKnowledgeSummaryCompressing, + knowledgeHome, + knowledgeTags, + knowledgeTitle, + knowledgeValue, + saveActiveKnowledgeDocumentNow, + t, + ]); + + const handleDeleteNote = useCallback( + (highlight: HighlightWithBook) => { + Alert.alert(t("common.confirm", "确认"), t("notes.deleteNoteConfirm", "确定删除此笔记?"), [ + { text: t("common.cancel", "取消"), style: "cancel" }, + { + text: t("common.delete", "删除"), + style: "destructive", + onPress: () => { + updateHighlight(highlight.id, { note: undefined }); + }, + }, + ]); + }, + [updateHighlight, t], + ); + + const handleDeleteHighlight = useCallback( + (highlight: HighlightWithBook) => { + Alert.alert( + t("common.confirm", "确认"), + t("notes.deleteHighlightConfirm", "确定删除此高亮?"), + [ + { text: t("common.cancel", "取消"), style: "cancel" }, + { + text: t("common.delete", "删除"), + style: "destructive", + onPress: () => { + removeHighlight(highlight.id); + }, + }, + ], + ); + }, + [removeHighlight, t], + ); + + const startEditNote = useCallback((highlight: HighlightWithBook) => { + setEditingId(highlight.id); + setEditNote(highlight.note || ""); + }, []); + + const saveNote = useCallback( + (id: string) => { + updateHighlight(id, { note: editNote || undefined }); + setEditingId(null); + setEditNote(""); + }, + [updateHighlight, editNote], + ); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditNote(""); + }, []); + + const handleExport = useCallback( + async (format: ExportFormat) => { + setShowExportMenu(false); + if (!selectedBook) return; + + const book = books.find((b) => b.id === selectedBook.bookId); + if (!book) return; + + const exporter = new AnnotationExporter(); + const content = exporter.export(selectedBook.highlights as Highlight[], [], book, { format }); + + try { + if (format === "notion") { + await exporter.copyToClipboard(content); + Alert.alert(t("common.success", "成功"), t("notes.copiedToClipboard", "已复制到剪贴板")); + } else { + const ext = format === "json" ? "json" : "md"; + await exporter.downloadAsFile(content, `${selectedBook.title}-${format}.${ext}`, format); + } + } catch (err) { + console.error("Export failed:", err); + Alert.alert(t("common.error", "错误"), t("notes.exportFailed", "导出失败")); + } + }, + [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, + title: knowledgeTitle.trim() || knowledgeHome.title, + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + tags: normalizeKnowledgeTags(knowledgeTags), + updatedAt: Date.now(), + }; + try { + const input = await collectBookKnowledgeExportInput( + selectedBook.bookId, + liveDocument, + book, + ); + const file = knowledgeExporter.exportBundle(input, { + format, + rootDir: "ReadAny", + title: `${selectedBook.title} Knowledge`, + }); + 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, knowledgeTitle, knowledgeTags, knowledgeValue, books, t], + ); + + const handleKnowledgeFolderExport = useCallback( + async (folder: KnowledgeDocument) => { + if ( + !selectedBook || + !knowledgeHome || + folder.type !== "folder" || + isKnowledgeFolderExporting + ) + return; + + const book = books.find((b) => b.id === selectedBook.bookId); + if (!book) return; + + setIsKnowledgeFolderExporting(true); + try { + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + const exporter = new AnnotationExporter(); + const liveDocument: KnowledgeDocument = { + ...knowledgeHome, + title: knowledgeTitle.trim() || knowledgeHome.title, + contentMd: knowledgeValue.contentMd, + contentJson: knowledgeValue.contentJson, + excerpt: createKnowledgeExcerpt(knowledgeValue.contentMd), + tags: normalizeKnowledgeTags(knowledgeTags), + updatedAt: Date.now(), + }; + const input = await collectBookKnowledgeExportInput( + selectedBook.bookId, + liveDocument, + book, + ); + const scopedInput = scopeKnowledgeExportInputToDocumentSubtree(input, folder); + + if (scopedInput.documents.length === 0) { + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeVaultScopedExportEmpty", "这个文件夹下没有可导出的知识文档"), + ); + return; + } + + const title = `${ + folder.title || t("notes.knowledgeUntitledDocument", "未命名文档") + } Knowledge`; + const file = knowledgeExporter.exportBundle(scopedInput, { + format: "obsidian", + rootDir: "ReadAny", + title, + }); + const filename = file.path.split("/").filter(Boolean).pop() || `${title}.md`; + await exporter.downloadAsFile(file.content, filename, "obsidian"); + } catch (err) { + console.error("[Notes] Knowledge folder export failed:", err); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeVaultExportFailed", "知识库文件夹导出失败"), + ); + } finally { + setIsKnowledgeFolderExporting(false); + } + }, + [ + books, + isKnowledgeFolderExporting, + knowledgeHome, + knowledgeTags, + knowledgeTitle, + knowledgeValue.contentJson, + knowledgeValue.contentMd, + saveActiveKnowledgeDocumentNow, + selectedBook, + t, + ], + ); + + const handleKnowledgeMarkdownImport = useCallback(async () => { + setShowExportMenu(false); + if ( + !selectedKnowledgeBookId || + isKnowledgeMarkdownImporting || + isKnowledgeMarkdownImportApplying + ) { + return; + } + + setIsKnowledgeMarkdownImporting(true); + setKnowledgeMarkdownImportReview(null); + + try { + const platform = getPlatformService(); + const pickerOptions = { + multiple: true, + filters: [ + { + name: "Markdown", + extensions: ["md", "markdown"], + }, + ], + }; + const pickedFiles = + (await platform.pickFiles?.(pickerOptions)) ?? + normalizePickedFiles(await platform.pickFile(pickerOptions)); + if (pickedFiles.length === 0) return; + + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + const defaultParentId = getKnowledgeDocumentCreateParentId({ + document: knowledgeHome, + isVaultRootOpen: isKnowledgeVaultRootOpen, + }); + const [files, cardTemplates] = await Promise.all([ + Promise.all( + pickedFiles.map(async (file) => ({ + path: file.path, + content: await platform.readTextFile(file.path), + })), + ), + getKnowledgeCardTemplates({ includeDisabled: true }), + ]); + const pickedFileByPath = new Map(pickedFiles.map((file) => [file.path, file])); + const plan = createKnowledgeMarkdownImportPlan({ + bookId: selectedKnowledgeBookId, + defaultParentId, + currentDocuments: knowledgeDocuments, + files, + cardTemplates, + }); + const items: KnowledgeMarkdownImportReviewItem[] = plan.items.map((item) => ({ + path: item.path, + sourcePath: item.path, + sourceName: pickedFileByPath.get(item.path)?.name, + proposal: { + ...item.proposal, + message: t("notes.knowledgeMarkdownImportProposalMessage", { + file: + pickedFileByPath.get(item.path)?.name ?? + mobileFileName(item.relativePath || item.path), + }), + }, + warnings: item.warnings, + })); + if (items.length === 0) return; + + setKnowledgeMarkdownImportReview({ items }); + } catch (error) { + console.error("[Notes] Knowledge Markdown import failed:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeMarkdownImportFailed", "Markdown 文件导入失败"), + ); + } finally { + setIsKnowledgeMarkdownImporting(false); + } + }, [ + isKnowledgeMarkdownImportApplying, + isKnowledgeMarkdownImporting, + isKnowledgeVaultRootOpen, + knowledgeDocuments, + knowledgeHome?.id, + knowledgeHome?.parentId, + knowledgeHome?.type, + saveActiveKnowledgeDocumentNow, + selectedKnowledgeBookId, + t, + ]); + + const handleApplyKnowledgeMarkdownImport = useCallback(async () => { + if (!knowledgeMarkdownImportReview || isKnowledgeMarkdownImportApplying) return; + + const saved = await saveActiveKnowledgeDocumentNow(); + if (!saved) return; + + setIsKnowledgeMarkdownImportApplying(true); + try { + const importedDocumentIds: string[] = []; + const preferredDocumentIds: string[] = []; + const summaryDocumentIds: string[] = []; + for (const item of knowledgeMarkdownImportReview.items) { + const result = await applyKnowledgeWriteProposal(item.proposal); + if (result.documentId) importedDocumentIds.push(result.documentId); + if (result.documentId) { + summaryDocumentIds.push(result.documentId); + } + if (item.proposal.action === "create" && item.proposal.draft.type !== "folder") { + if (result.documentId) preferredDocumentIds.push(result.documentId); + } + } + await refreshSelectedKnowledgeDocuments( + preferredDocumentIds[0] ?? importedDocumentIds[0] ?? knowledgeHome?.id, + ); + queueKnowledgeSummaryMaintenance(summaryDocumentIds); + setKnowledgeMarkdownImportReview(null); + Alert.alert( + t("common.success", "成功"), + t("notes.knowledgeMarkdownImportAppliedDetail", { + count: knowledgeMarkdownImportReview.items.length, + }), + ); + } catch (error) { + console.error("[Notes] Failed to apply knowledge Markdown import:", error); + Alert.alert( + t("common.error", "错误"), + t("notes.knowledgeMarkdownImportApplyFailed", "应用 Markdown 导入失败"), + ); + } finally { + setIsKnowledgeMarkdownImportApplying(false); + } + }, [ + isKnowledgeMarkdownImportApplying, + knowledgeHome?.id, + knowledgeMarkdownImportReview, + queueKnowledgeSummaryMaintenance, + refreshSelectedKnowledgeDocuments, + saveActiveKnowledgeDocumentNow, + t, + ]); + + const totalHighlights = stats?.totalHighlights ?? 0; + const totalNotes = stats?.highlightsWithNotes ?? 0; + const totalBooks = stats?.totalBooks ?? 0; + + // Loading + if (isLoading) { + return ( + + + + {t("common.loading", "加载中...")} ); @@ -383,6 +2088,20 @@ export function NotesView({ {/* Tabs + search */} + setDetailTab("knowledge")} + > + + + {t("notes.knowledgeTab", "知识主页")} + + setDetailTab("notes")} @@ -414,22 +2133,72 @@ export function NotesView({ - - - - + {detailTab === "knowledge" ? ( + + + {knowledgeDocuments.length} {t("notes.knowledgeDocuments", "文档")} + + + {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 @@ -489,21 +2258,66 @@ 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")} + + + ))} + + + + {isKnowledgeMarkdownImporting + ? t("notes.knowledgeMarkdownImporting", "读取中...") + : t("notes.knowledgeImportMarkdown", "导入 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")} + + + )) + )} + + setKnowledgeMarkdownImportReview(null)} + t={t} + styles={s} + colors={colors} + /> ); } @@ -587,7 +2401,7 @@ export function NotesView({ setSelectedBookId(item.bookId); setSearchQuery(""); setEditingId(null); - setDetailTab("notes"); + setDetailTab("knowledge"); }} /> )} @@ -596,4 +2410,2647 @@ export function NotesView({ ); } +function KnowledgeHomePanel({ + book, + document, + documents, + isVaultRootOpen, + activeDocumentId, + title, + tags, + value, + sourceReferenceRequest, + links, + backlinks, + isRelationsLoading, + isLoading, + isCreatingDocument, + isSaved, + isSaving, + isSummaryCompressing, + isFolderExporting, + onTitleChange, + onTagsChange, + onChange, + onOpenVaultRoot, + onSelectDocument, + onCreateDocument, + onDeleteDocument, + onMoveDocument, + onRenameDocument, + onExportFolder, + onCompressSummary, + onInsertSourceReference, + onPickImageAttachment, + onOpenBook, + t, + styles, + colors, +}: { + book: { + bookId: string; + title: string; + author: string; + highlights: HighlightWithBook[]; + notesCount: number; + highlightsOnlyCount: number; + }; + document: KnowledgeDocument | null; + documents: KnowledgeDocument[]; + isVaultRootOpen: boolean; + activeDocumentId: string | null; + title: string; + tags: string[]; + value: MobileKnowledgeEditorValue; + sourceReferenceRequest: MobileKnowledgeSourceReferenceRequest | null; + links: KnowledgeLink[]; + backlinks: KnowledgeBacklink[]; + isRelationsLoading: boolean; + isLoading: boolean; + isCreatingDocument: boolean; + isSaved: boolean; + isSaving: boolean; + isSummaryCompressing: boolean; + isFolderExporting: boolean; + onTitleChange: (title: string) => void; + onTagsChange: (tags: string[]) => void; + onChange: (value: MobileKnowledgeEditorValue) => void; + onOpenVaultRoot: () => boolean | Promise; + onSelectDocument: (document: KnowledgeDocument) => boolean | Promise; + onCreateDocument: (type?: CreatableKnowledgeDocumentType, parentId?: string) => void; + onDeleteDocument: (document: KnowledgeDocument) => void; + onMoveDocument: (document: KnowledgeDocument, parentId?: string | null) => void; + onRenameDocument: (document: KnowledgeDocument, title: string) => void; + onExportFolder: (document: KnowledgeDocument) => void; + onCompressSummary: () => void; + onInsertSourceReference: (highlight: HighlightWithBook) => void; + onPickImageAttachment: ( + document: KnowledgeDocument, + ) => Promise; + onOpenBook: (cfi?: string) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const recentHighlights = useMemo( + () => sortAnnotationsByPosition(book.highlights).slice(0, 3), + [book.highlights], + ); + const activeKnowledgeOpenMode = getKnowledgeDocumentOpenMode({ document, isVaultRootOpen }); + const isFolderDocument = activeKnowledgeOpenMode === "folder_browser"; + const rootDocuments = useMemo(() => { + const homeDocumentId = documents.find((item) => item.type === "book_home")?.id; + const sections = createKnowledgeRootDisplaySections(documents, homeDocumentId); + return [...sections.home, ...sections.folders, ...sections.documents, ...sections.orphaned]; + }, [documents]); + const folderChildren = useMemo(() => { + if (!document || isVaultRootOpen || document.type !== "folder") return []; + return orderKnowledgeDocuments( + documents.filter((item) => item.parentId === document.id), + undefined, + ); + }, [document, documents, isVaultRootOpen]); + const activePathItems = useMemo( + () => + isVaultRootOpen + ? [{ id: "__vault__", title: t("notes.knowledgeVaultRoot", "知识库") }] + : document + ? knowledgeDocumentPathItems(document, documents, t, title) + : [{ id: "__vault__", title: t("notes.knowledgeVaultRoot", "知识库") }], + [document, documents, isVaultRootOpen, t, title], + ); + const activePathLastId = activePathItems.at(-1)?.id; + const documentOutline = useMemo( + () => + document && !isVaultRootOpen && !isFolderDocument + ? extractKnowledgeDocumentOutline(value.contentJson, value.contentMd) + : [], + [document, isFolderDocument, isVaultRootOpen, value.contentJson, value.contentMd], + ); + const internalLinkTargets = useMemo( + () => + documents + .filter((item) => item.id !== document?.id) + .map((item) => { + const pathItems = knowledgeDocumentPathItems(item, documents, t).slice(1); + const path = pathItems + .slice(0, -1) + .map((part) => part.title) + .join(" / "); + const targetPath = pathItems.map((part) => part.title).join("/"); + return { + id: item.id, + title: item.title.trim() || t("notes.knowledgeUntitledDocument", "未命名文档"), + path, + targetPath, + typeLabel: knowledgeDocumentTypeLabel(item, t), + }; + }), + [document?.id, documents, t], + ); + const [outlineTarget, setOutlineTarget] = useState( + null, + ); + const keyboardInsets = useKeyboardInsets(); + const documentKeyboardBottomPadding = keyboardInsets.isVisible + ? Math.max(18, keyboardInsets.safeAreaBottom + 18) + : Math.max(12, keyboardInsets.safeAreaBottom + 12); + const [workspaceMode, setWorkspaceMode] = useState("vault"); + const [isContextSheetVisible, setIsContextSheetVisible] = useState(false); + const [actionDocument, setActionDocument] = useState(null); + const [moveDocument, setMoveDocument] = useState(null); + const [renameDocument, setRenameDocument] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const childCountByParentId = useMemo(() => { + const counts = new Map(); + for (const item of documents) { + if (!item.parentId) continue; + counts.set(item.parentId, (counts.get(item.parentId) ?? 0) + 1); + } + return counts; + }, [documents]); + const getMoveTargets = useCallback( + (targetDocument: KnowledgeDocument) => { + return createKnowledgeDocumentMoveTargets(targetDocument, documents, { + rootTitle: t("notes.knowledgeVaultRoot", "知识库"), + rootTargetTitle: t("notes.knowledgeMoveRoot", "根目录"), + untitledTitle: t("notes.knowledgeUntitledDocument", "未命名文档"), + orphanedParentTitle: t("notes.knowledgeOrphanedDocument", "孤立"), + }); + }, + [documents, t], + ); + const currentDocumentMoveTargets = useMemo( + () => (document ? getMoveTargets(document) : []), + [document, getMoveTargets], + ); + const moveTargets = useMemo( + () => (moveDocument ? getMoveTargets(moveDocument) : []), + [getMoveTargets, moveDocument], + ); + const actionDocumentChildCount = actionDocument + ? (childCountByParentId.get(actionDocument.id) ?? 0) + : 0; + const canDeleteActionDocument = + !!actionDocument && + canDeleteKnowledgeDocument(actionDocument) && + !(actionDocument.type === "folder" && actionDocumentChildCount > 0); + const canMoveActionDocument = !!actionDocument && getMoveTargets(actionDocument).length > 0; + const saveStatusLabel = isSaving + ? t("notes.knowledgeSaving", "保存中") + : isSaved + ? t("notes.knowledgeSaved", "已保存") + : t("notes.knowledgePending", "待保存"); + + const showMovePicker = useCallback( + (targetDocument?: KnowledgeDocument | null) => { + if (!targetDocument || getMoveTargets(targetDocument).length === 0) return; + setActionDocument(null); + setMoveDocument(targetDocument); + }, + [getMoveTargets], + ); + + const showRenameSheet = useCallback( + (targetDocument?: KnowledgeDocument | null) => { + if (!targetDocument) return; + setActionDocument(null); + setRenameDocument(targetDocument); + setRenameDraft(targetDocument.title.trim()); + }, + [], + ); + + const handleMoveToTarget = useCallback( + (targetId?: string) => { + if (!moveDocument) return; + setMoveDocument(null); + onMoveDocument(moveDocument, targetId); + }, + [moveDocument, onMoveDocument], + ); + + const handleSubmitRename = useCallback(() => { + if (!renameDocument) return; + const nextTitle = renameDraft.trim(); + setRenameDocument(null); + setRenameDraft(""); + if (!nextTitle || nextTitle === renameDocument.title.trim()) return; + onRenameDocument(renameDocument, nextTitle); + }, [onRenameDocument, renameDocument, renameDraft]); + + const handleSelectKnowledgeDocument = useCallback( + async (nextDocument: KnowledgeDocument) => { + const opened = await onSelectDocument(nextDocument); + if (opened === false) return; + setWorkspaceMode(getKnowledgeDocumentWorkspaceMode(nextDocument)); + }, + [onSelectDocument], + ); + const handleOpenVaultRoot = useCallback(async () => { + const opened = await onOpenVaultRoot(); + if (opened === false) return; + setWorkspaceMode("vault"); + }, [onOpenVaultRoot]); + const handleSelectContextDocument = useCallback( + (nextDocument: KnowledgeDocument) => { + setIsContextSheetVisible(false); + void handleSelectKnowledgeDocument(nextDocument); + }, + [handleSelectKnowledgeDocument], + ); + const handleOpenBookFromContext = useCallback( + (cfi?: string) => { + setIsContextSheetVisible(false); + onOpenBook(cfi); + }, + [onOpenBook], + ); + const handleSelectOutlineItem = useCallback((item: KnowledgeDocumentOutlineItem) => { + setIsContextSheetVisible(false); + setWorkspaceMode("document"); + setOutlineTarget((current) => ({ + index: item.index, + requestId: (current?.requestId ?? 0) + 1, + })); + }, []); + + useEffect(() => { + if (getKnowledgeDocumentWorkspaceMode(document) === "vault" && workspaceMode === "document") { + setWorkspaceMode("vault"); + } + }, [document, workspaceMode]); + + useEffect(() => { + if (workspaceMode !== "document") { + setIsContextSheetVisible(false); + } + }, [workspaceMode]); + + if (isLoading || !document) { + return ( + + + {t("notes.knowledgeLoading", "正在打开知识主页...")} + + ); + } + + return ( + + {workspaceMode === "vault" ? ( + + + + + {book.title} + + + + + + + + {isVaultRootOpen ? ( + + ) : isFolderDocument ? ( + + ) : null} + + ) : ( + + + setWorkspaceMode("vault")} + accessibilityRole="button" + accessibilityLabel={t("notes.knowledgeWorkspaceVault", "目录")} + > + + + + + + { + void handleSelectKnowledgeDocument(targetDocument); + }} + styles={styles} + colors={colors} + /> + + {saveStatusLabel} + + + + + + + + setIsContextSheetVisible(true)} + accessibilityLabel={t("notes.knowledgeContext", "上下文")} + > + + + {currentDocumentMoveTargets.length > 0 ? ( + showMovePicker(document)} + accessibilityLabel={t("notes.knowledgeMoveDocument", "移动文档")} + > + + + ) : null} + {canDeleteKnowledgeDocument(document) ? ( + onDeleteDocument(document)} + accessibilityLabel={t("notes.knowledgeDeleteDocument", "删除文档")} + > + + + ) : null} + + + + + onPickImageAttachment(document)} + isSaved={isSaved} + outlineTarget={outlineTarget} + internalLinkTargets={internalLinkTargets} + sourceReferenceRequest={sourceReferenceRequest} + placeholder={t( + "notes.knowledgePlaceholder", + "记录这本书的摘要、问题、想法和长期知识...", + )} + /> + + + )} + + setMoveDocument(null)} + > + setMoveDocument(null)} + /> + + + + + {t("notes.knowledgeMoveTo", "移动到")} + + + {moveDocument?.title || t("notes.knowledgeUntitledDocument", "未命名文档")} + + + setMoveDocument(null)} + accessibilityLabel={t("common.cancel", "取消")} + > + + + + + + {moveTargets.map((target) => ( + handleMoveToTarget(target.id)} + > + + {target.id ? ( + + ) : ( + + )} + + + + {target.title} + + + {target.path} + + + + ))} + + + + setActionDocument(null)} + > + setActionDocument(null)} + /> + + + + + {actionDocument?.title || t("notes.knowledgeUntitledDocument", "未命名文档")} + + + {actionDocument ? knowledgeDocumentPathText(actionDocument, documents, t) : ""} + + + setActionDocument(null)} + accessibilityLabel={t("common.cancel", "取消")} + > + + + + + {actionDocument ? ( + + { + const target = actionDocument; + setActionDocument(null); + handleSelectKnowledgeDocument(target); + }} + > + + {actionDocument.type === "folder" ? ( + + ) : ( + + )} + + + {t("notes.knowledgeOpenDocument", "打开文档")} + + + + + showRenameSheet(actionDocument)} + > + + + + + {t("common.rename", "重命名")} + + + + + {actionDocument.type === "folder" ? ( + <> + { + const target = actionDocument; + setActionDocument(null); + onCreateDocument("folder", target.id); + }} + disabled={isCreatingDocument} + > + + + + + + {t("notes.knowledgeNewFolder", "新建文件夹")} + + + {t("notes.knowledgeCreateIn", "创建于")} ·{" "} + {knowledgeDocumentPathText(actionDocument, documents, t)} + + + + + { + const target = actionDocument; + setActionDocument(null); + onCreateDocument("standalone_note", target.id); + }} + disabled={isCreatingDocument} + > + + + + + + {t("notes.knowledgeNewNote", "新建笔记")} + + + {t("notes.knowledgeCreateIn", "创建于")} ·{" "} + {knowledgeDocumentPathText(actionDocument, documents, t)} + + + + + { + const target = actionDocument; + setActionDocument(null); + onExportFolder(target); + }} + disabled={isFolderExporting} + > + + {isFolderExporting ? ( + + ) : ( + + )} + + + + {isFolderExporting + ? t("notes.knowledgeVaultExporting", "导出中...") + : t("notes.knowledgeExportCurrentFolder", "导出此文件夹")} + + + {knowledgeDocumentPathText(actionDocument, documents, t)} + + + + + + ) : null} + + {canMoveActionDocument ? ( + showMovePicker(actionDocument)} + > + + + + + {t("notes.knowledgeMoveDocument", "移动文档")} + + + + ) : null} + + {canDeleteActionDocument ? ( + { + const target = actionDocument; + setActionDocument(null); + onDeleteDocument(target); + }} + > + + + + + {t("notes.knowledgeDeleteDocument", "删除文档")} + + + ) : null} + + ) : null} + + + { + setRenameDocument(null); + setRenameDraft(""); + }} + > + { + setRenameDocument(null); + setRenameDraft(""); + }} + /> + + + + + + {t("common.rename", "重命名")} + + + {renameDocument ? knowledgeDocumentPathText(renameDocument, documents, t) : ""} + + + { + setRenameDocument(null); + setRenameDraft(""); + }} + accessibilityLabel={t("common.cancel", "取消")} + > + + + + + + + + { + setRenameDocument(null); + setRenameDraft(""); + }} + > + + {t("common.cancel", "取消")} + + + + + {t("common.save", "保存")} + + + + + + + + setIsContextSheetVisible(false)} + > + setIsContextSheetVisible(false)} + /> + + + + + + {t("notes.knowledgeContext", "上下文")} + + + {knowledgeDocumentPathText(document, documents, t, title)} + + + setIsContextSheetVisible(false)} + accessibilityLabel={t("common.cancel", "取消")} + > + + + + + + + + {!isFolderDocument ? ( + + ) : null} + + + + + + + + + {t("notes.knowledgeRecentExcerpts", "最近摘录")} + + handleOpenBookFromContext()} + > + + {t("notes.openBook", "打开书籍")} + + + + + {recentHighlights.length === 0 ? ( + + {t("notes.knowledgeNoSources", "暂无摘录")} + + ) : ( + + {recentHighlights.map((highlight) => ( + + handleOpenBookFromContext(highlight.cfi)} + > + + "{highlight.text}" + + {!!highlight.chapterTitle && ( + + {highlight.chapterTitle} + + )} + + + { + onInsertSourceReference(highlight); + setIsContextSheetVisible(false); + }} + accessibilityLabel={t( + "notes.knowledgeInsertSourceReference", + "插入引用", + )} + > + + + {t("notes.knowledgeInsertSourceReference", "插入引用")} + + + + + ))} + + )} + + + + + + ); +} + +function KnowledgePathTrail({ + items, + activeId, + documents, + onSelectRoot, + onSelectDocument, + styles, + colors, +}: { + items: Array<{ id: string; title: string; type?: KnowledgeDocumentType }>; + activeId?: string; + documents: KnowledgeDocument[]; + onSelectRoot: () => void; + onSelectDocument: (document: KnowledgeDocument) => void; + styles: ReturnType; + colors: ReturnType; +}) { + return ( + + {items.map((part, index) => { + const isLastPathPart = part.id === activeId; + const isRootPathPart = part.id === "__vault__"; + const targetDocument = documents.find((item) => item.id === part.id); + + return ( + + {index > 0 ? / : null} + { + if (isLastPathPart) return; + if (isRootPathPart) { + onSelectRoot(); + return; + } + if (!targetDocument) return; + onSelectDocument(targetDocument); + }} + disabled={(!targetDocument && !isRootPathPart) || isLastPathPart} + > + + {part.title} + + + + ); + })} + + ); +} + +function KnowledgeTagEditor({ + tags, + onChange, + t, + styles, +}: { + tags: string[]; + onChange: (tags: string[]) => void; + t: TFunction; + styles: ReturnType; +}) { + const [draft, setDraft] = useState(""); + + const commitDraft = useCallback( + (rawValue = draft) => { + const nextTags = rawValue + .split(/[,\uFF0C]/) + .map((tag) => tag.trim()) + .filter(Boolean); + if (nextTags.length === 0) { + setDraft(""); + return; + } + onChange(normalizeKnowledgeTags([...tags, ...nextTags])); + setDraft(""); + }, + [draft, onChange, tags], + ); + + return ( + + {t("notes.knowledgeTags", "标签")} + + {tags.map((tag) => ( + onChange(tags.filter((item) => item !== tag))} + accessibilityLabel={t("notes.knowledgeTagRemove", { tag })} + > + + {tag} + + × + + ))} + { + if (/[,\uFF0C]/.test(text)) { + commitDraft(text); + return; + } + setDraft(text); + }} + onSubmitEditing={() => commitDraft()} + onBlur={() => commitDraft()} + placeholder={t("notes.knowledgeTagPlaceholder", "添加标签")} + placeholderTextColor={styles.knowledgeTagInputPlaceholder.color} + style={styles.knowledgeTagInput} + returnKeyType="done" + /> + + + ); +} + +function KnowledgeDocumentOutlineCard({ + outline, + onSelectItem, + t, + styles, + colors, +}: { + outline: KnowledgeDocumentOutlineItem[]; + onSelectItem: (item: KnowledgeDocumentOutlineItem) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + return ( + + + + + + {t("notes.knowledgeDocumentOutline", "文档大纲")} + + + {outline.length > 0 ? ( + {outline.length} + ) : null} + + + {outline.length === 0 ? ( + + {t("notes.knowledgeDocumentOutlineEmpty", "添加标题后,这里会形成文档目录")} + + ) : ( + + {outline.map((item) => ( + onSelectItem(item)} + accessibilityRole="button" + accessibilityLabel={`${t("notes.knowledgeDocumentOutline", "文档大纲")} ${item.title}`} + > + + H{item.level} + + {item.title} + + + ))} + + )} + + ); +} + +function knowledgeLinkTargetLabel( + link: KnowledgeLink, + highlights: HighlightWithBook[], + t: TFunction, +): { title: string; detail: string; cfi?: string } { + if (link.toKind === "highlight") { + const highlight = highlights.find((item) => item.id === link.toId); + return { + title: link.label || highlight?.chapterTitle || t("notes.knowledgeSourceHighlight", "高亮"), + detail: highlight?.text || link.toId, + cfi: link.cfi || highlight?.cfi, + }; + } + + if (link.toKind === "cfi") { + return { + title: link.label || t("notes.knowledgeSourcePosition", "书中位置"), + detail: link.cfi || link.toId, + cfi: link.cfi || link.toId, + }; + } + + if (link.toKind === "book") { + return { + title: link.label || t("notes.knowledgeSourceBook", "书籍"), + detail: link.toId, + }; + } + + return { + title: link.label || t("notes.knowledgeSourceReference", "引用"), + detail: link.toId, + cfi: link.cfi, + }; +} + +function KnowledgeRelationsCard({ + links, + backlinks, + documents, + highlights, + isLoading, + onSelectDocument, + onOpenBook, + t, + styles, +}: { + links: KnowledgeLink[]; + backlinks: KnowledgeBacklink[]; + documents: KnowledgeDocument[]; + highlights: HighlightWithBook[]; + isLoading: boolean; + onSelectDocument: (document: KnowledgeDocument) => void; + onOpenBook: (cfi?: string) => void; + t: TFunction; + styles: ReturnType; +}) { + const sourceLinks = links.filter((link) => link.relation === "source").slice(0, 4); + const relatedLinks = links.filter((link) => link.relation !== "source").slice(0, 4); + const visibleBacklinks = backlinks.slice(0, 4); + const documentById = useMemo( + () => new Map(documents.map((document) => [document.id, document])), + [documents], + ); + + return ( + + + {t("notes.knowledgeRelations", "关系")} + {isLoading ? ( + + {t("notes.knowledgeRelationsLoading", "加载中")} + + ) : null} + + + + {t("notes.knowledgeSourceLinks", "来源")} + + {sourceLinks.length === 0 ? ( + + {t("notes.knowledgeNoSourceLinks", "暂无来源链接")} + + ) : ( + + {sourceLinks.map((link) => { + const targetDocument = + link.toKind === "document" ? documentById.get(link.toId) : undefined; + const target = targetDocument + ? { + title: + link.label || + targetDocument.title || + t("notes.knowledgeUntitledDocument", "未命名文档"), + detail: knowledgeDocumentPathText(targetDocument, documents, t), + cfi: undefined, + } + : knowledgeLinkTargetLabel(link, highlights, t); + const canOpenDocument = !!targetDocument; + const canOpenBook = !canOpenDocument && (!!target.cfi || link.toKind === "book"); + return ( + { + if (targetDocument) { + onSelectDocument(targetDocument); + return; + } + onOpenBook(target.cfi); + }} + > + + {target.title} + + + {target.detail} + + + ); + })} + + )} + + + {t("notes.knowledgeRelatedLinks", "关联文档")} + + {relatedLinks.length === 0 ? ( + + {t("notes.knowledgeNoRelatedLinks", "暂无关联文档")} + + ) : ( + + {relatedLinks.map((link) => { + const targetDocument = + link.toKind === "document" ? documentById.get(link.toId) : undefined; + const target = targetDocument + ? { + title: + link.label || + targetDocument.title || + t("notes.knowledgeUntitledDocument", "未命名文档"), + detail: knowledgeDocumentPathText(targetDocument, documents, t), + cfi: undefined, + } + : knowledgeLinkTargetLabel(link, highlights, t); + const canOpenDocument = !!targetDocument; + const canOpenBook = !canOpenDocument && (!!target.cfi || link.toKind === "book"); + + return ( + { + if (targetDocument) { + onSelectDocument(targetDocument); + return; + } + onOpenBook(target.cfi); + }} + > + + {target.title} + + + {target.detail} + + + ); + })} + + )} + + + {t("notes.knowledgeBacklinks", "反链")} + + {visibleBacklinks.length === 0 ? ( + + {t("notes.knowledgeNoBacklinks", "暂无反链")} + + ) : ( + + {visibleBacklinks.map(({ link, fromDocument }) => ( + onSelectDocument(fromDocument)} + > + + {fromDocument.title || t("notes.knowledgeUntitledDocument", "未命名文档")} + + + {knowledgeDocumentPathText(fromDocument, documents, t)} + + {!!fromDocument.excerpt && ( + + {fromDocument.excerpt} + + )} + + ))} + + )} + + ); +} + +function KnowledgeSummaryMemoryCard({ + document, + isCompressing, + onCompress, + t, + styles, + colors, +}: { + document: KnowledgeDocument; + isCompressing: boolean; + onCompress: () => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const summary = document.summaryMd?.trim(); + const updatedAt = document.summaryUpdatedAt; + const isStale = + !!summary && + document.summarySourceFingerprint !== createKnowledgeSummarySourceFingerprint(document); + const statusLabel = !summary + ? t("notes.knowledgeSummaryMissing", "暂无压缩记忆") + : isStale + ? t("notes.knowledgeSummaryStale", "需要刷新") + : t("notes.knowledgeSummaryReady", "压缩记忆已就绪"); + const statusColor = !summary + ? colors.mutedForeground + : isStale + ? colors.foreground + : colors.primary; + + const markdownStyles = useMemo( + () => ({ + body: styles.knowledgeSummaryMarkdownBody, + heading1: styles.knowledgeSummaryMarkdownHeading, + heading2: styles.knowledgeSummaryMarkdownHeading, + heading3: styles.knowledgeSummaryMarkdownHeading, + paragraph: styles.knowledgeSummaryMarkdownParagraph, + bullet_list: styles.knowledgeSummaryMarkdownList, + ordered_list: styles.knowledgeSummaryMarkdownList, + list_item: styles.knowledgeSummaryMarkdownListItem, + strong: styles.knowledgeSummaryMarkdownStrong, + em: styles.knowledgeSummaryMarkdownEm, + link: styles.knowledgeSummaryMarkdownLink, + blockquote: styles.knowledgeSummaryMarkdownQuote, + code_inline: styles.knowledgeSummaryMarkdownCode, + }), + [styles], + ); + + return ( + + + + + + + + + {t("notes.knowledgeSummaryMemory", "AI 记忆")} + + + {statusLabel} + + + + + + {isCompressing ? ( + + ) : ( + + )} + + {isCompressing + ? t("notes.knowledgeSummaryCompressing", "压缩中") + : t("notes.knowledgeSummaryCompress", "压缩")} + + + + + {summary ? ( + + + {updatedAt ? ( + + {t("notes.knowledgeSummaryUpdatedAt", { + time: new Date(updatedAt).toLocaleString(), + })} + + ) : null} + + ) : ( + + {t("notes.knowledgeSummaryPreview", "将当前文档压缩成紧凑记忆,方便后续 AI 检索。")} + + )} + + ); +} + +function KnowledgeMarkdownImportReviewSheet({ + review, + documents, + isApplying, + onApply, + onDismiss, + t, + styles, + colors, +}: { + review: KnowledgeMarkdownImportReview | null; + documents: KnowledgeDocument[]; + isApplying: boolean; + onApply: () => void; + onDismiss: () => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const visibleItems = review?.items.slice(0, 6) ?? []; + const hiddenCount = Math.max(0, (review?.items.length ?? 0) - visibleItems.length); + const documentById = useMemo( + () => new Map(documents.map((document) => [document.id, document])), + [documents], + ); + const importDestinationLabel = useCallback( + (proposal: KnowledgeImportWriteProposal): string | null => { + if (proposal.targetPath) return proposal.targetPath; + if (proposal.action === "update") return proposal.current?.path ?? null; + if (proposal.action !== "create") return null; + const parentId = proposal.draft.parentId; + const title = + proposal.draft.title?.trim() || t("notes.knowledgeUntitledDocument", "未命名文档"); + if (!parentId) return [t("notes.knowledgeVaultRoot", "知识库"), title].join(" / "); + const parent = documentById.get(parentId); + if (!parent) return [parentId, title].join(" / "); + return [ + ...knowledgeDocumentPathItems(parent, documents, t).map((item) => item.title), + title, + ].join(" / "); + }, + [documentById, documents, t], + ); + + return ( + + + + + + + + + + + + {t("notes.knowledgeMarkdownImportTitle", "导入 Markdown 为知识文档")} + + + {t("notes.knowledgeMarkdownImportDescription", { + count: review?.items.length ?? 0, + })} + + + + + + + + + + {visibleItems.map((item) => { + const proposal = item.proposal; + const title = + proposal.action === "create" + ? proposal.draft.title + : (proposal.patch.title ?? proposal.current?.title ?? proposal.documentId); + const tags = + proposal.action === "create" + ? (proposal.draft.tags ?? []) + : (proposal.patch.tags ?? proposal.current?.tags ?? []); + const preview = + proposal.action === "create" + ? proposal.draft.excerpt || proposal.draft.contentMd + : proposal.patch.excerpt || + proposal.patch.contentMd || + proposal.current?.excerpt || + ""; + const destinationLabel = importDestinationLabel(proposal); + + return ( + + + + + {title} + + + {item.sourceName ?? mobileFileName(item.path)} + + + {t("notes.knowledgeImportSource", { + path: item.sourceName ?? item.sourcePath, + })} + + {!!destinationLabel && ( + + + + {t("notes.knowledgeImportDestination", { + path: destinationLabel, + })} + + + )} + + + {proposal.action === "create" + ? t("notes.knowledgeMarkdownImportWillCreate", "将创建") + : t("notes.knowledgeVaultImportWillUpdate", "将更新")} + + + + {preview ? ( + + {preview} + + ) : null} + + {tags.length > 0 || item.warnings.length > 0 ? ( + + {tags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + {tags.length > 4 ? ( + +{tags.length - 4} + ) : null} + {item.warnings.slice(0, 3).map((warning) => ( + + {knowledgeMarkdownImportWarningLabel(warning, t)} + + ))} + {item.warnings.length > 3 ? ( + + {t("notes.knowledgeMarkdownImportWarningCount", { + count: item.warnings.length - 3, + })} + + ) : null} + + ) : null} + + ); + })} + + {hiddenCount > 0 ? ( + + {t("notes.knowledgeMarkdownImportMoreFiles", { count: hiddenCount })} + + ) : null} + + + + + {t( + "notes.knowledgeMarkdownImportSafeHint", + "导入会创建知识文档,不会修改原始 Markdown 文件。", + )} + + + + + {t("common.cancel", "取消")} + + + + {isApplying ? : null} + + {isApplying + ? t("notes.knowledgeMarkdownImportApplying", "导入中...") + : t("notes.knowledgeMarkdownImportApply", "导入文档")} + + + + + + + ); +} + +function knowledgeDocumentTypeLabel(document: KnowledgeDocument, t: TFunction): string { + if (document.type === "book_home") return t("notes.knowledgeDocumentHome", "主页"); + if (document.type === "folder") return t("notes.knowledgeDocumentFolder", "文件夹"); + if (document.type === "review") return t("notes.knowledgeDocumentReview", "书评"); + if (document.type === "summary") return t("notes.knowledgeDocumentSummary", "摘要"); + if (document.type === "highlight_note") return t("notes.knowledgeDocumentHighlight", "高亮笔记"); + return t("notes.knowledgeDocumentNote", "笔记"); +} + +function formatKnowledgeDocumentUpdatedDate( + document: Pick, +): string { + if (!Number.isFinite(document.updatedAt)) return ""; + return new Date(document.updatedAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function knowledgeDocumentPathItems( + document: KnowledgeDocument, + documents: KnowledgeDocument[], + t: TFunction, + activeTitle?: string, +): Array<{ id: string; title: string; type?: KnowledgeDocumentType }> { + const path = resolveKnowledgeDocumentPath(document, documents); + + return [ + { id: "__vault__", title: t("notes.knowledgeVaultRoot", "知识库") }, + ...path.map( + (item, index) => + ({ + id: item.id, + type: item.type, + title: + index === path.length - 1 && activeTitle?.trim() + ? activeTitle.trim() + : item.title.trim() || t("notes.knowledgeUntitledDocument", "未命名文档"), + }) satisfies { id: string; title: string; type?: KnowledgeDocumentType }, + ), + ]; +} + +function knowledgeDocumentPathText( + document: KnowledgeDocument, + documents: KnowledgeDocument[], + t: TFunction, + activeTitle?: string, +): string { + return knowledgeDocumentPathItems(document, documents, t, activeTitle) + .map((item) => item.title) + .join(" / "); +} + +function knowledgeDocumentParentPathText( + document: KnowledgeDocument, + documents: KnowledgeDocument[], + t: TFunction, +): string { + const parts = knowledgeDocumentPathText(document, documents, t).split(" / "); + return parts.slice(0, -1).join(" / "); +} + +function KnowledgeDocumentExplorer({ + documents, + activeDocument, + activeDocumentId, + isRootActive, + isCreating, + onSelectRoot, + onSelect, + onCreate, + t, + styles, + colors, +}: { + documents: KnowledgeDocument[]; + activeDocument: KnowledgeDocument | null; + activeDocumentId: string | null; + isRootActive: boolean; + isCreating: boolean; + onSelectRoot: () => void; + onSelect: (document: KnowledgeDocument) => void; + onCreate: (type?: CreatableKnowledgeDocumentType, parentId?: string) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const [query, setQuery] = useState(""); + const homeDocumentId = useMemo( + () => documents.find((document) => document.type === "book_home")?.id, + [documents], + ); + const tree = useMemo( + () => buildKnowledgeDocumentTree(documents, homeDocumentId), + [documents, homeDocumentId], + ); + const activePathIds = useMemo( + () => + new Set( + activeDocument + ? knowledgeDocumentPathItems(activeDocument, documents, t) + .map((item) => item.id) + .filter((id) => id !== "__vault__") + : [], + ), + [activeDocument, documents, t], + ); + const orphanedDocumentIds = useMemo( + () => new Set(tree.orphaned.map((document) => document.id)), + [tree], + ); + const childCountByParentId = useMemo(() => { + const counts = new Map(); + for (const document of documents) { + if (!document.parentId) continue; + counts.set(document.parentId, (counts.get(document.parentId) ?? 0) + 1); + } + return counts; + }, [documents]); + const [expandedFolderIds, setExpandedFolderIds] = useState>(() => new Set()); + const [createParentId, setCreateParentId] = useState(); + const [isCreateSheetVisible, setIsCreateSheetVisible] = useState(false); + const activeCreateParentId = getKnowledgeDocumentCreateParentId({ + document: activeDocument, + isVaultRootOpen: isRootActive, + }); + const normalizedQuery = query.trim().toLowerCase(); + const createParentDocument = useMemo( + () => documents.find((document) => document.id === createParentId), + [createParentId, documents], + ); + const createDestinationLabel = createParentDocument + ? knowledgeDocumentPathText(createParentDocument, documents, t) + : t("notes.knowledgeVaultRoot", "知识库"); + const createOptions = useMemo( + () => + [ + { + type: "folder" as const, + label: t("notes.knowledgeNewFolder", "新建文件夹"), + icon: "folder", + }, + { + type: "standalone_note" as const, + label: t("notes.knowledgeNewNote", "新建笔记"), + icon: "note", + }, + { + type: "review" as const, + label: t("notes.knowledgeNewReview", "新建书评"), + icon: "review", + }, + { + type: "summary" as const, + label: t("notes.knowledgeNewSummary", "新建摘要"), + icon: "summary", + }, + ] satisfies Array<{ + type: CreatableKnowledgeDocumentType; + label: string; + icon: "folder" | "note" | "review" | "summary"; + }>, + [t], + ); + + useEffect(() => { + if (!activeDocument) return; + setExpandedFolderIds((current) => { + const next = new Set(current); + if (activeDocument.type === "folder") next.add(activeDocument.id); + let parentId = activeDocument.parentId; + while (parentId) { + next.add(parentId); + parentId = documents.find((document) => document.id === parentId)?.parentId; + } + return next; + }); + }, [activeDocument, documents]); + + const visibleSearchNodes = useMemo(() => { + return filterKnowledgeDocumentTreeNodesForSearch( + flattenKnowledgeDocumentTree(tree.roots), + documents, + query, + { + rootTitle: t("notes.knowledgeVaultRoot", "知识库"), + untitledTitle: t("notes.knowledgeUntitledDocument", "未命名文档"), + orphanedParentTitle: t("notes.knowledgeOrphanedDocument", "孤立"), + getTypeLabel: (document) => knowledgeDocumentTypeLabel(document, t), + }, + ); + }, [documents, query, t, tree.roots]); + + const toggleFolder = useCallback((id: string) => { + setExpandedFolderIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const showCreatePicker = useCallback((parentId?: string) => { + setCreateParentId(parentId); + setIsCreateSheetVisible(true); + }, []); + const handleCreate = useCallback( + (type: CreatableKnowledgeDocumentType) => { + setIsCreateSheetVisible(false); + onCreate(type, createParentId); + }, + [createParentId, onCreate], + ); + + return ( + + + + + {t("notes.knowledgeWorkspaceVault", "目录")} + + + {t("notes.knowledgeVaultRoot", "知识库")} / {documents.length}{" "} + {t("notes.knowledgeDocuments", "文档")} + + + showCreatePicker(activeCreateParentId)} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewDocument", "新建文档")} + > + + + + + + + + + + + {!normalizedQuery ? ( + + + + + + + + {t("notes.knowledgeVaultRoot", "知识库")} + + + {t("notes.knowledgeFolderInside", "目录内容")} + + + + {documents.length} + + + ) : null} + {normalizedQuery ? ( + visibleSearchNodes.length === 0 ? ( + + + {t("notes.knowledgeNoDocumentResults", "没有匹配的文档")} + + + ) : ( + visibleSearchNodes.map((node) => ( + + )) + ) + ) : tree.roots.length === 0 ? ( + + + {t("notes.knowledgeNoDocumentResults", "没有匹配的文档")} + + + ) : ( + tree.roots.map((node) => ( + + )) + )} + + + setIsCreateSheetVisible(false)} + > + setIsCreateSheetVisible(false)} + /> + + + + + {t("notes.knowledgeNewDocument", "新建文档")} + + + {t("notes.knowledgeCreateIn", "创建于")} · {createDestinationLabel} + + + setIsCreateSheetVisible(false)} + accessibilityLabel={t("common.cancel", "取消")} + > + + + + + + {createOptions.map((option) => ( + handleCreate(option.type)} + disabled={isCreating} + accessibilityLabel={option.label} + > + + {option.icon === "folder" ? ( + + ) : option.icon === "review" ? ( + + ) : option.icon === "summary" ? ( + + ) : ( + + )} + + + {option.label} + + + + ))} + + + + + ); +} + +function KnowledgeDocumentTreeRow({ + node, + activeDocumentId, + activePathIds, + expandedFolderIds, + childCountByParentId, + orphanedDocumentIds, + onToggleFolder, + onSelect, + t, + styles, + colors, + forceLeaf, + pathLabel, +}: { + node: KnowledgeDocumentTreeNode; + activeDocumentId: string | null; + activePathIds: Set; + expandedFolderIds: Set; + childCountByParentId: Map; + orphanedDocumentIds: Set; + onToggleFolder: (id: string) => void; + onSelect: (document: KnowledgeDocument) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; + forceLeaf?: boolean; + pathLabel?: string; +}) { + const document = node.document; + const isFolder = document.type === "folder"; + const isExpanded = expandedFolderIds.has(document.id); + const isActive = document.id === activeDocumentId; + const isInActivePath = !isActive && activePathIds.has(document.id); + const isOrphaned = orphanedDocumentIds.has(document.id); + const childCount = childCountByParentId.get(document.id) ?? 0; + const title = document.title.trim() || t("notes.knowledgeUntitledDocument", "未命名文档"); + const showChildren = isFolder && isExpanded && node.children.length > 0 && !forceLeaf; + + return ( + + onSelect(document)} + > + {node.depth > 0 && !forceLeaf ? ( + + ) : null} + {isFolder && !forceLeaf ? ( + onToggleFolder(document.id)} + accessibilityLabel={ + isExpanded + ? t("notes.knowledgeCollapseFolder", "收起文件夹") + : t("notes.knowledgeExpandFolder", "展开文件夹") + } + > + + + + + ) : ( + + )} + + {isFolder ? ( + + ) : ( + + )} + + + + {title} + + + {pathLabel || + (isOrphaned + ? `${knowledgeDocumentTypeLabel(document, t)} · ${t( + "notes.knowledgeOrphanedDocument", + "孤立", + )}` + : knowledgeDocumentTypeLabel(document, t))} + + + {isFolder ? ( + + {childCount} + + ) : null} + + + {showChildren + ? node.children.map((child) => ( + + )) + : null} + + ); +} + +function KnowledgeFolderBrowserItem({ + item, + documents, + childCountByParentId, + onSelect, + onOpenActions, + t, + styles, + colors, +}: { + item: KnowledgeDocument; + documents: KnowledgeDocument[]; + childCountByParentId: Map; + onSelect: (document: KnowledgeDocument) => void; + onOpenActions: (document: KnowledgeDocument) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const isFolder = item.type === "folder"; + const isHome = item.type === "book_home"; + const childCount = childCountByParentId.get(item.id) ?? 0; + const updatedLabel = formatKnowledgeDocumentUpdatedDate(item); + const parentPathLabel = knowledgeDocumentParentPathText(item, documents, t); + const meta = [ + knowledgeDocumentTypeLabel(item, t), + parentPathLabel, + updatedLabel, + isFolder ? t("notes.knowledgeFolderChildCount", { count: childCount }) : item.excerpt, + ] + .filter(Boolean) + .join(" · "); + + return ( + + onSelect(item)} + > + + {isFolder ? ( + + ) : isHome ? ( + + ) : ( + + )} + + + + {item.title || t("notes.knowledgeUntitledDocument", "未命名文档")} + + + {meta} + + + {isFolder ? {childCount} : null} + + + + onOpenActions(item)} + accessibilityLabel={t("notes.knowledgeDocumentActions", "文档操作")} + > + + + + ); +} + +function KnowledgeFolderBrowserGroup({ + title, + items, + documents, + childCountByParentId, + onSelect, + onOpenActions, + t, + styles, + colors, +}: { + title: string; + items: KnowledgeDocument[]; + documents: KnowledgeDocument[]; + childCountByParentId: Map; + onSelect: (document: KnowledgeDocument) => void; + onOpenActions: (document: KnowledgeDocument) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + if (items.length === 0) return null; + + return ( + + + + {title} + + {items.length} + + + {items.map((item) => ( + + ))} + + + ); +} + +function KnowledgeVaultRootOverview({ + items, + documents, + isCreating, + onSelect, + onCreate, + onOpenActions, + t, + styles, + colors, +}: { + items: KnowledgeDocument[]; + documents: KnowledgeDocument[]; + isCreating: boolean; + onSelect: (document: KnowledgeDocument) => void; + onCreate: (type?: CreatableKnowledgeDocumentType, parentId?: string) => void; + onOpenActions: (document: KnowledgeDocument) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const childCountByParentId = useMemo(() => { + const counts = new Map(); + for (const document of documents) { + if (!document.parentId) continue; + counts.set(document.parentId, (counts.get(document.parentId) ?? 0) + 1); + } + return counts; + }, [documents]); + const homeDocumentId = documents.find((item) => item.type === "book_home")?.id; + const childSections = useMemo( + () => createKnowledgeRootDisplaySections(documents, homeDocumentId), + [documents, homeDocumentId], + ); + + return ( + + + + + {t("notes.knowledgeVaultRoot", "知识库")} + + + {t("notes.knowledgeFolderInside", "目录内容")} / {items.length}{" "} + {t("notes.knowledgeDocuments", "文档")} + + + onCreate("folder")} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewFolder", "新建文件夹")} + > + + + {t("notes.knowledgeDocumentFolder", "文件夹")} + + + onCreate("standalone_note")} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewNote", "新建笔记")} + > + + + {t("notes.knowledgeDocumentNote", "笔记")} + + + + + {items.length === 0 ? ( + + + {t("notes.knowledgeFolderEmpty", "这个文件夹还是空的")} + + + {t("notes.knowledgeFolderEmptyHint", "在这里新建笔记或文件夹,慢慢搭出自己的目录。")} + + + onCreate("folder")} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewFolder", "新建文件夹")} + > + + + {t("notes.knowledgeNewFolder", "新建文件夹")} + + + onCreate("standalone_note")} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewNote", "新建笔记")} + > + + + {t("notes.knowledgeNewNote", "新建笔记")} + + + + + ) : ( + + + + + + + )} + + ); +} + +function KnowledgeFolderOverview({ + folder, + items, + documents, + isCreating, + onSelect, + onCreate, + onOpenActions, + t, + styles, + colors, +}: { + folder: KnowledgeDocument; + items: KnowledgeDocument[]; + documents: KnowledgeDocument[]; + isCreating: boolean; + onSelect: (document: KnowledgeDocument) => void; + onCreate: (type?: CreatableKnowledgeDocumentType, parentId?: string) => void; + onOpenActions: (document: KnowledgeDocument) => void; + t: TFunction; + styles: ReturnType; + colors: ReturnType; +}) { + const folderPath = knowledgeDocumentPathText(folder, documents, t); + const childCountByParentId = useMemo(() => { + const counts = new Map(); + for (const document of documents) { + if (!document.parentId) continue; + counts.set(document.parentId, (counts.get(document.parentId) ?? 0) + 1); + } + return counts; + }, [documents]); + const orderedItems = useMemo(() => orderKnowledgeDocuments(items, undefined), [items]); + const childSections = useMemo( + () => createKnowledgeFolderDisplaySections(orderedItems), + [orderedItems], + ); + + return ( + + + + + {folder.title || t("notes.knowledgeUntitledDocument", "未命名文档")} + + + {folderPath} / {items.length} {t("notes.knowledgeDocuments", "文档")} + + + onCreate("folder", folder.id)} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewFolder", "新建文件夹")} + > + + + {t("notes.knowledgeDocumentFolder", "文件夹")} + + + onCreate("standalone_note", folder.id)} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewNote", "新建笔记")} + > + + + {t("notes.knowledgeDocumentNote", "笔记")} + + + onOpenActions(folder)} + accessibilityLabel={t("notes.knowledgeDocumentActions", "文档操作")} + > + + + + + {items.length === 0 ? ( + + + {t("notes.knowledgeFolderEmpty", "这个文件夹还是空的")} + + + {t("notes.knowledgeFolderEmptyHint", "在这里新建笔记或文件夹,慢慢搭出自己的目录。")} + + + onCreate("folder", folder.id)} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewFolder", "新建文件夹")} + > + + + {t("notes.knowledgeNewFolder", "新建文件夹")} + + + onCreate("standalone_note", folder.id)} + disabled={isCreating} + accessibilityLabel={t("notes.knowledgeNewNote", "新建笔记")} + > + + + {t("notes.knowledgeNewNote", "新建笔记")} + + + + + ) : ( + + + + + )} + + ); +} + /** Note detail card — matching Tauri NoteDetailCard */ 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 ? ( - + 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,63 +195,1396 @@ 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 }, - chapterGroup: { marginBottom: 16 }, - chapterDivider: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, - chapterLine: { flex: 1, height: 0.5, backgroundColor: colors.border }, - chapterName: { + knowledgeLoading: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 }, + knowledgeRoot: { flex: 1, backgroundColor: colors.background }, + knowledgeScroll: { flex: 1 }, + knowledgeContent: { + paddingHorizontal: 12, + paddingTop: 2, + paddingBottom: 30, + gap: 10, + }, + knowledgeDocumentContent: { + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 18, + gap: 10, + }, + knowledgeVaultHeader: { + minHeight: 50, + zIndex: 2, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 0, + paddingBottom: 8, + }, + knowledgeVaultIcon: { + width: 30, + height: 30, + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.1), + alignItems: "center", + justifyContent: "center", + }, + knowledgeVaultText: { minWidth: 0 }, + knowledgeVaultEyebrow: { + fontSize: 11, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + lineHeight: 14, + }, + knowledgeVaultTitle: { + marginTop: 0, + fontSize: fontSize.md, + lineHeight: 21, + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeVaultPath: { + marginTop: 2, + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + }, + knowledgeVaultPathScroll: { + marginTop: 4, + maxHeight: 24, + }, + knowledgeVaultPathTrail: { + flexDirection: "row", + alignItems: "center", + gap: 2, + paddingRight: 14, + }, + knowledgeVaultPathSegment: { + flexDirection: "row", + alignItems: "center", + gap: 2, + maxWidth: 240, + }, + knowledgeVaultPathSlash: { + fontSize: 11, + lineHeight: 14, + color: withOpacity(colors.mutedForeground, 0.5), + fontWeight: fontWeight.medium, + }, + knowledgeVaultPathChip: { + maxWidth: 180, + minHeight: 22, + flexDirection: "row", + alignItems: "center", + gap: 0, + borderRadius: 0, + borderWidth: 0, + borderColor: "transparent", + backgroundColor: "transparent", + justifyContent: "flex-start", + paddingHorizontal: 1, + paddingVertical: 2, + }, + knowledgeVaultPathChipActive: { + backgroundColor: "transparent", + borderBottomWidth: 1, + borderBottomColor: withOpacity(colors.foreground, 0.36), + }, + knowledgeVaultPathChipText: { + flexShrink: 1, + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeVaultPathChipTextActive: { + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeVaultStats: { + alignItems: "flex-end", + justifyContent: "center", + gap: 3, + maxWidth: 98, + }, + knowledgeVaultStatText: { + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeDocumentEyebrow: { fontSize: fontSize.xs, + color: colors.mutedForeground, fontWeight: fontWeight.medium, + marginBottom: 4, + }, + knowledgeDocumentScreen: { + gap: 12, + paddingTop: 2, + }, + knowledgeDocumentFullScreen: { + flex: 1, + paddingHorizontal: 14, + paddingTop: 8, + paddingBottom: 12, + }, + knowledgeDocumentKeyboardAvoider: { + flex: 1, + }, + knowledgeDocumentCanvasHeader: { + minHeight: 66, + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + paddingBottom: 10, + }, + knowledgeDocumentBackButton: { + width: 36, + height: 36, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", + }, + knowledgeDocumentCanvasTitleBlock: { + flex: 1, + minWidth: 0, + }, + knowledgeCanvasTitleInput: { + minHeight: 34, + maxHeight: 58, + padding: 0, + fontSize: fontSize.xl, + lineHeight: 27, + color: colors.foreground, + fontWeight: fontWeight.bold, + }, + knowledgeCanvasPath: { + marginTop: 3, + fontSize: fontSize.xs, + lineHeight: 17, color: colors.mutedForeground, - paddingHorizontal: 8, }, - noteCard: { + knowledgeCanvasMeta: { + marginTop: 3, + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeDocumentActionRail: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingTop: 2, + }, + knowledgeCanvasStatus: { + width: 32, + height: 32, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, backgroundColor: colors.card, - borderRadius: radius.lg, + alignItems: "center", + justifyContent: "center", + }, + knowledgeCanvasIconButton: { + width: 32, + height: 32, + borderRadius: radius.md, borderWidth: 0.5, borderColor: colors.border, - padding: 12, - marginBottom: 8, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", }, - noteCardTop: { flexDirection: "row", alignItems: "flex-start", gap: 8 }, - colorDot: { width: 10, height: 10, borderRadius: 5, marginTop: 4 }, - noteQuote: { flex: 1, fontSize: fontSize.sm, color: colors.foreground, lineHeight: 20 }, - noteBody: { - marginTop: 8, - backgroundColor: colors.muted, + knowledgeCanvasSaveText: { + marginTop: -6, + alignSelf: "flex-end", + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeDocumentCanvas: { + flex: 1, + minHeight: 540, borderRadius: radius.sm, - paddingHorizontal: 8, - paddingVertical: 6, + borderWidth: 0, + overflow: "hidden", + backgroundColor: colors.background, }, - noteText: { fontSize: fontSize.xs, color: colors.mutedForeground, lineHeight: 16 }, - noteActions: { flexDirection: "row", justifyContent: "flex-end", gap: 4, marginTop: 8 }, - noteActionBtn: { padding: 6, borderRadius: radius.sm }, - editArea: { marginTop: 8 }, - editorContainer: { + knowledgeMoveSheetBackdrop: { flex: 1, - minHeight: 180, - borderRadius: radius.lg, - borderWidth: 1, + backgroundColor: withOpacity("#000000", 0.28), + }, + knowledgeMoveSheet: { + position: "absolute", + left: 12, + right: 12, + bottom: 12, + maxHeight: "58%", + borderRadius: radius.xl, + borderWidth: 0.5, borderColor: colors.border, - overflow: "hidden", + backgroundColor: colors.card, + padding: 12, + shadowColor: "#000", + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.22, + shadowRadius: 18, + elevation: 10, }, - editInput: { - minHeight: 80, + knowledgeMoveSheetHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + paddingBottom: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + }, + knowledgeMoveSheetTitleBlock: { flex: 1, minWidth: 0 }, + knowledgeMoveSheetTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + lineHeight: 19, + }, + knowledgeMoveSheetSubtitle: { + marginTop: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeMoveSheetClose: { + width: 32, + height: 32, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.md, backgroundColor: colors.muted, + }, + knowledgeRenameBody: { + gap: 12, + paddingTop: 12, + }, + knowledgeRenameInput: { + minHeight: 42, borderRadius: radius.md, - padding: 12, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 12, + paddingVertical: 9, fontSize: fontSize.sm, color: colors.foreground, - textAlignVertical: "top", }, - 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 }, - editTabTextActive: { color: colors.primary }, - editPreviewArea: { minHeight: 80, backgroundColor: colors.muted, borderRadius: radius.md, padding: 12 }, + knowledgeImportSheet: { + position: "absolute", + left: 10, + right: 10, + bottom: 10, + maxHeight: "82%", + borderRadius: radius.xl, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.card, + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 12, + shadowColor: "#000", + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.24, + shadowRadius: 18, + elevation: 12, + }, + knowledgeImportSheetHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + paddingBottom: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + }, + knowledgeImportSheetTitleWrap: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + }, + knowledgeImportSheetIcon: { + width: 32, + height: 32, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.22), + backgroundColor: withOpacity(colors.primary, 0.1), + alignItems: "center", + justifyContent: "center", + }, + knowledgeImportSheetTitleBlock: { flex: 1, minWidth: 0 }, + knowledgeImportSheetTitle: { + fontSize: fontSize.sm, + lineHeight: 19, + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeImportSheetSubtitle: { + marginTop: 3, + fontSize: fontSize.xs, + lineHeight: 17, + color: colors.mutedForeground, + }, + knowledgeImportSheetScroll: { marginTop: 10 }, + knowledgeImportSheetContent: { gap: 8, paddingBottom: 4 }, + knowledgeImportItem: { + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 11, + paddingVertical: 10, + }, + knowledgeImportItemHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + }, + knowledgeImportItemTitleBlock: { flex: 1, minWidth: 0 }, + knowledgeImportItemTitle: { + fontSize: fontSize.sm, + lineHeight: 18, + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeImportItemPath: { + marginTop: 2, + fontSize: 11, + lineHeight: 15, + color: colors.mutedForeground, + }, + knowledgeImportSourcePath: { + marginTop: 1, + fontSize: 10, + lineHeight: 14, + color: withOpacity(colors.mutedForeground, 0.78), + }, + knowledgeImportDestination: { + marginTop: 5, + minHeight: 18, + flexDirection: "row", + alignItems: "center", + gap: 5, + }, + knowledgeImportDestinationText: { + flex: 1, + minWidth: 0, + fontSize: 11, + lineHeight: 15, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeImportItemBadge: { + overflow: "hidden", + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 8, + paddingVertical: 4, + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeImportItemPreview: { + marginTop: 8, + fontSize: fontSize.xs, + lineHeight: 18, + color: colors.mutedForeground, + }, + knowledgeImportMetaRow: { + marginTop: 8, + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + knowledgeImportTag: { + maxWidth: 118, + overflow: "hidden", + borderRadius: radius.sm, + backgroundColor: colors.muted, + paddingHorizontal: 7, + paddingVertical: 4, + fontSize: 11, + lineHeight: 14, + color: colors.mutedForeground, + }, + knowledgeImportWarning: { + overflow: "hidden", + borderRadius: radius.sm, + backgroundColor: withOpacity("#f59e0b", 0.12), + paddingHorizontal: 7, + paddingVertical: 4, + fontSize: 11, + lineHeight: 14, + color: "#b45309", + fontWeight: fontWeight.medium, + }, + knowledgeImportHiddenText: { + paddingHorizontal: 2, + fontSize: fontSize.xs, + lineHeight: 16, + color: colors.mutedForeground, + }, + knowledgeImportFooter: { + gap: 10, + marginTop: 10, + paddingTop: 10, + borderTopWidth: 0.5, + borderTopColor: colors.border, + }, + knowledgeImportSafeHint: { + fontSize: fontSize.xs, + lineHeight: 17, + color: colors.mutedForeground, + }, + knowledgeImportFooterActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: 8, + }, + knowledgeImportSecondaryButton: { + minHeight: 36, + borderRadius: radius.md, + backgroundColor: colors.muted, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 14, + paddingVertical: 8, + }, + knowledgeImportSecondaryButtonText: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.medium, + }, + knowledgeImportPrimaryButton: { + minHeight: 36, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + borderRadius: radius.md, + backgroundColor: colors.primary, + paddingHorizontal: 14, + paddingVertical: 8, + }, + knowledgeImportButtonDisabled: { opacity: 0.62 }, + knowledgeImportPrimaryButtonText: { + fontSize: fontSize.sm, + color: colors.primaryForeground, + fontWeight: fontWeight.semibold, + }, + knowledgeContextSheet: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + maxHeight: "82%", + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + borderWidth: 0.5, + borderBottomWidth: 0, + borderColor: colors.border, + backgroundColor: colors.card, + paddingHorizontal: 14, + paddingTop: 8, + paddingBottom: 12, + shadowColor: "#000", + shadowOffset: { width: 0, height: -10 }, + shadowOpacity: 0.18, + shadowRadius: 20, + elevation: 12, + }, + knowledgeContextSheetHandle: { + alignSelf: "center", + width: 36, + height: 4, + borderRadius: radius.full, + backgroundColor: colors.border, + marginBottom: 10, + }, + knowledgeContextSheetHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + paddingBottom: 10, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + }, + knowledgeContextSheetTitleBlock: { flex: 1, minWidth: 0 }, + knowledgeContextSheetTitle: { + fontSize: fontSize.md, + color: colors.foreground, + fontWeight: fontWeight.semibold, + lineHeight: 21, + }, + knowledgeContextSheetSubtitle: { + marginTop: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeContextSheetScroll: { + marginTop: 12, + }, + knowledgeContextSheetContent: { + gap: 12, + paddingBottom: 18, + }, + knowledgeMoveTargetScroll: { marginTop: 10 }, + knowledgeMoveTargetList: { gap: 6, paddingBottom: 4 }, + knowledgeCreateTargetList: { marginTop: 10 }, + knowledgeMoveTarget: { + minHeight: 44, + flexDirection: "row", + alignItems: "center", + gap: 9, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingRight: 12, + paddingVertical: 8, + }, + knowledgeMoveTargetIcon: { + width: 28, + height: 28, + borderRadius: radius.md, + backgroundColor: colors.muted, + alignItems: "center", + justifyContent: "center", + }, + knowledgeMoveTargetTextBlock: { + flex: 1, + minWidth: 0, + gap: 2, + }, + knowledgeMoveTargetText: { + flex: 1, + minWidth: 0, + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.medium, + lineHeight: 18, + }, + knowledgeMoveTargetTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.medium, + lineHeight: 18, + }, + knowledgeMoveTargetPath: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeMoveTargetDangerText: { + color: colors.destructive, + }, + knowledgeTagWrap: { gap: 7 }, + knowledgeTagLabel: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeTagRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: 7, + }, + knowledgeTagChip: { + minHeight: 30, + maxWidth: 136, + flexDirection: "row", + alignItems: "center", + gap: 5, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.muted, + paddingHorizontal: 9, + paddingVertical: 6, + }, + knowledgeTagText: { + flexShrink: 1, + fontSize: fontSize.xs, + color: colors.foreground, + fontWeight: fontWeight.medium, + }, + knowledgeTagRemove: { + fontSize: fontSize.sm, + lineHeight: 16, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeTagInput: { + minWidth: 96, + minHeight: 30, + flexGrow: 1, + borderRadius: radius.md, + borderWidth: 0.5, + borderStyle: "dashed", + borderColor: colors.border, + paddingHorizontal: 9, + paddingVertical: 6, + fontSize: fontSize.xs, + color: colors.foreground, + }, + knowledgeTagInputPlaceholder: { + color: colors.mutedForeground, + }, + knowledgeExplorerCard: { + backgroundColor: colors.background, + borderRadius: radius.sm, + borderWidth: 0, + padding: 0, + gap: 7, + }, + knowledgeExplorerHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + paddingHorizontal: 0, + paddingTop: 1, + paddingBottom: 8, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + }, + knowledgeExplorerTitleBlock: { flex: 1, minWidth: 0 }, + knowledgeExplorerHint: { + marginTop: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeExplorerCreateButton: { + width: 32, + height: 32, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.28), + backgroundColor: withOpacity(colors.primary, 0.06), + alignItems: "center", + justifyContent: "center", + }, + knowledgeDocumentSearch: { + minHeight: 34, + flexDirection: "row", + alignItems: "center", + gap: 7, + borderRadius: radius.sm, + borderWidth: 0.5, + borderColor: withOpacity(colors.border, 0.62), + backgroundColor: withOpacity(colors.muted, 0.36), + paddingHorizontal: 10, + }, + knowledgeDocumentSearchInput: { + flex: 1, + minHeight: 32, + paddingVertical: 6, + fontSize: fontSize.xs, + color: colors.foreground, + }, + knowledgeTreeList: { + borderRadius: radius.sm, + backgroundColor: colors.background, + gap: 1, + borderTopWidth: 0.5, + borderTopColor: withOpacity(colors.border, 0.72), + borderLeftWidth: 0, + paddingTop: 4, + paddingBottom: 3, + paddingLeft: 0, + }, + knowledgeTreeNode: { + position: "relative", + minHeight: 40, + flexDirection: "row", + alignItems: "center", + gap: 5, + borderRadius: radius.sm, + borderWidth: 0.5, + borderColor: "transparent", + paddingRight: 10, + paddingVertical: 4, + }, + knowledgeTreeConnector: { + position: "absolute", + top: 7, + bottom: 7, + width: 0.5, + backgroundColor: colors.border, + }, + knowledgeTreeNodeActive: { + borderLeftWidth: 2, + borderLeftColor: colors.primary, + borderColor: withOpacity(colors.primary, 0.18), + backgroundColor: withOpacity(colors.primary, 0.045), + }, + knowledgeTreeNodeAncestor: { + borderLeftWidth: 2, + borderLeftColor: withOpacity(colors.border, 0.85), + borderColor: withOpacity(colors.border, 0.58), + backgroundColor: withOpacity(colors.mutedForeground, 0.045), + }, + knowledgeTreeToggle: { + width: 22, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + }, + knowledgeTreeToggleExpanded: { + transform: [{ rotate: "90deg" }], + }, + knowledgeTreeToggleSpacer: { width: 22, height: 28 }, + knowledgeTreeIcon: { + width: 20, + height: 22, + borderRadius: radius.sm, + backgroundColor: "transparent", + alignItems: "center", + justifyContent: "center", + }, + knowledgeTreeIconActive: { + backgroundColor: "transparent", + }, + knowledgeTreeTextBlock: { flex: 1, minWidth: 0 }, + knowledgeTreeTitle: { + fontSize: fontSize.xs, + color: colors.foreground, + fontWeight: fontWeight.semibold, + lineHeight: 17, + }, + knowledgeTreeTitleActive: { color: colors.primary }, + knowledgeTreeTitleAncestor: { color: colors.foreground }, + knowledgeTreeMeta: { + marginTop: 1, + fontSize: 11, + color: colors.mutedForeground, + lineHeight: 14, + }, + knowledgeTreeCount: { + minWidth: 22, + textAlign: "right", + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeTreeCountActive: { color: withOpacity(colors.primary, 0.78) }, + knowledgeDocumentEmptyResult: { + minHeight: 48, + borderRadius: radius.sm, + backgroundColor: colors.muted, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 12, + paddingVertical: 10, + }, + knowledgeDocumentEmptyResultText: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + textAlign: "center", + }, + knowledgeSectionTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + }, + knowledgeEditorFrame: { + minHeight: 0, + borderRadius: radius.lg, + borderWidth: 0.5, + borderColor: colors.border, + overflow: "hidden", + backgroundColor: colors.background, + }, + knowledgeFolderOverview: { + borderRadius: radius.sm, + borderWidth: 0, + backgroundColor: colors.background, + paddingHorizontal: 0, + paddingVertical: 0, + gap: 11, + }, + knowledgeFolderHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 0, + paddingTop: 2, + paddingBottom: 9, + backgroundColor: colors.background, + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + borderLeftWidth: 0, + }, + knowledgeFolderLead: { + flexDirection: "row", + alignItems: "flex-start", + gap: 11, + }, + knowledgeFolderLeadIcon: { + width: 38, + height: 38, + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.1), + alignItems: "center", + justifyContent: "center", + }, + knowledgeFolderLeadText: { flex: 1, minWidth: 0 }, + knowledgeFolderTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + lineHeight: 19, + }, + knowledgeFolderDescription: { + marginTop: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeFolderMeta: { + marginTop: 2, + fontSize: 11, + color: colors.mutedForeground, + lineHeight: 14, + fontWeight: fontWeight.medium, + }, + knowledgeFolderIconAction: { + minWidth: 31, + height: 31, + flexDirection: "row", + borderRadius: radius.sm, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", + gap: 4, + paddingHorizontal: 8, + }, + knowledgeFolderIconActionText: { + maxWidth: 46, + fontSize: 11, + color: colors.primary, + fontWeight: fontWeight.semibold, + lineHeight: 14, + }, + knowledgeFolderActionRow: { + flexDirection: "row", + gap: 8, + marginTop: 14, + width: "100%", + }, + knowledgeFolderAction: { + flex: 1, + minHeight: 38, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + borderRadius: radius.sm, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.card, + paddingHorizontal: 10, + paddingVertical: 8, + }, + knowledgeFolderActionText: { + fontSize: fontSize.xs, + color: colors.primary, + fontWeight: fontWeight.semibold, + }, + knowledgeFolderEmpty: { + minHeight: 116, + borderRadius: 0, + borderWidth: 0.5, + borderStyle: "dashed", + borderColor: colors.border, + backgroundColor: withOpacity(colors.muted, 0.24), + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 18, + paddingVertical: 18, + }, + knowledgeFolderEmptyTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + textAlign: "center", + }, + knowledgeFolderEmptyHint: { + marginTop: 6, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 18, + textAlign: "center", + }, + knowledgeFolderItemList: { + gap: 0, + borderTopWidth: 0.5, + borderBottomWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + }, + knowledgeFolderGroupStack: { gap: 14 }, + knowledgeFolderGroup: { gap: 6 }, + knowledgeFolderGroupHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderBottomWidth: 0.5, + borderBottomColor: colors.border, + paddingHorizontal: 2, + paddingBottom: 6, + }, + knowledgeFolderGroupTitle: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.semibold, + textTransform: "uppercase", + }, + knowledgeFolderGroupCount: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeFolderItem: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: 8, + borderRadius: 0, + borderWidth: 0, + borderBottomWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 0, + paddingVertical: 6, + }, + knowledgeFolderItemMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + knowledgeFolderItemMore: { + width: 32, + height: 32, + borderRadius: radius.sm, + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + }, + knowledgeFolderItemIcon: { + width: 22, + height: 28, + borderRadius: radius.sm, + backgroundColor: "transparent", + alignItems: "center", + justifyContent: "center", + }, + knowledgeFolderIcon: { + backgroundColor: "transparent", + borderWidth: 0, + borderColor: "transparent", + }, + knowledgeFolderItemText: { flex: 1, minWidth: 0 }, + knowledgeFolderItemTitle: { + fontSize: fontSize.sm, + color: colors.foreground, + fontWeight: fontWeight.semibold, + lineHeight: 18, + }, + knowledgeFolderItemMeta: { + marginTop: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, + knowledgeFolderItemCount: { + minWidth: 22, + paddingHorizontal: 2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + textAlign: "center", + }, + knowledgeSourcesCard: { + backgroundColor: colors.card, + borderRadius: radius.md, + 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, + }, + knowledgeRelationLoading: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + }, + knowledgeOutlineTitleWrap: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + knowledgeOutlineList: { + gap: 4, + }, + knowledgeOutlineRow: { + minHeight: 32, + flexDirection: "row", + alignItems: "center", + gap: 8, + borderRadius: radius.md, + backgroundColor: colors.background, + paddingRight: 10, + paddingVertical: 6, + }, + knowledgeOutlineDot: { + width: 5, + height: 5, + borderRadius: radius.full, + backgroundColor: withOpacity(colors.primary, 0.52), + }, + knowledgeOutlineLevel: { + width: 22, + fontSize: 10, + lineHeight: 13, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeOutlineText: { + flex: 1, + minWidth: 0, + fontSize: fontSize.xs, + lineHeight: 17, + color: colors.foreground, + fontWeight: fontWeight.medium, + }, + knowledgeRelationGroupTitle: { + marginTop: 2, + marginBottom: -2, + fontSize: fontSize.xs, + color: colors.mutedForeground, + fontWeight: fontWeight.medium, + }, + knowledgeSummaryCard: { + backgroundColor: colors.card, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: colors.border, + padding: 12, + gap: 10, + }, + knowledgeSummaryHeader: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 10, + }, + knowledgeSummaryTitleWrap: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: 9, + }, + knowledgeSummaryIcon: { + width: 30, + height: 30, + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.12), + alignItems: "center", + justifyContent: "center", + }, + knowledgeSummaryTitleTextWrap: { + flex: 1, + minWidth: 0, + gap: 2, + }, + knowledgeSummaryStatus: { + fontSize: fontSize.xs, + lineHeight: 16, + fontWeight: fontWeight.medium, + }, + knowledgeSummaryButton: { + minHeight: 34, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 5, + borderRadius: radius.md, + borderWidth: 0.5, + borderColor: withOpacity(colors.primary, 0.28), + backgroundColor: withOpacity(colors.primary, 0.08), + paddingHorizontal: 10, + paddingVertical: 7, + }, + knowledgeSummaryButtonDisabled: { + opacity: 0.64, + }, + knowledgeSummaryButtonText: { + fontSize: fontSize.xs, + color: colors.primary, + fontWeight: fontWeight.semibold, + }, + knowledgeSummaryPreview: { + maxHeight: 170, + overflow: "hidden", + borderRadius: radius.lg, + borderWidth: 0.5, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 12, + paddingTop: 10, + paddingBottom: 9, + }, + knowledgeSummaryEmpty: { + borderRadius: radius.lg, + backgroundColor: colors.muted, + color: colors.mutedForeground, + fontSize: fontSize.sm, + lineHeight: 20, + paddingHorizontal: 12, + paddingVertical: 14, + }, + knowledgeSummaryUpdated: { + marginTop: 8, + paddingTop: 7, + borderTopWidth: 0.5, + borderTopColor: colors.border, + fontSize: 11, + color: colors.mutedForeground, + }, + knowledgeSummaryMarkdownBody: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 18, + }, + knowledgeSummaryMarkdownHeading: { + color: colors.foreground, + fontSize: fontSize.xs, + lineHeight: 18, + fontWeight: fontWeight.semibold, + marginTop: 0, + marginBottom: 4, + }, + knowledgeSummaryMarkdownParagraph: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 18, + marginTop: 0, + marginBottom: 5, + }, + knowledgeSummaryMarkdownList: { + marginTop: 0, + marginBottom: 4, + }, + knowledgeSummaryMarkdownListItem: { + color: colors.mutedForeground, + fontSize: fontSize.xs, + lineHeight: 18, + marginBottom: 2, + }, + knowledgeSummaryMarkdownStrong: { fontWeight: fontWeight.semibold }, + knowledgeSummaryMarkdownEm: { fontStyle: "italic" }, + knowledgeSummaryMarkdownLink: { color: colors.primary, textDecorationLine: "none" }, + knowledgeSummaryMarkdownQuote: { + borderLeftWidth: 2, + borderLeftColor: withOpacity(colors.primary, 0.4), + paddingLeft: 8, + marginLeft: 0, + marginVertical: 4, + backgroundColor: "transparent", + }, + knowledgeSummaryMarkdownCode: { + backgroundColor: colors.muted, + color: colors.foreground, + fontSize: fontSize.xs, + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: radius.sm, + }, + 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, + }, + knowledgeSourceActionRow: { + marginTop: 9, + paddingTop: 8, + borderTopWidth: 0.5, + borderTopColor: colors.border, + alignItems: "flex-end", + }, + knowledgeSourceAction: { + minHeight: 30, + flexDirection: "row", + alignItems: "center", + gap: 5, + borderRadius: radius.sm, + paddingHorizontal: 8, + paddingVertical: 5, + backgroundColor: withOpacity(colors.primary, 0.08), + }, + knowledgeSourceActionText: { + fontSize: fontSize.xs, + color: colors.primary, + fontWeight: fontWeight.semibold, + }, + 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 }, + chapterName: { + fontSize: fontSize.xs, + fontWeight: fontWeight.medium, + color: colors.mutedForeground, + paddingHorizontal: 8, + }, + noteCard: { + backgroundColor: colors.card, + borderRadius: radius.lg, + borderWidth: 0.5, + borderColor: colors.border, + padding: 12, + marginBottom: 8, + }, + noteCardTop: { flexDirection: "row", alignItems: "flex-start", gap: 8 }, + colorDot: { width: 10, height: 10, borderRadius: 5, marginTop: 4 }, + noteQuote: { flex: 1, fontSize: fontSize.sm, color: colors.foreground, lineHeight: 20 }, + noteBody: { + marginTop: 8, + backgroundColor: colors.muted, + borderRadius: radius.sm, + paddingHorizontal: 8, + paddingVertical: 6, + }, + noteText: { fontSize: fontSize.xs, color: colors.mutedForeground, lineHeight: 16 }, + noteActions: { flexDirection: "row", justifyContent: "flex-end", gap: 4, marginTop: 8 }, + noteActionBtn: { padding: 6, borderRadius: radius.sm }, + editArea: { marginTop: 8 }, + editorContainer: { + flex: 1, + minHeight: 180, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.border, + overflow: "hidden", + }, + editInput: { + minHeight: 80, + backgroundColor: colors.muted, + borderRadius: radius.md, + padding: 12, + fontSize: fontSize.sm, + color: colors.foreground, + textAlignVertical: "top", + }, + 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, + }, + editTabTextActive: { color: colors.primary }, + editPreviewArea: { + minHeight: 80, + backgroundColor: colors.muted, + borderRadius: radius.md, + padding: 12, + }, editActions: { flexDirection: "row", justifyContent: "flex-end", gap: 8, marginTop: 8 }, editCancelBtn: { flexDirection: "row", @@ -281,7 +1625,7 @@ export const makeStyles = (colors: ThemeColors) => position: "absolute", top: 56, right: 16, - minWidth: 140, + minWidth: 176, backgroundColor: colors.card, borderRadius: radius.lg, borderWidth: 0.5, @@ -294,5 +1638,7 @@ export const makeStyles = (colors: ThemeColors) => shadowRadius: 8, }, exportItem: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: radius.md }, + exportItemDisabled: { opacity: 0.5 }, + exportDivider: { height: 0.5, backgroundColor: colors.border, marginVertical: 4 }, exportItemText: { fontSize: fontSize.sm, color: colors.foreground }, }); diff --git a/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx b/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx index 061386b4..aa8b7aaa 100644 --- a/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx +++ b/packages/app-expo/src/screens/reader/ReaderNoteViewModal.tsx @@ -3,14 +3,23 @@ */ import { MarkdownRenderer } from "@/components/chat/MarkdownRenderer"; import { CheckIcon, EditIcon, XIcon } from "@/components/ui/Icon"; -import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { RichTextEditor } from "@/components/ui/RichTextEditor"; +import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { useAnnotationStore } from "@/stores"; import { useColors } from "@/styles/theme"; import { createSelectionNoteMutation } from "@readany/core/reader"; -import { KeyboardAvoidingView, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; +import { + KeyboardAvoidingView, + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { makeStyles } from "./reader-styles"; export type NoteViewHighlight = { @@ -51,21 +60,12 @@ export function ReaderNoteViewModal({ const { t } = useTranslation(); return ( - + - + t.id === activeThreadId); + const loadBooks = useLibraryStore((s) => s.loadBooks); const handleSend = useCallback( async (content: string, deepThinking = false, spoilerFree = false) => { @@ -268,11 +271,40 @@ export function ChatPage() { setGeneralActiveThread(null); }, [setGeneralActiveThread]); - const handleCitationClick = useCallback((citation: CitationPart) => { - // TODO: Navigate to reader page with this citation - // For now, log to console. Future enhancement: use router to navigate to /reader/${citation.bookId}?cfi=${citation.cfi} - console.log("Citation clicked:", citation); - }, []); + const handleCitationClick = useCallback( + async (citation: CitationPart) => { + const bookId = citation.bookId?.trim(); + if (!bookId) { + toast.error(t("chat.citationBookMissing", "引用没有关联书籍")); + return; + } + + let book = useLibraryStore.getState().books.find((item) => item.id === bookId); + if (!book) { + await loadBooks(); + book = useLibraryStore.getState().books.find((item) => item.id === bookId); + } + + if (!book) { + toast.error(t("library.bookNotFound", "书籍不存在")); + return; + } + + const opened = await openDesktopBook({ + book, + t, + initialCfi: citation.cfi?.trim() || undefined, + }); + if (!opened) { + return; + } + + if (!citation.cfi?.trim()) { + toast.message(t("chat.citationOpenedBook", "已打开书籍,当前引用没有精确位置")); + } + }, + [loadBooks, t], + ); const displayMessages = convertToMessageV2(activeThread?.messages || []); const allMessages = mergeMessagesWithStreaming(displayMessages, currentMessage, isStreaming); diff --git a/packages/app/src/components/chat/PartRenderer.tsx b/packages/app/src/components/chat/PartRenderer.tsx index 47f6b928..d60fc4b9 100644 --- a/packages/app/src/components/chat/PartRenderer.tsx +++ b/packages/app/src/components/chat/PartRenderer.tsx @@ -2,7 +2,24 @@ * 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 { getKnowledgeCardTemplates } from "@/lib/db/database"; +import { + getKnowledgeToolResultDisplay, + getToolResultError, + maybeCompressKnowledgeDocumentsById, +} from "@readany/core/ai"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { KnowledgeToolResultDisplay } from "@readany/core/ai"; +import { + type KnowledgeProposalApplyResult, + type KnowledgeWriteProposal, + applyKnowledgeWriteProposal, + createKnowledgeWriteProposalPreview, + getKnowledgeProposalApplyErrorDetails, + getKnowledgeWriteProposal, +} from "@readany/core/knowledge/proposals"; import type { AbortedPart, CitationPart, @@ -12,28 +29,74 @@ import type { TextPart, ToolCallPart, } from "@readany/core/types/message"; -import { cn } from "@readany/core/utils"; +import type { KnowledgeCardTemplate } from "@readany/core/types"; +import { cn, providerRequiresApiKey } from "@readany/core/utils"; +import { eventBus } from "@readany/core/utils/event-bus"; import { Brain, CheckCircle, ChevronDown, Circle, + FileText, Loader2, OctagonX, 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; +let knowledgeCardTemplatesCache: KnowledgeCardTemplate[] | null = null; +let knowledgeCardTemplatesPromise: Promise | null = null; + // Lazy load MindmapView to avoid bundling markmap for non-mindmap messages const LazyMindmapView = lazy(() => import("@/components/common/MindmapView").then((m) => ({ default: m.MindmapView })), ); +function queueKnowledgeProposalSummaryMaintenance(documentId: string | undefined): void { + if (!documentId) return; + + const { aiConfig } = useSettingsStore.getState(); + const endpoint = aiConfig.endpoints.find((item) => item.id === aiConfig.activeEndpointId); + const needsKey = endpoint ? providerRequiresApiKey(endpoint.provider) : true; + if (!endpoint || (needsKey && !endpoint.apiKey) || !aiConfig.activeModel) return; + + void maybeCompressKnowledgeDocumentsById([documentId], aiConfig).catch((error) => { + console.warn("[KnowledgeProposal] Background summary maintenance failed:", error); + }); +} + +function loadKnowledgeCardTemplatesForPreview(): Promise { + if (knowledgeCardTemplatesCache) return Promise.resolve(knowledgeCardTemplatesCache); + if (knowledgeCardTemplatesPromise) return knowledgeCardTemplatesPromise; + + knowledgeCardTemplatesPromise = getKnowledgeCardTemplates({ includeDisabled: true }) + .then((templates) => { + knowledgeCardTemplatesCache = templates; + return templates; + }) + .catch((error) => { + console.warn("[KnowledgeProposal] Failed to load card templates:", error); + knowledgeCardTemplatesCache = []; + return []; + }) + .finally(() => { + knowledgeCardTemplatesPromise = null; + }); + + return knowledgeCardTemplatesPromise; +} + +function clearKnowledgeCardTemplatesForPreview(): void { + knowledgeCardTemplatesCache = null; + knowledgeCardTemplatesPromise = null; +} + function useThrottledText(text: string): string { const [throttledText, setThrottledText] = useState(text); const lastUpdateRef = useRef(0); @@ -216,15 +279,499 @@ const TOOL_LABEL_KEYS: Record = { fallbackToc: "toolLabels.fallbackToc", fallbackSearch: "toolLabels.fallbackSearch", fallbackChapterContext: "toolLabels.fallbackChapterContext", + searchKnowledgeBase: "toolLabels.searchKnowledgeBase", + getKnowledgeDocument: "toolLabels.getKnowledgeDocument", + getBookKnowledge: "toolLabels.getBookKnowledge", + proposeKnowledgeDocumentCreate: "toolLabels.proposeKnowledgeDocumentCreate", + proposeKnowledgeDocumentUpdate: "toolLabels.proposeKnowledgeDocumentUpdate", + proposeKnowledgeDocumentTagsUpdate: "toolLabels.proposeKnowledgeDocumentTagsUpdate", + proposeKnowledgeLinkCreate: "toolLabels.proposeKnowledgeLinkCreate", + compressKnowledgeDocumentSummary: "toolLabels.compressKnowledgeDocumentSummary", +}; + +type KnowledgeProposalApplyState = "idle" | "applying" | "applied" | "failed"; +type KnowledgeOpenDocumentSource = "ai_result" | "ai_relation" | "ai_proposal"; +type KnowledgeOpenDocumentTarget = { + id?: string; + bookId?: string; + title?: string; + path?: string; +}; + +function requestKnowledgeDocumentOpen( + document: KnowledgeOpenDocumentTarget, + source: KnowledgeOpenDocumentSource, +): boolean { + if (!document.id) return false; + let handled = false; + eventBus.emit("knowledge:open-document", { + documentId: document.id, + bookId: document.bookId, + title: document.title, + path: document.path, + source, + timestamp: Date.now(), + respond: (nextHandled) => { + handled = handled || nextHandled; + }, + }); + return handled; +} + +const KNOWLEDGE_DOCUMENT_TYPE_KEYS: Record = { + book_home: "knowledgeProposal.types.bookHome", + folder: "knowledgeProposal.types.folder", + standalone_note: "knowledgeProposal.types.standaloneNote", + highlight_note: "knowledgeProposal.types.highlightNote", + review: "knowledgeProposal.types.review", + summary: "knowledgeProposal.types.summary", + imported_markdown: "knowledgeProposal.types.importedMarkdown", +}; + +const KNOWLEDGE_CHANGED_FIELD_KEYS: Record = { + parentId: "knowledgeProposal.fields.parentFolder", + title: "knowledgeProposal.fields.title", + contentMd: "knowledgeProposal.fields.content", + contentJson: "knowledgeProposal.fields.content", + excerpt: "knowledgeProposal.fields.content", + tags: "knowledgeProposal.fields.tags", }; +function formatKnowledgeChangedFields( + fields: string[], + t: (key: string, options?: Record) => string, +): string[] { + return [ + ...new Set( + fields.map((field) => + t(KNOWLEDGE_CHANGED_FIELD_KEYS[field] ?? `knowledgeProposal.fields.${field}`, { + defaultValue: field, + }), + ), + ), + ]; +} + +function KnowledgeToolResultDocumentRows({ + documents, + max = 5, +}: { + documents: KnowledgeToolResultDisplay["documents"]; + max?: number; +}) { + const { t } = useTranslation(); + const handleOpenDocument = (document: KnowledgeToolResultDisplay["documents"][number]) => { + if (requestKnowledgeDocumentOpen(document, "ai_result")) return; + toast.info( + t("knowledgeToolResult.openUnavailable", { + defaultValue: "Open the book knowledge tab to view this document.", + }), + ); + }; + + return ( + <> + {documents.slice(0, max).map((document) => { + const matchFieldLabels = + document.matchFields?.map((field) => + t(`knowledgeToolResult.matchFields.${field}`, { defaultValue: field }), + ) ?? []; + + return ( +
+
+
+
+ {document.title} +
+ {document.path ? ( +
+ {document.path} +
+ ) : null} +
+ {document.type ? ( +
+ + {t(`knowledgeToolResult.types.${document.type}`, { + defaultValue: document.type, + })} + + {document.id ? ( + + ) : null} +
+ ) : document.id ? ( + + ) : null} +
+ {matchFieldLabels.length > 0 ? ( +
+ {t("knowledgeToolResult.matchedIn", { + fields: matchFieldLabels.join(", "), + defaultValue: `Matched in ${matchFieldLabels.join(", ")}`, + })} +
+ ) : null} + {document.snippet ? ( +

+ {document.snippet} +

+ ) : null} +
+ ); + })} + + ); +} + +function KnowledgeToolResultRelationRows({ + relations = [], + max = 4, +}: { + relations?: NonNullable; + max?: number; +}) { + const { t } = useTranslation(); + if (relations.length === 0) return null; + + const handleOpenDocument = (document: KnowledgeToolResultDisplay["documents"][number]) => { + if (requestKnowledgeDocumentOpen(document, "ai_relation")) return; + toast.info( + t("knowledgeToolResult.openUnavailable", { + defaultValue: "Open the book knowledge tab to view this document.", + }), + ); + }; + + return ( +
+
+ {t("knowledgeToolResult.relations", { defaultValue: "Related paths" })} +
+
+ {relations.slice(0, max).map((relation) => { + const direction = + relation.direction === "outgoing" + ? t("knowledgeToolResult.relationOutgoing", { defaultValue: "Links to" }) + : t("knowledgeToolResult.relationBacklink", { defaultValue: "Linked from" }); + const relationLabel = relation.label || relation.relation; + + return ( +
+ + {direction} + +
+
+ + {relation.document.title} + + {relationLabel ? ( + + {relationLabel} + + ) : null} +
+ {relation.document.path ? ( +
+ {relation.document.path} +
+ ) : null} + {relation.document.id ? ( + + ) : null} +
+
+ ); + })} +
+ {relations.length > max ? ( +
+ {t("knowledgeToolResult.moreRelations", { + count: relations.length - max, + defaultValue: `+${relations.length - max} more relations`, + })} +
+ ) : null} +
+ ); +} + +function KnowledgeToolResultCard({ display }: { display: KnowledgeToolResultDisplay }) { + const { t } = useTranslation(); + const toolLabel = + display.toolName && TOOL_LABEL_KEYS[display.toolName] + ? t(TOOL_LABEL_KEYS[display.toolName]) + : display.toolName; + const title = + display.kind === "failure" + ? t("knowledgeToolResult.failureTitle", { + tool: toolLabel || t("knowledgeToolResult.tool", { defaultValue: "Knowledge tool" }), + defaultValue: `${toolLabel || "Knowledge tool"} failed`, + }) + : display.kind === "search" + ? t("knowledgeToolResult.searchTitle", { defaultValue: "Knowledge search results" }) + : display.kind === "document" + ? t("knowledgeToolResult.documentTitle", { defaultValue: "Knowledge document read" }) + : display.kind === "bookKnowledge" + ? t("knowledgeToolResult.bookKnowledgeTitle", { defaultValue: "Book knowledge read" }) + : t("knowledgeToolResult.summaryTitle", { defaultValue: "Knowledge memory updated" }); + + const countText = + display.kind === "failure" + ? [ + display.status + ? t("knowledgeToolResult.status", { + status: display.status, + defaultValue: `Status: ${display.status}`, + }) + : null, + display.documentId + ? t("knowledgeToolResult.documentId", { + id: display.documentId, + defaultValue: `Document: ${display.documentId}`, + }) + : null, + ] + .filter(Boolean) + .join(" · ") + : display.kind === "summary" + ? [ + display.status + ? t("knowledgeToolResult.status", { + status: display.status, + defaultValue: `Status: ${display.status}`, + }) + : null, + display.persisted !== undefined + ? display.persisted + ? t("knowledgeToolResult.persisted", { defaultValue: "Persisted" }) + : t("knowledgeToolResult.notPersisted", { defaultValue: "Not persisted" }) + : null, + ] + .filter(Boolean) + .join(" · ") + : display.kind === "document" + ? display.documentId + ? t("knowledgeToolResult.documentId", { + id: display.documentId, + defaultValue: `Document: ${display.documentId}`, + }) + : "" + : t("knowledgeToolResult.count", { + total: display.total ?? display.documents.length, + showing: display.showing ?? display.documents.length, + defaultValue: `${display.documents.length} document(s)`, + }); + const writeSafetyLabel = t( + `knowledgeToolResult.writeSafety.${display.writeSafety.state}.label`, + { + defaultValue: display.writeSafety.label, + }, + ); + const writeSafetyDescription = t( + `knowledgeToolResult.writeSafety.${display.writeSafety.state}.description`, + { + defaultValue: display.writeSafety.description, + }, + ); + + return ( +
+
+
+
+ {title} +
+ {countText ? ( +
{countText}
+ ) : null} +
+ {display.kind === "summary" && display.sourceChars ? ( +
+ {t("knowledgeToolResult.sourceChars", { + count: display.sourceChars, + defaultValue: `${display.sourceChars} chars`, + })} +
+ ) : null} +
+ +
+
+
{writeSafetyLabel}
+
+ {writeSafetyDescription} +
+
+ {display.kind === "failure" ? ( + <> +
+
+ {display.error || + t("knowledgeToolResult.failureUnknown", { + defaultValue: "Tool execution failed", + })} +
+ {display.reason ? ( +
+ {t("knowledgeToolResult.reason", { + reason: display.reason, + defaultValue: `Reason: ${display.reason}`, + })} +
+ ) : null} +
+ {t("knowledgeToolResult.failureSafeHint", { + defaultValue: + display.safeNoWriteHint || + "This failed tool call did not write to the knowledge base or change your documents.", + })} +
+
+ {display.documents.length > 0 ? ( + + ) : null} + + ) : display.kind === "summary" ? ( + <> + {display.documents.length > 0 ? ( + + ) : display.documentId ? ( +
+ {t("knowledgeToolResult.documentId", { + id: display.documentId, + defaultValue: `Document: ${display.documentId}`, + })} +
+ ) : null} + {display.reason ? ( +

+ {t("knowledgeToolResult.reason", { + reason: display.reason, + defaultValue: `Reason: ${display.reason}`, + })} +

+ ) : null} + {display.summaryPreview ? ( +
+ {display.summaryPreview} +
+ ) : null} + + ) : display.documents.length === 0 ? ( +

+ {t("knowledgeToolResult.empty", { defaultValue: "No matching knowledge documents." })} +

+ ) : ( + <> + + + + )} + + {display.documents.length > 5 ? ( +
+ {t("knowledgeToolResult.more", { + count: display.documents.length - 5, + defaultValue: `+${display.documents.length - 5} more`, + })} +
+ ) : null} +
+
+ ); +} + function ToolCallPartView({ part }: { part: ToolCallPart }) { const { t } = useTranslation(); - const hasError = part.status === "error" || Boolean(part.error); + const toolResultError = useMemo(() => getToolResultError(part.result), [part.result]); + const hasError = part.status === "error" || Boolean(part.error) || Boolean(toolResultError); + const proposal = useMemo(() => getKnowledgeWriteProposal(part.result), [part.result]); + const errorMessage = part.error || toolResultError || ""; + const knowledgeResult = useMemo( + () => getKnowledgeToolResultDisplay(part.name, part.result, { error: errorMessage }), + [errorMessage, part.name, part.result], + ); - const [isOpen, setIsOpen] = useState(hasError); + const [isOpen, setIsOpen] = useState(hasError || Boolean(proposal) || Boolean(knowledgeResult)); + const [proposalApplyState, setProposalApplyState] = useState("idle"); + const [proposalApplyResult, setProposalApplyResult] = + useState(null); + const [proposalApplyError, setProposalApplyError] = useState(null); const getStatusIcon = () => { + if (hasError) return ; + switch (part.status) { case "pending": return ; @@ -242,15 +789,38 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { const label = TOOL_LABEL_KEYS[part.name] ? t(TOOL_LABEL_KEYS[part.name]) : part.name; const queryText = part.args.query ? String(part.args.query) : ""; const scopeText = part.args.scope ? String(part.args.scope) : ""; - const errorMessage = - part.error || - (part.result && typeof part.result === "object" - ? String((part.result as Record).error || "") - : ""); - useEffect(() => { - if (hasError) setIsOpen(true); - }, [hasError]); + if (hasError || proposal || knowledgeResult) setIsOpen(true); + setProposalApplyState("idle"); + setProposalApplyResult(null); + setProposalApplyError(null); + }, [hasError, proposal, knowledgeResult]); + + const handleApplyProposal = async () => { + if (!proposal || proposalApplyState === "applying" || proposalApplyState === "applied") return; + setProposalApplyState("applying"); + setProposalApplyError(null); + try { + const result = await applyKnowledgeWriteProposal(proposal); + if (proposal.action !== "link") { + queueKnowledgeProposalSummaryMaintenance(result.documentId); + } + setProposalApplyResult(result); + setProposalApplyState("applied"); + toast.success(t("knowledgeProposal.applySuccess")); + } catch (error) { + const details = getKnowledgeProposalApplyErrorDetails(error); + const message = details + ? t(details.i18nKey, { defaultValue: details.message }) + : error instanceof Error + ? error.message + : t("knowledgeProposal.applyFailed"); + setProposalApplyError(message); + setProposalApplyState("failed"); + console.error("[KnowledgeProposal] Failed to apply proposal:", error); + toast.error(message); + } + }; return (
@@ -277,6 +847,22 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {t("streaming.toolFailed")} )} + {proposal && !hasError && ( + + {proposalApplyState === "applied" + ? t("knowledgeProposal.savedBadge") + : proposalApplyState === "failed" + ? t("knowledgeProposal.failedBadge") + : t("knowledgeProposal.pendingBadge")} + + )} {queryText && ( {queryText.slice(0, 50)} @@ -298,6 +884,14 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) {
+ {hasError && knowledgeResult?.kind !== "failure" && ( +
+
{t("streaming.toolFailedDetail")}
+
{errorMessage || t("streaming.toolFailed")}
+
{t("streaming.toolFailedHint")}
+
+ )} + {part.reasoning && (

{part.reasoning}

@@ -315,7 +909,7 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {key}:{" "} {typeof value === "string" && value.length > 100 - ? value.slice(0, 100) + "..." + ? `${value.slice(0, 100)}...` : String(value)}
@@ -329,20 +923,25 @@ 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)}
-                    
-
-
- )} - - {hasError && ( -
-
{t("streaming.toolFailedDetail")}
-
{errorMessage || t("streaming.toolFailed")}
+ {proposal ? ( + + ) : knowledgeResult ? ( + + ) : ( +
+
+                        {typeof part.result === "string" && part.result.length > 500
+                          ? `${part.result.slice(0, 500)}...`
+                          : JSON.stringify(part.result, null, 2)}
+                      
+
+ )}
)}
@@ -353,6 +952,272 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { ); } +function KnowledgeProposalCard({ + proposal, + applyState, + applyResult, + applyError, + onApply, +}: { + proposal: KnowledgeWriteProposal; + applyState: KnowledgeProposalApplyState; + applyResult: KnowledgeProposalApplyResult | null; + applyError: string | null; + onApply: () => void; +}) { + const { t } = useTranslation(); + const [cardTemplates, setCardTemplates] = useState( + () => knowledgeCardTemplatesCache ?? [], + ); + useEffect(() => { + let mounted = true; + const refreshTemplates = () => { + void loadKnowledgeCardTemplatesForPreview().then((templates) => { + if (mounted) setCardTemplates(templates); + }); + }; + const invalidateTemplates = () => { + clearKnowledgeCardTemplatesForPreview(); + refreshTemplates(); + }; + + refreshTemplates(); + const offTemplateChange = eventBus.on("knowledge:card-templates-changed", invalidateTemplates); + const offSyncCompleted = eventBus.on("sync:completed", invalidateTemplates); + + return () => { + mounted = false; + offTemplateChange(); + offSyncCompleted(); + }; + }, []); + const preview = useMemo( + () => createKnowledgeWriteProposalPreview(proposal, { cardTemplates }), + [cardTemplates, proposal], + ); + const actionLabel = + preview.action === "create" + ? t("knowledgeProposal.create") + : preview.action === "update" + ? t("knowledgeProposal.update") + : t("knowledgeProposal.link"); + const typeLabel = preview.documentType + ? t(KNOWLEDGE_DOCUMENT_TYPE_KEYS[preview.documentType], { + defaultValue: preview.documentType, + }) + : t( + preview.action === "link" + ? "knowledgeProposal.types.knowledgeLink" + : "knowledgeProposal.types.knowledgeDocument", + ); + const changedFieldLabels = formatKnowledgeChangedFields(preview.changedFields, t); + const proposalSafetyLabel = t( + `knowledgeProposal.writeSafety.${preview.writeSafety.state}.label`, + { + defaultValue: preview.writeSafety.label, + }, + ); + const proposalSafetyDescription = t( + `knowledgeProposal.writeSafety.${preview.writeSafety.state}.description`, + { + defaultValue: preview.writeSafety.description, + }, + ); + const openTarget = useMemo(() => { + if (applyState !== "applied" || !applyResult?.documentId) return null; + if (proposal.action === "create") { + return { + id: applyResult.documentId, + bookId: proposal.draft.bookId, + title: proposal.draft.title, + path: preview.targetPath ?? preview.visiblePath, + }; + } + if (proposal.action === "update") { + return { + id: applyResult.documentId, + bookId: proposal.current?.bookId, + title: proposal.patch.title ?? proposal.current?.title, + path: preview.targetPath ?? proposal.current?.path ?? preview.visiblePath, + }; + } + return { + id: applyResult.documentId, + bookId: proposal.source?.bookId, + title: proposal.source?.title, + path: proposal.source?.path ?? preview.currentPath, + }; + }, [applyResult, applyState, preview, proposal]); + const handleOpenAppliedDocument = () => { + if (!openTarget) return; + if (requestKnowledgeDocumentOpen(openTarget, "ai_proposal")) return; + toast.info( + t("knowledgeToolResult.openUnavailable", { + defaultValue: "Open the book knowledge tab to view this document.", + }), + ); + }; + + return ( +
+
+
+
+
{actionLabel}
+
{preview.title}
+ {preview.visiblePath ? ( +
+ + {preview.visiblePath} +
+ ) : null} +
+
+ {typeLabel} +
+
+
+ +
+
+
{proposalSafetyLabel}
+
{proposalSafetyDescription}
+
+ + {preview.tags.length > 0 && ( +
+ {preview.tags.slice(0, 6).map((tag) => ( + + {tag} + + ))} + {preview.tags.length > 6 && ( + + +{preview.tags.length - 6} + + )} +
+ )} + + {changedFieldLabels.length > 0 && ( +
+
+ {t("knowledgeProposal.changes")} +
+
{changedFieldLabels.join(", ")}
+
+ )} + + {preview.visiblePath && ( +
+
+ {t("knowledgeProposal.location")} +
+ {preview.hasPathChange ? ( +
+
+ {preview.currentPath} +
+
+ → {preview.targetPath} +
+
+ ) : ( +
+ {preview.visiblePath} +
+ )} +
+ )} + + {(preview.contentPreview || preview.contentPreviewHtml) && ( +
+
+ {t("knowledgeProposal.contentPreview")} +
+ {preview.contentPreviewHtml ? ( +
+ ) : ( +
+ {preview.contentPreview.length > 520 + ? `${preview.contentPreview.slice(0, 520)}...` + : preview.contentPreview} +
+ )} +
+ )} + + {applyState === "failed" ? ( +
+
{t("knowledgeProposal.applyFailed")}
+ {applyError ? ( +
{applyError}
+ ) : null} +
+ {t("knowledgeProposal.applyFailedSafeHint")} +
+
+ ) : null} + +
+ {openTarget ? ( + + ) : null} + +
+
+
+ ); +} + function MindmapPartView({ part }: { part: MindmapPart }) { const { t } = useTranslation(); return ( diff --git a/packages/app/src/components/knowledge/KnowledgeEditor.tsx b/packages/app/src/components/knowledge/KnowledgeEditor.tsx new file mode 100644 index 00000000..64efd280 --- /dev/null +++ b/packages/app/src/components/knowledge/KnowledgeEditor.tsx @@ -0,0 +1,3993 @@ +import { + disableKnowledgeCardTemplate, + getKnowledgeCardTemplates, + upsertKnowledgeCardTemplate, +} from "@/lib/db/database"; +import { + type KnowledgeEditorFeature, + type KnowledgeEditorSurface, + type KnowledgeEditorTier, + READANY_ATTACHMENT_URI_PREFIX, + type ReadAnyCardAttrs, + type ReadAnyCardTemplateField, + builtInReadAnyCards, + createCustomReadAnyCardTemplate, + createDefaultReadAnyCardAttrs, + createKnowledgeEditorDraftKey, + createReadAnyCardAttrsFromTemplate, + createReadAnyCardReadOnlyModel, + createReadAnyCardTiptapContent, + clearKnowledgeEditorDraft, + formatReadAnyCardDataForEditor, + getKnowledgeEditorFeatureForCardType, + getKnowledgeEditorProfile, + getKnowledgeEditorSurfaceProfile, + getReadAnyCardTemplateDescription, + getReadAnyCardTemplateFields, + getReadAnyCardTemplateInsertLabel, + getVisibleReadAnyCardTemplateFields, + hasKnowledgeEditorFeature, + isKnowledgeEditorDraftRestorable, + isReadAnyCardTemplateRequiredValueMissing, + knowledgeEditorDraftFingerprint, + loadKnowledgeEditorDraft, + normalizeReadAnyCardTemplateFields, + normalizeTiptapDocument, + parseReadAnyCardDataFromEditor, + renderKnowledgeJsonToMarkdown, + saveKnowledgeEditorDraft, + updateCustomReadAnyCardTemplate, +} from "@readany/core/knowledge"; +import type { KnowledgeEditorDraft } from "@readany/core/knowledge"; +import type { JSONValue, KnowledgeCardTemplate } from "@readany/core/types"; +import { cn, generateId } from "@readany/core/utils"; +import { eventBus } from "@readany/core/utils/event-bus"; +import Link from "@tiptap/extension-link"; +import Placeholder from "@tiptap/extension-placeholder"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import type { Content } from "@tiptap/core"; +import { + EditorContent, + Node, + NodeViewWrapper, + ReactNodeViewRenderer, + mergeAttributes, + useEditor, +} from "@tiptap/react"; +import type { NodeViewProps } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { + Bold, + BookOpen, + Brain, + Code, + FileQuestion, + FileText, + Heading1, + Heading2, + Heading3, + ImagePlus, + Italic, + Link2, + List, + ListOrdered, + ListTodo, + Map as MapIcon, + MessageSquareQuote, + Minus, + Network, + OctagonX, + Pencil, + Plus, + Quote, + Redo2, + Sparkles, + Strikethrough, + TextQuote, + Trash2, + Undo2, + Unlink, +} from "lucide-react"; +import { + Fragment, + type ReactNode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +export interface KnowledgeEditorValue { + contentJson: JSONValue; + contentMd: string; + plainText: string; +} + +export interface KnowledgeImageInsertAttrs { + src: string; + alt?: string; + title?: string; + attachmentId?: string; + fileName?: string; +} + +export interface KnowledgeEditorOutlineTarget { + index: number; + requestId: number; +} + +export interface KnowledgeInternalLinkTarget { + id: string; + title: string; + path?: string; + targetPath?: string; + typeLabel?: string; +} + +export interface KnowledgeSourceReferenceRequest { + requestId: number; + label: string; + sourceTitle?: string; + sourceId?: string; + cfi?: string; +} + +interface KnowledgeEditorProps { + documentId?: string; + value: KnowledgeEditorValue; + onChange: (value: KnowledgeEditorValue) => void; + placeholder?: string; + className?: string; + contentClassName?: string; + autoFocus?: boolean; + readOnly?: boolean; + chrome?: "default" | "canvas"; + tier?: KnowledgeEditorTier; + surface?: KnowledgeEditorSurface; + isSaved?: boolean; + onPickLocalImage?: () => Promise; + outlineTarget?: KnowledgeEditorOutlineTarget | null; + internalLinkTargets?: KnowledgeInternalLinkTarget[]; + sourceReferenceRequest?: KnowledgeSourceReferenceRequest | null; +} + +const cardIconMap = { + bookQuote: MessageSquareQuote, + callout: TextQuote, + bookMetadata: BookOpen, + aiSummary: Sparkles, + aiToolFailure: OctagonX, + qa: FileQuestion, + review: Quote, + mindmap: MapIcon, + mermaid: Network, + relatedNotes: Brain, +}; + +interface InsertableCardItem { + key: string; + cardType: string; + insertLabel: string; + description?: string; + template?: KnowledgeCardTemplate; + createAttrs: () => ReadAnyCardAttrs; +} + +const customCardFieldTypes = [ + "text", + "multiline", + "number", + "checkbox", + "select", + "multiselect", +] as const satisfies ReadAnyCardTemplateField["type"][]; + +const customCardFieldWidths = ["", "full", "half", "third"] as const satisfies readonly ( + | "" + | NonNullable +)[]; + +const customCardFieldConditionOperators = [ + "equals", + "notEquals", + "contains", + "notContains", + "empty", + "notEmpty", +] as const satisfies NonNullable["operator"][]; + +const DESKTOP_DRAFT_SAVE_DELAY_MS = 650; +type TranslationFn = ReturnType["t"]; + +function isChoiceTemplateField(field: ReadAnyCardTemplateField) { + return field.type === "select" || field.type === "multiselect"; +} + +function defaultTemplateFieldOptionLabel(t: TranslationFn, count: number): string { + return t("notes.knowledgeCustomCardFieldOptionDefault", { + count, + defaultValue: `Option ${count}`, + }); +} + +function createDefaultTemplateFieldOptions(t: TranslationFn) { + return [ + { label: defaultTemplateFieldOptionLabel(t, 1), value: "option_1" }, + { label: defaultTemplateFieldOptionLabel(t, 2), value: "option_2" }, + ]; +} + +function formatTemplateFieldOptionsText(field: ReadAnyCardTemplateField): string { + return (field.options ?? []) + .map((option) => + option.label === option.value ? option.value : `${option.label} | ${option.value}`, + ) + .join("\n"); +} + +function parseTemplateFieldOptionsText( + input: string, + t: TranslationFn, +): ReadAnyCardTemplateField["options"] { + return input + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [labelPart, valuePart] = line.split("|").map((part) => part.trim()); + const label = labelPart || defaultTemplateFieldOptionLabel(t, index + 1); + const value = + valuePart || + label + .toLowerCase() + .replace(/[^a-z0-9_-\s]/g, "") + .replace(/[\s-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") || + `option_${index + 1}`; + return { label, value }; + }); +} + +function getTemplateFieldDefaultString(field: ReadAnyCardTemplateField): string { + if (field.defaultValue === undefined || field.defaultValue === null) return ""; + return String(field.defaultValue); +} + +function getTemplateConditionValueString( + condition: ReadAnyCardTemplateField["visibleWhen"] | undefined, +): string { + const value = condition?.value; + if (value === undefined || value === null) return ""; + if (Array.isArray(value)) return value.length > 0 ? String(value[0]) : ""; + return String(value); +} + +function getTemplateFieldConditionValueString(field: ReadAnyCardTemplateField): string { + return getTemplateConditionValueString(field.visibleWhen); +} + +function parseTemplateFieldConditionValue( + sourceField: ReadAnyCardTemplateField | undefined, + value: string, +) { + if (sourceField?.type === "checkbox") return value === "true"; + if (sourceField?.type === "number") { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : value; + } + return value; +} + +const ReadAnyCardExtension = Node.create({ + name: "readanyCard", + group: "block", + atom: true, + draggable: true, + + addStorage() { + return { + cardTemplates: [] as KnowledgeCardTemplate[], + }; + }, + + 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); + }, +}); + +const ReadAnyInternalLinkExtension = Node.create({ + name: "readanyInternalLink", + group: "inline", + inline: true, + atom: true, + selectable: true, + + addAttributes() { + return { + documentId: { default: null }, + targetPath: { default: null }, + label: { default: null }, + title: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-readany-internal-link]" }]; + }, + + renderHTML({ HTMLAttributes }) { + const label = + HTMLAttributes.label || + HTMLAttributes.title || + HTMLAttributes.documentId || + HTMLAttributes.targetPath || + ""; + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-readany-internal-link": + HTMLAttributes.documentId || HTMLAttributes.targetPath || label, + class: "readany-internal-link", + }), + label, + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ReadAnyInternalLinkView); + }, +}); + +const ReadAnySourceReferenceExtension = Node.create({ + name: "readanySourceReference", + group: "inline", + inline: true, + atom: true, + selectable: true, + + addAttributes() { + return { + label: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute("data-label") || element.textContent || null, + renderHTML: (attributes: { label?: string | null }) => + attributes.label ? { "data-label": attributes.label } : {}, + }, + sourceTitle: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute("data-source-title") || element.textContent || null, + renderHTML: (attributes: { sourceTitle?: string | null }) => + attributes.sourceTitle ? { "data-source-title": attributes.sourceTitle } : {}, + }, + sourceId: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute("data-source-id") || + element.getAttribute("data-readany-source-id") || + null, + renderHTML: (attributes: { sourceId?: string | null }) => + attributes.sourceId ? { "data-source-id": attributes.sourceId } : {}, + }, + cfi: { + default: null, + parseHTML: (element: HTMLElement) => { + const cfi = element.getAttribute("data-cfi"); + if (cfi) return cfi; + const legacyReference = element.getAttribute("data-readany-source-reference") || ""; + return legacyReference.startsWith("epubcfi(") ? legacyReference : null; + }, + renderHTML: (attributes: { cfi?: string | null }) => + attributes.cfi ? { "data-cfi": attributes.cfi } : {}, + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-readany-source-reference]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const label = node.attrs.label || node.attrs.sourceTitle || "Source"; + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-readany-source-reference": node.attrs.cfi || node.attrs.sourceId || label, + class: "readany-source-reference", + }), + label, + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ReadAnySourceReferenceView); + }, +}); + +const KnowledgeImageExtension = Node.create({ + name: "image", + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { + src: { default: null }, + alt: { default: null }, + title: { default: null }, + attachmentId: { default: null }, + fileName: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "img[src]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "img", + mergeAttributes(HTMLAttributes, { + class: + "mx-auto my-4 max-h-[520px] max-w-full rounded-md border border-border/60 object-contain", + }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(KnowledgeImageNodeView); + }, +}); + +function KnowledgeImageNodeView({ node }: NodeViewProps) { + const { t } = useTranslation(); + const [hasLoadError, setHasLoadError] = useState(false); + const attrs = node.attrs as KnowledgeImageInsertAttrs; + const src = typeof attrs.src === "string" ? attrs.src.trim() : ""; + const fileName = + (typeof attrs.fileName === "string" && attrs.fileName.trim()) || + (typeof attrs.title === "string" && attrs.title.trim()) || + (typeof attrs.alt === "string" && attrs.alt.trim()) || + t("notes.knowledgeAttachmentFile", { defaultValue: "Attachment" }); + const isUnresolvedAttachment = + !!attrs.attachmentId && (!src || src.startsWith(READANY_ATTACHMENT_URI_PREFIX)); + const isMissing = hasLoadError || !src || isUnresolvedAttachment; + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset image load state when the resolved src changes. + useEffect(() => { + setHasLoadError(false); + }, [src]); + + return ( + + {isMissing ? ( +
+
+ +
+
+

{fileName}

+

+ {t("notes.knowledgeAttachmentUnavailable", { + defaultValue: "Image attachment is not available on this device yet.", + })} +

+

+ {t("notes.knowledgeAttachmentUnavailableHint", { + defaultValue: "Sync again or keep the original device online to restore it.", + })} +

+
+
+ ) : ( + {attrs.alt?.trim() setHasLoadError(true)} + /> + )} + {attrs.alt?.trim() && !isMissing ? ( +
+ {attrs.alt.trim()} +
+ ) : null} +
+ ); +} + +function contentJsonEquals(left: JSONValue, right: JSONValue): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function KnowledgeEditor({ + documentId, + value, + onChange, + placeholder, + className, + contentClassName, + autoFocus = false, + readOnly = false, + chrome = "default", + tier = "knowledge_doc", + surface, + isSaved, + onPickLocalImage, + outlineTarget, + internalLinkTargets = [], + sourceReferenceRequest, +}: KnowledgeEditorProps) { + const { t } = useTranslation(); + const [isInsertOpen, setIsInsertOpen] = useState(false); + const [isBlockInsertOpen, setIsBlockInsertOpen] = useState(false); + const [isImageInsertOpen, setIsImageInsertOpen] = useState(false); + const [isInternalLinkOpen, setIsInternalLinkOpen] = useState(false); + const [internalLinkQuery, setInternalLinkQuery] = useState(""); + const [imageSrc, setImageSrc] = useState(""); + const [imageAlt, setImageAlt] = useState(""); + const [isPickingLocalImage, setIsPickingLocalImage] = useState(false); + const [isTemplateFormOpen, setIsTemplateFormOpen] = useState(false); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [templateName, setTemplateName] = useState(""); + const [templateDescription, setTemplateDescription] = useState(""); + const [templateMarkdown, setTemplateMarkdown] = useState(""); + const [templateFields, setTemplateFields] = useState([]); + const [isSavingTemplate, setIsSavingTemplate] = useState(false); + const [templateSaveError, setTemplateSaveError] = useState(null); + const [floatingToolbarPosition, setFloatingToolbarPosition] = useState<{ + left: number; + top: number; + } | null>(null); + const imageSrcInputId = useId(); + const imageAltInputId = useId(); + const [cardTemplates, setCardTemplates] = useState([]); + const cardTemplatesRef = useRef([]); + const templateLoaderMountedRef = useRef(false); + const isInternalUpdate = useRef(false); + const editorShellRef = useRef(null); + const internalLinkInputRef = useRef(null); + const handledSourceReferenceRequestIdRef = useRef(null); + const normalizedContentJson = useMemo( + () => normalizeTiptapDocument(value.contentJson, { cardTemplates }), + [cardTemplates, value.contentJson], + ); + const valueFingerprint = useMemo( + () => knowledgeEditorDraftFingerprint(normalizedContentJson as unknown as JSONValue), + [normalizedContentJson], + ); + const draftKey = useMemo( + () => (documentId ? createKnowledgeEditorDraftKey(documentId, "desktop") : null), + [documentId], + ); + const previousDraftKeyRef = useRef(draftKey); + const draftKeyRef = useRef(draftKey); + const readOnlyRef = useRef(readOnly); + const baseFingerprintRef = useRef(valueFingerprint); + const draftSaveTimerRef = useRef | null>(null); + const lastWrittenDraftFingerprintRef = useRef(null); + const [pendingDraft, setPendingDraft] = useState(null); + const editorProfile = useMemo( + () => (surface ? getKnowledgeEditorSurfaceProfile(surface) : getKnowledgeEditorProfile(tier)), + [surface, tier], + ); + const canUse = useCallback( + (feature: KnowledgeEditorFeature) => hasKnowledgeEditorFeature(editorProfile, feature), + [editorProfile], + ); + const canInsertCard = useCallback( + (cardType: string) => { + const feature = getKnowledgeEditorFeatureForCardType(cardType); + return canUse("readAnyCards") || (feature ? canUse(feature) : false); + }, + [canUse], + ); + const allowedCards = useMemo( + () => [ + ...builtInReadAnyCards + .filter((card) => canInsertCard(card.cardType)) + .map((card) => ({ + key: `built-in:${card.cardType}`, + cardType: card.cardType, + insertLabel: t(`notes.knowledgeCards.${card.cardType}`, { + defaultValue: card.insertLabel, + }), + description: t(`notes.knowledgeCardDescriptions.${card.cardType}`, { + defaultValue: "", + }), + createAttrs: () => + createDefaultReadAnyCardAttrs(card.cardType, { + title: t(`notes.knowledgeCards.${card.cardType}`, { + defaultValue: card.insertLabel, + }), + version: card.version, + }), + })), + ...cardTemplates + .map((template) => { + const attrs = createReadAnyCardAttrsFromTemplate(template); + const cardType = attrs.cardType ?? `custom:${template.id}`; + return { template, cardType }; + }) + .filter(({ template, cardType }) => template.enabled !== false && canInsertCard(cardType)) + .map(({ template, cardType }) => ({ + key: `template:${template.id}`, + cardType, + insertLabel: getReadAnyCardTemplateInsertLabel(template), + description: getReadAnyCardTemplateDescription(template), + template, + createAttrs: () => createReadAnyCardAttrsFromTemplate(template), + })), + ], + [canInsertCard, cardTemplates, t], + ); + + const reloadCardTemplates = useCallback(async () => { + try { + const templates = await getKnowledgeCardTemplates({ includeDisabled: true }); + if (!templateLoaderMountedRef.current) return; + setCardTemplates(templates.filter((template) => !template.builtIn)); + } catch (error) { + console.warn("[KnowledgeEditor] Failed to load card templates:", error); + } + }, []); + + useEffect(() => { + templateLoaderMountedRef.current = true; + void reloadCardTemplates(); + const offTemplateChange = eventBus.on("knowledge:card-templates-changed", () => { + void reloadCardTemplates(); + }); + const offSyncCompleted = eventBus.on("sync:completed", () => { + void reloadCardTemplates(); + }); + return () => { + templateLoaderMountedRef.current = false; + offTemplateChange(); + offSyncCompleted(); + }; + }, [reloadCardTemplates]); + + useEffect(() => { + cardTemplatesRef.current = cardTemplates; + }, [cardTemplates]); + + useEffect(() => { + if (!isInternalLinkOpen) return; + window.requestAnimationFrame(() => { + internalLinkInputRef.current?.focus(); + }); + }, [isInternalLinkOpen]); + + const extensions = useMemo( + () => [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + dropcursor: false, + gapcursor: false, + }), + Link.configure({ + autolink: true, + openOnClick: false, + }), + ReadAnyInternalLinkExtension, + ReadAnySourceReferenceExtension, + TaskList, + TaskItem.configure({ + nested: true, + }), + KnowledgeImageExtension, + ReadAnyCardExtension, + Placeholder.configure({ + placeholder: placeholder || "", + emptyEditorClass: "is-editor-empty", + }), + ], + [placeholder], + ); + const visibleInternalLinkTargets = useMemo(() => { + const query = internalLinkQuery.trim().toLowerCase(); + const source = query + ? internalLinkTargets.filter((target) => + [target.title, target.path ?? "", target.typeLabel ?? "", target.id] + .join(" ") + .toLowerCase() + .includes(query), + ) + : internalLinkTargets; + return source.slice(0, 8); + }, [internalLinkQuery, internalLinkTargets]); + + useEffect(() => { + return () => { + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + }; + }, []); + + useEffect(() => { + draftKeyRef.current = draftKey; + }, [draftKey]); + + useEffect(() => { + readOnlyRef.current = readOnly; + }, [readOnly]); + + useEffect(() => { + if (previousDraftKeyRef.current === draftKey) return; + previousDraftKeyRef.current = draftKey; + baseFingerprintRef.current = valueFingerprint; + lastWrittenDraftFingerprintRef.current = null; + setPendingDraft(null); + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + }, [draftKey, valueFingerprint]); + + useEffect(() => { + let mounted = true; + if (!draftKey || readOnly) { + setPendingDraft(null); + return; + } + + const initialFingerprint = baseFingerprintRef.current; + const loadDraft = async () => { + const draft = await loadKnowledgeEditorDraft(draftKey); + if (!mounted) return; + if (isKnowledgeEditorDraftRestorable(draft, initialFingerprint)) { + setPendingDraft(draft); + } else if (draft) { + void clearKnowledgeEditorDraft(draftKey); + } + }; + + void loadDraft(); + return () => { + mounted = false; + }; + }, [draftKey, readOnly]); + + useEffect(() => { + if (!draftKey || !isSaved) return; + if (lastWrittenDraftFingerprintRef.current !== valueFingerprint) return; + + lastWrittenDraftFingerprintRef.current = null; + baseFingerprintRef.current = valueFingerprint; + setPendingDraft((draft) => (draft?.contentFingerprint === valueFingerprint ? null : draft)); + void clearKnowledgeEditorDraft(draftKey); + }, [draftKey, isSaved, valueFingerprint]); + + const scheduleDraftSave = useCallback((nextValue: KnowledgeEditorValue) => { + const activeDraftKey = draftKeyRef.current; + if (readOnlyRef.current || !activeDraftKey) return; + if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current); + + const nextFingerprint = knowledgeEditorDraftFingerprint(nextValue.contentJson); + if (nextFingerprint === baseFingerprintRef.current) { + lastWrittenDraftFingerprintRef.current = null; + void clearKnowledgeEditorDraft(activeDraftKey); + return; + } + + draftSaveTimerRef.current = setTimeout(() => { + void saveKnowledgeEditorDraft(activeDraftKey, nextValue, { + baseFingerprint: baseFingerprintRef.current, + }) + .then((draft) => { + lastWrittenDraftFingerprintRef.current = draft.contentFingerprint; + }) + .catch((error) => { + console.warn("[KnowledgeEditor] Failed to save editor draft:", error); + }); + }, DESKTOP_DRAFT_SAVE_DELAY_MS); + }, []); + + const editor = useEditor({ + extensions, + content: normalizedContentJson, + editable: !readOnly, + editorProps: { + attributes: { + class: cn( + "prose prose-sm dark:prose-invert max-w-none min-h-[80px] outline-none", + "readany-knowledge-editor", + "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 }) => { + if (readOnly) return; + const currentCardTemplates = cardTemplatesRef.current; + const contentJson = normalizeTiptapDocument(editor.getJSON() as unknown as JSONValue, { + cardTemplates: currentCardTemplates, + }) as unknown as JSONValue; + const nextValue = { + contentJson, + contentMd: renderKnowledgeJsonToMarkdown(contentJson, { + cardTemplates: currentCardTemplates, + }), + plainText: editor.getText(), + }; + isInternalUpdate.current = true; + scheduleDraftSave(nextValue); + onChange(nextValue); + }, + immediatelyRender: false, + }); + + useEffect(() => { + if (!editor) return; + const storage = editor.storage as unknown as Record; + const readAnyCardStorage = + (storage.readanyCard as { cardTemplates?: KnowledgeCardTemplate[] } | undefined) ?? {}; + readAnyCardStorage.cardTemplates = cardTemplates; + storage.readanyCard = readAnyCardStorage; + }, [cardTemplates, editor]); + + useEffect(() => { + if (!editor) return; + editor.setEditable(!readOnly); + if (readOnly) { + editor.commands.blur(); + setFloatingToolbarPosition(null); + setIsBlockInsertOpen(false); + setIsImageInsertOpen(false); + setIsInsertOpen(false); + setIsInternalLinkOpen(false); + setIsTemplateFormOpen(false); + } + }, [editor, readOnly]); + + useEffect(() => { + if (!editor || isInternalUpdate.current) { + isInternalUpdate.current = false; + return; + } + + const normalizedJson = normalizedContentJson as unknown as JSONValue; + const currentJson = editor.getJSON() as unknown as JSONValue; + if (!contentJsonEquals(currentJson, normalizedJson)) { + editor.commands.setContent(normalizedContentJson); + } + + if (!readOnly && !contentJsonEquals(value.contentJson, normalizedJson)) { + isInternalUpdate.current = true; + onChange({ + contentJson: normalizedJson, + contentMd: renderKnowledgeJsonToMarkdown(normalizedJson, { cardTemplates }), + plainText: editor.getText(), + }); + } + }, [cardTemplates, editor, normalizedContentJson, onChange, readOnly, value.contentJson]); + + useEffect(() => { + if (editor && autoFocus && !readOnly) { + editor.commands.focus(); + } + }, [editor, autoFocus, readOnly]); + + useEffect(() => { + if (!editor || !outlineTarget) return; + const headings = Array.from( + editor.view.dom.querySelectorAll("h1,h2,h3,h4,h5,h6"), + ) as HTMLElement[]; + const target = headings[outlineTarget.index]; + if (!target) return; + target.scrollIntoView({ block: "center", behavior: "smooth" }); + target.animate?.( + [ + { outline: "0 solid transparent", outlineOffset: "0px" }, + { outline: "2px solid var(--primary)", outlineOffset: "4px" }, + { outline: "0 solid transparent", outlineOffset: "8px" }, + ], + { duration: 900, easing: "ease-out" }, + ); + }, [editor, outlineTarget]); + + useEffect(() => { + if (!editor || readOnly || !sourceReferenceRequest || !canUse("sourceReference")) return; + if (handledSourceReferenceRequestIdRef.current === sourceReferenceRequest.requestId) return; + const label = sourceReferenceRequest.label.trim(); + if (!label) return; + handledSourceReferenceRequestIdRef.current = sourceReferenceRequest.requestId; + editor + .chain() + .focus() + .insertContent([ + { + type: "readanySourceReference", + attrs: { + label, + sourceTitle: sourceReferenceRequest.sourceTitle?.trim() || label, + sourceId: sourceReferenceRequest.sourceId?.trim() || null, + cfi: sourceReferenceRequest.cfi?.trim() || null, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, [canUse, editor, readOnly, sourceReferenceRequest]); + + const hasFloatingInlineTools = + !readOnly && + (canUse("bold") || + canUse("italic") || + canUse("strike") || + canUse("inlineCode") || + canUse("link")); + + const updateFloatingToolbarPosition = useCallback(() => { + if (!editor || readOnly || !hasFloatingInlineTools || editor.state.selection.empty) { + setFloatingToolbarPosition(null); + return; + } + + const shell = editorShellRef.current; + if (!shell) { + setFloatingToolbarPosition(null); + return; + } + + try { + const { from, to } = editor.state.selection; + const start = editor.view.coordsAtPos(from); + const end = editor.view.coordsAtPos(to); + const shellRect = shell.getBoundingClientRect(); + const selectionLeft = Math.min(start.left, end.left); + const selectionRight = Math.max(start.right, end.right, start.left, end.left); + const rawLeft = (selectionLeft + selectionRight) / 2 - shellRect.left; + const rawTop = Math.min(start.top, end.top) - shellRect.top - 8; + const left = Math.min(Math.max(rawLeft, 42), Math.max(shellRect.width - 42, 42)); + const top = Math.max(rawTop, 44); + + setFloatingToolbarPosition({ left, top }); + } catch { + setFloatingToolbarPosition(null); + } + }, [editor, hasFloatingInlineTools, readOnly]); + + const syncCardControlsEditable = useCallback(() => { + const shell = editorShellRef.current; + if (!shell) return; + const controls = shell.querySelectorAll( + "[data-readany-card-control]", + ); + for (const control of controls) { + control.readOnly = readOnly; + control.tabIndex = readOnly ? -1 : 0; + control.setAttribute("aria-readonly", readOnly ? "true" : "false"); + } + }, [readOnly]); + + useEffect(() => { + if (!editor) return; + + editor.on("selectionUpdate", updateFloatingToolbarPosition); + editor.on("transaction", updateFloatingToolbarPosition); + editor.on("transaction", syncCardControlsEditable); + window.addEventListener("resize", updateFloatingToolbarPosition); + syncCardControlsEditable(); + + return () => { + editor.off("selectionUpdate", updateFloatingToolbarPosition); + editor.off("transaction", updateFloatingToolbarPosition); + editor.off("transaction", syncCardControlsEditable); + window.removeEventListener("resize", updateFloatingToolbarPosition); + }; + }, [editor, syncCardControlsEditable, updateFloatingToolbarPosition]); + + useEffect(() => { + syncCardControlsEditable(); + }, [syncCardControlsEditable]); + + const setLink = useCallback(() => { + if (!editor || readOnly || !canUse("link")) 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(); + }, [canUse, editor, readOnly, t]); + const unsetLink = useCallback(() => { + if (!editor || readOnly || !canUse("link")) return; + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + }, [canUse, editor, readOnly]); + + const insertInternalLink = useCallback( + (target?: KnowledgeInternalLinkTarget) => { + if (!editor || readOnly || !canUse("internalLink")) return; + const label = (target?.title ?? internalLinkQuery).trim(); + if (!label) return; + editor + .chain() + .focus() + .insertContent({ + type: "readanyInternalLink", + attrs: { + label, + title: label, + ...(target?.id ? { documentId: target.id } : {}), + ...(target?.targetPath ? { targetPath: target.targetPath } : {}), + }, + }) + .run(); + setInternalLinkQuery(""); + setIsInternalLinkOpen(false); + }, + [canUse, editor, internalLinkQuery, readOnly], + ); + + const insertImageAttrs = useCallback( + (attrs: KnowledgeImageInsertAttrs) => { + if (!editor || readOnly || !canUse("image")) return; + const src = attrs.src.trim(); + if (!src) return; + editor + .chain() + .focus() + .insertContent({ + type: "image", + attrs: { + src, + alt: attrs.alt?.trim() ?? "", + title: attrs.title?.trim() ?? "", + attachmentId: attrs.attachmentId?.trim() ?? "", + fileName: attrs.fileName?.trim() ?? "", + }, + }) + .run(); + setImageSrc(""); + setImageAlt(""); + setIsImageInsertOpen(false); + }, + [canUse, editor, readOnly], + ); + + const insertImage = useCallback(() => { + if (!editor || readOnly || !canUse("image")) return; + const src = imageSrc.trim(); + if (!src) return; + insertImageAttrs({ src, alt: imageAlt }); + }, [canUse, editor, imageAlt, imageSrc, insertImageAttrs, readOnly]); + + const pickLocalImage = useCallback(async () => { + if (readOnly || !onPickLocalImage || isPickingLocalImage) return; + setIsPickingLocalImage(true); + try { + const attrs = await onPickLocalImage(); + if (attrs) insertImageAttrs(attrs); + } finally { + setIsPickingLocalImage(false); + } + }, [insertImageAttrs, isPickingLocalImage, onPickLocalImage, readOnly]); + + const insertCard = useCallback( + (card: InsertableCardItem) => { + if (!editor || readOnly || !canInsertCard(card.cardType)) return; + editor + .chain() + .focus() + .insertContent({ + type: "readanyCard", + attrs: card.createAttrs(), + }) + .run(); + setIsInsertOpen(false); + setIsBlockInsertOpen(false); + }, + [canInsertCard, editor, readOnly], + ); + + const resetTemplateForm = useCallback(() => { + setEditingTemplateId(null); + setTemplateName(""); + setTemplateDescription(""); + setTemplateMarkdown(""); + setTemplateFields([]); + setTemplateSaveError(null); + }, []); + + const addTemplateField = useCallback(() => { + setTemplateFields((current) => [ + ...current, + { + key: `field_${current.length + 1}`, + label: t("notes.knowledgeCustomCardFieldNew", { + count: current.length + 1, + defaultValue: `Field ${current.length + 1}`, + }), + type: "text", + }, + ]); + setTemplateSaveError(null); + }, [t]); + + const updateTemplateField = useCallback( + (index: number, patch: Partial) => { + setTemplateFields((current) => + current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)), + ); + setTemplateSaveError(null); + }, + [], + ); + + const updateTemplateGroupVisibleWhen = useCallback( + ( + group: string | undefined, + visibleWhen: ReadAnyCardTemplateField["groupVisibleWhen"] | undefined, + ) => { + const groupName = group?.trim(); + if (!groupName) return; + setTemplateFields((current) => + current.map((field) => + field.group?.trim() === groupName ? { ...field, groupVisibleWhen: visibleWhen } : field, + ), + ); + setTemplateSaveError(null); + }, + [], + ); + + const removeTemplateField = useCallback((index: number) => { + setTemplateFields((current) => current.filter((_, fieldIndex) => fieldIndex !== index)); + setTemplateSaveError(null); + }, []); + + const openNewTemplateForm = useCallback(() => { + if (readOnly) return; + resetTemplateForm(); + setIsTemplateFormOpen(true); + }, [readOnly, resetTemplateForm]); + + const openTemplateEditForm = useCallback( + (template: KnowledgeCardTemplate) => { + if (readOnly) return; + const attrs = createReadAnyCardAttrsFromTemplate(template); + setEditingTemplateId(template.id); + setTemplateName(getReadAnyCardTemplateInsertLabel(template)); + setTemplateDescription(getReadAnyCardTemplateDescription(template) ?? ""); + setTemplateMarkdown(attrs.markdown ?? attrs.text ?? ""); + setTemplateFields(getReadAnyCardTemplateFields(template)); + setTemplateSaveError(null); + setIsTemplateFormOpen(true); + }, + [readOnly], + ); + + const saveTemplate = useCallback(async () => { + if (readOnly || !editor || !canUse("readAnyCards") || isSavingTemplate) return; + const name = templateName.trim(); + if (!name) return; + + setIsSavingTemplate(true); + setTemplateSaveError(null); + try { + const normalizedTemplateFields = normalizeReadAnyCardTemplateFields(templateFields); + const editingTemplate = editingTemplateId + ? cardTemplates.find((template) => template.id === editingTemplateId) + : null; + if (editingTemplateId && !editingTemplate) { + throw new Error( + t("notes.knowledgeCustomCardMissing", { + defaultValue: "This custom card template no longer exists.", + }), + ); + } + const template = editingTemplate + ? updateCustomReadAnyCardTemplate({ + template: editingTemplate, + name, + description: templateDescription, + markdown: templateMarkdown, + fields: normalizedTemplateFields, + }) + : createCustomReadAnyCardTemplate({ + id: `card-template-${generateId()}`, + name, + description: templateDescription, + markdown: templateMarkdown, + fields: normalizedTemplateFields, + }); + + await upsertKnowledgeCardTemplate(template); + setCardTemplates((current) => + [...current.filter((item) => item.id !== template.id), template].sort((a, b) => + a.name.localeCompare(b.name), + ), + ); + if (!editingTemplate) { + editor + .chain() + .focus() + .insertContent({ + type: "readanyCard", + attrs: createReadAnyCardAttrsFromTemplate(template), + }) + .run(); + setIsInsertOpen(false); + setIsBlockInsertOpen(false); + } + resetTemplateForm(); + setIsTemplateFormOpen(false); + } catch (error) { + console.warn("[KnowledgeEditor] Failed to save card template:", error); + setTemplateSaveError( + error instanceof Error + ? error.message + : t("notes.knowledgeCustomCardCreateFailed", { + defaultValue: "Failed to save custom card.", + }), + ); + } finally { + setIsSavingTemplate(false); + } + }, [ + canUse, + cardTemplates, + editingTemplateId, + editor, + isSavingTemplate, + readOnly, + resetTemplateForm, + t, + templateDescription, + templateFields, + templateMarkdown, + templateName, + ]); + const disableTemplate = useCallback( + async (template: KnowledgeCardTemplate) => { + if (readOnly) return; + const confirmed = window.confirm( + t("notes.knowledgeCustomCardDisableConfirm", { + name: template.name, + defaultValue: `Remove "${template.name}" from the insert menu? Existing cards in documents will stay unchanged.`, + }), + ); + if (!confirmed) return; + + try { + await disableKnowledgeCardTemplate(template.id); + setCardTemplates((current) => + current.map((item) => (item.id === template.id ? { ...item, enabled: false } : item)), + ); + if (editingTemplateId === template.id) { + resetTemplateForm(); + setIsTemplateFormOpen(false); + } + } catch (error) { + console.warn("[KnowledgeEditor] Failed to disable card template:", error); + setTemplateSaveError( + error instanceof Error + ? error.message + : t("notes.knowledgeCustomCardDisableFailed", { + defaultValue: "Failed to remove custom card.", + }), + ); + } + }, + [editingTemplateId, readOnly, resetTemplateForm, t], + ); + + const restorePendingDraft = useCallback(() => { + if (readOnly || !pendingDraft) return; + const contentJson = normalizeTiptapDocument(pendingDraft.value.contentJson, { + cardTemplates, + }) as unknown as JSONValue; + const nextValue = { + contentJson, + contentMd: renderKnowledgeJsonToMarkdown(contentJson, { cardTemplates }), + plainText: pendingDraft.value.plainText, + }; + const nextFingerprint = knowledgeEditorDraftFingerprint(contentJson); + setPendingDraft(null); + lastWrittenDraftFingerprintRef.current = nextFingerprint; + isInternalUpdate.current = true; + editor?.commands.setContent(contentJson as Content); + onChange(nextValue); + }, [cardTemplates, editor, onChange, pendingDraft, readOnly]); + + const discardPendingDraft = useCallback(() => { + setPendingDraft(null); + lastWrittenDraftFingerprintRef.current = null; + if (draftKey) void clearKnowledgeEditorDraft(draftKey); + }, [draftKey]); + + if (!editor) return null; + + const hasBlockInsertItems = + canUse("heading1") || + canUse("heading2") || + canUse("heading3") || + canUse("bulletList") || + canUse("orderedList") || + canUse("taskList") || + canUse("blockquote") || + canUse("codeBlock") || + canUse("horizontalRule") || + canUse("image") || + allowedCards.length > 0; + const toolbarGroupCandidates: ({ key: string; node: ReactNode } | null)[] = [ + hasBlockInsertItems + ? { + key: "insert", + node: ( +
+ { + setIsBlockInsertOpen((open) => !open); + setIsInternalLinkOpen(false); + setIsImageInsertOpen(false); + setIsInsertOpen(false); + }} + isActive={isBlockInsertOpen} + title={t("notes.knowledgeInsertBlock", { defaultValue: "Insert block" })} + > + + + + {isBlockInsertOpen ? ( +
+
+ {t("notes.knowledgeInsertBlock", { defaultValue: "Insert block" })} +
+ {canUse("heading1") ? ( + } + title={t("editor.heading1")} + hint={t("notes.knowledgeInsertHeadingHint", { + defaultValue: "Start a section", + })} + onClick={() => { + editor.chain().focus().toggleHeading({ level: 1 }).run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("heading2") ? ( + } + title={t("editor.heading2")} + hint={t("notes.knowledgeInsertSubheadingHint", { + defaultValue: "Nest a smaller section", + })} + onClick={() => { + editor.chain().focus().toggleHeading({ level: 2 }).run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("heading3") ? ( + } + title={t("editor.heading3")} + hint={t("notes.knowledgeInsertMinorHeadingHint", { + defaultValue: "Add a small subsection", + })} + onClick={() => { + editor.chain().focus().toggleHeading({ level: 3 }).run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("bulletList") ? ( + } + title={t("editor.bulletList")} + hint={t("notes.knowledgeInsertListHint", { + defaultValue: "Collect points", + })} + onClick={() => { + editor.chain().focus().toggleBulletList().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("orderedList") ? ( + } + title={t("editor.orderedList")} + hint={t("notes.knowledgeInsertOrderedListHint", { + defaultValue: "Write ordered steps", + })} + onClick={() => { + editor.chain().focus().toggleOrderedList().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("taskList") ? ( + } + title={t("editor.taskList")} + hint={t("notes.knowledgeInsertTaskHint", { + defaultValue: "Track follow-up reading work", + })} + onClick={() => { + editor.chain().focus().toggleTaskList().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("blockquote") ? ( + } + title={t("editor.blockquote")} + hint={t("notes.knowledgeInsertQuoteHint", { + defaultValue: "Set off an idea or cited passage", + })} + onClick={() => { + editor.chain().focus().toggleBlockquote().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("codeBlock") ? ( + } + title={t("editor.codeBlock")} + hint={t("notes.knowledgeInsertCodeBlockHint", { + defaultValue: "Capture code, prompts, or structured snippets", + })} + onClick={() => { + editor.chain().focus().toggleCodeBlock().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("horizontalRule") ? ( + } + title={t("editor.horizontalRule")} + hint={t("notes.knowledgeInsertDividerHint", { + defaultValue: "Separate two sections", + })} + onClick={() => { + editor.chain().focus().setHorizontalRule().run(); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {canUse("image") ? ( + } + title={t("notes.knowledgeInsertImage")} + hint={t("notes.knowledgeInsertImageHint", { + defaultValue: "Add a synced image attachment", + })} + onClick={() => { + setIsImageInsertOpen(true); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + {allowedCards.length > 0 ? ( + <> +
+ {allowedCards.slice(0, 5).map((card) => { + const Icon = + cardIconMap[card.cardType as keyof typeof cardIconMap] ?? Sparkles; + return ( + } + title={card.insertLabel} + hint={card.description || t("notes.knowledgeInsertCard")} + onClick={() => insertCard(card)} + /> + ); + })} + {allowedCards.length > 5 ? ( + } + title={t("notes.knowledgeCardPickerTitle")} + hint={t("notes.knowledgeCardPickerHint")} + onClick={() => { + setIsInsertOpen(true); + setIsBlockInsertOpen(false); + }} + /> + ) : null} + + ) : null} +
+ ) : null} +
+ ), + } + : 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("internalLink") ? ( +
+ { + setIsInternalLinkOpen((open) => !open); + setIsImageInsertOpen(false); + setIsInsertOpen(false); + setIsBlockInsertOpen(false); + }} + isActive={isInternalLinkOpen} + title={t("notes.knowledgeInsertInternalLink")} + > + + + + {isInternalLinkOpen ? ( +
+
+ {t("notes.knowledgeInsertInternalLink")} +
+ setInternalLinkQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + insertInternalLink(visibleInternalLinkTargets[0]); + } + }} + placeholder={t("notes.knowledgeInternalLinkSearchPlaceholder")} + className="mb-2 h-8 w-full rounded-md border border-border/70 bg-background px-2 text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/45 focus:ring-2 focus:ring-primary/10" + /> +
+ {visibleInternalLinkTargets.map((target) => ( + + ))} + {internalLinkQuery.trim() ? ( + + ) : null} +
+
+ ) : null} +
+ ) : null} +
+ ), + }, + canUse("bulletList") || + canUse("orderedList") || + canUse("taskList") || + 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("taskList") ? ( + editor.chain().focus().toggleTaskList().run()} + isActive={editor.isActive("taskList")} + title={t("editor.taskList")} + > + + + ) : 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("image") + ? { + key: "media", + node: ( +
+ + { + setIsImageInsertOpen((open) => !open); + setIsBlockInsertOpen(false); + setIsInsertOpen(false); + }} + title={t("notes.knowledgeInsertImage")} + disabled={!canUse("image")} + isActive={isImageInsertOpen} + > + + + + + {isImageInsertOpen && ( +
{ + event.preventDefault(); + insertImage(); + }} + > +
+ {t("notes.knowledgeInsertImage")} +
+ {onPickLocalImage ? ( + <> + +
+ + + URL + + +
+ + ) : null} + + setImageSrc(event.target.value)} + placeholder="https://..." + className="mb-2 h-8 w-full rounded-md border border-border/70 bg-background px-2 text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/45 focus:ring-2 focus:ring-primary/10" + /> + + setImageAlt(event.target.value)} + placeholder={t("notes.knowledgeImageAltPrompt")} + className="h-8 w-full rounded-md border border-border/70 bg-background px-2 text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/45 focus:ring-2 focus:ring-primary/10" + /> +
+ + +
+
+ )} +
+ ), + } + : null, + allowedCards.length > 0 + ? { + key: "cards", + node: ( +
+ { + setIsInsertOpen((open) => !open); + setIsImageInsertOpen(false); + setIsBlockInsertOpen(false); + }} + isActive={isInsertOpen} + title={t("notes.knowledgeInsertCard", { defaultValue: "Insert card" })} + > + + + + {isInsertOpen && ( +
+ {allowedCards.map((card) => { + const Icon = cardIconMap[card.cardType as keyof typeof cardIconMap] ?? Sparkles; + return ( +
+ + {card.template ? ( +
+ + +
+ ) : null} +
+ ); + })} + {canUse("readAnyCards") ? ( + <> +
+ {isTemplateFormOpen ? ( +
{ + event.preventDefault(); + void saveTemplate(); + }} + > +
+

+ {editingTemplateId + ? t("notes.knowledgeCustomCardEdit", { + defaultValue: "Edit custom card", + }) + : t("notes.knowledgeCustomCardNew", { + defaultValue: "New custom card", + })} +

+

+ {editingTemplateId + ? t("notes.knowledgeCustomCardEditHint", { + defaultValue: + "Updates future insertions. Cards already in documents stay unchanged.", + }) + : t("notes.knowledgeCustomCardNewHint", { + defaultValue: "Create a reusable structure that syncs.", + })} +

+
+
+ + { + setTemplateName(event.target.value); + setTemplateSaveError(null); + }} + placeholder={t("notes.knowledgeCustomCardNamePlaceholder", { + defaultValue: "Concept, timeline, reading question...", + })} + className="h-8 w-full rounded-md border border-border/55 bg-background px-2.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/45" + /> +
+
+ + setTemplateDescription(event.target.value)} + placeholder={t("notes.knowledgeCustomCardDescriptionPlaceholder", { + defaultValue: "What this structure is for", + })} + className="h-8 w-full rounded-md border border-border/55 bg-background px-2.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/45" + /> +
+
+ +