From 4e803381bc20e9d8350ec0ca334b9dfd8cb1fae7 Mon Sep 17 00:00:00 2001
From: mrsibe
Date: Mon, 28 Sep 2026 19:24:27 +0800
Subject: [PATCH] feat(watch): a watched folder keeps itself current, and never
deletes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#158. A folder import is a snapshot; this is what keeps it current.
- documents.source_state + source_mtime_ms (migration 0021) separate the SOURCE
file's state from the index state: a deleted file becomes `missing` while its
index, notes, excerpts and citations all stay.
- folder_watches persists the watch (one row per notebook + folder).
- diffFolder() decides added / changed / restored / missing. It is pure, so the
decision table is unit-tested without a filesystem or a database — and "changed"
and "missing" are determined, not guessed.
- FolderWatchService reconciles on launch (so a change made while the app was closed
is picked up) and live via fs.watch with an 800ms debounce. New files go through
the same batch import as #98; a changed file is re-parsed into the SAME documentId
(a plain reindex would reuse the stored content — exactly the thing that changed).
- The source list shows the `missing` state; the Add menu gains "Watch folder".
Verified: npm run typecheck; npm test (409 pass, incl. 8 diffFolder tests);
npm run check:design; npm run build; eval baseline unchanged; electron . --smoke-test
PASS (29 checks), incl. a watched folder that imports a newly added file and marks a
deleted one missing without dropping its chunks.
---
src/main/db/migrations/0021_aromatic_pyro.sql | 12 +
.../db/migrations/meta/0021_snapshot.json | 1827 +++++++++++++++++
src/main/db/migrations/meta/_journal.json | 7 +
src/main/db/schema.ts | 36 +-
src/main/index.ts | 7 +
src/main/ipc/knowledgeHandlers.ts | 126 +-
src/main/ipc/validation.ts | 14 +-
src/main/services/KnowledgeService.ts | 100 +-
src/main/services/ingestion/folderDiff.ts | 61 +
src/main/services/ingestion/folderScan.ts | 7 +-
src/main/services/ingestion/folderWatch.ts | 217 ++
src/main/smokeTest.ts | 35 +
src/preload/index.d.ts | 13 +-
src/preload/index.ts | 7 +-
.../src/components/notebook/SourcePanel.tsx | 47 +-
.../notebook/source/DocumentList.tsx | 4 +
src/renderer/src/locales/en-US/ui.json | 2 +
src/renderer/src/locales/zh-CN/ui.json | 2 +
src/renderer/src/store/knowledgeStore.ts | 12 +-
test/folderDiff.test.ts | 109 +
20 files changed, 2563 insertions(+), 82 deletions(-)
create mode 100644 src/main/db/migrations/0021_aromatic_pyro.sql
create mode 100644 src/main/db/migrations/meta/0021_snapshot.json
create mode 100644 src/main/services/ingestion/folderDiff.ts
create mode 100644 src/main/services/ingestion/folderWatch.ts
create mode 100644 test/folderDiff.test.ts
diff --git a/src/main/db/migrations/0021_aromatic_pyro.sql b/src/main/db/migrations/0021_aromatic_pyro.sql
new file mode 100644
index 0000000..98a5838
--- /dev/null
+++ b/src/main/db/migrations/0021_aromatic_pyro.sql
@@ -0,0 +1,12 @@
+CREATE TABLE `folder_watches` (
+ `id` text PRIMARY KEY NOT NULL,
+ `notebook_id` text NOT NULL,
+ `path` text NOT NULL,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ FOREIGN KEY (`notebook_id`) REFERENCES `notebooks`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `idx_folder_watches_notebook_path` ON `folder_watches` (`notebook_id`,`path`);--> statement-breakpoint
+ALTER TABLE `documents` ADD `source_state` text DEFAULT 'available' NOT NULL;--> statement-breakpoint
+ALTER TABLE `documents` ADD `source_mtime_ms` integer;
\ No newline at end of file
diff --git a/src/main/db/migrations/meta/0021_snapshot.json b/src/main/db/migrations/meta/0021_snapshot.json
new file mode 100644
index 0000000..11bb8ef
--- /dev/null
+++ b/src/main/db/migrations/meta/0021_snapshot.json
@@ -0,0 +1,1827 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "baab174f-764a-417c-8f5c-c1a56c3320fe",
+ "prevId": "7afec921-a432-43f3-aafe-054c28f80f89",
+ "tables": {
+ "anki_cards": {
+ "name": "anki_cards",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "cards_data": {
+ "name": "cards_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_mapping": {
+ "name": "chunk_mapping",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generating'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ankicards_notebook": {
+ "name": "idx_ankicards_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "idx_ankicards_version": {
+ "name": "idx_ankicards_version",
+ "columns": [
+ "notebook_id",
+ "version"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "anki_cards_notebook_id_notebooks_id_fk": {
+ "name": "anki_cards_notebook_id_notebooks_id_fk",
+ "tableFrom": "anki_cards",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chat_messages": {
+ "name": "chat_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_content": {
+ "name": "reasoning_content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finish_reason": {
+ "name": "finish_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "usage": {
+ "name": "usage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt_of": {
+ "name": "attempt_of",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_messages_session": {
+ "name": "idx_messages_session",
+ "columns": [
+ "session_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chat_messages_session_id_chat_sessions_id_fk": {
+ "name": "chat_messages_session_id_chat_sessions_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "chat_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chat_sessions": {
+ "name": "chat_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_tokens": {
+ "name": "total_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "parent_session_id": {
+ "name": "parent_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retrieval_scope": {
+ "name": "retrieval_scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sessions_notebook": {
+ "name": "idx_sessions_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chat_sessions_notebook_id_notebooks_id_fk": {
+ "name": "chat_sessions_notebook_id_notebooks_id_fk",
+ "tableFrom": "chat_sessions",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_sessions_parent_session_id_chat_sessions_id_fk": {
+ "name": "chat_sessions_parent_session_id_chat_sessions_id_fk",
+ "tableFrom": "chat_sessions",
+ "tableTo": "chat_sessions",
+ "columnsFrom": [
+ "parent_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chunk_blocks": {
+ "name": "chunk_blocks",
+ "columns": {
+ "chunk_id": {
+ "name": "chunk_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "block_id": {
+ "name": "block_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_in_block": {
+ "name": "start_in_block",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_in_block": {
+ "name": "end_in_block",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_chunk_blocks_block": {
+ "name": "idx_chunk_blocks_block",
+ "columns": [
+ "block_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chunk_blocks_chunk_id_chunks_id_fk": {
+ "name": "chunk_blocks_chunk_id_chunks_id_fk",
+ "tableFrom": "chunk_blocks",
+ "tableTo": "chunks",
+ "columnsFrom": [
+ "chunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chunk_blocks_block_id_document_blocks_id_fk": {
+ "name": "chunk_blocks_block_id_document_blocks_id_fk",
+ "tableFrom": "chunk_blocks",
+ "tableTo": "document_blocks",
+ "columnsFrom": [
+ "block_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "chunk_blocks_chunk_id_block_id_pk": {
+ "columns": [
+ "chunk_id",
+ "block_id"
+ ],
+ "name": "chunk_blocks_chunk_id_block_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chunks": {
+ "name": "chunks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_index": {
+ "name": "chunk_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_offset": {
+ "name": "start_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "end_offset": {
+ "name": "end_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_start": {
+ "name": "page_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_end": {
+ "name": "page_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_count": {
+ "name": "token_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_chunks_document": {
+ "name": "idx_chunks_document",
+ "columns": [
+ "document_id"
+ ],
+ "isUnique": false
+ },
+ "idx_chunks_notebook": {
+ "name": "idx_chunks_notebook",
+ "columns": [
+ "notebook_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chunks_document_id_documents_id_fk": {
+ "name": "chunks_document_id_documents_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chunks_notebook_id_notebooks_id_fk": {
+ "name": "chunks_notebook_id_notebooks_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "document_blocks": {
+ "name": "document_blocks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "page": {
+ "name": "page",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "level": {
+ "name": "level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_offset": {
+ "name": "start_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_offset": {
+ "name": "end_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bbox": {
+ "name": "bbox",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_blocks_document_order": {
+ "name": "idx_blocks_document_order",
+ "columns": [
+ "document_id",
+ "order"
+ ],
+ "isUnique": false
+ },
+ "idx_blocks_document_page": {
+ "name": "idx_blocks_document_page",
+ "columns": [
+ "document_id",
+ "page"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "document_blocks_document_id_documents_id_fk": {
+ "name": "document_blocks_document_id_documents_id_fk",
+ "tableFrom": "document_blocks",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "documents": {
+ "name": "documents",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_uri": {
+ "name": "source_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "local_file_path": {
+ "name": "local_file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_note_id": {
+ "name": "source_note_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "structure": {
+ "name": "structure",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "source_state": {
+ "name": "source_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'available'"
+ },
+ "source_mtime_ms": {
+ "name": "source_mtime_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "chunk_count": {
+ "name": "chunk_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_documents_notebook": {
+ "name": "idx_documents_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "idx_documents_status": {
+ "name": "idx_documents_status",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "documents_notebook_id_notebooks_id_fk": {
+ "name": "documents_notebook_id_notebooks_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "documents_source_note_id_notes_id_fk": {
+ "name": "documents_source_note_id_notes_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "notes",
+ "columnsFrom": [
+ "source_note_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "embeddings": {
+ "name": "embeddings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_id": {
+ "name": "chunk_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dimensions": {
+ "name": "dimensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_embeddings_chunk": {
+ "name": "idx_embeddings_chunk",
+ "columns": [
+ "chunk_id"
+ ],
+ "isUnique": false
+ },
+ "idx_embeddings_notebook": {
+ "name": "idx_embeddings_notebook",
+ "columns": [
+ "notebook_id"
+ ],
+ "isUnique": false
+ },
+ "idx_embeddings_model": {
+ "name": "idx_embeddings_model",
+ "columns": [
+ "model"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "embeddings_chunk_id_chunks_id_fk": {
+ "name": "embeddings_chunk_id_chunks_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "chunks",
+ "columnsFrom": [
+ "chunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "embeddings_notebook_id_notebooks_id_fk": {
+ "name": "embeddings_notebook_id_notebooks_id_fk",
+ "tableFrom": "embeddings",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "folder_watches": {
+ "name": "folder_watches",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_folder_watches_notebook_path": {
+ "name": "idx_folder_watches_notebook_path",
+ "columns": [
+ "notebook_id",
+ "path"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "folder_watches_notebook_id_notebooks_id_fk": {
+ "name": "folder_watches_notebook_id_notebooks_id_fk",
+ "tableFrom": "folder_watches",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ingestion_runs": {
+ "name": "ingestion_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stage": {
+ "name": "stage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "progress": {
+ "name": "progress",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ingestion_runs_document": {
+ "name": "idx_ingestion_runs_document",
+ "columns": [
+ "document_id",
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ingestion_runs_document_id_documents_id_fk": {
+ "name": "ingestion_runs_document_id_documents_id_fk",
+ "tableFrom": "ingestion_runs",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "items": {
+ "name": "items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_items_notebook_order": {
+ "name": "idx_items_notebook_order",
+ "columns": [
+ "notebook_id",
+ "order"
+ ],
+ "isUnique": false
+ },
+ "idx_items_type": {
+ "name": "idx_items_type",
+ "columns": [
+ "type"
+ ],
+ "isUnique": false
+ },
+ "idx_items_resource": {
+ "name": "idx_items_resource",
+ "columns": [
+ "type",
+ "resource_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "items_notebook_id_notebooks_id_fk": {
+ "name": "items_notebook_id_notebooks_id_fk",
+ "tableFrom": "items",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "mind_maps": {
+ "name": "mind_maps",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "tree_data": {
+ "name": "tree_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_mapping": {
+ "name": "chunk_mapping",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generating'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_mindmaps_notebook": {
+ "name": "idx_mindmaps_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "idx_mindmaps_version": {
+ "name": "idx_mindmaps_version",
+ "columns": [
+ "notebook_id",
+ "version"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "mind_maps_notebook_id_notebooks_id_fk": {
+ "name": "mind_maps_notebook_id_notebooks_id_fk",
+ "tableFrom": "mind_maps",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notebook_embedding_spaces": {
+ "name": "notebook_embedding_spaces",
+ "columns": {
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "space_id": {
+ "name": "space_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "backend": {
+ "name": "backend",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "dimensions": {
+ "name": "dimensions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notebook_embedding_spaces_notebook_id_notebooks_id_fk": {
+ "name": "notebook_embedding_spaces_notebook_id_notebooks_id_fk",
+ "tableFrom": "notebook_embedding_spaces",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notebooks": {
+ "name": "notebooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notebooks_updated": {
+ "name": "idx_notebooks_updated",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notes": {
+ "name": "notes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_notes_notebook": {
+ "name": "idx_notes_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notes_notebook_id_notebooks_id_fk": {
+ "name": "notes_notebook_id_notebooks_id_fk",
+ "tableFrom": "notes",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "quiz_sessions": {
+ "name": "quiz_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quiz_id": {
+ "name": "quiz_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "answers": {
+ "name": "answers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_questions": {
+ "name": "total_questions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "correct_count": {
+ "name": "correct_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_quiz_sessions_quiz": {
+ "name": "idx_quiz_sessions_quiz",
+ "columns": [
+ "quiz_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "idx_quiz_sessions_notebook": {
+ "name": "idx_quiz_sessions_notebook",
+ "columns": [
+ "notebook_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "quiz_sessions_quiz_id_quizzes_id_fk": {
+ "name": "quiz_sessions_quiz_id_quizzes_id_fk",
+ "tableFrom": "quiz_sessions",
+ "tableTo": "quizzes",
+ "columnsFrom": [
+ "quiz_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "quiz_sessions_notebook_id_notebooks_id_fk": {
+ "name": "quiz_sessions_notebook_id_notebooks_id_fk",
+ "tableFrom": "quiz_sessions",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "quizzes": {
+ "name": "quizzes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notebook_id": {
+ "name": "notebook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "questions_data": {
+ "name": "questions_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_mapping": {
+ "name": "chunk_mapping",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generating'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_quizzes_notebook": {
+ "name": "idx_quizzes_notebook",
+ "columns": [
+ "notebook_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "idx_quizzes_version": {
+ "name": "idx_quizzes_version",
+ "columns": [
+ "notebook_id",
+ "version"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "quizzes_notebook_id_notebooks_id_fk": {
+ "name": "quizzes_notebook_id_notebooks_id_fk",
+ "tableFrom": "quizzes",
+ "tableTo": "notebooks",
+ "columnsFrom": [
+ "notebook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/main/db/migrations/meta/_journal.json b/src/main/db/migrations/meta/_journal.json
index db04dc0..fdbf467 100644
--- a/src/main/db/migrations/meta/_journal.json
+++ b/src/main/db/migrations/meta/_journal.json
@@ -148,6 +148,13 @@
"when": 1790587593731,
"tag": "0020_normal_nextwave",
"breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "6",
+ "when": 1790594238292,
+ "tag": "0021_aromatic_pyro",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts
index 9d8736d..baba4e4 100644
--- a/src/main/db/schema.ts
+++ b/src/main/db/schema.ts
@@ -1,4 +1,4 @@
-import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core'
+import { sqliteTable, text, integer, index, primaryKey, uniqueIndex } from 'drizzle-orm/sqlite-core'
import type { QuizQuestion } from '../../shared/types/quiz'
import type {
ChatExecutionError,
@@ -165,6 +165,15 @@ export const documents = sqliteTable(
status: text('status', { enum: ['pending', 'processing', 'indexed', 'failed'] })
.notNull()
.default('pending'),
+ /**
+ * 来源文件本身的状态(#158),与 `status`(索引状态)分开:
+ * 文件被删是 `missing`,索引只是过期;笔记、摘录、citation 都还在。
+ */
+ sourceState: text('source_state', { enum: ['available', 'missing', 'changed'] })
+ .notNull()
+ .default('available'),
+ /** 上次导入/刷新时来源文件的 mtime(ms);watch 靠它判断文件是否被改过。 */
+ sourceMtimeMs: integer('source_mtime_ms'),
errorMessage: text('error_message'),
chunkCount: integer('chunk_count').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
@@ -179,6 +188,31 @@ export const documents = sqliteTable(
export type Document = typeof documents.$inferSelect
export type NewDocument = typeof documents.$inferInsert
+/**
+ * 被监听的文件夹(#158)。
+ *
+ * 文件夹不是 document(document 是一份来源文件),所以监听关系单独一张表:一个
+ * notebook 可以监听多个文件夹,同一个(notebook, path)不会重复(唯一索引)。
+ */
+export const folderWatches = sqliteTable(
+ 'folder_watches',
+ {
+ id: text('id').primaryKey(),
+ notebookId: text('notebook_id')
+ .notNull()
+ .references(() => notebooks.id, { onDelete: 'cascade' }),
+ path: text('path').notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
+ },
+ (table) => ({
+ uniquePath: uniqueIndex('idx_folder_watches_notebook_path').on(table.notebookId, table.path)
+ })
+)
+
+export type FolderWatch = typeof folderWatches.$inferSelect
+export type NewFolderWatch = typeof folderWatches.$inferInsert
+
/**
* 索引尝试记录(#95)。
*
diff --git a/src/main/index.ts b/src/main/index.ts
index a6e24cf..c3b1497 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -137,6 +137,12 @@ app.whenReady().then(async () => {
knowledgeService = new KnowledgeService(embeddingService)
Logger.info('Main', 'Knowledge Service initialized')
+ // Watched folders (#158): reconcile once so changes made while the app was closed
+ // are picked up, then keep them current live.
+ Logger.info('Main', 'Reconciling watched folders...')
+ await knowledgeService.reconcileWatchedFolders()
+ knowledgeService.startFolderWatching()
+
// Initialize Update Service
Logger.info('Main', 'Initializing Update Service...')
updateService = new UpdateService()
@@ -245,6 +251,7 @@ app.on('before-quit', () => {
}
Logger.info('Main', 'Closing database connection...')
+ knowledgeService?.stopFolderWatching()
closeDatabase()
})
diff --git a/src/main/ipc/knowledgeHandlers.ts b/src/main/ipc/knowledgeHandlers.ts
index f624cf9..8b32cc0 100644
--- a/src/main/ipc/knowledgeHandlers.ts
+++ b/src/main/ipc/knowledgeHandlers.ts
@@ -337,64 +337,92 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) {
})
// 文件夹导入:一次快照(#98)
- ipcMain.handle(
- 'knowledge:add-folder',
- async (event: IpcMainInvokeEvent, args: unknown) => {
- const validated = await validate(KnowledgeSchemas.addFolder, async (params) => {
- Logger.debug('KnowledgeHandlers', 'add-folder:', params)
+ ipcMain.handle('knowledge:add-folder', async (event: IpcMainInvokeEvent, args: unknown) => {
+ const validated = await validate(KnowledgeSchemas.addFolder, async (params) => {
+ Logger.debug('KnowledgeHandlers', 'add-folder:', params)
- try {
- const result = await knowledgeService.addFolder(
- params.notebookId,
- params.folderPath,
- (stage, progress) => {
- event.sender.send('knowledge:index-progress', {
- notebookId: params.notebookId,
- stage,
- progress
- })
- }
- )
- return { success: true, ...result }
- } catch (error) {
- Logger.error('KnowledgeHandlers', 'Error adding folder:', error)
- return { success: false, added: [], skipped: [], failed: [], error: (error as Error).message }
+ try {
+ const result = await knowledgeService.addFolder(
+ params.notebookId,
+ params.folderPath,
+ (stage, progress) => {
+ event.sender.send('knowledge:index-progress', {
+ notebookId: params.notebookId,
+ stage,
+ progress
+ })
+ }
+ )
+
+ // 监听(#158)在快照之后建立:持久化监听关系、立刻对齐一次、开始实时监听。
+ if (params.watch) {
+ await knowledgeService.watchFolder(params.notebookId, params.folderPath)
}
- })(event, args)
- return validated
- }
+ return { success: true, ...result }
+ } catch (error) {
+ Logger.error('KnowledgeHandlers', 'Error adding folder:', error)
+ return {
+ success: false,
+ added: [],
+ skipped: [],
+ failed: [],
+ error: (error as Error).message
+ }
+ }
+ })(event, args)
+
+ return validated
+ })
+
+ // 监听文件夹(#158)
+ ipcMain.handle(
+ 'knowledge:list-folder-watches',
+ validate(KnowledgeSchemas.listFolderWatches, async (params) => {
+ return knowledgeService.listFolderWatches(params.notebookId)
+ })
)
- // 批量导入一组文件(#98,拖放/多选共用)
ipcMain.handle(
- 'knowledge:add-files',
- async (event: IpcMainInvokeEvent, args: unknown) => {
- const validated = await validate(KnowledgeSchemas.addFiles, async (params) => {
- Logger.debug('KnowledgeHandlers', 'add-files:', params.paths.length)
+ 'knowledge:unwatch-folder',
+ validate(KnowledgeSchemas.unwatchFolder, async (params) => {
+ knowledgeService.unwatchFolder(params.watchId)
+ return { success: true }
+ })
+ )
- try {
- const result = await knowledgeService.addDocumentsFromPaths(
- params.notebookId,
- params.paths,
- (stage, progress) => {
- event.sender.send('knowledge:index-progress', {
- notebookId: params.notebookId,
- stage,
- progress
- })
- }
- )
- return { success: true, ...result }
- } catch (error) {
- Logger.error('KnowledgeHandlers', 'Error adding files:', error)
- return { success: false, added: [], skipped: [], failed: [], error: (error as Error).message }
+ // 批量导入一组文件(#98,拖放/多选共用)
+ ipcMain.handle('knowledge:add-files', async (event: IpcMainInvokeEvent, args: unknown) => {
+ const validated = await validate(KnowledgeSchemas.addFiles, async (params) => {
+ Logger.debug('KnowledgeHandlers', 'add-files:', params.paths.length)
+
+ try {
+ const result = await knowledgeService.addDocumentsFromPaths(
+ params.notebookId,
+ params.paths,
+ (stage, progress) => {
+ event.sender.send('knowledge:index-progress', {
+ notebookId: params.notebookId,
+ stage,
+ progress
+ })
+ }
+ )
+ return { success: true, ...result }
+ } catch (error) {
+ Logger.error('KnowledgeHandlers', 'Error adding files:', error)
+ return {
+ success: false,
+ added: [],
+ skipped: [],
+ failed: [],
+ error: (error as Error).message
}
- })(event, args)
+ }
+ })(event, args)
- return validated
- }
- )
+ return validated
+ })
// 打开文档源文件
ipcMain.handle(
diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts
index fee8194..e409938 100644
--- a/src/main/ipc/validation.ts
+++ b/src/main/ipc/validation.ts
@@ -146,10 +146,20 @@ export const KnowledgeSchemas = {
filePath: z.string().min(1, '文件路径不能为空')
}),
- // 批量/文件夹导入(#98)
+ // 批量/文件夹导入(#98),以及监听文件夹(#158)
addFolder: z.object({
notebookId: z.string().min(1, '笔记本 ID 不能为空'),
- folderPath: z.string().min(1, '文件夹路径不能为空')
+ folderPath: z.string().min(1, '文件夹路径不能为空'),
+ /** true 时把文件夹改为监听来源(#158):持续对齐,而不仅仅是一次快照。 */
+ watch: z.boolean().optional()
+ }),
+
+ listFolderWatches: z.object({
+ notebookId: z.string().min(1, '笔记本 ID 不能为空')
+ }),
+
+ unwatchFolder: z.object({
+ watchId: z.string().min(1, '监听 ID 不能为空')
}),
addFiles: z.object({
diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts
index 9a38a45..521b050 100644
--- a/src/main/services/KnowledgeService.ts
+++ b/src/main/services/KnowledgeService.ts
@@ -5,7 +5,7 @@
import { createHash } from 'crypto'
import { app } from 'electron'
-import { join, basename } from 'path'
+import { join, basename, sep } from 'path'
import { mkdir, copyFile, unlink, stat } from 'fs/promises'
import {
getDatabase,
@@ -60,6 +60,15 @@ import type {
Retriever
} from './retrieval'
import { WebFetchService } from './WebFetchService'
+import type { WatchedDocument } from './ingestion/folderWatch'
+import {
+ FolderWatchService,
+ addFolderWatch,
+ listFolderWatchesForNotebook,
+ removeFolderWatch,
+ type FolderWatchRecord,
+ type ReconcileResult
+} from './ingestion/folderWatch'
import { scanFolder } from './ingestion/folderScan'
import { deleteDocumentChunksFts, ensureChunksFts, indexChunksFts, searchChunksFts } from './fts'
import { vectorStoreManager } from '../vectorstore'
@@ -182,6 +191,7 @@ export class KnowledgeService {
private fileParserService: FileParserService
private webFetchService: WebFetchService
private knowledgeFilesDir: string
+ private folderWatch: FolderWatchService
constructor(embeddingService: EmbeddingService) {
this.embeddingService = embeddingService
@@ -192,6 +202,8 @@ export class KnowledgeService {
// 知识库文件存储目录
this.knowledgeFilesDir = join(app.getPath('userData'), 'knowledge-files')
this.ensureKnowledgeFilesDir()
+ // 监听服务只持有本服务的引用,构造本身没有副作用(不会开数据库、不会开始监听)。
+ this.folderWatch = new FolderWatchService(this)
}
/**
@@ -468,6 +480,9 @@ export class KnowledgeService {
fileSize: parseResult.metadata?.fileSize as number | undefined,
metadata: parseResult.metadata,
errorMessage: null,
+ // 文件回到 available,并记下这次看到的 mtime:watch(#158)靠它判断是否被改过。
+ sourceState: 'available',
+ sourceMtimeMs: (await stat(filePath).catch(() => null))?.mtimeMs,
updatedAt: new Date()
})
.where(eq(documents.id, documentId))
@@ -1175,6 +1190,89 @@ export class KnowledgeService {
return runsFor(documentId)
}
+ /** 当前所有已注册 loader 认识的扩展名(#98 / #158 的扫描用它)。 */
+ supportedExtensions(): string[] {
+ return this.fileParserService.supportedExtensions()
+ }
+
+ /**
+ * 一个被监听文件夹下的所有 source(#158)。
+ *
+ * 按 `sourceUri` 前缀匹配:文件导入后 sourceUri 仍是原路径,所以「这个文件夹下的
+ * 来源」可以直接算出来,不需要另存一份父子关系。
+ */
+ getWatchedDocuments(notebookId: string, folderPath: string): WatchedDocument[] {
+ const prefix = folderPath.endsWith(sep) ? folderPath : folderPath + sep
+
+ return getDatabase()
+ .select({
+ documentId: documents.id,
+ sourceUri: documents.sourceUri,
+ sourceMtimeMs: documents.sourceMtimeMs,
+ sourceState: documents.sourceState
+ })
+ .from(documents)
+ .where(eq(documents.notebookId, notebookId))
+ .all()
+ .filter(
+ (row): row is WatchedDocument =>
+ typeof row.sourceUri === 'string' &&
+ (row.sourceUri === folderPath || row.sourceUri.startsWith(prefix))
+ )
+ }
+
+ /** 标记来源文件本身的状态(#158):`missing` 不删行,也不碰笔记与 citation。 */
+ markSourceState(documentId: string, state: 'available' | 'missing' | 'changed'): void {
+ getDatabase()
+ .update(documents)
+ .set({ sourceState: state, updatedAt: new Date() })
+ .where(eq(documents.id, documentId))
+ .run()
+ }
+
+ /**
+ * 来源文件变了:重新拷贝、解析并索引**同一个** documentId(#158)。
+ *
+ * 与 reindex 的区别:reindex 用的是已持久化的 content,而这里文件本身被改过,必须
+ * 重新解析。来源身份不变,所以历史 citation 与摘录仍然指向同一个来源。
+ */
+ async refreshDocumentFromFile(documentId: string, filePath: string): Promise {
+ await this.ingestFile(documentId, filePath, { copyFrom: filePath }, 'reindex')
+ }
+
+ /**
+ * 监听一个文件夹(#158):持久化监听关系,立刻对齐一次,然后开始实时监听。
+ * 立刻对齐是必要的:watch 建立之前的改动也要被覆盖,而不仅仅依赖之后的 fs 事件。
+ */
+ async watchFolder(notebookId: string, folderPath: string): Promise {
+ addFolderWatch(notebookId, folderPath)
+ const result = await this.folderWatch.reconcileFolder(notebookId, folderPath)
+ this.folderWatch.start()
+ return result
+ }
+
+ unwatchFolder(watchId: string): void {
+ removeFolderWatch(watchId)
+ this.folderWatch.stopWatch(watchId)
+ }
+
+ listFolderWatches(notebookId: string): FolderWatchRecord[] {
+ return listFolderWatchesForNotebook(notebookId)
+ }
+
+ /** 启动时对齐一次(#158):app 关着的时候发生的改动不会被漏掉。 */
+ async reconcileWatchedFolders(): Promise {
+ return this.folderWatch.reconcileAll()
+ }
+
+ startFolderWatching(): void {
+ this.folderWatch.start()
+ }
+
+ stopFolderWatching(): void {
+ this.folderWatch.stop()
+ }
+
/**
* 从本地副本重新解析出结构,用于升级前没有持久化 `structure` 的文档。
*
diff --git a/src/main/services/ingestion/folderDiff.ts b/src/main/services/ingestion/folderDiff.ts
new file mode 100644
index 0000000..6cfc1c8
--- /dev/null
+++ b/src/main/services/ingestion/folderDiff.ts
@@ -0,0 +1,61 @@
+import type { ScannedFile } from './folderScan'
+
+/**
+ * The pure part of folder watching (#158).
+ *
+ * Kept out of `folderWatch.ts` on purpose: that module opens the database (and so
+ * loads Electron), and the decision table below is the part worth unit-testing.
+ * "Changed" and "missing" have to be *determined*, not guessed, so they are a pure
+ * function of the scan and the rows already imported.
+ */
+
+/** A document already imported from a file inside the watched folder. */
+export interface WatchedDocument {
+ documentId: string
+ sourceUri: string
+ sourceMtimeMs: number | null
+ sourceState: 'available' | 'missing' | 'changed'
+}
+
+export interface FolderDiff {
+ /** Files on disk with no document yet. */
+ added: ScannedFile[]
+ /** Files whose mtime differs from what was recorded. */
+ changed: ScannedFile[]
+ /** Documents whose file is gone. */
+ missing: WatchedDocument[]
+ /** Documents previously not `available` whose file is back, mtime unchanged. */
+ restored: WatchedDocument[]
+}
+
+export function diffFolder(
+ scanned: readonly ScannedFile[],
+ existing: readonly WatchedDocument[]
+): FolderDiff {
+ const byPath = new Map(existing.map((document) => [document.sourceUri, document]))
+ const seen = new Set()
+
+ const added: ScannedFile[] = []
+ const changed: ScannedFile[] = []
+ const restored: WatchedDocument[] = []
+
+ for (const file of scanned) {
+ seen.add(file.path)
+ const document = byPath.get(file.path)
+
+ if (!document) {
+ added.push(file)
+ continue
+ }
+
+ const mtimeChanged = document.sourceMtimeMs === null || document.sourceMtimeMs !== file.mtimeMs
+ if (mtimeChanged) {
+ changed.push(file)
+ } else if (document.sourceState !== 'available') {
+ restored.push(document)
+ }
+ }
+
+ const missing = existing.filter((document) => !seen.has(document.sourceUri))
+ return { added, changed, missing, restored }
+}
diff --git a/src/main/services/ingestion/folderScan.ts b/src/main/services/ingestion/folderScan.ts
index ff29c3c..ae567ae 100644
--- a/src/main/services/ingestion/folderScan.ts
+++ b/src/main/services/ingestion/folderScan.ts
@@ -1,4 +1,4 @@
-import { readdir } from 'fs/promises'
+import { readdir, stat } from 'fs/promises'
import { extname, join, relative } from 'path'
/**
@@ -15,6 +15,8 @@ export interface ScannedFile {
path: string
/** Path relative to the scanned root, for stable ordering and messages. */
relativePath: string
+ /** File modification time in ms — what a watch compares to decide "changed" (#158). */
+ mtimeMs: number
}
/**
@@ -46,7 +48,8 @@ export async function scanFolder(
const extension = extname(entry.name).toLowerCase().slice(1)
if (allowed.has(extension)) {
- files.push({ path: fullPath, relativePath: relative(dir, fullPath) })
+ const info = await stat(fullPath)
+ files.push({ path: fullPath, relativePath: relative(dir, fullPath), mtimeMs: info.mtimeMs })
}
}
}
diff --git a/src/main/services/ingestion/folderWatch.ts b/src/main/services/ingestion/folderWatch.ts
new file mode 100644
index 0000000..3262c31
--- /dev/null
+++ b/src/main/services/ingestion/folderWatch.ts
@@ -0,0 +1,217 @@
+import { watch, type FSWatcher } from 'fs'
+import { eq } from 'drizzle-orm'
+import { getDatabase } from '../../db'
+import { folderWatches } from '../../db/schema'
+import type { KnowledgeService } from '../KnowledgeService'
+import { scanFolder, type ScannedFile } from './folderScan'
+import { diffFolder } from './folderDiff'
+
+// Re-exported so the rest of the app keeps importing the watcher from one place;
+// the pure decision table lives in `folderDiff.ts` so it is testable without Electron.
+export { diffFolder, type FolderDiff, type WatchedDocument } from './folderDiff'
+
+/**
+ * Watched folders (#158).
+ *
+ * A folder import (#98) is a snapshot; this is what keeps a folder current. The
+ * two are deliberately separate code paths: the snapshot never runs on a timer,
+ * and the watcher never re-implements parsing.
+ *
+ * The division of labour:
+ *
+ * filesystem event → (debounced) reconcile that one folder
+ * new file → enqueue an import run (#95)
+ * changed file → re-parse + re-index the SAME documentId
+ * deleted file → mark the source `missing`; never delete the row,
+ * its notes, its excerpts or its citations
+ *
+ * Reconciliation is a *diff*, not a re-import: an unchanged file costs one stat
+ * and nothing else.
+ */
+
+export interface FolderWatchRecord {
+ id: string
+ notebookId: string
+ path: string
+}
+
+export function listFolderWatches(): FolderWatchRecord[] {
+ return getDatabase()
+ .select({
+ id: folderWatches.id,
+ notebookId: folderWatches.notebookId,
+ path: folderWatches.path
+ })
+ .from(folderWatches)
+ .all()
+}
+
+export function listFolderWatchesForNotebook(notebookId: string): FolderWatchRecord[] {
+ return getDatabase()
+ .select({
+ id: folderWatches.id,
+ notebookId: folderWatches.notebookId,
+ path: folderWatches.path
+ })
+ .from(folderWatches)
+ .where(eq(folderWatches.notebookId, notebookId))
+ .all()
+}
+
+/** Add a watch. Adding the same folder twice is a no-op, not a second row. */
+export function addFolderWatch(notebookId: string, path: string): FolderWatchRecord {
+ const existing = listFolderWatchesForNotebook(notebookId).find((entry) => entry.path === path)
+ if (existing) return existing
+
+ const now = new Date()
+ const id = `watch_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
+ getDatabase()
+ .insert(folderWatches)
+ .values({ id, notebookId, path, createdAt: now, updatedAt: now })
+ .run()
+
+ return { id, notebookId, path }
+}
+
+export function removeFolderWatch(watchId: string): void {
+ getDatabase().delete(folderWatches).where(eq(folderWatches.id, watchId)).run()
+}
+
+export interface ReconcileResult {
+ notebookId: string
+ path: string
+ added: number
+ changed: number
+ missing: number
+ restored: number
+}
+
+/**
+ * Keeps watched folders current.
+ *
+ * `reconcileAll()` runs on launch, so a change made while the app was closed is
+ * not missed; `start()` adds live watchers on top of that. Both funnel through
+ * `reconcileFolder()`, which is the only place that decides what changed.
+ */
+export class FolderWatchService {
+ private readonly watchers = new Map()
+ private readonly timers = new Map()
+ private stopped = false
+
+ constructor(private readonly knowledgeService: KnowledgeService) {}
+
+ async reconcileAll(): Promise {
+ const results: ReconcileResult[] = []
+ for (const watch of listFolderWatches()) {
+ results.push(await this.reconcileFolder(watch.notebookId, watch.path))
+ }
+ return results
+ }
+
+ async reconcileFolder(notebookId: string, folderPath: string): Promise {
+ const extensions = this.knowledgeService.supportedExtensions()
+ let scanned: ScannedFile[]
+ try {
+ scanned = await scanFolder(folderPath, extensions)
+ } catch {
+ // A watched folder can disappear (unmounted drive, deleted folder). Every
+ // source under it becomes `missing`; nothing is deleted.
+ scanned = []
+ }
+
+ const existing = this.knowledgeService.getWatchedDocuments(notebookId, folderPath)
+ const diff = diffFolder(scanned, existing)
+
+ for (const document of diff.missing) {
+ this.knowledgeService.markSourceState(document.documentId, 'missing')
+ }
+ for (const document of diff.restored) {
+ this.knowledgeService.markSourceState(document.documentId, 'available')
+ }
+ for (const file of diff.changed) {
+ const document = existing.find((entry) => entry.sourceUri === file.path)
+ if (!document) continue
+ try {
+ await this.knowledgeService.refreshDocumentFromFile(document.documentId, file.path)
+ } catch {
+ // A refresh failure is recorded on the ingestion run and leaves the old
+ // index in place (#95); reconciliation itself must not throw.
+ }
+ }
+
+ if (diff.added.length > 0) {
+ await this.knowledgeService.addDocumentsFromPaths(
+ notebookId,
+ diff.added.map((file) => file.path)
+ )
+ }
+
+ return {
+ notebookId,
+ path: folderPath,
+ added: diff.added.length,
+ changed: diff.changed.length,
+ missing: diff.missing.length,
+ restored: diff.restored.length
+ }
+ }
+
+ /** Live watchers over the persisted watches. Safe to call once at startup. */
+ start(): void {
+ for (const record of listFolderWatches()) {
+ this.watchFolder(record)
+ }
+ }
+
+ stop(): void {
+ this.stopped = true
+ for (const timer of this.timers.values()) clearTimeout(timer)
+ this.timers.clear()
+ for (const watcher of this.watchers.values()) watcher.close()
+ this.watchers.clear()
+ }
+
+ /** 停止监听单个文件夹(取消监听时);行还在时下次 `start()` 不会把它加回来。 */
+ stopWatch(watchId: string): void {
+ const timer = this.timers.get(watchId)
+ if (timer) clearTimeout(timer)
+ this.timers.delete(watchId)
+
+ const watcher = this.watchers.get(watchId)
+ if (watcher) watcher.close()
+ this.watchers.delete(watchId)
+ }
+
+ private watchFolder(record: FolderWatchRecord): void {
+ if (this.watchers.has(record.id)) return
+
+ try {
+ // `recursive` is supported on macOS/Windows and on Linux since Node 20. If a
+ // platform refuses it, the launch-time reconcile still keeps the folder
+ // current — only live updates are lost, and that is not silent: it is this catch.
+ const watcher = watch(record.path, { recursive: true }, () => this.debounce(record))
+ watcher.on('error', () => {
+ watcher.close()
+ this.watchers.delete(record.id)
+ })
+ this.watchers.set(record.id, watcher)
+ } catch {
+ // Folder gone or recursive unsupported: reconcile-on-launch remains the fallback.
+ }
+ }
+
+ /** One folder's burst of events becomes one reconcile. */
+ private debounce(watch: FolderWatchRecord): void {
+ if (this.stopped) return
+ const pending = this.timers.get(watch.id)
+ if (pending) clearTimeout(pending)
+
+ this.timers.set(
+ watch.id,
+ setTimeout(() => {
+ this.timers.delete(watch.id)
+ void this.reconcileFolder(watch.notebookId, watch.path)
+ }, 800)
+ )
+ }
+}
diff --git a/src/main/smokeTest.ts b/src/main/smokeTest.ts
index e07c777..70555c4 100644
--- a/src/main/smokeTest.ts
+++ b/src/main/smokeTest.ts
@@ -757,6 +757,41 @@ async function runChecks(): Promise {
)
pass('a folder import is a snapshot: unsupported files are skipped and re-import is a no-op')
+ // --- watching a folder keeps it current, and never deletes (#158) -----------
+ const watchedFolder = mkdtempSync(join(tmpdir(), 'knownote-watch-'))
+ copyFileSync(join(fixtures, 'sample.md'), join(watchedFolder, 'watched.md'))
+ await knowledge.watchFolder(reindexNotebook, watchedFolder)
+
+ const watchedDoc = knowledge
+ .getDocuments(reindexNotebook)
+ .find((doc) => doc.sourceUri === join(watchedFolder, 'watched.md'))
+ assert(watchedDoc, 'watching a folder did not import the file already in it')
+
+ // A new file appears: reconcile imports it without the user doing anything.
+ copyFileSync(join(fixtures, 'sample.md'), join(watchedFolder, 'added.md'))
+ await knowledge.reconcileWatchedFolders()
+ assert(
+ knowledge
+ .getDocuments(reindexNotebook)
+ .some((doc) => doc.sourceUri === join(watchedFolder, 'added.md')),
+ 'reconcile did not import a file added to a watched folder'
+ )
+
+ // A file disappears: the source is marked missing; the row, its chunks and its
+ // citations survive, because a citation is a snapshot.
+ rmSync(join(watchedFolder, 'watched.md'))
+ await knowledge.reconcileWatchedFolders()
+ const missingDoc = knowledge.getDocument(watchedDoc.id)
+ assert(
+ missingDoc?.sourceState === 'missing',
+ `a deleted file left source_state=${missingDoc?.sourceState}`
+ )
+ assert(
+ knowledge.getDocumentChunks(watchedDoc.id).length > 0,
+ 'marking a source missing destroyed its derived index'
+ )
+ pass('a watched folder picks up new files and marks deleted ones missing without deleting them')
+
// --- a failed re-index leaves the previous index usable (#95) ---------------
// The failure this guards: `reindexDocument()` used to clear the derived index
// *before* embedding, so an embedding failure at 70% left the document with no
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 18bdb80..cef081e 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -260,8 +260,17 @@ declare global {
notebookId: string,
filePath: string
) => Promise<{ success: boolean; documentId?: string; error?: string }>
- /** 文件夹导入:一次快照(#98)。 */
- addFolder: (notebookId: string, folderPath: string) => Promise
+ /** 文件夹导入:一次快照;`watch` 为 true 时同时改为监听来源(#158)。 */
+ addFolder: (
+ notebookId: string,
+ folderPath: string,
+ watch?: boolean
+ ) => Promise
+ /** 监听列表(#158)。 */
+ listFolderWatches: (
+ notebookId: string
+ ) => Promise>
+ unwatchFolder: (watchId: string) => Promise<{ success: boolean }>
/** 批量导入一组文件(#98):拖放与多选共用。 */
addFiles: (notebookId: string, paths: string[]) => Promise
/** 拖放的 `File` → 绝对路径(#98)。 */
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 191c69a..f24b510 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -168,8 +168,11 @@ const api = {
ipcRenderer.invoke('knowledge:add-document', { notebookId, options }),
addDocumentFromFile: (notebookId: string, filePath: string) =>
ipcRenderer.invoke('knowledge:add-document-from-file', { notebookId, filePath }),
- addFolder: (notebookId: string, folderPath: string) =>
- ipcRenderer.invoke('knowledge:add-folder', { notebookId, folderPath }),
+ addFolder: (notebookId: string, folderPath: string, watch = false) =>
+ ipcRenderer.invoke('knowledge:add-folder', { notebookId, folderPath, watch }),
+ listFolderWatches: (notebookId: string) =>
+ ipcRenderer.invoke('knowledge:list-folder-watches', { notebookId }),
+ unwatchFolder: (watchId: string) => ipcRenderer.invoke('knowledge:unwatch-folder', { watchId }),
addFiles: (notebookId: string, paths: string[]) =>
ipcRenderer.invoke('knowledge:add-files', { notebookId, paths }),
/**
diff --git a/src/renderer/src/components/notebook/SourcePanel.tsx b/src/renderer/src/components/notebook/SourcePanel.tsx
index 7268052..366331b 100644
--- a/src/renderer/src/components/notebook/SourcePanel.tsx
+++ b/src/renderer/src/components/notebook/SourcePanel.tsx
@@ -11,7 +11,8 @@ import {
ArrowLeft,
Quote,
ExternalLink,
- FolderOpen
+ FolderOpen,
+ FolderSync
} from 'lucide-react'
import { toast } from 'sonner'
import { useKnowledgeStore, setupKnowledgeListeners } from '../../store/knowledgeStore'
@@ -27,10 +28,7 @@ import { Card } from '../ui/card'
import { PanelHeader } from '../ui/panel-header'
import DocumentList from './source/DocumentList'
import SourceReader from './source/reader/SourceReader'
-import type {
- KnowledgeDocument,
- BatchImportOutcome
-} from '../../../../shared/types/knowledge'
+import type { KnowledgeDocument, BatchImportOutcome } from '../../../../shared/types/knowledge'
import type { ReaderAnchor, ReaderSelection } from '../../../../shared/types/source'
import {
selectionToSourceAnchor,
@@ -511,20 +509,23 @@ export default function SourcePanel(): ReactElement {
reportBatch(await addFiles(notebookId, files))
}, [notebookId, hasEmbeddingModel, selectFiles, addFiles, reportBatch, t])
- // 处理文件夹导入(#98):一次快照,不是监听(监听是 #158)
- const handleFolderUpload = useCallback(async () => {
- if (!notebookId) return
- if (!hasEmbeddingModel) {
- alert(t('noEmbeddingModelConfigured'))
- return
- }
+ // 处理文件夹导入(#98 快照 / #158 监听)
+ const handleFolderUpload = useCallback(
+ async (watch: boolean) => {
+ if (!notebookId) return
+ if (!hasEmbeddingModel) {
+ alert(t('noEmbeddingModelConfigured'))
+ return
+ }
- const folders = await selectFolder()
- setShowAddMenu(false)
- for (const folder of folders) {
- reportBatch(await addFolder(notebookId, folder))
- }
- }, [notebookId, hasEmbeddingModel, selectFolder, addFolder, reportBatch, t])
+ const folders = await selectFolder()
+ setShowAddMenu(false)
+ for (const folder of folders) {
+ reportBatch(await addFolder(notebookId, folder, watch))
+ }
+ },
+ [notebookId, hasEmbeddingModel, selectFolder, addFolder, reportBatch, t]
+ )
// 拖放文件/文件夹(#98)。Electron 39 下路径必须由 preload 的 webUtils 给出。
const handleDrop = useCallback(
@@ -764,13 +765,21 @@ export default function SourcePanel(): ReactElement {
{t('uploadFile')}
+
)}
+ {/* 来源文件不在了(#158):citation / 摘录仍然有效,只是文件暂时取不到。 */}
+ {document.sourceState === 'missing' && (
+ {t('sourceMissing')}
+ )}
diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json
index f5d6b82..607b48e 100644
--- a/src/renderer/src/locales/en-US/ui.json
+++ b/src/renderer/src/locales/en-US/ui.json
@@ -86,6 +86,8 @@
"retryDocument": "Try again",
"reindexDocument": "Re-index",
"uploadFolder": "Add folder",
+ "watchFolder": "Watch folder",
+ "sourceMissing": "The file is no longer at its path",
"dropToImport": "Drop files or a folder to import",
"importAdded": "Imported {{count}}",
"importSkipped": "skipped {{count}} (already imported)",
diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json
index dbbb6e1..89e74ff 100644
--- a/src/renderer/src/locales/zh-CN/ui.json
+++ b/src/renderer/src/locales/zh-CN/ui.json
@@ -86,6 +86,8 @@
"retryDocument": "重试",
"reindexDocument": "重建索引",
"uploadFolder": "添加文件夹",
+ "watchFolder": "监听文件夹",
+ "sourceMissing": "文件已不在原路径",
"dropToImport": "拖入文件或文件夹以导入",
"importAdded": "已导入 {{count}} 个",
"importSkipped": "跳过 {{count}} 个(已存在)",
diff --git a/src/renderer/src/store/knowledgeStore.ts b/src/renderer/src/store/knowledgeStore.ts
index a49c1d8..31bb093 100644
--- a/src/renderer/src/store/knowledgeStore.ts
+++ b/src/renderer/src/store/knowledgeStore.ts
@@ -70,8 +70,12 @@ interface KnowledgeStore {
notebookId: string,
noteId: string
) => Promise<{ success: boolean; documentId?: string; error?: string }>
- /** 文件夹导入:一次快照(#98)。 */
- addFolder: (notebookId: string, folderPath: string) => Promise
+ /** 文件夹导入:一次快照;`watch` 为 true 时同时改为监听来源(#158)。 */
+ addFolder: (
+ notebookId: string,
+ folderPath: string,
+ watch?: boolean
+ ) => Promise
/** 批量导入一组文件(#98):拖放与多选共用同一路径。 */
addFiles: (notebookId: string, paths: string[]) => Promise
selectFolder: () => Promise
@@ -207,9 +211,9 @@ export const useKnowledgeStore = create()((set, get) => ({
},
// 文件夹 / 批量导入(#98)
- addFolder: async (notebookId, folderPath) => {
+ addFolder: async (notebookId, folderPath, watch = false) => {
set({ isIndexing: true, error: null })
- const result = await window.api.knowledge.addFolder(notebookId, folderPath)
+ const result = await window.api.knowledge.addFolder(notebookId, folderPath, watch)
await refreshAfterImport(notebookId, result, set, get)
return result
},
diff --git a/test/folderDiff.test.ts b/test/folderDiff.test.ts
new file mode 100644
index 0000000..a25031d
--- /dev/null
+++ b/test/folderDiff.test.ts
@@ -0,0 +1,109 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { diffFolder, type WatchedDocument } from '../src/main/services/ingestion/folderDiff.ts'
+import type { ScannedFile } from '../src/main/services/ingestion/folderScan.ts'
+
+/**
+ * The decision table behind watching a folder (#158).
+ *
+ * This is the part that must not be guessed: a document is re-indexed only when
+ * its file's mtime actually changed, and a document is only marked `missing` when
+ * its file is really gone. Everything downstream (re-index, mark missing) is a
+ * consequence of this diff.
+ */
+
+const file = (path: string, mtimeMs: number): ScannedFile => ({ path, relativePath: path, mtimeMs })
+
+const doc = (
+ path: string,
+ mtimeMs: number | null,
+ sourceState: WatchedDocument['sourceState'] = 'available'
+): WatchedDocument => ({
+ documentId: `doc_${path}`,
+ sourceUri: path,
+ sourceMtimeMs: mtimeMs,
+ sourceState
+})
+
+test('a file with no document is added', () => {
+ const diff = diffFolder([file('/p/a.pdf', 100)], [])
+ assert.deepEqual(
+ diff.added.map((f) => f.path),
+ ['/p/a.pdf']
+ )
+ assert.equal(diff.changed.length + diff.missing.length + diff.restored.length, 0)
+})
+
+test('an mtime change is changed, not added', () => {
+ const diff = diffFolder([file('/p/a.pdf', 200)], [doc('/p/a.pdf', 100)])
+ assert.deepEqual(
+ diff.changed.map((f) => f.path),
+ ['/p/a.pdf']
+ )
+ assert.equal(diff.added.length, 0)
+})
+
+test('an unchanged file is neither added nor changed', () => {
+ const diff = diffFolder([file('/p/a.pdf', 100)], [doc('/p/a.pdf', 100)])
+ assert.equal(
+ diff.added.length + diff.changed.length + diff.missing.length + diff.restored.length,
+ 0
+ )
+})
+
+test('a document with no mtime recorded counts as changed, not as current', () => {
+ // An older row has no mtime; the first reconcile after the upgrade must treat it
+ // as out of date rather than assume it is current.
+ const diff = diffFolder([file('/p/a.pdf', 100)], [doc('/p/a.pdf', null)])
+ assert.deepEqual(
+ diff.changed.map((f) => f.path),
+ ['/p/a.pdf']
+ )
+})
+
+test('a document whose file is gone is missing', () => {
+ const diff = diffFolder([], [doc('/p/gone.pdf', 100)])
+ assert.deepEqual(
+ diff.missing.map((d) => d.sourceUri),
+ ['/p/gone.pdf']
+ )
+ assert.equal(diff.added.length + diff.changed.length, 0)
+})
+
+test('a missing document whose file is back and unchanged is restored', () => {
+ const diff = diffFolder([file('/p/a.pdf', 100)], [doc('/p/a.pdf', 100, 'missing')])
+ assert.deepEqual(
+ diff.restored.map((d) => d.sourceUri),
+ ['/p/a.pdf']
+ )
+ assert.equal(diff.changed.length, 0)
+})
+
+test('a missing document whose file is back but changed is re-indexed', () => {
+ const diff = diffFolder([file('/p/a.pdf', 300)], [doc('/p/a.pdf', 100, 'missing')])
+ assert.deepEqual(
+ diff.changed.map((f) => f.path),
+ ['/p/a.pdf']
+ )
+ assert.equal(diff.restored.length, 0)
+})
+
+test('added, changed and missing are computed together', () => {
+ const diff = diffFolder(
+ [file('/p/new.pdf', 10), file('/p/edited.pdf', 20), file('/p/same.pdf', 30)],
+ [doc('/p/edited.pdf', 5), doc('/p/same.pdf', 30), doc('/p/deleted.pdf', 40)]
+ )
+
+ assert.deepEqual(
+ diff.added.map((f) => f.path),
+ ['/p/new.pdf']
+ )
+ assert.deepEqual(
+ diff.changed.map((f) => f.path),
+ ['/p/edited.pdf']
+ )
+ assert.deepEqual(
+ diff.missing.map((d) => d.sourceUri),
+ ['/p/deleted.pdf']
+ )
+})