From eb7223ea4c2d7740e079070a253d0a8e3fb6aea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 19 Aug 2026 03:51:18 +0800 Subject: [PATCH] feat: admit signed web novel module family --- .../module-donor-admission-registry.json | 16 +- .../contracts/numbered-ipc-registry.json | 485 ++- .../web-novel-module-marketplace-plan.json | 138 + .../contracts/web-novel-workspace.json | 131 + .../docs/ARCHITECTURE.md | 2 + ...FICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod | 7 + ...AL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig | 1 + ...D-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod | 7 + ...FICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig | 1 + ...FFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod | 7 + ...IAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig | 1 + ...CIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod | 7 + ...-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig | 1 + ...ICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod | 26 + ...L-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig | 1 + .../HL-MOD-WEBNOVEL-DELIVERY-004.json | 12 + .../HL-MOD-WEBNOVEL-GRID-002.json | 12 + .../HL-MOD-WEBNOVEL-OUTLINE-001.json | 12 + .../HL-MOD-WEBNOVEL-STORYWORLD-003.json | 12 + .../fixtures/web-novel-legacy-0.4.1/README.md | 6 + .../unified-number-coordinate-tree.json | 536 ++- .../scripts/module-donor-admission.test.mjs | 10 +- .../unified-number-coordinate-tree.test.mjs | 4 +- .../web-novel-workbench-admission.test.mjs | 54 + .../src-tauri/Cargo.lock | 3 + .../src-tauri/Cargo.toml | 3 + .../src-tauri/src/lib.rs | 4 + .../src-tauri/src/module_package_runtime.rs | 107 +- .../src-tauri/src/number_coordinate_tree.rs | 2 +- .../src-tauri/src/numbered_ipc.rs | 2 +- .../src-tauri/src/numbered_ipc_dispatch.rs | 130 + .../src-tauri/src/web_novel_author.rs | 1039 ++++++ .../src-tauri/src/web_novel_import.rs | 810 +++++ .../src-tauri/src/web_novel_modules.rs | 2004 +++++++++++ .../src-tauri/src/web_novel_workspace.rs | 3007 +++++++++++++++++ .../hololake-native-desktop/src/main.tsx | 46 +- .../src/modules/numbered-ipc.ts | 304 ++ .../modules/web-novel/AuthorModuleCenter.tsx | 228 ++ .../web-novel/AuthorWritingSidecar.tsx | 80 + .../modules/web-novel/WebNovelWorkspace.tsx | 929 +++++ .../src/modules/web-novel/styles.css | 286 ++ 41 files changed, 10452 insertions(+), 21 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/web-novel-module-marketplace-plan.json create mode 100644 product-source/hololake-native-desktop/contracts/web-novel-workspace.json create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json create mode 100644 product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json create mode 100644 product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json create mode 100644 product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json create mode 100644 product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/README.md create mode 100644 product-source/hololake-native-desktop/scripts/web-novel-workbench-admission.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/web_novel_author.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/web_novel_import.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/web_novel_modules.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/web_novel_workspace.rs create mode 100644 product-source/hololake-native-desktop/src/modules/web-novel/AuthorModuleCenter.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/web-novel/AuthorWritingSidecar.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/web-novel/WebNovelWorkspace.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/web-novel/styles.css diff --git a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json index 08644f2ed..1e138cd8c 100644 --- a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json +++ b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json @@ -1,7 +1,7 @@ { "schema": "hololake.module-donor-admission-registry/v1", "record_id": "HLP-MODULE-DONOR-ADMISSION-001", - "state": "FOUR_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", + "state": "FIVE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", "root_rule": { "official_base": "HOLOLAKE_0.5.0_NUMBERED_IPC_ROOT", "repair_old_application_in_place": false, @@ -130,18 +130,22 @@ "admission_order": 5, "candidate_number": "HLP-DONOR-CAND-0005", "name": "web_novel_workbench_and_author_modules", - "state": "QUARANTINED_PENDING_ADMISSION", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", "paths": [ "contracts/web-novel-workspace.json", "contracts/web-novel-module-marketplace-plan.json", - "src/WebNovelWorkspace.tsx", - "src/AuthorModuleCenter.tsx", - "src/AuthorWritingSidecar.tsx", + "src/modules/web-novel/WebNovelWorkspace.tsx", + "src/modules/web-novel/AuthorModuleCenter.tsx", + "src/modules/web-novel/AuthorWritingSidecar.tsx", "src-tauri/src/web_novel_workspace.rs", "src-tauri/src/web_novel_import.rs", "src-tauri/src/web_novel_author.rs", "src-tauri/src/web_novel_modules.rs" - ] + ], + "runtime_module_numbers": ["HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001"], + "numbered_ipc_modules": ["HLP-NIPC-MOD-0025", "HLP-NIPC-MOD-0026", "HLP-NIPC-MOD-0027", "HLP-NIPC-MOD-0028", "HLP-NIPC-MOD-0029"], + "acceptance_evidence": "contracts/web-novel-workspace.json#current_acceptance", + "boundary": "CURRENT_ACCOUNT_LOCAL; ONE_STORY_GRAPH; ADVANCED_EFFECTS_REQUIRE_EXACT_ACTIVE_MODULE_NUMBER; THIRD_PARTY_AUTO_LOGIN_AND_PUBLISH_DENIED" }, { "admission_order": 6, diff --git a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json index 114749a4a..e6f0232b6 100644 --- a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json +++ b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json @@ -69,7 +69,9 @@ "get_channel_growth_snapshot", "get_education_workspace_snapshot", "import_education_tables_from_dialog", - "get_education_recognition_capability" + "get_education_recognition_capability", + "get_web_novel_workspace_snapshot", + "inspect_web_novel_document_from_dialog" ], "input_wrapper_aliases": [ "confirm_hololake_update_install", @@ -131,7 +133,43 @@ "save_education_automation_rule", "archive_education_automation_rule", "preview_education_automation_rule", - "execute_education_automation_rule" + "execute_education_automation_rule", + "create_web_novel_work", + "read_web_novel_work", + "save_web_novel_work", + "create_web_novel_volume", + "create_web_novel_chapter", + "read_web_novel_chapter", + "save_web_novel_chapter", + "transition_web_novel_chapter", + "create_web_novel_checkpoint", + "restore_web_novel_checkpoint", + "upsert_web_novel_story_entity", + "create_web_novel_story_relation", + "upsert_web_novel_foreshadow", + "create_web_novel_review_note", + "resolve_web_novel_review_note", + "save_web_novel_metric", + "run_web_novel_continuity_audit", + "export_web_novel_markdown", + "commit_web_novel_document_import", + "get_web_novel_author_snapshot", + "record_web_novel_writing_activity", + "create_web_novel_inspiration", + "set_web_novel_inspiration_status", + "search_web_novel_full_text", + "format_web_novel_chapter", + "format_web_novel_work", + "upsert_web_novel_shot", + "get_web_novel_author_module_data", + "upsert_web_novel_author_scene", + "upsert_web_novel_author_beat", + "upsert_web_novel_story_field_definition", + "upsert_web_novel_story_field_value", + "upsert_web_novel_timeline_event", + "link_web_novel_scene_entity", + "restore_web_novel_chapter_version", + "export_web_novel_author_delivery" ], "direct_field_aliases": { "perform_code_repo_login": [ @@ -298,6 +336,31 @@ "module_number": "HLP-NIPC-MOD-0024", "target_number": "HLP-NIPC-TGT-0024", "internal_name": "education_workspace" + }, + { + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "internal_name": "web_novel_workbench" + }, + { + "module_number": "HLP-NIPC-MOD-0026", + "target_number": "HLP-NIPC-TGT-0026", + "internal_name": "web_novel_outline" + }, + { + "module_number": "HLP-NIPC-MOD-0027", + "target_number": "HLP-NIPC-TGT-0027", + "internal_name": "web_novel_story_grid" + }, + { + "module_number": "HLP-NIPC-MOD-0028", + "target_number": "HLP-NIPC-TGT-0028", + "internal_name": "web_novel_storyworld" + }, + { + "module_number": "HLP-NIPC-MOD-0029", + "target_number": "HLP-NIPC-TGT-0029", + "internal_name": "web_novel_delivery" } ], "operations": [ @@ -1422,6 +1485,424 @@ "admission": "VERIFIED_HUMAN_ROUTE", "effect": "STATE_CHANGE", "payload_schema": "hololake.numbered-ipc.payload/execute_education_automation_rule/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0103", + "alias": "get_web_novel_workspace_snapshot", + "handler": "web_novel_workspace::get_web_novel_workspace_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_web_novel_workspace_snapshot/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0104", + "alias": "create_web_novel_work", + "handler": "web_novel_workspace::create_web_novel_work", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_work/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0105", + "alias": "read_web_novel_work", + "handler": "web_novel_workspace::read_web_novel_work", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/read_web_novel_work/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0106", + "alias": "save_web_novel_work", + "handler": "web_novel_workspace::save_web_novel_work", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_web_novel_work/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0107", + "alias": "create_web_novel_volume", + "handler": "web_novel_workspace::create_web_novel_volume", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_volume/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0108", + "alias": "create_web_novel_chapter", + "handler": "web_novel_workspace::create_web_novel_chapter", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_chapter/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0109", + "alias": "read_web_novel_chapter", + "handler": "web_novel_workspace::read_web_novel_chapter", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/read_web_novel_chapter/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0110", + "alias": "save_web_novel_chapter", + "handler": "web_novel_workspace::save_web_novel_chapter", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_web_novel_chapter/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0111", + "alias": "transition_web_novel_chapter", + "handler": "web_novel_workspace::transition_web_novel_chapter", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/transition_web_novel_chapter/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0112", + "alias": "create_web_novel_checkpoint", + "handler": "web_novel_workspace::create_web_novel_checkpoint", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_checkpoint/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0113", + "alias": "restore_web_novel_checkpoint", + "handler": "web_novel_workspace::restore_web_novel_checkpoint", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/restore_web_novel_checkpoint/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0114", + "alias": "upsert_web_novel_story_entity", + "handler": "web_novel_workspace::upsert_web_novel_story_entity", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_story_entity/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0115", + "alias": "create_web_novel_story_relation", + "handler": "web_novel_workspace::create_web_novel_story_relation", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_story_relation/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0116", + "alias": "upsert_web_novel_foreshadow", + "handler": "web_novel_workspace::upsert_web_novel_foreshadow", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_foreshadow/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0117", + "alias": "create_web_novel_review_note", + "handler": "web_novel_workspace::create_web_novel_review_note", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_review_note/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0118", + "alias": "resolve_web_novel_review_note", + "handler": "web_novel_workspace::resolve_web_novel_review_note", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/resolve_web_novel_review_note/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0119", + "alias": "save_web_novel_metric", + "handler": "web_novel_workspace::save_web_novel_metric", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_web_novel_metric/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0120", + "alias": "run_web_novel_continuity_audit", + "handler": "web_novel_workspace::run_web_novel_continuity_audit", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/run_web_novel_continuity_audit/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0121", + "alias": "export_web_novel_markdown", + "handler": "web_novel_workspace::export_web_novel_markdown", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/export_web_novel_markdown/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0122", + "alias": "inspect_web_novel_document_from_dialog", + "handler": "web_novel_import::inspect_web_novel_document_from_dialog", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/inspect_web_novel_document_from_dialog/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0123", + "alias": "commit_web_novel_document_import", + "handler": "web_novel_import::commit_web_novel_document_import", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/commit_web_novel_document_import/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0124", + "alias": "get_web_novel_author_snapshot", + "handler": "web_novel_author::get_web_novel_author_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_web_novel_author_snapshot/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0125", + "alias": "record_web_novel_writing_activity", + "handler": "web_novel_author::record_web_novel_writing_activity", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/record_web_novel_writing_activity/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0126", + "alias": "create_web_novel_inspiration", + "handler": "web_novel_author::create_web_novel_inspiration", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_web_novel_inspiration/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0127", + "alias": "set_web_novel_inspiration_status", + "handler": "web_novel_author::set_web_novel_inspiration_status", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/set_web_novel_inspiration_status/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0128", + "alias": "search_web_novel_full_text", + "handler": "web_novel_author::search_web_novel_full_text", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/search_web_novel_full_text/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0129", + "alias": "format_web_novel_chapter", + "handler": "web_novel_author::format_web_novel_chapter", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/format_web_novel_chapter/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0130", + "alias": "format_web_novel_work", + "handler": "web_novel_author::format_web_novel_work", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/format_web_novel_work/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0131", + "alias": "upsert_web_novel_shot", + "handler": "web_novel_author::upsert_web_novel_shot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_shot/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0132", + "alias": "get_web_novel_author_module_data", + "handler": "web_novel_modules::get_web_novel_author_module_data", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0025", + "target_number": "HLP-NIPC-TGT-0025", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_web_novel_author_module_data/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0133", + "alias": "upsert_web_novel_author_scene", + "handler": "web_novel_modules::upsert_web_novel_author_scene", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0026", + "target_number": "HLP-NIPC-TGT-0026", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_author_scene/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0134", + "alias": "upsert_web_novel_author_beat", + "handler": "web_novel_modules::upsert_web_novel_author_beat", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0026", + "target_number": "HLP-NIPC-TGT-0026", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_author_beat/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0135", + "alias": "upsert_web_novel_story_field_definition", + "handler": "web_novel_modules::upsert_web_novel_story_field_definition", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0027", + "target_number": "HLP-NIPC-TGT-0027", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_story_field_definition/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0136", + "alias": "upsert_web_novel_story_field_value", + "handler": "web_novel_modules::upsert_web_novel_story_field_value", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0027", + "target_number": "HLP-NIPC-TGT-0027", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_story_field_value/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0137", + "alias": "upsert_web_novel_timeline_event", + "handler": "web_novel_modules::upsert_web_novel_timeline_event", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0028", + "target_number": "HLP-NIPC-TGT-0028", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/upsert_web_novel_timeline_event/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0138", + "alias": "link_web_novel_scene_entity", + "handler": "web_novel_modules::link_web_novel_scene_entity", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0028", + "target_number": "HLP-NIPC-TGT-0028", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/link_web_novel_scene_entity/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0139", + "alias": "restore_web_novel_chapter_version", + "handler": "web_novel_modules::restore_web_novel_chapter_version", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0029", + "target_number": "HLP-NIPC-TGT-0029", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/restore_web_novel_chapter_version/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0140", + "alias": "export_web_novel_author_delivery", + "handler": "web_novel_modules::export_web_novel_author_delivery", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0029", + "target_number": "HLP-NIPC-TGT-0029", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/export_web_novel_author_delivery/v1" } ] } diff --git a/product-source/hololake-native-desktop/contracts/web-novel-module-marketplace-plan.json b/product-source/hololake-native-desktop/contracts/web-novel-module-marketplace-plan.json new file mode 100644 index 000000000..6d1c2d24a --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/web-novel-module-marketplace-plan.json @@ -0,0 +1,138 @@ +{ + "schema": "hololake.web-novel-module-marketplace-plan/v1", + "record_id": "HLP-WEBNOVEL-MODULE-MARKETPLACE-PLAN-001", + "state": "BASE_AND_FOUR_OFFICIAL_SIGNED_MODULES_ADMITTED_LOCAL_PUBLICATION_READY", + "distribution_model": { + "hololake_role": "LIGHTWEIGHT_FRAMEWORK_LANGUAGE_WORLD_AND_MODULE_RUNTIME", + "channel_profile": "SINGLE_HUMAN_SINGLE_FACT_LANE", + "built_in_author_workbench": "BUNDLED_SIGNED_LIGHTWEIGHT_REAL_NATIVE_ENGINE", + "complete_author_capabilities": "INDEPENDENT_HOT_PLUGGABLE_OFFICIAL_MODULES", + "repository_role": "IMMUTABLE_SOURCE_AND_RELEASE_FACT_NOT_UNREVIEWED_DIRECT_RUNTIME", + "persona_role": "DISCOVER_PROPOSE_DEPLOY_VERIFY_AND_RECEIPT_WITHIN_USER_AUTHORIZATION" + }, + "built_in_light_author_workbench": { + "distribution": "BUNDLED_SIGNED_PACKAGE_EXPLICIT_FIRST_ACTIVATION", + "marketplace_module": false, + "real_engine_owner": "HOLOLAKE_NATIVE_RUST_SQLITE", + "capabilities": [ + "CREATE_OPEN_AND_REOPEN_WORK", + "VOLUME_AND_CHAPTER_TREE", + "REAL_CHAPTER_TEXT_EDITING", + "DEBOUNCED_AUTOSAVE_AND_RESTART_READBACK", + "WORD_COUNT", + "REAL_EDITING_TIME_RECEIPTS", + "PROMINENT_CREATE_CHAPTER_OR_EPISODE", + "LONG_NOVEL_SHORT_NOVEL_AND_SHORT_DRAMA_SHAPES", + "SHORT_DRAMA_SCREENPLAY_TEMPLATE", + "AUTO_FORMAT_ON_IMPORT_WITH_SOURCE_VERSION", + "ONE_CLICK_FORMAT_WITH_VERSION_SAFETY", + "CHAPTER_OR_WHOLE_WORK_FORMAT_PRESETS", + "OUTLINE_SOURCE_AND_STRUCTURED_RENDER", + "INSPIRATION_CAPTURE", + "FULL_TEXT_SEARCH", + "SHORT_DRAMA_SHOT_AND_PROMPT_EDITING", + "BASIC_VERSION_SAFETY", + "BASIC_TXT_MARKDOWN_DOCX_IMPORT", + "BASIC_MARKDOWN_EXPORT" + ], + "forbidden_substitutes": [ + "STATIC_EDITOR_SHELL", + "FRONTEND_ONLY_LOCAL_ARRAY", + "FAKE_AUTOSAVE", + "IMPORT_FILENAME_WITHOUT_PARSE_AND_PERSIST" + ] + }, + "official_module_candidates": [ + { + "candidate_key": "AUTHOR_STRUCTURE_AND_OUTLINE_TRACKING", + "module_number": "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001", + "numbering_state": "OFFICIAL_NUMBER_REGISTERED_AFTER_INSTALLED_ACCEPTANCE", + "version": "0.1.0", + "package_sha256": "6b4395ecdb5e546c6ccbf71e1b8475e5cbe0c36868d54a8e10c61c328a5f50f9", + "capabilities": ["SCENE_AND_BEAT_STRUCTURE", "OUTLINE_STATUS_TRACKING", "GOAL_CONFLICT_OUTCOME", "HOOK_FORESHADOW_AND_PAYOFF_TRACKING"] + }, + { + "candidate_key": "AUTHOR_MULTIDIMENSIONAL_STORY_GRID_AND_BOARD", + "module_number": "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001", + "numbering_state": "OFFICIAL_NUMBER_REGISTERED_AFTER_INSTALLED_ACCEPTANCE", + "version": "0.1.0", + "package_sha256": "cd837adef8aecf0e027a0cc2c5845d32c972fa79d762d3780637c525dd4fc18e", + "capabilities": ["ONE_STORY_GRAPH_EDITABLE_GRID", "GROUPABLE_STORY_BOARD", "CUSTOM_FIELDS", "CROSS_VIEW_SYNCHRONIZATION"] + }, + { + "candidate_key": "AUTHOR_TIMELINE_AND_STORY_BIBLE", + "module_number": "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001", + "numbering_state": "OFFICIAL_NUMBER_REGISTERED_AFTER_INSTALLED_ACCEPTANCE", + "version": "0.1.0", + "package_sha256": "868008e66b0897bd6cbf15383d2ec824436a46230f58b791499363af088fb63e", + "capabilities": ["STORY_TIME_MODEL", "CHARACTER_LOCATION_ITEM_ORGANIZATION_ENTITIES", "ENTITY_RELATIONS", "CHARACTER_AND_PLOTLINE_TRAJECTORIES"] + }, + { + "candidate_key": "AUTHOR_ADVANCED_IMPORT_VERSION_AND_DELIVERY", + "module_number": "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001", + "numbering_state": "OFFICIAL_NUMBER_REGISTERED_AFTER_INSTALLED_ACCEPTANCE", + "version": "0.1.0", + "package_sha256": "c07078f8c3998f8504aec5f0b73215e7d61b4ff165ba9e6efa3ebcaffcd97322", + "capabilities": ["REIMPORT_DIFF_AND_SOURCE_BINDING", "FULL_STORY_SNAPSHOT", "RESTORE_OR_FORK", "DOCX_EPUB_MARKDOWN_TXT_DELIVERY"] + } + ], + "acceptance_evidence": { + "native_engine_tests": "PASS", + "real_novel": "504_CHAPTERS_IMPORTED_ORGANIZED_AND_EPUB_EXPORTED", + "real_outline": "50_CHAPTERS_IMPORTED_ORGANIZED_AND_DOCX_TXT_EXPORTED", + "real_script": "75_EPISODES_IMPORTED_ORGANIZED_AND_JSON_EXPORTED", + "desktop_install_mount_self_test": "PASS_BASE_AND_ALL_FOUR_ADVANCED_MODULES_THROUGH_SHARED_SIGNED_RUNTIME", + "desktop_restart_readback": "PASS_5_ACTIVE_MODULES_ACCEPTANCE_WORK_CHAPTER_SCENE_GRID_FIELD_AND_TIMELINE", + "current_account_visible_novel_import": "504_CHAPTERS_1082978_WORDS_OPENED_IN_SIGNED_DESKTOP_APP", + "prominent_create_chapter_entry": "PASS_SHORT_DRAMA_LABEL_NEW_EPISODE_LONG_AND_SHORT_NOVEL_LABEL_NEW_CHAPTER", + "unmount_preserves_data_and_receipts": "PASS_BY_SHARED_RUNTIME_AND_RESTART_READBACK", + "signed_desktop_bundle": "APPLE_DEVELOPER_ID_825A9L3G7Q", + "remote_marketplace_publication": "PENDING_OFFICIAL_REPOSITORY_RELEASE" + }, + "publication_gate": [ + "SOURCE_AND_LICENSE_REVIEW", + "NATIVE_OR_AUDITED_ADAPTER_IMPLEMENTATION", + "AUTOMATED_ENGINE_TESTS", + "REAL_NOVEL_OUTLINE_AND_SCRIPT_FIXTURE_ACCEPTANCE", + "DESKTOP_INSTALL_MOUNT_RUN_RESTART_AND_UNINSTALL_ACCEPTANCE", + "PERMISSION_DATA_EXPORT_ROLLBACK_AND_RECEIPT_ACCEPTANCE", + "IMMUTABLE_RELEASE_BUILD_AND_SIGNATURE", + "THEN_ASSIGN_PERMANENT_MODULE_NUMBER", + "THEN_REGISTER_OFFICIAL_MODULE_REGISTRY", + "THEN_PUBLISH_OFFICIAL_MARKETPLACE" + ], + "channel_deployment_flow": [ + "AUTHOR_EXPRESSES_NEED", + "PERSONA_SEARCHES_OFFICIAL_REGISTRY", + "PERSONA_EXPLAINS_MODULE_PERMISSION_DATA_AND_RESOURCE_BOUNDARY", + "HUMAN_CONFIRMS_WHEN_BOUNDARY_REQUIRES", + "RESOLVE_MODULE_NUMBER_AND_PINNED_VERSION", + "FETCH_IMMUTABLE_RELEASE_ARTIFACT", + "VERIFY_SOURCE_SIGNATURE_HASH_DEPENDENCIES_AND_COMPATIBILITY", + "INSTALL_TO_LOCAL_CACHE", + "MOUNT_IN_CURRENT_CHANNEL", + "RUN_MODULE_SELF_TEST", + "WRITE_INSTALLATION_AND_RUNTIME_RECEIPT", + "ROLL_BACK_ON_FAILURE" + ], + "deployment_experience": { + "warm_or_small_module_target_seconds": 30, + "target_is_unconditional_guarantee": false, + "depends_on": ["ARTIFACT_SIZE", "NETWORK", "CACHE", "DEPENDENCY_STATE", "SELF_TEST_DURATION"], + "already_installed_module_offline_start_allowed": true + }, + "data_boundary": { + "one_story_graph_for_builtin_and_modules": true, + "module_program_and_user_story_data_separated": true, + "unmount_preserves_story_data": true, + "uninstall_preserves_story_data_and_receipts": true, + "module_install_grants_all_channel_data": false + }, + "sources": [ + "source://current-dialogue/2026-08-18/bingshuo-light-author-workbench-built-in-and-complete-modules-in-official-marketplace", + "REPO-012:gls/GLS-0233-GH-AIOS-MODULAR-AI-OPERATING-PLATFORM-AND-FAIR-ECOSYSTEM.hdlp", + "REPO-012:gls/GLS-0236-PERSONA-BRAIN-HANDS-VISIBLE-EXECUTION-RECURSIVE-MEMORY-AND-HOTPLUG-RUNTIME.hdlp", + "REPO-012:gls/GLS-0241-HOLOLAKE-SOURCE-OWNERSHIP-AND-DEPLOYMENT-ROUTING.hdlp", + "REPO-012:gls/GLS-0245-HOLOLAKE-LANGUAGE-PERSONA-OPERATING-SYSTEM-PRODUCT-MAPPING.hdlp" + ] +} diff --git a/product-source/hololake-native-desktop/contracts/web-novel-workspace.json b/product-source/hololake-native-desktop/contracts/web-novel-workspace.json new file mode 100644 index 000000000..69fb77932 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/web-novel-workspace.json @@ -0,0 +1,131 @@ +{ + "schema": "hololake.web-novel-workspace/v1", + "record_id": "HLP-WEBNOVEL-WORKSPACE-001", + "state": "SIGNED_BASE_AND_FOUR_ADVANCED_MODULES_ADMITTED_RESTART_ACCEPTED", + "domain_entry": "BRANCH_DOMAIN", + "industry_key": "WEB_NOVEL", + "industry_number": "IND-WEBNOVEL-001", + "channel_id": "GH-WEBNOVEL-INIT-001", + "ontology_correction": { + "channel_body_contract": "contracts/user-channel-body.json", + "marketplace_plan_contract": "contracts/web-novel-module-marketplace-plan.json", + "this_contract_is": "BUNDLED_SIGNED_LIGHT_AUTHOR_WORKBENCH_AND_FOUR_OFFICIAL_NUMBERED_ADAPTERS", + "this_contract_is_not": "USER_CHANNEL_BODY", + "legacy_channel_id_semantics": "DEPRECATED_INDUSTRY_PROJECTION_IDENTIFIER", + "author_editor_operator_are_ui_tabs": false, + "shared_story_graph_copies": 1 + }, + "user_channel_entry": { + "current_domain": "FIFTH_DOMAIN", + "current_channel": "HEARTBEAT_CORE_CHANNEL", + "display_name": "作者工作台", + "routes_to_same_native_workspace": true, + "duplicates_account_story_data": false + }, + "distribution_boundary": { + "preinstalled": "SIGNED_PACKAGE_ARTIFACTS_WITH_EXPLICIT_FIRST_ACTIVATION", + "complete_author_features": "FOUR_INDEPENDENT_NUMBERED_OFFICIAL_MODULES_ACTIVE_IN_SHARED_RUNTIME", + "current_advanced_features_are_registered_marketplace_modules": true, + "hololake_bundles_entire_web_novel_world": false + }, + "native_storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "engine": "SQLITE", + "authenticated_account_required": true, + "cross_account_projection_allowed": false, + "restart_readback_required": true, + "source_manuscript_mutation_allowed": false + }, + "work_objects": [ + "WORK", + "VOLUME", + "CHAPTER", + "CHAPTER_VERSION", + "CHECKPOINT", + "STORY_ENTITY", + "STORY_RELATION", + "FORESHADOW", + "EDITOR_REVIEW_NOTE", + "WORKFLOW_EVENT", + "AUTHORIZED_METRIC" + ,"SCENE" + ,"BEAT" + ,"STORY_GRID_FIELD" + ,"TIMELINE_EVENT" + ,"SCENE_ENTITY_LINK" + ,"WRITING_ACTIVITY" + ,"INSPIRATION" + ,"SHOT" + ], + "writing_shapes": [ + "LONG_NOVEL", + "SHORT_NOVEL", + "SHORT_DRAMA" + ], + "chapter_workflow": { + "states": [ + "DRAFT", + "SELF_REVIEW", + "EDITOR_REVIEW", + "REVISION_REQUIRED", + "APPROVED", + "SCHEDULED", + "PUBLISHED" + ], + "human_confirmed_transitions_only": true, + "unreviewed_auto_publish_allowed": false + }, + "required_real_engines": [ + "CREATE_AND_READ_WORK", + "VOLUME_AND_CHAPTER_TREE", + "DEBOUNCED_CHAPTER_PERSISTENCE", + "OPTIMISTIC_REVISION_CONFLICT", + "CHAPTER_VERSION_HISTORY", + "CREATE_AND_RESTORE_CHECKPOINT", + "STORY_BIBLE_AND_RELATIONS", + "FORESHADOW_LIFECYCLE", + "CONTINUITY_AUDIT", + "EDITORIAL_WORKFLOW_AND_REVIEW_NOTES", + "AUTHORIZED_OPERATIONS_METRICS", + "MARKDOWN_EXPORT", + "SHARED_SIGNED_MODULE_PACKAGE_HASH_VERIFICATION", + "SHARED_MODULE_INSTALL_MOUNT_SELF_TEST_UNMOUNT", + "SHARED_HASH_CHAINED_LIFECYCLE_RECEIPT", + "SCENE_BEAT_AND_OUTLINE_TRACKING", + "MULTIDIMENSIONAL_STORY_GRID", + "TIMELINE_AND_SCENE_ENTITY_LINKS", + "CHAPTER_VERSION_RESTORE", + "TXT_DOCX_EPUB_JSON_DELIVERY", + "PROMINENT_CREATE_CHAPTER_OR_EPISODE", + "SHORT_DRAMA_SCREENPLAY_TEMPLATE_ON_CREATE", + "AUTO_FORMAT_ON_IMPORT_WITH_SOURCE_VERSION_PRESERVED", + "LIVE_WORD_COUNT_AND_REAL_EDITING_TIME", + "ONE_CLICK_FORMAT_WITH_NEW_CHAPTER_VERSION", + "SELECTABLE_CHAPTER_OR_WHOLE_WORK_FORMAT_PRESET", + "SYNCHRONIZED_OUTLINE_SOURCE_AND_STRUCTURED_RENDER", + "INSPIRATION_CAPTURE_AND_STATUS", + "FULL_TEXT_SEARCH_AND_TRACKING", + "SHORT_DRAMA_SHOT_AND_PROMPT_STORAGE" + ], + "forbidden_substitutes": [ + "HARDCODED_STATIC_PROJECTS", + "UI_ONLY_BUTTONS_WITHOUT_NATIVE_COMMANDS", + "PRIVATE_MANUSCRIPT_MODEL_TRAINING", + "THIRD_PARTY_AUTO_LOGIN", + "UNREVIEWED_AUTO_PUBLISH", + "PLATFORM_DETECTION_EVASION" + ], + "current_acceptance": { + "state": "PASS", + "numbered_ipc_modules": ["HLP-NIPC-MOD-0025", "HLP-NIPC-MOD-0026", "HLP-NIPC-MOD-0027", "HLP-NIPC-MOD-0028", "HLP-NIPC-MOD-0029"], + "numbered_operations": "HLP-NIPC-OP-0103..HLP-NIPC-OP-0140", + "runtime_module_numbers": ["HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001"], + "node_tests": "130_PASS", + "rust_tests": "170_PASS_2_EXPLICIT_DESKTOP_FIXTURES_IGNORED", + "clippy": "PASS_DENY_WARNINGS", + "developer_id_team": "825A9L3G7Q", + "signed_binary_sha256": "2f8fdd71f9d5a9178bc23058261dbb43f1388486dc5a4415e865cfcf78869db3", + "restart_readback": "PASS_5_MODULES_ACTIVE_ACCEPTANCE_WORK_1_CHAPTER_53_WORDS_SCENE_GRID_FIELD_TIMELINE_PRESENT", + "legacy_account_readback": "PASS_504_CHAPTER_1082978_WORD_NOVEL_50_CHAPTER_OUTLINE_75_EPISODE_SCRIPT_UNCHANGED" + } +} diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index a1d6a16af..4c30797fa 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -102,6 +102,8 @@ Each donor capability receives a candidate coordinate, but no permanent runtime The module-package runtime is now the shared admission executor. It accepts an exact detached-minisign `.ghmod` artifact, validates the package and its compatibility/permission manifest, stores it inside the authenticated account, and advances only through numbered install, mount, self-test, unmount and rollback operations. Lifecycle state and receipts are durable SQLite records; unmount never removes user data. A package is declarative and selects a host-registered adapter: repositories, native binaries and arbitrary webview JavaScript are not executable module inputs. Public lighthouse numbers remain unavailable until a candidate completes its own installed acceptance; private channel packages use a separate local number class. +The admitted web-novel family uses that one lifecycle rather than the donor's private installer. Its signed base module owns account-local works, volumes, chapters, versions, story objects, editorial workflow, import and author activity. Outline, story-grid, story-world and delivery are four separately signed official numbers; each advanced mutation checks its own exact `ACTIVE` record before touching the shared story graph. The donor's four legacy manifests remain byte-exact test fixtures only and have no numbered IPC route. Installed acceptance reopened the existing 504-chapter novel, 50-chapter outline and 75-episode script in place, then created a separate one-chapter acceptance work, scene, grid field and timeline event and read all of them back after process restart. + ## Stage-one convergence verdict The Tauri source in this directory is the only future HoloLake desktop mainline. An installed build of it is an acceptance candidate, not a separate product line and not proof that stage one exists. The Electron 0.8.0 product and the legacy Tauri/platform sources remain read-only UX, behavior, engineering and protected-data donors until inventory, backup, readback, reversible migration rehearsal and signed installed-runtime acceptance all pass. diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod new file mode 100644 index 000000000..651c346b1 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod @@ -0,0 +1,7 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001", "registrationClass": "OFFICIAL_LIGHTHOUSE", "displayName": "高级版本与交付", "version": "0.1.0", "minimumHostVersion": "0.5.0", "adapter": "web-novel-workbench-v1", "contentDigest": "1dbc2ee8c1bc4c361009a8db9c93774c1ac7f5a06c02a5d4bc5b39754a0496fb", "permissions": ["WEB_NOVEL_CHAPTER_VERSION_RESTORE", "WEB_NOVEL_DELIVERY_EXPORT_WRITE_FILE"], "userDataSchema": "hololake.module-data/web-novel-delivery/v1", "selfTest": { "kind": "DECLARATIVE_SCHEMA_V1", "expectedContentDigest": "1dbc2ee8c1bc4c361009a8db9c93774c1ac7f5a06c02a5d4bc5b39754a0496fb" } + }, + "payload": { "entry": "web-novel-delivery", "adapterConfig": { "baseModule": "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "capabilities": ["CHAPTER_VERSION_RESTORE", "TXT_EXPORT", "DOCX_EXPORT", "EPUB_EXPORT", "JSON_EXPORT"], "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", "humanConfirmationRequiredForRestore": true, "persistent": true } } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..735b86b00 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTBrb01WSENhMmIvUGxyRnozY2k0aTVaQ1lFL3VLdWllSURCak54VWwrM2h1QXM4Sk10ZGc0Qm1EYzhhUVJVTFB6OXRqLzc5cSt3U0NleS9WR2owN3djPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgxNTE1CWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1XRUItTk9WRUwtREVMSVZFUlktMDAwMS0wLjEuMC5naG1vZApQWWFETDdISGFvbGdtVVZCK0V5VUVqNlFvSGRmQWVuY3dSRS9ZWm1UVHhCS0U4bHNCQzl0RFFtbDZEUXREWnp6MjQxbHF1bVgwS0NhS1ZBUGZxd3ZBUT09Cg== \ No newline at end of file diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod new file mode 100644 index 000000000..54d60a93c --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod @@ -0,0 +1,7 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001", "registrationClass": "OFFICIAL_LIGHTHOUSE", "displayName": "多维情节表与情节板", "version": "0.1.0", "minimumHostVersion": "0.5.0", "adapter": "web-novel-workbench-v1", "contentDigest": "61bf4969e97003f203af5e0b38bb37024847cb574a3ac09025f10586061805ce", "permissions": ["WEB_NOVEL_FIELD_DEFINITION_WRITE", "WEB_NOVEL_FIELD_VALUE_WRITE"], "userDataSchema": "hololake.module-data/web-novel-grid/v1", "selfTest": { "kind": "DECLARATIVE_SCHEMA_V1", "expectedContentDigest": "61bf4969e97003f203af5e0b38bb37024847cb574a3ac09025f10586061805ce" } + }, + "payload": { "entry": "web-novel-grid", "adapterConfig": { "baseModule": "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "capabilities": ["FIELD_DEFINITION_WRITE", "FIELD_VALUE_WRITE"], "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", "persistent": true } } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..0e6868c55 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTBuWjBmYWczdEg3QXByZS9RaEY4N2x6RXN1WVY2ZEJGSmdSYmZyTGUrclVvYm1sd0t4REhOY1dVdW1EeGtramhqeSs4YU9nQVNBbkdCWVlEYmNteXdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgxNTE1CWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1XRUItTk9WRUwtR1JJRC0wMDAxLTAuMS4wLmdobW9kCkJLczNMbmpqYzVTdXQ5RklHMXdlMWpDcE9tRnlBYWlPcUJkMzZQbFlRSzFYVDFtNmYrZ1htN1VqZWJkaktUWmZ5VVZvczZ4YmVrWHRuQ3lNOXU0SkFBPT0K \ No newline at end of file diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod new file mode 100644 index 000000000..a2048eabe --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod @@ -0,0 +1,7 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001", "registrationClass": "OFFICIAL_LIGHTHOUSE", "displayName": "作品结构与大纲追踪", "version": "0.1.0", "minimumHostVersion": "0.5.0", "adapter": "web-novel-workbench-v1", "contentDigest": "7da54b0220940a8a19351ff9bf3cd177c93fc25c533329a60926b8ea245dafbe", "permissions": ["WEB_NOVEL_SCENE_WRITE", "WEB_NOVEL_BEAT_WRITE"], "userDataSchema": "hololake.module-data/web-novel-outline/v1", "selfTest": { "kind": "DECLARATIVE_SCHEMA_V1", "expectedContentDigest": "7da54b0220940a8a19351ff9bf3cd177c93fc25c533329a60926b8ea245dafbe" } + }, + "payload": { "entry": "web-novel-outline", "adapterConfig": { "baseModule": "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "capabilities": ["SCENE_WRITE", "BEAT_WRITE"], "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", "persistent": true } } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..32b0cbc03 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTNUYVhwVTlzdWt6VkVQYTUyanB6UFlYeU5BdW1qN0xuM1haWjlCYUU3UFlMRTdPZG90YVZvNGFyaVo4UHY0THowUWlPVzIzemYrQWNZbDFvRVd5NndrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgxNTE2CWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1XRUItTk9WRUwtT1VUTElORS0wMDAxLTAuMS4wLmdobW9kCkZob1VsQlkwZFRyZXI2ck1OckhUc2ZqaHVJOWhSQnIraUFRSEllU3BxRlc3UStFekJRZDFMZFpmZlBvK09jTFJ6cERpcVl2M2N2emIvS0ptdnRrTkR3PT0K \ No newline at end of file diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod new file mode 100644 index 000000000..539076437 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod @@ -0,0 +1,7 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001", "registrationClass": "OFFICIAL_LIGHTHOUSE", "displayName": "时间线与故事资料库", "version": "0.1.0", "minimumHostVersion": "0.5.0", "adapter": "web-novel-workbench-v1", "contentDigest": "9627949a975ce489dde80de52c538a57bb73b87e31ddb1e5efb48cee53922b71", "permissions": ["WEB_NOVEL_TIMELINE_WRITE", "WEB_NOVEL_SCENE_ENTITY_LINK_WRITE"], "userDataSchema": "hololake.module-data/web-novel-storyworld/v1", "selfTest": { "kind": "DECLARATIVE_SCHEMA_V1", "expectedContentDigest": "9627949a975ce489dde80de52c538a57bb73b87e31ddb1e5efb48cee53922b71" } + }, + "payload": { "entry": "web-novel-storyworld", "adapterConfig": { "baseModule": "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", "capabilities": ["TIMELINE_WRITE", "SCENE_ENTITY_LINK_WRITE"], "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", "persistent": true } } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..000bbdb81 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRXpuRmFPWjJUSDVpSmQzQXl0aVF3N0xhRkRvVWhLalVtcDBnRFprNnpOUEdCWS9CTURlUHBrYWkyRG9MUFZwVEtHc0w5ZUltRlNUVlZBcm1vU2VnUlFZPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgxNTE2CWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1XRUItTk9WRUwtU1RPUllXT1JMRC0wMDAxLTAuMS4wLmdobW9kCnBzQkJYaTV6NFR2Vnk3NUdpK3JSQnJTUzhSd2hxUWdmM0UrcVZuN0VuS0JOc1lYclY5Y3FBbVJldVM1Y2FGamNtT3NiU0NsaThMRE5qT3VvTXJUUUJ3PT0K \ No newline at end of file diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod new file mode 100644 index 000000000..9776e90b5 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod @@ -0,0 +1,26 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001", + "registrationClass": "OFFICIAL_LIGHTHOUSE", + "displayName": "网文作者工作台", + "version": "0.1.0", + "minimumHostVersion": "0.5.0", + "adapter": "web-novel-workbench-v1", + "contentDigest": "df03a94751cd50ea26e2fc0f68ac21bd55e62f2034ff83c562a840c8aab72d2e", + "permissions": ["WEB_NOVEL_WORKSPACE_READ", "WEB_NOVEL_WORKSPACE_WRITE", "WEB_NOVEL_IMPORT_READ_FILE", "WEB_NOVEL_AUTHOR_ACTIVITY_WRITE", "WEB_NOVEL_STORY_BIBLE_WRITE", "WEB_NOVEL_EDITORIAL_WORKFLOW_WRITE", "WEB_NOVEL_OPERATIONS_WRITE", "WEB_NOVEL_CHECKPOINT_WRITE"], + "userDataSchema": "hololake.module-data/web-novel-workbench/v1", + "selfTest": { "kind": "DECLARATIVE_SCHEMA_V1", "expectedContentDigest": "df03a94751cd50ea26e2fc0f68ac21bd55e62f2034ff83c562a840c8aab72d2e" } + }, + "payload": { + "entry": "web-novel-workspace", + "adapterConfig": { + "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", + "legacyDataPolicy": "READ_IN_PLACE_NO_DESTRUCTIVE_MIGRATION", + "workspaceFeatures": ["WORKS", "VOLUMES", "CHAPTERS", "ENTITIES", "EDITORIAL", "OPERATIONS", "CHECKPOINTS", "IMPORT"], + "advancedModules": ["HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001", "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001"], + "thirdPartyPublishDefault": "DENY", + "persistent": true + } + } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..3ad7f68ae --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTZyTUlUYWdZZXk3bFhDRTJBWElWMkVEQXZHa3dSWDFXSFJ2QjduVFk1bzFLUk5kOVFQamIwU2w1L3FzOWtibm9Kblg2NWJLWFdVeEtEbDNzTEltVmdFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDgxNTE3CWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1XRUItTk9WRUwtV09SS0JFTkNILTAwMDEtMC4xLjAuZ2htb2QKV0VqV3B4cFNLRng2UUcrNXlYZjY5SytERk14YTNyRkM1bldzUXo5RWpzMU0vRmVJWldvTGhmbmExcFhmSGZiWTFreElreGU2UHVsaXVhK0ZXZnVyQ2c9PQo= \ No newline at end of file diff --git a/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json new file mode 100644 index 000000000..7dccdaf66 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json @@ -0,0 +1,12 @@ +{ + "schema": "hololake.module-package/v1", + "moduleId": "HL-MOD-WEBNOVEL-DELIVERY-004", + "name": "高级版本与交付", + "version": "1.0.0", + "publisher": "HoloLake Official", + "runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE", + "capabilities": ["chapter_version.read", "chapter_version.restore", "delivery.txt", "delivery.docx", "delivery.epub", "delivery.json"], + "permissions": ["current_account.web_novel.read", "current_account.web_novel.write", "human_selected_export_path.write"], + "dataPolicy": "PROGRAM_AND_USER_DATA_SEPARATED", + "selfTest": "WEBNOVEL_VERSION_READBACK_AND_EXPORT_BUILD" +} diff --git a/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json new file mode 100644 index 000000000..12cc21737 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json @@ -0,0 +1,12 @@ +{ + "schema": "hololake.module-package/v1", + "moduleId": "HL-MOD-WEBNOVEL-GRID-002", + "name": "多维情节表与情节板", + "version": "1.0.0", + "publisher": "HoloLake Official", + "runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE", + "capabilities": ["story_grid.read", "story_grid.write", "custom_field.write", "story_board.group"], + "permissions": ["current_account.web_novel.read", "current_account.web_novel.write"], + "dataPolicy": "ONE_SCENE_GRAPH_NO_DUPLICATE_STORY_STORE", + "selfTest": "WEBNOVEL_GRID_SCHEMA_AND_SHARED_SCENE_PROJECTION" +} diff --git a/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json new file mode 100644 index 000000000..f7d53c0c9 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json @@ -0,0 +1,12 @@ +{ + "schema": "hololake.module-package/v1", + "moduleId": "HL-MOD-WEBNOVEL-OUTLINE-001", + "name": "作品结构与大纲追踪", + "version": "1.0.0", + "publisher": "HoloLake Official", + "runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE", + "capabilities": ["scene.write", "beat.write", "outline.track"], + "permissions": ["current_account.web_novel.read", "current_account.web_novel.write"], + "dataPolicy": "USER_DATA_REMAINS_AFTER_UNINSTALL", + "selfTest": "WEBNOVEL_OUTLINE_SCHEMA_AND_ROUNDTRIP" +} diff --git a/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json new file mode 100644 index 000000000..c52d77a49 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json @@ -0,0 +1,12 @@ +{ + "schema": "hololake.module-package/v1", + "moduleId": "HL-MOD-WEBNOVEL-STORYWORLD-003", + "name": "时间线与故事资料库", + "version": "1.0.0", + "publisher": "HoloLake Official", + "runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE", + "capabilities": ["timeline.read", "timeline.write", "scene_entity.link", "story_bible.read"], + "permissions": ["current_account.web_novel.read", "current_account.web_novel.write"], + "dataPolicy": "USER_DATA_REMAINS_AFTER_UNINSTALL", + "selfTest": "WEBNOVEL_TIMELINE_SCHEMA_AND_RELATION_ROUNDTRIP" +} diff --git a/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/README.md b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/README.md new file mode 100644 index 000000000..30794287b --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/web-novel-legacy-0.4.1/README.md @@ -0,0 +1,6 @@ +# Web novel 0.4.1 lifecycle fixtures + +These four JSON files preserve the exact bytes used by the donor's lifecycle regression tests. +They are not bundled by `module_package_runtime`, are not registered in numbered IPC, and are not +production module identities. HoloLake 0.5.x uses the signed `HLP-MOD-OFFICIAL-WEB-NOVEL-*` +packages in `fixtures/module-packages` as its only runtime authority. diff --git a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json index 981cd6075..4adab0446 100644 --- a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json +++ b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json @@ -54,7 +54,7 @@ }, { "recordId": "HLP-NUMBERED-IPC-ROOT-001", - "sha256": "be7f2a746a78284d4c763d9da62a381bf0c9430c56a923398e1948d2a0ae1fc0" + "sha256": "06516d366a37a6e2f680c225b43920919e04db3603c2c1f11bd256b320631465" }, { "recordId": "HLP-NBROKER-ROOT-001", @@ -69,7 +69,7 @@ "everyAcceptedCallHasEvidenceClass": true, "mismatchedCoordinate": "FAIL_CLOSED" }, - "routeCount": 124, + "routeCount": 162, "routes": [ { "transport": "DIRECT_LOCAL_NUMBERED_BROKER", @@ -1415,6 +1415,538 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0102/HLP-NIPC-TGT-0024" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0103", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "get_web_novel_workspace_snapshot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0103/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0104", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_work", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0104/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0105", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "read_web_novel_work", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0105/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0106", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "save_web_novel_work", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0106/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0107", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_volume", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0107/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0108", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_chapter", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0108/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0109", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "read_web_novel_chapter", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0109/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0110", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "save_web_novel_chapter", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0110/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0111", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "transition_web_novel_chapter", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0111/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0112", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_checkpoint", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0112/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0113", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "restore_web_novel_checkpoint", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0113/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0114", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "upsert_web_novel_story_entity", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0114/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0115", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_story_relation", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0115/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0116", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "upsert_web_novel_foreshadow", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0116/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0117", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_review_note", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0117/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0118", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "resolve_web_novel_review_note", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0118/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0119", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "save_web_novel_metric", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0119/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0120", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "run_web_novel_continuity_audit", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0120/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0121", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "export_web_novel_markdown", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0121/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0122", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "inspect_web_novel_document_from_dialog", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0122/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0123", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "commit_web_novel_document_import", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0123/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0124", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "get_web_novel_author_snapshot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0124/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0125", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "record_web_novel_writing_activity", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0125/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0126", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "create_web_novel_inspiration", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0126/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0127", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "set_web_novel_inspiration_status", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0127/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0128", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "search_web_novel_full_text", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0128/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0129", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "format_web_novel_chapter", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0129/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0130", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "format_web_novel_work", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0130/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0131", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "upsert_web_novel_shot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0131/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0132", + "targetNumber": "HLP-NIPC-TGT-0025", + "alias": "get_web_novel_author_module_data", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0025/HLP-NIPC-OP-0132/HLP-NIPC-TGT-0025" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0026", + "operationNumber": "HLP-NIPC-OP-0133", + "targetNumber": "HLP-NIPC-TGT-0026", + "alias": "upsert_web_novel_author_scene", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0026/HLP-NIPC-OP-0133/HLP-NIPC-TGT-0026" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0026", + "operationNumber": "HLP-NIPC-OP-0134", + "targetNumber": "HLP-NIPC-TGT-0026", + "alias": "upsert_web_novel_author_beat", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0026/HLP-NIPC-OP-0134/HLP-NIPC-TGT-0026" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0027", + "operationNumber": "HLP-NIPC-OP-0135", + "targetNumber": "HLP-NIPC-TGT-0027", + "alias": "upsert_web_novel_story_field_definition", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0027/HLP-NIPC-OP-0135/HLP-NIPC-TGT-0027" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0027", + "operationNumber": "HLP-NIPC-OP-0136", + "targetNumber": "HLP-NIPC-TGT-0027", + "alias": "upsert_web_novel_story_field_value", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0027/HLP-NIPC-OP-0136/HLP-NIPC-TGT-0027" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0028", + "operationNumber": "HLP-NIPC-OP-0137", + "targetNumber": "HLP-NIPC-TGT-0028", + "alias": "upsert_web_novel_timeline_event", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0028/HLP-NIPC-OP-0137/HLP-NIPC-TGT-0028" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0028", + "operationNumber": "HLP-NIPC-OP-0138", + "targetNumber": "HLP-NIPC-TGT-0028", + "alias": "link_web_novel_scene_entity", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0028/HLP-NIPC-OP-0138/HLP-NIPC-TGT-0028" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0029", + "operationNumber": "HLP-NIPC-OP-0139", + "targetNumber": "HLP-NIPC-TGT-0029", + "alias": "restore_web_novel_chapter_version", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0029/HLP-NIPC-OP-0139/HLP-NIPC-TGT-0029" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0029", + "operationNumber": "HLP-NIPC-OP-0140", + "targetNumber": "HLP-NIPC-TGT-0029", + "alias": "export_web_novel_author_delivery", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0029/HLP-NIPC-OP-0140/HLP-NIPC-TGT-0029" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", diff --git a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs index b10a33ffe..11c66db52 100644 --- a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs +++ b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs @@ -6,7 +6,7 @@ const catalog = JSON.parse(readFileSync(new URL('../contracts/module-donor-admis const numbered = JSON.parse(readFileSync(new URL('../contracts/numbered-ipc-registry.json', import.meta.url), 'utf8')) test('chaotic donors are read-only candidates and never a bulk merge source', () => { - assert.equal(catalog.state, 'THREE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED') + assert.match(catalog.state, /^[A-Z]+_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED$/) assert.equal(catalog.root_rule.repair_old_application_in_place, false) assert.equal(catalog.root_rule.bulk_merge_or_wholesale_copy_allowed, false) assert.equal(catalog.root_rule.one_candidate_per_admission_cycle, true) @@ -15,9 +15,13 @@ test('chaotic donors are read-only candidates and never a bulk merge source', () test('candidate coordinates are unique but are not permanent runtime module numbers', () => { const coordinates = catalog.candidates.map((candidate) => candidate.candidate_number) + const admitted = catalog.candidates.filter((candidate) => candidate.state.startsWith('ADMITTED')) + const pending = catalog.candidates.filter((candidate) => candidate.state.startsWith('QUARANTINED')) assert.equal(new Set(coordinates).size, coordinates.length) - assert.equal(catalog.candidates.filter((candidate) => candidate.state.startsWith('ADMITTED')).length, 3) - assert.ok(catalog.candidates.slice(3).every((candidate) => candidate.state.startsWith('QUARANTINED'))) + assert.ok(admitted.length > 0) + assert.ok(pending.length > 0) + assert.deepEqual(catalog.candidates.slice(0, admitted.length), admitted) + assert.deepEqual(catalog.candidates.slice(admitted.length), pending) assert.equal(catalog.root_rule.candidate_number_is_runtime_module_number, false) assert.equal(catalog.root_rule.permanent_module_number_assignment_before_acceptance, false) }) diff --git a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs index 79d84cd5e..dd110c9f7 100644 --- a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs +++ b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs @@ -7,8 +7,8 @@ test('identity, webview and direct broker numbers compile into one unique eviden const generated = JSON.parse(readFileSync(new URL('../generated/unified-number-coordinate-tree.json', import.meta.url), 'utf8')) assert.deepEqual(generated, compileUnifiedNumberTree()) assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001') - assert.equal(generated.routeCount, 124) - assert.equal(new Set(generated.routes.map((route) => route.path)).size, 124) + assert.equal(generated.routeCount, 162) + assert.equal(new Set(generated.routes.map((route) => route.path)).size, 162) assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true) assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true) assert.ok(generated.routes.every((route) => route.admission && route.evidence)) diff --git a/product-source/hololake-native-desktop/scripts/web-novel-workbench-admission.test.mjs b/product-source/hololake-native-desktop/scripts/web-novel-workbench-admission.test.mjs new file mode 100644 index 000000000..36d7fcdda --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/web-novel-workbench-admission.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8') +const registry = JSON.parse(read('contracts/numbered-ipc-registry.json')) +const runtime = read('src-tauri/src/module_package_runtime.rs') +const moduleAdapter = read('src-tauri/src/web_novel_modules.rs') +const frontend = [ + read('src/modules/web-novel/WebNovelWorkspace.tsx'), + read('src/modules/web-novel/AuthorModuleCenter.tsx'), + read('src/modules/web-novel/AuthorWritingSidecar.tsx'), +].join('\n') + +const packages = [ + 'WORKBENCH', 'OUTLINE', 'GRID', 'STORYWORLD', 'DELIVERY', +].map((name) => JSON.parse(read(`fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-${name}-0001-0.1.0.ghmod`))) + +test('web novel family has five immutable official identities behind one shared lifecycle', () => { + assert.equal(packages.length, 5) + assert.ok(packages.every((value) => value.manifest.registrationClass === 'OFFICIAL_LIGHTHOUSE')) + assert.ok(packages.every((value) => value.manifest.adapter === 'web-novel-workbench-v1')) + assert.equal(new Set(packages.map((value) => value.manifest.moduleNumber)).size, 5) + assert.match(runtime, /const BUNDLED_MODULES/) + assert.doesNotMatch(frontend, /install_web_novel_author_module|mount_web_novel_author_module|uninstall_web_novel_author_module/) +}) + +test('all 38 web novel effects cross exact numbered routes and no raw tauri command surface', () => { + const routes = registry.operations.filter((route) => Number(route.operation_number.slice(-4)) >= 103 && Number(route.operation_number.slice(-4)) <= 140) + assert.equal(routes.length, 38) + assert.deepEqual([...new Set(routes.map((route) => route.module_number))], [ + 'HLP-NIPC-MOD-0025', 'HLP-NIPC-MOD-0026', 'HLP-NIPC-MOD-0027', 'HLP-NIPC-MOD-0028', 'HLP-NIPC-MOD-0029', + ]) + assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE')) + assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/) + for (const source of ['web_novel_workspace.rs', 'web_novel_import.rs', 'web_novel_author.rs', 'web_novel_modules.rs']) { + assert.doesNotMatch(read(`src-tauri/src/${source}`), /#\[tauri::command\]/) + } +}) + +test('advanced effects require their own exact active module number', () => { + assert.match(moduleAdapter, /require_module_active\(&app, OUTLINE\)/) + assert.match(moduleAdapter, /require_module_active\(&app, GRID\)/) + assert.match(moduleAdapter, /require_module_active\(&app, STORYWORLD\)/) + assert.match(moduleAdapter, /require_module_active\(&app, DELIVERY\)/) + assert.match(moduleAdapter, /shared signed package runtime/) +}) + +test('workspace is independently lazy-loaded and preserves old account data in place', () => { + assert.match(read('src/main.tsx'), /lazy\(\(\) => import\('\.\/modules\/web-novel\/WebNovelWorkspace'\)/) + assert.equal(packages[0].payload.adapterConfig.legacyDataPolicy, 'READ_IN_PLACE_NO_DESTRUCTIVE_MIGRATION') + assert.equal(packages[0].payload.adapterConfig.thirdPartyPublishDefault, 'DENY') + assert.doesNotMatch(read('src-tauri/src/web_novel_workspace.rs'), /serde_json::from_str[^\n]+unwrap_or_default\(\)/) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 818ac7dc6..2725b1833 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -1558,6 +1558,8 @@ dependencies = [ "futures-util", "interprocess", "minisign-verify", + "quick-xml 0.31.0", + "regex", "reqwest", "ring", "rusqlite", @@ -1575,6 +1577,7 @@ dependencies = [ "url", "uuid", "widestring", + "zip 0.6.6", ] [[package]] diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index 91df30f3c..e2f07020b 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ base64 = "0.22" calamine = { version = "=0.26.1", features = ["dates"] } csv = "=1.3.0" encoding_rs = "0.8" +quick-xml = "=0.31.0" +regex = "=1.12.3" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "=2.10.2", features = ["devtools"] } @@ -35,6 +37,7 @@ uuid = { version = "1", features = ["v4"] } url = "2" reqwest = { version = "0.13.2", default-features = false, features = ["cookies", "form", "json", "rustls", "stream"] } rust_xlsxwriter = "=0.64.2" +zip = { version = "=0.6.6", default-features = false, features = ["deflate"] } tokio = { version = "1", features = ["time"] } futures-util = "0.3" minisign-verify = "0.2.5" diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index b83092ca0..032077fd1 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -35,6 +35,10 @@ mod pncc_server_projection; mod release_trust; mod release_update; mod user_pncc_channel; +mod web_novel_author; +mod web_novel_import; +mod web_novel_modules; +mod web_novel_workspace; mod zero_core_numbering; mod zero_point; diff --git a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs index 50b73f42c..bc544f10c 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs @@ -52,6 +52,41 @@ const EDUCATION_WORKBENCH_PACKAGE: &[u8] = include_bytes!( const EDUCATION_WORKBENCH_SIGNATURE: &str = include_str!( "../../fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig" ); +const WEB_NOVEL_WORKBENCH_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001"; +const WEB_NOVEL_WORKBENCH_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod" +); +const WEB_NOVEL_WORKBENCH_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001-0.1.0.ghmod.sig" +); +const WEB_NOVEL_OUTLINE_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001"; +const WEB_NOVEL_OUTLINE_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod" +); +const WEB_NOVEL_OUTLINE_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001-0.1.0.ghmod.sig" +); +const WEB_NOVEL_GRID_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001"; +const WEB_NOVEL_GRID_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod" +); +const WEB_NOVEL_GRID_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001-0.1.0.ghmod.sig" +); +const WEB_NOVEL_STORYWORLD_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001"; +const WEB_NOVEL_STORYWORLD_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod" +); +const WEB_NOVEL_STORYWORLD_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001-0.1.0.ghmod.sig" +); +const WEB_NOVEL_DELIVERY_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001"; +const WEB_NOVEL_DELIVERY_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod" +); +const WEB_NOVEL_DELIVERY_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001-0.1.0.ghmod.sig" +); struct BundledModuleSource { module_number: &'static str, @@ -80,6 +115,31 @@ const BUNDLED_MODULES: &[BundledModuleSource] = &[ package: EDUCATION_WORKBENCH_PACKAGE, signature: EDUCATION_WORKBENCH_SIGNATURE, }, + BundledModuleSource { + module_number: WEB_NOVEL_WORKBENCH_NUMBER, + package: WEB_NOVEL_WORKBENCH_PACKAGE, + signature: WEB_NOVEL_WORKBENCH_SIGNATURE, + }, + BundledModuleSource { + module_number: WEB_NOVEL_OUTLINE_NUMBER, + package: WEB_NOVEL_OUTLINE_PACKAGE, + signature: WEB_NOVEL_OUTLINE_SIGNATURE, + }, + BundledModuleSource { + module_number: WEB_NOVEL_GRID_NUMBER, + package: WEB_NOVEL_GRID_PACKAGE, + signature: WEB_NOVEL_GRID_SIGNATURE, + }, + BundledModuleSource { + module_number: WEB_NOVEL_STORYWORLD_NUMBER, + package: WEB_NOVEL_STORYWORLD_PACKAGE, + signature: WEB_NOVEL_STORYWORLD_SIGNATURE, + }, + BundledModuleSource { + module_number: WEB_NOVEL_DELIVERY_NUMBER, + package: WEB_NOVEL_DELIVERY_PACKAGE, + signature: WEB_NOVEL_DELIVERY_SIGNATURE, + }, ]; #[derive(Debug, Deserialize)] @@ -1525,7 +1585,7 @@ mod tests { assert_eq!(package.manifest.adapter, "channel-workbench-v1"); assert_eq!(package.manifest.permissions.len(), 4); assert!(is_sha256(&digest)); - assert_eq!(BUNDLED_MODULES.len(), 4); + assert_eq!(BUNDLED_MODULES.len(), 9); } #[test] @@ -1565,6 +1625,51 @@ mod tests { assert!(is_sha256(&digest)); } + #[test] + fn bundled_web_novel_family_uses_one_signed_lifecycle_and_exact_numbers() { + let expected = [ + ( + WEB_NOVEL_WORKBENCH_NUMBER, + WEB_NOVEL_WORKBENCH_PACKAGE, + WEB_NOVEL_WORKBENCH_SIGNATURE, + 8usize, + ), + ( + WEB_NOVEL_OUTLINE_NUMBER, + WEB_NOVEL_OUTLINE_PACKAGE, + WEB_NOVEL_OUTLINE_SIGNATURE, + 2usize, + ), + ( + WEB_NOVEL_GRID_NUMBER, + WEB_NOVEL_GRID_PACKAGE, + WEB_NOVEL_GRID_SIGNATURE, + 2usize, + ), + ( + WEB_NOVEL_STORYWORLD_NUMBER, + WEB_NOVEL_STORYWORLD_PACKAGE, + WEB_NOVEL_STORYWORLD_SIGNATURE, + 2usize, + ), + ( + WEB_NOVEL_DELIVERY_NUMBER, + WEB_NOVEL_DELIVERY_PACKAGE, + WEB_NOVEL_DELIVERY_SIGNATURE, + 2usize, + ), + ]; + for (number, bytes, signature, permission_count) in expected { + let (_, package, digest) = + read_verified_package_bytes(bytes, signature, RELEASE_TRUST_RAW).unwrap(); + assert_eq!(package.manifest.module_number, number); + assert_eq!(package.manifest.registration_class, "OFFICIAL_LIGHTHOUSE"); + assert_eq!(package.manifest.adapter, "web-novel-workbench-v1"); + assert_eq!(package.manifest.permissions.len(), permission_count); + assert!(is_sha256(&digest)); + } + } + #[cfg(unix)] #[test] fn symlinked_package_input_is_rejected_before_signature_processing() { diff --git a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs index 538d717f3..ddd421573 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs @@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> { || tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001" || tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED" || tree.root_number != "HLP-NUMBER-WORLD-ROOT-001" - || tree.route_count != 124 + || tree.route_count != 162 || tree.routes.len() != tree.route_count || !tree.invariants.number_is_stable_coordinate_not_authority || !tree.invariants.path_is_unique_navigation diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs index e502e4ab6..1f9bafc13 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs @@ -934,7 +934,7 @@ mod tests { #[test] fn registry_is_closed_and_contains_every_migrated_command() { let registry = load_registry().unwrap(); - assert_eq!(registry.operations.len(), 102); + assert_eq!(registry.operations.len(), 140); assert!(!registry.runtime.legacy_direct_commands_allowed); } diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs index 814544c56..cb9961788 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs @@ -228,6 +228,136 @@ pub(crate) async fn dispatch( crate::education_workspace::execute_education_automation_rule(app, input(&payload)?) .await?, ), + "web_novel_workspace::get_web_novel_workspace_snapshot" => { + json(crate::web_novel_workspace::get_web_novel_workspace_snapshot(app).await?) + } + "web_novel_workspace::create_web_novel_work" => { + json(crate::web_novel_workspace::create_web_novel_work(app, input(&payload)?).await?) + } + "web_novel_workspace::read_web_novel_work" => { + json(crate::web_novel_workspace::read_web_novel_work(app, input(&payload)?).await?) + } + "web_novel_workspace::save_web_novel_work" => { + json(crate::web_novel_workspace::save_web_novel_work(app, input(&payload)?).await?) + } + "web_novel_workspace::create_web_novel_volume" => { + json(crate::web_novel_workspace::create_web_novel_volume(app, input(&payload)?).await?) + } + "web_novel_workspace::create_web_novel_chapter" => { + json(crate::web_novel_workspace::create_web_novel_chapter(app, input(&payload)?).await?) + } + "web_novel_workspace::read_web_novel_chapter" => { + json(crate::web_novel_workspace::read_web_novel_chapter(app, input(&payload)?).await?) + } + "web_novel_workspace::save_web_novel_chapter" => { + json(crate::web_novel_workspace::save_web_novel_chapter(app, input(&payload)?).await?) + } + "web_novel_workspace::transition_web_novel_chapter" => json( + crate::web_novel_workspace::transition_web_novel_chapter(app, input(&payload)?).await?, + ), + "web_novel_workspace::create_web_novel_checkpoint" => json( + crate::web_novel_workspace::create_web_novel_checkpoint(app, input(&payload)?).await?, + ), + "web_novel_workspace::restore_web_novel_checkpoint" => json( + crate::web_novel_workspace::restore_web_novel_checkpoint(app, input(&payload)?).await?, + ), + "web_novel_workspace::upsert_web_novel_story_entity" => json( + crate::web_novel_workspace::upsert_web_novel_story_entity(app, input(&payload)?) + .await?, + ), + "web_novel_workspace::create_web_novel_story_relation" => json( + crate::web_novel_workspace::create_web_novel_story_relation(app, input(&payload)?) + .await?, + ), + "web_novel_workspace::upsert_web_novel_foreshadow" => json( + crate::web_novel_workspace::upsert_web_novel_foreshadow(app, input(&payload)?).await?, + ), + "web_novel_workspace::create_web_novel_review_note" => json( + crate::web_novel_workspace::create_web_novel_review_note(app, input(&payload)?).await?, + ), + "web_novel_workspace::resolve_web_novel_review_note" => json( + crate::web_novel_workspace::resolve_web_novel_review_note(app, input(&payload)?) + .await?, + ), + "web_novel_workspace::save_web_novel_metric" => { + json(crate::web_novel_workspace::save_web_novel_metric(app, input(&payload)?).await?) + } + "web_novel_workspace::run_web_novel_continuity_audit" => json( + crate::web_novel_workspace::run_web_novel_continuity_audit(app, input(&payload)?) + .await?, + ), + "web_novel_workspace::export_web_novel_markdown" => json( + crate::web_novel_workspace::export_web_novel_markdown(app, input(&payload)?).await?, + ), + "web_novel_import::inspect_web_novel_document_from_dialog" => { + json(crate::web_novel_import::inspect_web_novel_document_from_dialog(app).await?) + } + "web_novel_import::commit_web_novel_document_import" => json( + crate::web_novel_import::commit_web_novel_document_import(app, input(&payload)?) + .await?, + ), + "web_novel_author::get_web_novel_author_snapshot" => json( + crate::web_novel_author::get_web_novel_author_snapshot(app, input(&payload)?).await?, + ), + "web_novel_author::record_web_novel_writing_activity" => json( + crate::web_novel_author::record_web_novel_writing_activity(app, input(&payload)?) + .await?, + ), + "web_novel_author::create_web_novel_inspiration" => json( + crate::web_novel_author::create_web_novel_inspiration(app, input(&payload)?).await?, + ), + "web_novel_author::set_web_novel_inspiration_status" => json( + crate::web_novel_author::set_web_novel_inspiration_status(app, input(&payload)?) + .await?, + ), + "web_novel_author::search_web_novel_full_text" => { + json(crate::web_novel_author::search_web_novel_full_text(app, input(&payload)?).await?) + } + "web_novel_author::format_web_novel_chapter" => { + json(crate::web_novel_author::format_web_novel_chapter(app, input(&payload)?).await?) + } + "web_novel_author::format_web_novel_work" => { + json(crate::web_novel_author::format_web_novel_work(app, input(&payload)?).await?) + } + "web_novel_author::upsert_web_novel_shot" => { + json(crate::web_novel_author::upsert_web_novel_shot(app, input(&payload)?).await?) + } + "web_novel_modules::get_web_novel_author_module_data" => json( + crate::web_novel_modules::get_web_novel_author_module_data(app, input(&payload)?) + .await?, + ), + "web_novel_modules::upsert_web_novel_author_scene" => json( + crate::web_novel_modules::upsert_web_novel_author_scene(app, input(&payload)?).await?, + ), + "web_novel_modules::upsert_web_novel_author_beat" => json( + crate::web_novel_modules::upsert_web_novel_author_beat(app, input(&payload)?).await?, + ), + "web_novel_modules::upsert_web_novel_story_field_definition" => json( + crate::web_novel_modules::upsert_web_novel_story_field_definition( + app, + input(&payload)?, + ) + .await?, + ), + "web_novel_modules::upsert_web_novel_story_field_value" => json( + crate::web_novel_modules::upsert_web_novel_story_field_value(app, input(&payload)?) + .await?, + ), + "web_novel_modules::upsert_web_novel_timeline_event" => json( + crate::web_novel_modules::upsert_web_novel_timeline_event(app, input(&payload)?) + .await?, + ), + "web_novel_modules::link_web_novel_scene_entity" => json( + crate::web_novel_modules::link_web_novel_scene_entity(app, input(&payload)?).await?, + ), + "web_novel_modules::restore_web_novel_chapter_version" => json( + crate::web_novel_modules::restore_web_novel_chapter_version(app, input(&payload)?) + .await?, + ), + "web_novel_modules::export_web_novel_author_delivery" => json( + crate::web_novel_modules::export_web_novel_author_delivery(app, input(&payload)?) + .await?, + ), "local_development_bridge::acquire_development_write_lane" => json( crate::local_development_bridge::acquire_development_write_lane(app, input(&payload)?) .await?, diff --git a/product-source/hololake-native-desktop/src-tauri/src/web_novel_author.rs b/product-source/hololake-native-desktop/src-tauri/src/web_novel_author.rs new file mode 100644 index 000000000..1eafb429d --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/web_novel_author.rs @@ -0,0 +1,1039 @@ +//! 作者频道内置写作底座。 +//! +//! 这里承载三种作品形态共用的真实能力:写作活动回执、即时灵感、 +//! 全文检索、一键排版,以及短剧分镜和生成提示词。数据与正文共用当前 +//! 已验证账号的 SQLite;模块商城不是这些基础能力的前置条件。 + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tauri::AppHandle; +use uuid::Uuid; + +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001"; +const ADAPTER: &str = "web-novel-workbench-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +const MAX_INSPIRATION_BYTES: usize = 100_000; +const MAX_SHOT_TEXT_BYTES: usize = 200_000; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelInspiration { + pub inspiration_id: String, + pub work_id: String, + pub chapter_id: Option, + pub content: String, + pub tags: Vec, + pub status: String, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelShot { + pub shot_id: String, + pub work_id: String, + pub chapter_id: String, + pub position: i64, + pub shot_number: String, + pub shot_size: String, + pub camera_movement: String, + pub location: String, + pub time_of_day: String, + pub action: String, + pub dialogue: String, + pub duration_seconds: i64, + pub visual_prompt: String, + pub image_prompt: String, + pub video_prompt: String, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelAuthorSnapshot { + pub work_id: String, + pub work_kind: String, + pub total_words: i64, + pub today_words: i64, + pub total_active_ms: i64, + pub today_active_ms: i64, + pub inspiration_count: usize, + pub inspirations: Vec, + pub shots: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelSearchHit { + pub chapter_id: String, + pub chapter_title: String, + pub field: String, + pub snippet: String, + pub occurrence_count: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelSearchResult { + pub query: String, + pub chapter_count: usize, + pub occurrence_count: i64, + pub hits: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorSnapshotInput { + pub work_id: String, + pub local_date: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RecordWritingActivityInput { + pub activity_id: String, + pub work_id: String, + pub chapter_id: String, + pub local_date: String, + pub active_ms: i64, + pub words_delta: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateInspirationInput { + pub work_id: String, + pub chapter_id: Option, + pub content: String, + pub tags: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetInspirationStatusInput { + pub inspiration_id: String, + pub status: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SearchFullTextInput { + pub work_id: String, + pub query: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FormatChapterInput { + pub chapter_id: String, + pub expected_revision: i64, + pub preset: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FormatWorkInput { + pub work_id: String, + pub preset: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FormatWorkReceipt { + pub work_id: String, + pub formatted_chapter_count: usize, + pub preset: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertShotInput { + pub work_id: String, + pub chapter_id: String, + pub shot_id: Option, + pub shot_number: String, + pub shot_size: String, + pub camera_movement: String, + pub location: String, + pub time_of_day: String, + pub action: String, + pub dialogue: String, + pub duration_seconds: i64, + pub visual_prompt: String, + pub image_prompt: String, + pub video_prompt: String, + pub expected_revision: Option, +} + +pub async fn get_web_novel_author_snapshot( + app: AppHandle, + input: AuthorSnapshotInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || author_snapshot_at(&path, &input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn record_web_novel_writing_activity( + app: AppHandle, + input: RecordWritingActivityInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || record_activity_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_inspiration( + app: AppHandle, + input: CreateInspirationInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_inspiration_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn set_web_novel_inspiration_status( + app: AppHandle, + input: SetInspirationStatusInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || set_inspiration_status_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn search_web_novel_full_text( + app: AppHandle, + input: SearchFullTextInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || search_full_text_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn format_web_novel_chapter( + app: AppHandle, + input: FormatChapterInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || format_chapter_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn format_web_novel_work( + app: AppHandle, + input: FormatWorkInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || format_work_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_shot( + app: AppHandle, + input: UpsertShotInput, +) -> Result { + require_active(&app)?; + let path = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_shot_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))? +} + +fn ensure_schema(connection: &Connection) -> Result<(), String> { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS web_novel_writing_activity( + activity_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + local_date TEXT NOT NULL, + active_ms INTEGER NOT NULL, + words_delta INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_inspirations( + inspiration_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT REFERENCES web_novel_chapters(chapter_id) ON DELETE SET NULL, + content TEXT NOT NULL, + tags_json TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('INBOX','USED','ARCHIVED')), + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_shots( + shot_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + position INTEGER NOT NULL, + shot_number TEXT NOT NULL, + shot_size TEXT NOT NULL, + camera_movement TEXT NOT NULL, + location TEXT NOT NULL, + time_of_day TEXT NOT NULL, + action TEXT NOT NULL, + dialogue TEXT NOT NULL, + duration_seconds INTEGER NOT NULL, + visual_prompt TEXT NOT NULL, + image_prompt TEXT NOT NULL, + video_prompt TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), + UNIQUE(chapter_id, position) + ); + CREATE INDEX IF NOT EXISTS idx_web_novel_activity_work_date ON web_novel_writing_activity(work_id, local_date); + CREATE INDEX IF NOT EXISTS idx_web_novel_inspiration_work ON web_novel_inspirations(work_id, status, updated_at_unix_ms DESC); + CREATE INDEX IF NOT EXISTS idx_web_novel_shots_chapter ON web_novel_shots(chapter_id, position);", + ) + .map_err(db_write) +} + +fn author_snapshot_at( + path: &Path, + input: &AuthorSnapshotInput, +) -> Result { + validate_local_date(&input.local_date)?; + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let work_kind: String = connection + .query_row( + "SELECT work_kind FROM web_novel_works WHERE work_id=?1 AND archived=0", + [&input.work_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".to_string())?; + let total_words: i64 = connection + .query_row( + "SELECT COALESCE(SUM(word_count),0) FROM web_novel_chapters WHERE work_id=?1 AND archived=0", + [&input.work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + let (total_active_ms, today_active_ms, today_words): (i64, i64, i64) = connection + .query_row( + "SELECT COALESCE(SUM(active_ms),0), + COALESCE(SUM(CASE WHEN local_date=?2 THEN active_ms ELSE 0 END),0), + COALESCE(SUM(CASE WHEN local_date=?2 THEN words_delta ELSE 0 END),0) + FROM web_novel_writing_activity WHERE work_id=?1", + params![input.work_id, input.local_date], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(db_read)?; + let inspirations = query_inspirations(&connection, &input.work_id)?; + let shots = query_shots(&connection, &input.work_id)?; + Ok(WebNovelAuthorSnapshot { + work_id: input.work_id.clone(), + work_kind, + total_words, + today_words, + total_active_ms, + today_active_ms, + inspiration_count: inspirations.len(), + inspirations, + shots, + }) +} + +fn record_activity_at( + path: &Path, + input: RecordWritingActivityInput, +) -> Result { + if !input.activity_id.starts_with("WN-ACT-") || input.activity_id.len() > 100 { + return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_ID_INVALID".into()); + } + validate_local_date(&input.local_date)?; + if !(1_000..=300_000).contains(&input.active_ms) + || !(-100_000..=100_000).contains(&input.words_delta) + { + return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_BOUNDS_INVALID".into()); + } + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let chapter_work: Option = connection + .query_row( + "SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [&input.chapter_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + if chapter_work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_SCOPE_MISMATCH".into()); + } + connection + .execute( + "INSERT OR IGNORE INTO web_novel_writing_activity( + activity_id,work_id,chapter_id,local_date,active_ms,words_delta,created_at_unix_ms + ) VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + input.activity_id, + input.work_id, + input.chapter_id, + input.local_date, + input.active_ms, + input.words_delta, + now_ms() + ], + ) + .map_err(db_write)?; + author_snapshot_at( + path, + &AuthorSnapshotInput { + work_id: input.work_id, + local_date: input.local_date, + }, + ) +} + +fn create_inspiration_at( + path: &Path, + input: CreateInspirationInput, +) -> Result { + let content = input.content.trim(); + if content.is_empty() || content.len() > MAX_INSPIRATION_BYTES || input.tags.len() > 30 { + return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_INVALID".into()); + } + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + ensure_scope(&connection, &input.work_id, input.chapter_id.as_deref())?; + let inspiration_id = format!("WN-INSP-{}", Uuid::new_v4()); + let tags_json = serde_json::to_string(&input.tags) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_INSPIRATION_TAGS_INVALID: {error}"))?; + let now = now_ms(); + connection + .execute( + "INSERT INTO web_novel_inspirations( + inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms + ) VALUES(?1,?2,?3,?4,?5,'INBOX',?6,?6)", + params![inspiration_id, input.work_id, input.chapter_id, content, tags_json, now], + ) + .map_err(db_write)?; + query_inspiration(&connection, &inspiration_id) +} + +fn set_inspiration_status_at( + path: &Path, + input: SetInspirationStatusInput, +) -> Result { + let status = input.status.trim().to_ascii_uppercase(); + if !matches!(status.as_str(), "INBOX" | "USED" | "ARCHIVED") { + return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_STATUS_INVALID".into()); + } + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let changed = connection + .execute( + "UPDATE web_novel_inspirations SET status=?1,updated_at_unix_ms=?2 WHERE inspiration_id=?3", + params![status, now_ms(), input.inspiration_id], + ) + .map_err(db_write)?; + if changed != 1 { + return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_NOT_FOUND".into()); + } + query_inspiration(&connection, &input.inspiration_id) +} + +fn search_full_text_at( + path: &Path, + input: SearchFullTextInput, +) -> Result { + let query = input.query.trim().to_owned(); + if query.is_empty() || query.chars().count() > 100 { + return Err("HOLOLAKE_WEBNOVEL_SEARCH_QUERY_INVALID".into()); + } + let connection = super::web_novel_workspace::open_database(path)?; + let mut statement = connection + .prepare( + "SELECT chapter_id,title,synopsis,content FROM web_novel_chapters + WHERE work_id=?1 AND archived=0 AND (instr(title,?2)>0 OR instr(synopsis,?2)>0 OR instr(content,?2)>0) + ORDER BY volume_id,position LIMIT 200", + ) + .map_err(db_read)?; + let rows = statement + .query_map(params![input.work_id, query], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + let mut hits = Vec::new(); + let mut occurrence_count = 0; + for (chapter_id, chapter_title, synopsis, content) in rows { + let (field, source) = if content.contains(&query) { + ("正文", content) + } else if synopsis.contains(&query) { + ("章节梗概", synopsis) + } else { + ("章节标题", chapter_title.clone()) + }; + let count = source + .matches(&query) + .count() + .max(chapter_title.matches(&query).count()) as i64; + occurrence_count += count; + hits.push(WebNovelSearchHit { + chapter_id, + chapter_title, + field: field.into(), + snippet: make_snippet(&source, &query), + occurrence_count: count, + }); + } + Ok(WebNovelSearchResult { + query, + chapter_count: hits.len(), + occurrence_count, + hits, + }) +} + +fn format_chapter_at( + path: &Path, + input: FormatChapterInput, +) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + let current = connection + .query_row( + "SELECT chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status, + scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms + FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [&input.chapter_id], + |row| { + Ok(super::web_novel_workspace::WebNovelChapter { + chapter_id: row.get(0)?, + volume_id: row.get(1)?, + work_id: row.get(2)?, + title: row.get(3)?, + position: row.get(4)?, + synopsis: row.get(5)?, + content: row.get(6)?, + workflow_status: row.get(7)?, + scheduled_at_unix_ms: row.get(8)?, + word_count: row.get(9)?, + revision: row.get(10)?, + created_at_unix_ms: row.get(11)?, + updated_at_unix_ms: row.get(12)?, + }) + }, + ) + .optional() + .map_err(db_read)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_CHAPTER_NOT_FOUND".to_string())?; + if current.revision != input.expected_revision { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT".into()); + } + let work_kind: String = connection + .query_row( + "SELECT work_kind FROM web_novel_works WHERE work_id=?1", + [¤t.work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + let volume_title: String = connection + .query_row( + "SELECT title FROM web_novel_volumes WHERE volume_id=?1", + [¤t.volume_id], + |row| row.get(0), + ) + .map_err(db_read)?; + drop(connection); + let requested = input.preset.as_deref().unwrap_or("AUTO"); + let inferred = if requested == "AUTO" && volume_title.contains("细纲") { + "OUTLINE" + } else { + requested + }; + let content = format_content(¤t.content, &work_kind, inferred); + super::web_novel_workspace::save_chapter_at( + path, + super::web_novel_workspace::SaveWebNovelChapterInput { + chapter_id: current.chapter_id, + title: current.title, + synopsis: current.synopsis, + content, + expected_revision: current.revision, + save_reason: Some("ONE_CLICK_FORMAT".into()), + }, + ) +} + +fn format_work_at(path: &Path, input: FormatWorkInput) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + ensure_scope(&connection, &input.work_id, None)?; + let mut statement = connection + .prepare( + "SELECT chapter_id,revision FROM web_novel_chapters + WHERE work_id=?1 AND archived=0 ORDER BY position,created_at_unix_ms", + ) + .map_err(db_read)?; + let chapters = statement + .query_map([&input.work_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + drop(statement); + drop(connection); + let preset = input.preset.unwrap_or_else(|| "AUTO".into()); + for (chapter_id, expected_revision) in &chapters { + format_chapter_at( + path, + FormatChapterInput { + chapter_id: chapter_id.clone(), + expected_revision: *expected_revision, + preset: Some(preset.clone()), + }, + )?; + } + Ok(FormatWorkReceipt { + work_id: input.work_id, + formatted_chapter_count: chapters.len(), + preset, + }) +} + +fn upsert_shot_at(path: &Path, input: UpsertShotInput) -> Result { + if input.duration_seconds < 0 || input.duration_seconds > 3600 { + return Err("HOLOLAKE_WEBNOVEL_SHOT_DURATION_INVALID".into()); + } + for value in [ + &input.action, + &input.dialogue, + &input.visual_prompt, + &input.image_prompt, + &input.video_prompt, + ] { + if value.len() > MAX_SHOT_TEXT_BYTES { + return Err("HOLOLAKE_WEBNOVEL_SHOT_TEXT_TOO_LARGE".into()); + } + } + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + ensure_scope(&connection, &input.work_id, Some(&input.chapter_id))?; + let work_kind: String = connection + .query_row( + "SELECT work_kind FROM web_novel_works WHERE work_id=?1", + [&input.work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + if work_kind != "SHORT_DRAMA" { + return Err("HOLOLAKE_WEBNOVEL_SHOT_REQUIRES_SHORT_DRAMA".into()); + } + let shot_id = input + .shot_id + .unwrap_or_else(|| format!("WN-SHOT-{}", Uuid::new_v4())); + let existing: Option<(i64, i64)> = connection + .query_row( + "SELECT position,revision FROM web_novel_shots WHERE shot_id=?1 AND archived=0", + [&shot_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(db_read)?; + let now = now_ms(); + if let Some((_, revision)) = existing { + if input.expected_revision != Some(revision) { + return Err("HOLOLAKE_WEBNOVEL_SHOT_REVISION_CONFLICT".into()); + } + connection.execute( + "UPDATE web_novel_shots SET shot_number=?1,shot_size=?2,camera_movement=?3,location=?4,time_of_day=?5, + action=?6,dialogue=?7,duration_seconds=?8,visual_prompt=?9,image_prompt=?10,video_prompt=?11, + revision=revision+1,updated_at_unix_ms=?12 WHERE shot_id=?13 AND work_id=?14 AND revision=?15", + params![input.shot_number,input.shot_size,input.camera_movement,input.location,input.time_of_day,input.action,input.dialogue, + input.duration_seconds,input.visual_prompt,input.image_prompt,input.video_prompt,now,shot_id,input.work_id,revision], + ).map_err(db_write)?; + } else { + let position: i64 = connection.query_row( + "SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_shots WHERE chapter_id=?1 AND archived=0", + [&input.chapter_id], |row| row.get(0) + ).map_err(db_read)?; + connection.execute( + "INSERT INTO web_novel_shots(shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day, + action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms,archived) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,1,?16,?16,0)", + params![shot_id,input.work_id,input.chapter_id,position,input.shot_number,input.shot_size,input.camera_movement,input.location, + input.time_of_day,input.action,input.dialogue,input.duration_seconds,input.visual_prompt,input.image_prompt,input.video_prompt,now], + ).map_err(db_write)?; + } + query_shot(&connection, &shot_id) +} + +fn query_inspirations( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection.prepare( + "SELECT inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms + FROM web_novel_inspirations WHERE work_id=?1 AND status!='ARCHIVED' ORDER BY updated_at_unix_ms DESC LIMIT 100" + ).map_err(db_read)?; + let rows = statement + .query_map([work_id], map_inspiration) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + Ok(rows) +} + +fn query_inspiration( + connection: &Connection, + inspiration_id: &str, +) -> Result { + connection.query_row( + "SELECT inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms + FROM web_novel_inspirations WHERE inspiration_id=?1", [inspiration_id], map_inspiration + ).map_err(db_read) +} + +fn map_inspiration(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let tags_json: String = row.get(4)?; + Ok(WebNovelInspiration { + inspiration_id: row.get(0)?, + work_id: row.get(1)?, + chapter_id: row.get(2)?, + content: row.get(3)?, + tags: serde_json::from_str(&tags_json).unwrap_or_default(), + status: row.get(5)?, + created_at_unix_ms: row.get(6)?, + updated_at_unix_ms: row.get(7)?, + }) +} + +fn query_shots(connection: &Connection, work_id: &str) -> Result, String> { + let mut statement = connection.prepare( + "SELECT shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day, + action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms + FROM web_novel_shots WHERE work_id=?1 AND archived=0 ORDER BY chapter_id,position LIMIT 2000" + ).map_err(db_read)?; + let rows = statement + .query_map([work_id], map_shot) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + Ok(rows) +} + +fn query_shot(connection: &Connection, shot_id: &str) -> Result { + connection.query_row( + "SELECT shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day, + action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms + FROM web_novel_shots WHERE shot_id=?1 AND archived=0", [shot_id], map_shot + ).map_err(db_read) +} + +fn map_shot(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WebNovelShot { + shot_id: row.get(0)?, + work_id: row.get(1)?, + chapter_id: row.get(2)?, + position: row.get(3)?, + shot_number: row.get(4)?, + shot_size: row.get(5)?, + camera_movement: row.get(6)?, + location: row.get(7)?, + time_of_day: row.get(8)?, + action: row.get(9)?, + dialogue: row.get(10)?, + duration_seconds: row.get(11)?, + visual_prompt: row.get(12)?, + image_prompt: row.get(13)?, + video_prompt: row.get(14)?, + revision: row.get(15)?, + created_at_unix_ms: row.get(16)?, + updated_at_unix_ms: row.get(17)?, + }) +} + +fn ensure_scope( + connection: &Connection, + work_id: &str, + chapter_id: Option<&str>, +) -> Result<(), String> { + let work_exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)", + [work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + if !work_exists { + return Err("HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into()); + } + if let Some(chapter_id) = chapter_id { + let chapter_work: Option = connection + .query_row( + "SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [chapter_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + if chapter_work.as_deref() != Some(work_id) { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_SCOPE_MISMATCH".into()); + } + } + Ok(()) +} + +pub(crate) fn format_content(content: &str, work_kind: &str, preset: &str) -> String { + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized + .lines() + .map(|line| line.trim_matches([' ', '\t', '\u{3000}'])) + .filter(|line| !line.is_empty()) + .collect(); + let mode = match preset { + "NOVEL" | "OUTLINE" | "SCREENPLAY" | "CLEAN" => preset, + _ if work_kind == "SHORT_DRAMA" => "SCREENPLAY", + _ => "NOVEL", + }; + if mode == "CLEAN" { + return lines.join("\n\n"); + } + let formatted: Vec = lines + .into_iter() + .map(|line| match mode { + "NOVEL" if !is_heading(line) => format!("  {}", line.trim_start_matches("  ")), + _ => line.to_owned(), + }) + .collect(); + // 每个逻辑段之间明确留一行。正文、细纲和剧本都因此可读, + // 同时不会用空白字符改变字数统计。 + formatted.join("\n\n").trim_end().to_owned() +} + +fn is_heading(line: &str) -> bool { + (line.starts_with('第') + && (line.contains('章') + || line.contains('卷') + || line.contains('节') + || line.contains('集'))) + || line.starts_with("场次:") + || line.starts_with("人物:") + || line.starts_with('【') +} + +fn make_snippet(source: &str, query: &str) -> String { + let chars: Vec = source.chars().collect(); + let needle: Vec = query.chars().collect(); + let found = chars + .windows(needle.len()) + .position(|window| window == needle.as_slice()) + .unwrap_or(0); + let start = found.saturating_sub(35); + let end = (found + needle.len() + 55).min(chars.len()); + let mut snippet: String = chars[start..end].iter().collect(); + snippet = snippet.replace('\n', " "); + if start > 0 { + snippet.insert(0, '…'); + } + if end < chars.len() { + snippet.push('…'); + } + snippet +} + +fn validate_local_date(value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + if bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) + { + Ok(()) + } else { + Err("HOLOLAKE_WEBNOVEL_LOCAL_DATE_INVALID".into()) + } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn db_read(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_AUTHOR_DATABASE_READ_FAILED: {error}") +} + +fn db_write(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_AUTHOR_DATABASE_WRITE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn create_work( + path: &Path, + kind: &str, + ) -> super::super::web_novel_workspace::WebNovelWorkDetail { + super::super::web_novel_workspace::create_work_at( + path, + super::super::web_novel_workspace::CreateWebNovelWorkInput { + title: "作者底座测试".into(), + pen_name: "冰朔".into(), + genre: "测试".into(), + work_kind: kind.into(), + }, + ) + .unwrap() + } + + #[test] + fn inspiration_activity_search_and_format_are_persistent() { + let temp = tempdir().unwrap(); + let path = temp.path().join("author.sqlite3"); + let detail = create_work(&path, "LONG_NOVEL"); + let chapter = super::super::web_novel_workspace::create_chapter_at( + &path, + super::super::web_novel_workspace::CreateWebNovelChapterInput { + work_id: detail.work.work_id.clone(), + volume_id: detail.volumes[0].volume_id.clone(), + title: "第一章".into(), + }, + ) + .unwrap(); + let chapter = super::super::web_novel_workspace::save_chapter_at( + &path, + super::super::web_novel_workspace::SaveWebNovelChapterInput { + chapter_id: chapter.chapter_id.clone(), + title: chapter.title, + synopsis: "开场".into(), + content: "第一段\n\n第二段有线索".into(), + expected_revision: chapter.revision, + save_reason: Some("TEST".into()), + }, + ) + .unwrap(); + let formatted = format_chapter_at( + &path, + FormatChapterInput { + chapter_id: chapter.chapter_id.clone(), + expected_revision: chapter.revision, + preset: Some("NOVEL".into()), + }, + ) + .unwrap(); + assert!(formatted.content.starts_with("  第一段")); + assert!(formatted.content.contains("\n\n  第二段")); + create_inspiration_at( + &path, + CreateInspirationInput { + work_id: detail.work.work_id.clone(), + chapter_id: Some(chapter.chapter_id.clone()), + content: "让钥匙提前出现".into(), + tags: vec!["伏笔".into()], + }, + ) + .unwrap(); + record_activity_at( + &path, + RecordWritingActivityInput { + activity_id: "WN-ACT-test".into(), + work_id: detail.work.work_id.clone(), + chapter_id: chapter.chapter_id.clone(), + local_date: "2026-08-18".into(), + active_ms: 30_000, + words_delta: 8, + }, + ) + .unwrap(); + let search = search_full_text_at( + &path, + SearchFullTextInput { + work_id: detail.work.work_id.clone(), + query: "线索".into(), + }, + ) + .unwrap(); + assert_eq!(search.chapter_count, 1); + let snapshot = author_snapshot_at( + &path, + &AuthorSnapshotInput { + work_id: detail.work.work_id, + local_date: "2026-08-18".into(), + }, + ) + .unwrap(); + assert_eq!(snapshot.today_active_ms, 30_000); + assert_eq!(snapshot.today_words, 8); + assert_eq!(snapshot.inspiration_count, 1); + } + + #[test] + fn short_drama_chapter_has_template_and_real_shot() { + let temp = tempdir().unwrap(); + let path = temp.path().join("drama.sqlite3"); + let detail = create_work(&path, "SHORT_DRAMA"); + let chapter = super::super::web_novel_workspace::create_chapter_at( + &path, + super::super::web_novel_workspace::CreateWebNovelChapterInput { + work_id: detail.work.work_id.clone(), + volume_id: detail.volumes[0].volume_id.clone(), + title: "第1集".into(), + }, + ) + .unwrap(); + assert!(chapter.content.contains("场次:1-1")); + let shot = upsert_shot_at( + &path, + UpsertShotInput { + work_id: detail.work.work_id, + chapter_id: chapter.chapter_id, + shot_id: None, + shot_number: "1".into(), + shot_size: "近景".into(), + camera_movement: "推".into(), + location: "侯府".into(), + time_of_day: "夜".into(), + action: "女主抬眼".into(), + dialogue: "你终于来了。".into(), + duration_seconds: 4, + visual_prompt: "烛火冷夜".into(), + image_prompt: "古装近景".into(), + video_prompt: "缓慢推镜".into(), + expected_revision: None, + }, + ) + .unwrap(); + assert_eq!(shot.revision, 1); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/web_novel_import.rs b/product-source/hololake-native-desktop/src-tauri/src/web_novel_import.rs new file mode 100644 index 000000000..d70f2d74e --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/web_novel_import.rs @@ -0,0 +1,810 @@ +//! 网文真实文档导入器。 +//! +//! 文档在 Rust 侧解析,先生成可确认的拆分预览,再以单个 SQLite +//! 事务写入当前账号。WebView 不会获取本机源路径。 + +use encoding_rs::GBK; +use quick_xml::events::Event; +use quick_xml::Reader; +use regex::Regex; +use ring::digest::{digest, SHA256}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Cursor, Read}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; +use zip::ZipArchive; + +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001"; +const ADAPTER: &str = "web-novel-workbench-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +const MAX_SOURCE_BYTES: u64 = 30 * 1024 * 1024; +const MAX_SECTION_BYTES: usize = 4_000_000; +const MAX_SYNOPSIS_BYTES: usize = 40_000; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImportSection { + pub ordinal: usize, + pub title: String, + pub body: String, + pub word_count: i64, + pub scene_count: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImportSectionPreview { + pub ordinal: usize, + pub title: String, + pub word_count: i64, + pub scene_count: i64, + pub preview: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelImportPreview { + pub import_id: String, + pub source_filename: String, + pub source_format: String, + pub source_sha256: String, + pub source_bytes: u64, + pub detected_family: String, + pub detected_title: String, + pub detected_pen_name: String, + pub detected_genre: String, + pub section_count: usize, + pub total_word_count: i64, + pub preface_word_count: i64, + pub sections: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CommitImportInput { + pub import_id: String, + pub target_mode: String, + pub target_work_id: Option, + pub title: String, + pub pen_name: String, + pub genre: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelImportReceipt { + pub state: String, + pub import_id: String, + pub work_id: String, + pub volume_id: String, + pub source_filename: String, + pub source_sha256: String, + pub detected_family: String, + pub chapter_count: usize, + pub word_count: i64, + pub first_chapter_title: String, + pub last_chapter_title: String, +} + +#[derive(Debug)] +struct ParsedDocument { + source_filename: String, + source_format: String, + source_sha256: String, + source_bytes: u64, + detected_family: String, + detected_title: String, + detected_pen_name: String, + detected_genre: String, + preface: String, + sections: Vec, +} + +pub async fn inspect_web_novel_document_from_dialog( + app: AppHandle, +) -> Result, String> { + require_active(&app)?; + let selected = app + .dialog() + .file() + .set_title("选择小说、细纲或剧本原文") + .add_filter("网文文档", &["txt", "md", "docx"]) + .blocking_pick_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let source = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_PATH_INVALID: {error}"))?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || stage_document_at(&database, &source)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_JOIN_FAILED: {error}"))? + .map(Some) +} + +pub async fn commit_web_novel_document_import( + app: AppHandle, + input: CommitImportInput, +) -> Result { + require_active(&app)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || commit_import_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_JOIN_FAILED: {error}"))? +} + +fn ensure_import_schema(connection: &rusqlite::Connection) -> Result<(), String> { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS web_novel_document_imports( + import_id TEXT PRIMARY KEY NOT NULL, + source_filename TEXT NOT NULL, + source_format TEXT NOT NULL, + source_sha256 TEXT NOT NULL, + source_bytes INTEGER NOT NULL, + detected_family TEXT NOT NULL, + detected_title TEXT NOT NULL, + detected_pen_name TEXT NOT NULL, + detected_genre TEXT NOT NULL, + preface TEXT NOT NULL, + sections_json TEXT NOT NULL, + state TEXT NOT NULL, + target_work_id TEXT, + target_volume_id TEXT, + created_at_unix_ms INTEGER NOT NULL, + committed_at_unix_ms INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_web_novel_import_hash + ON web_novel_document_imports(source_sha256, state);", + ) + .map_err(db_write) +} + +pub(crate) fn stage_document_at( + database: &Path, + source: &Path, +) -> Result { + let parsed = parse_document(source)?; + let mut connection = super::web_novel_workspace::open_database(database)?; + ensure_import_schema(&connection)?; + let import_id = format!("WN-IMPORT-{}", Uuid::new_v4()); + let sections_json = serde_json::to_string(&parsed.sections) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_SERIALIZE_FAILED: {error}"))?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + tx.execute( + "INSERT INTO web_novel_document_imports( + import_id, source_filename, source_format, source_sha256, source_bytes, + detected_family, detected_title, detected_pen_name, detected_genre, + preface, sections_json, state, created_at_unix_ms + ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,'STAGED',?12)", + params![ + import_id, + parsed.source_filename, + parsed.source_format, + parsed.source_sha256, + parsed.source_bytes as i64, + parsed.detected_family, + parsed.detected_title, + parsed.detected_pen_name, + parsed.detected_genre, + parsed.preface, + sections_json, + now_ms(), + ], + ) + .map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(preview_from(&import_id, &parsed)) +} + +fn preview_from(import_id: &str, parsed: &ParsedDocument) -> WebNovelImportPreview { + let mut selected: Vec<&ImportSection> = parsed.sections.iter().take(8).collect(); + if parsed.sections.len() > 10 { + selected.extend(parsed.sections.iter().skip(parsed.sections.len() - 2)); + } + WebNovelImportPreview { + import_id: import_id.to_owned(), + source_filename: parsed.source_filename.clone(), + source_format: parsed.source_format.clone(), + source_sha256: parsed.source_sha256.clone(), + source_bytes: parsed.source_bytes, + detected_family: parsed.detected_family.clone(), + detected_title: parsed.detected_title.clone(), + detected_pen_name: parsed.detected_pen_name.clone(), + detected_genre: parsed.detected_genre.clone(), + section_count: parsed.sections.len(), + total_word_count: parsed.sections.iter().map(|item| item.word_count).sum(), + preface_word_count: count_words(&parsed.preface), + sections: selected + .into_iter() + .map(|section| ImportSectionPreview { + ordinal: section.ordinal, + title: section.title.clone(), + word_count: section.word_count, + scene_count: section.scene_count, + preview: section.body.chars().take(120).collect(), + }) + .collect(), + } +} + +pub(crate) fn commit_import_at( + database: &Path, + input: CommitImportInput, +) -> Result { + if !input.import_id.starts_with("WN-IMPORT-") { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_ID_INVALID".into()); + } + let mut connection = super::web_novel_workspace::open_database(database)?; + ensure_import_schema(&connection)?; + let staged = connection + .query_row( + "SELECT source_filename, source_sha256, detected_family, preface, sections_json, state + FROM web_novel_document_imports WHERE import_id=?1", + [&input.import_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }, + ) + .optional() + .map_err(db_read)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_IMPORT_NOT_FOUND".to_string())?; + if staged.5 != "STAGED" { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_ALREADY_COMMITTED".into()); + } + let sections: Vec = serde_json::from_str(&staged.4) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_STAGING_INVALID: {error}"))?; + if sections.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_NO_SECTIONS".into()); + } + for section in §ions { + if section.body.len() > MAX_SECTION_BYTES { + return Err(format!( + "HOLOLAKE_WEBNOVEL_IMPORT_SECTION_TOO_LARGE: {}", + section.title + )); + } + } + + let create_new = input.target_mode == "CREATE_NEW"; + if !create_new && input.target_mode != "APPEND_EXISTING" { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_TARGET_MODE_INVALID".into()); + } + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let now = now_ms(); + let work_id = if create_new { + let work_id = format!("WN-WORK-{}", Uuid::new_v4()); + let title = required_text(&input.title, "未命名导入作品"); + let pen_name = required_text(&input.pen_name, "待确认"); + let genre = required_text(&input.genre, "网文导入"); + let synopsis = truncate_utf8(&staged.3, MAX_SYNOPSIS_BYTES); + let work_kind = match staged.2.as_str() { + "SCRIPT" => "SHORT_DRAMA", + _ => "LONG_NOVEL", + }; + tx.execute( + "INSERT INTO web_novel_works( + work_id,title,pen_name,genre,work_kind,synopsis,contract_status,copyright_status, + workflow_status,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived + ) VALUES(?1,?2,?3,?4,?5,?6,'UNREGISTERED','AUTHOR_OWNED','DRAFT',0,1,?7,?7,0)", + params![work_id, title, pen_name, genre, work_kind, synopsis, now], + ) + .map_err(db_write)?; + work_id + } else { + let work_id = input + .target_work_id + .clone() + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_IMPORT_TARGET_REQUIRED".to_string())?; + let exists: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)", + [&work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + if !exists { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_TARGET_NOT_FOUND".into()); + } + work_id + }; + let volume_id = format!("WN-VOL-{}", Uuid::new_v4()); + let volume_position: i64 = tx.query_row( + "SELECT COALESCE(MAX(position), -1)+1 FROM web_novel_volumes WHERE work_id=?1 AND archived=0", + [&work_id], |row| row.get(0) + ).map_err(db_read)?; + let volume_title = match staged.2.as_str() { + "OUTLINE" => "导入·拆分细纲", + "SCRIPT" => "导入·分集剧本", + _ => "导入·小说正文", + }; + tx.execute( + "INSERT INTO web_novel_volumes(volume_id,work_id,title,position,revision,created_at_unix_ms,updated_at_unix_ms,archived) + VALUES(?1,?2,?3,?4,1,?5,?5,0)", + params![volume_id, work_id, volume_title, volume_position, now], + ).map_err(db_write)?; + let work_kind: String = tx + .query_row( + "SELECT work_kind FROM web_novel_works WHERE work_id=?1 AND archived=0", + [&work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + for (position, section) in sections.iter().enumerate() { + let chapter_id = format!("WN-CH-{}", Uuid::new_v4()); + let formatted_content = + super::web_novel_author::format_content(§ion.body, &work_kind, &staged.2); + let formatted_word_count = count_words(&formatted_content); + tx.execute( + "INSERT INTO web_novel_chapters( + chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status, + scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms,archived + ) VALUES(?1,?2,?3,?4,?5,'',?6,'DRAFT',NULL,?7,2,?8,?8,0)", + params![chapter_id, volume_id, work_id, section.title, position as i64, formatted_content, formatted_word_count, now], + ).map_err(db_write)?; + tx.execute( + "INSERT INTO web_novel_chapter_versions( + version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms + ) VALUES(?1,?2,?3,1,?4,'',?5,'DRAFT',?6,'DOCUMENT_IMPORT_SOURCE',?7)", + params![format!("WN-VER-{}", Uuid::new_v4()), chapter_id, work_id, section.title, section.body, section.word_count, now], + ).map_err(db_write)?; + tx.execute( + "INSERT INTO web_novel_chapter_versions( + version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms + ) VALUES(?1,?2,?3,2,?4,'',?5,'DRAFT',?6,'AUTO_FORMAT_ON_IMPORT',?7)", + params![format!("WN-VER-{}", Uuid::new_v4()), chapter_id, work_id, section.title, formatted_content, formatted_word_count, now], + ).map_err(db_write)?; + } + tx.execute( + "UPDATE web_novel_works SET revision=revision+1, updated_at_unix_ms=?1 WHERE work_id=?2", + params![now, work_id], + ) + .map_err(db_write)?; + tx.execute( + "UPDATE web_novel_document_imports SET state='COMMITTED',target_work_id=?1,target_volume_id=?2,committed_at_unix_ms=?3 + WHERE import_id=?4 AND state='STAGED'", + params![work_id, volume_id, now, input.import_id], + ).map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(WebNovelImportReceipt { + state: "COMMITTED".into(), + import_id: input.import_id, + work_id, + volume_id, + source_filename: staged.0, + source_sha256: staged.1, + detected_family: staged.2, + chapter_count: sections.len(), + word_count: sections.iter().map(|section| section.word_count).sum(), + first_chapter_title: sections + .first() + .map(|item| item.title.clone()) + .unwrap_or_default(), + last_chapter_title: sections + .last() + .map(|item| item.title.clone()) + .unwrap_or_default(), + }) +} + +fn parse_document(source: &Path) -> Result { + let metadata = fs::metadata(source) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_READ_FAILED: {error}"))?; + if metadata.len() == 0 || metadata.len() > MAX_SOURCE_BYTES { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_FILE_SIZE_INVALID".into()); + } + let bytes = fs::read(source) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_READ_FAILED: {error}"))?; + let source_format = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let raw = match source_format.as_str() { + "docx" => read_docx(&bytes)?, + "txt" | "md" => decode_plain_text(&bytes), + _ => return Err("HOLOLAKE_WEBNOVEL_IMPORT_FORMAT_UNSUPPORTED".into()), + }; + let text = normalize_text(&raw); + if text.trim().is_empty() { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_DOCUMENT_EMPTY".into()); + } + let source_filename = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("未命名文档") + .to_owned(); + let family = detect_family(&source_filename, &text); + let (preface, sections) = split_sections(&text, &family)?; + let hash = digest(&SHA256, &bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(ParsedDocument { + source_filename: source_filename.clone(), + source_format: source_format.to_uppercase(), + source_sha256: hash, + source_bytes: bytes.len() as u64, + detected_family: family.clone(), + detected_title: detect_title(&source_filename, &text), + detected_pen_name: detect_author(&text), + detected_genre: match family.as_str() { + "SCRIPT" => "短剧剧本", + "OUTLINE" => "拆分细纲", + _ => "网文小说", + } + .into(), + preface, + sections, + }) +} + +fn read_docx(bytes: &[u8]) -> Result { + let cursor = Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_INVALID: {error}"))?; + let mut document = archive + .by_name("word/document.xml") + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_BODY_MISSING: {error}"))?; + let mut xml = String::new(); + document + .read_to_string(&mut xml) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_READ_FAILED: {error}"))?; + let mut reader = Reader::from_str(&xml); + reader.trim_text(false); + let mut output = String::new(); + let mut in_text = false; + loop { + match reader.read_event() { + Ok(Event::Start(event)) => { + let name = event.name(); + if local_name(name.as_ref()) == b"t" { + in_text = true; + } + if local_name(name.as_ref()) == b"tab" { + output.push('\t'); + } + if local_name(name.as_ref()) == b"br" { + output.push('\n'); + } + } + Ok(Event::Empty(event)) => { + let name = event.name(); + if local_name(name.as_ref()) == b"tab" { + output.push('\t'); + } + if local_name(name.as_ref()) == b"br" { + output.push('\n'); + } + } + Ok(Event::Text(event)) if in_text => { + output.push_str(&event.unescape().map_err(|error| { + format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_XML_INVALID: {error}") + })?); + } + Ok(Event::End(event)) => { + let name = event.name(); + if local_name(name.as_ref()) == b"t" { + in_text = false; + } + if local_name(name.as_ref()) == b"p" { + output.push('\n'); + } + } + Ok(Event::Eof) => break, + Err(error) => { + return Err(format!( + "HOLOLAKE_WEBNOVEL_IMPORT_DOCX_XML_INVALID: {error}" + )) + } + _ => {} + } + } + Ok(output) +} + +fn local_name(name: &[u8]) -> &[u8] { + name.rsplit(|value| *value == b':').next().unwrap_or(name) +} + +fn decode_plain_text(bytes: &[u8]) -> String { + if let Ok(value) = std::str::from_utf8(bytes) { + return value.trim_start_matches('\u{feff}').to_owned(); + } + let (value, _, _) = GBK.decode(bytes); + value.into_owned() +} + +fn normalize_text(raw: &str) -> String { + let raw = raw + .replace("\r\n", "\n") + .replace('\r', "\n") + .replace('\u{00a0}', " "); + let mut output = String::with_capacity(raw.len()); + let mut blank = 0; + for line in raw.lines() { + let cleaned = line.trim_end_matches([' ', '\t', '\u{3000}']); + if cleaned.trim().is_empty() { + blank += 1; + if blank <= 2 { + output.push('\n'); + } + } else { + blank = 0; + output.push_str(cleaned); + output.push('\n'); + } + } + output.trim().to_owned() +} + +fn detect_family(filename: &str, text: &str) -> String { + let sample: String = text.chars().take(80_000).collect(); + let episode = heading_regex("集").find_iter(&sample).count(); + if episode >= 2 || (sample.contains("剧本") && scene_regex().is_match(&sample)) { + "SCRIPT".into() + } else if filename.contains("细纲") || filename.contains("大纲") || sample.contains("章节细纲") + { + "OUTLINE".into() + } else { + "NOVEL".into() + } +} + +fn split_sections(text: &str, family: &str) -> Result<(String, Vec), String> { + let heading = if family == "SCRIPT" { + heading_regex("集") + } else { + heading_regex("章") + }; + let mut preface = Vec::new(); + let mut sections: Vec = Vec::new(); + let mut current_title: Option = None; + let mut current_body: Vec = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if heading.is_match(trimmed) { + if let Some(title) = current_title.take() { + push_section(&mut sections, title, ¤t_body); + current_body.clear(); + } + current_title = Some(trimmed.trim_end_matches([':', ':']).trim().to_owned()); + } else if current_title.is_some() { + current_body.push(line.to_owned()); + } else { + preface.push(line.to_owned()); + } + } + if let Some(title) = current_title { + push_section(&mut sections, title, ¤t_body); + } + if sections.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_IMPORT_NO_CHAPTER_OR_EPISODE_HEADINGS".into()); + } + Ok((preface.join("\n").trim().to_owned(), sections)) +} + +fn push_section(sections: &mut Vec, title: String, body_lines: &[String]) { + let body = body_lines.join("\n").trim().to_owned(); + let scene_count = scene_regex().find_iter(&body).count() as i64; + sections.push(ImportSection { + ordinal: sections.len() + 1, + title, + word_count: count_words(&body), + scene_count, + body, + }); +} + +fn heading_regex(unit: &str) -> Regex { + Regex::new(&format!( + r"(?m)^\s*第[0-90-9一二三四五六七八九十百千万零〇两]{{1,12}}{}(?:\s*[::]?\s*.*)?$", + unit + )) + .expect("valid heading regex") +} + +fn scene_regex() -> Regex { + Regex::new(r"(?m)^\s*[0-90-9]{1,3}-[0-90-9]{1,3}\s+").expect("valid scene regex") +} + +fn detect_title(filename: &str, text: &str) -> String { + let bracket = Regex::new(r"《([^》]{1,120})》").expect("valid title regex"); + if let Some(value) = bracket + .captures( + text.lines() + .take(20) + .collect::>() + .join("\n") + .as_str(), + ) + .and_then(|capture| capture.get(1)) + { + return value.as_str().trim().to_owned(); + } + if let Some(value) = bracket + .captures(filename) + .and_then(|capture| capture.get(1)) + { + return value.as_str().trim().to_owned(); + } + filename + .trim_end_matches(|value: char| value == '.' || value.is_ascii_alphabetic()) + .split(['【', '[']) + .next() + .unwrap_or("未命名作品") + .trim_matches(['《', '》', ' ', ' ']) + .to_owned() +} + +fn detect_author(text: &str) -> String { + let regex = Regex::new(r"作者\s*[::]\s*([^\s\r\n|]+)").expect("valid author regex"); + let sample: String = text.chars().take(10_000).collect(); + regex + .captures(&sample) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_owned()) + .unwrap_or_else(|| "待确认".into()) +} + +fn count_words(content: &str) -> i64 { + content + .chars() + .filter(|value| !value.is_whitespace()) + .count() as i64 +} + +fn required_text(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.into() + } else { + value.chars().take(120).collect() + } +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary -= 1; + } + value[..boundary].to_owned() +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn db_read(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_IMPORT_DATABASE_READ_FAILED: {error}") +} + +fn db_write(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_IMPORT_DATABASE_WRITE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn splits_novel_outline_and_script_headings() { + let novel = "作者:冰朔\n第001章 起点\n正文一\n第002章 转折\n正文二"; + let (_, chapters) = split_sections(novel, "NOVEL").unwrap(); + assert_eq!(chapters.len(), 2); + assert_eq!(chapters[1].title, "第002章 转折"); + let script = "第1集:\n1-1 日 外 广场\n△人物入场\n第2集\n2-1 夜 内 房间"; + let (_, episodes) = split_sections(script, "SCRIPT").unwrap(); + assert_eq!(episodes.len(), 2); + assert_eq!(episodes[0].scene_count, 1); + } + + #[test] + fn import_auto_formats_and_keeps_the_source_version() { + let temp = tempdir().unwrap(); + let source = temp.path().join("自动排版小说.txt"); + fs::write(&source, "第1章 开始\n第一段\n第二段\n第2章 后续\n第三段").unwrap(); + let database = temp.path().join("auto-format.sqlite3"); + let preview = stage_document_at(&database, &source).unwrap(); + let receipt = commit_import_at( + &database, + CommitImportInput { + import_id: preview.import_id, + target_mode: "CREATE_NEW".into(), + target_work_id: None, + title: "自动排版小说".into(), + pen_name: "测试作者".into(), + genre: "测试".into(), + }, + ) + .unwrap(); + let connection = super::super::web_novel_workspace::open_database(&database).unwrap(); + let (content, revision): (String, i64) = connection.query_row( + "SELECT content,revision FROM web_novel_chapters WHERE work_id=?1 ORDER BY position LIMIT 1", + [&receipt.work_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap(); + assert_eq!(revision, 2); + assert_eq!(content, "  第一段\n\n  第二段"); + let reasons: Vec = connection.prepare( + "SELECT save_reason FROM web_novel_chapter_versions + WHERE chapter_id=(SELECT chapter_id FROM web_novel_chapters WHERE work_id=?1 ORDER BY position LIMIT 1) + ORDER BY revision" + ).unwrap().query_map([&receipt.work_id], |row| row.get(0)).unwrap() + .collect::, _>>().unwrap(); + assert_eq!(reasons[0], "DOCUMENT_IMPORT_SOURCE"); + assert_eq!(reasons[1], "AUTO_FORMAT_ON_IMPORT"); + } + + #[test] + #[ignore = "explicit real Desktop fixture acceptance"] + fn imports_real_desktop_documents_and_reads_them_back() { + let fixtures = [ + ("HOLOLAKE_REAL_NOVEL", 504usize), + ("HOLOLAKE_REAL_OUTLINE", 50usize), + ("HOLOLAKE_REAL_SCRIPT", 75usize), + ]; + let temp = tempdir().unwrap(); + let database = temp.path().join("real-import.sqlite3"); + for (variable, expected) in fixtures { + let path = std::env::var(variable).expect("real fixture path is required"); + let preview = stage_document_at(&database, Path::new(&path)).unwrap(); + assert_eq!(preview.section_count, expected, "{variable}"); + let receipt = commit_import_at( + &database, + CommitImportInput { + import_id: preview.import_id, + target_mode: "CREATE_NEW".into(), + target_work_id: None, + title: preview.detected_title, + pen_name: preview.detected_pen_name, + genre: preview.detected_genre, + }, + ) + .unwrap(); + let connection = super::super::web_novel_workspace::open_database(&database).unwrap(); + let (count, non_empty, version_count): (i64, i64, i64) = connection + .query_row( + "SELECT COUNT(*), SUM(CASE WHEN LENGTH(content)>0 THEN 1 ELSE 0 END), + (SELECT COUNT(*) FROM web_novel_chapter_versions WHERE work_id=?1) + FROM web_novel_chapters WHERE work_id=?1 AND archived=0", + [&receipt.work_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(count as usize, expected); + assert_eq!(non_empty as usize, expected); + assert_eq!(version_count as usize, expected * 2); + println!("{}", serde_json::to_string(&receipt).unwrap()); + } + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/web_novel_modules.rs b/product-source/hololake-native-desktop/src-tauri/src/web_novel_modules.rs new file mode 100644 index 000000000..c7780b97c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/web_novel_modules.rs @@ -0,0 +1,2004 @@ +//! 网文作者官方模块数据适配器。 +//! +//! 生产安装、签名验证、挂载、自检和回执只由 `module_package_runtime` 执行。 +//! 本文件保留 0.4.1 安装器作为迁移回归测试夹具;它没有编号 IPC 路由,不能成为 +//! 第二套生产生命周期权威。 + +#![allow(dead_code)] + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; + +const BASE: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001"; +const ADAPTER: &str = "web-novel-workbench-v1"; + +fn require_module_active(app: &AppHandle, module_number: &str) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, module_number, ADAPTER) +} + +const OUTLINE: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001"; +const GRID: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001"; +const STORYWORLD: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001"; +const DELIVERY: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001"; +const VERSION: &str = "1.0.0"; + +struct Package { + module_id: &'static str, + name: &'static str, + description: &'static str, + sha256: &'static str, + bytes: &'static [u8], +} + +fn packages() -> [Package; 4] { + [ + Package { + module_id: OUTLINE, + name: "作品结构与大纲追踪", + description: "场景、情节拍、目标冲突结果、钩子与伏笔追踪。", + sha256: "6d31d3f530862000bd84a8f5dc73bcb9cf0d5738219f7a287562d4f4bcfa5318", + bytes: include_bytes!( + "../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json" + ), + }, + Package { + module_id: GRID, + name: "多维情节表与情节板", + description: "由同一场景对象生成可编辑表格、自定义字段与分组情节板。", + sha256: "2e34e0337c51dbbdd1efdd807be71e69a0cb2a1745624e1f51b452b7b54f1553", + bytes: include_bytes!( + "../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json" + ), + }, + Package { + module_id: STORYWORLD, + name: "时间线与故事资料库", + description: "故事时间、场景与人物地点物品的真实关系投影。", + sha256: "d486719500ddfc08054d9c9bb6d3683afe5547a2b24c9b04d0f899ac66094d2f", + bytes: include_bytes!( + "../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json" + ), + }, + Package { + module_id: DELIVERY, + name: "高级版本与交付", + description: "章节版本读回与恢复,以及 TXT、DOCX、EPUB、JSON 真实导出。", + sha256: "4af1d4610826edd29759bd0dc12b4b0c04fd2ebebb4ebc362d47d762810989e8", + bytes: include_bytes!( + "../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json" + ), + }, + ] +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorModuleDescriptor { + pub module_id: String, + pub name: String, + pub description: String, + pub version: String, + pub package_sha256: String, + pub install_state: String, + pub self_test_state: String, + pub installed_at_unix_ms: Option, + pub mounted_at_unix_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorModuleReceipt { + pub receipt_id: String, + pub module_id: String, + pub action: String, + pub state: String, + pub detail: String, + pub receipt_hash: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorModuleMarketplaceSnapshot { + pub state: &'static str, + pub modules: Vec, + pub recent_receipts: Vec, + pub storage: &'static str, + pub active_action_controller: &'static str, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ModuleActionInput { + pub module_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadAuthorModuleDataInput { + pub work_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorScene { + pub scene_id: String, + pub work_id: String, + pub chapter_id: String, + pub title: String, + pub position: i64, + pub synopsis: String, + pub status: String, + pub goal: String, + pub conflict: String, + pub outcome: String, + pub hook: String, + pub emotional_point: String, + pub target_words: i64, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorBeat { + pub beat_id: String, + pub scene_id: String, + pub position: i64, + pub title: String, + pub note: String, + pub status: String, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StoryFieldDefinition { + pub field_id: String, + pub work_id: String, + pub label: String, + pub field_type: String, + pub options: Vec, + pub position: i64, + pub revision: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StoryFieldValue { + pub field_id: String, + pub scene_id: String, + pub value: String, + pub revision: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StoryTimelineEvent { + pub event_id: String, + pub work_id: String, + pub scene_id: Option, + pub entity_id: Option, + pub title: String, + pub calendar_kind: String, + pub story_day: Option, + pub date_text: String, + pub time_text: String, + pub duration_minutes: i64, + pub description: String, + pub revision: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SceneEntityLink { + pub link_id: String, + pub work_id: String, + pub scene_id: String, + pub entity_id: String, + pub role: String, + pub note: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChapterVersionSummary { + pub version_id: String, + pub chapter_id: String, + pub chapter_title: String, + pub revision: i64, + pub word_count: i64, + pub save_reason: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorModuleData { + pub work_id: String, + pub scenes: Vec, + pub beats: Vec, + pub field_definitions: Vec, + pub field_values: Vec, + pub timeline_events: Vec, + pub scene_entity_links: Vec, + pub chapter_versions: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertAuthorSceneInput { + pub work_id: String, + pub scene_id: Option, + pub chapter_id: String, + pub title: String, + pub position: Option, + pub synopsis: String, + pub status: String, + pub goal: String, + pub conflict: String, + pub outcome: String, + pub hook: String, + pub emotional_point: String, + pub target_words: i64, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertAuthorBeatInput { + pub scene_id: String, + pub beat_id: Option, + pub title: String, + pub note: String, + pub status: String, + pub position: Option, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertStoryFieldDefinitionInput { + pub work_id: String, + pub field_id: Option, + pub label: String, + pub field_type: String, + pub options: Vec, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertStoryFieldValueInput { + pub field_id: String, + pub scene_id: String, + pub value: String, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertTimelineEventInput { + pub work_id: String, + pub event_id: Option, + pub scene_id: Option, + pub entity_id: Option, + pub title: String, + pub calendar_kind: String, + pub story_day: Option, + pub date_text: String, + pub time_text: String, + pub duration_minutes: i64, + pub description: String, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LinkSceneEntityInput { + pub work_id: String, + pub scene_id: String, + pub entity_id: String, + pub role: String, + pub note: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RestoreChapterVersionInput { + pub version_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExportAuthorDeliveryInput { + pub work_id: String, + pub format: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorDeliveryReceipt { + pub format: String, + pub path: String, + pub bytes: u64, + pub chapter_count: usize, + pub package_sha256: String, +} + +pub async fn get_web_novel_author_module_marketplace( + app: AppHandle, +) -> Result { + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || marketplace_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn install_web_novel_author_module( + app: AppHandle, + input: ModuleActionInput, +) -> Result { + let database = super::web_novel_workspace::workspace_database(&app)?; + let cache = crate::authenticated_storage::account_storage_root(&app, "web-novel-modules-v1")?; + tauri::async_runtime::spawn_blocking(move || install_at(&database, &cache, &input.module_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn mount_web_novel_author_module( + app: AppHandle, + input: ModuleActionInput, +) -> Result { + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || set_mount_at(&database, &input.module_id, true)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn unmount_web_novel_author_module( + app: AppHandle, + input: ModuleActionInput, +) -> Result { + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || set_mount_at(&database, &input.module_id, false)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn uninstall_web_novel_author_module( + app: AppHandle, + input: ModuleActionInput, +) -> Result { + let database = super::web_novel_workspace::workspace_database(&app)?; + let cache = crate::authenticated_storage::account_storage_root(&app, "web-novel-modules-v1")?; + tauri::async_runtime::spawn_blocking(move || uninstall_at(&database, &cache, &input.module_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn get_web_novel_author_module_data( + app: AppHandle, + input: ReadAuthorModuleDataInput, +) -> Result { + require_module_active(&app, BASE)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || module_data_at(&database, &input.work_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_author_scene( + app: AppHandle, + input: UpsertAuthorSceneInput, +) -> Result { + require_module_active(&app, OUTLINE)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_scene_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_author_beat( + app: AppHandle, + input: UpsertAuthorBeatInput, +) -> Result { + require_module_active(&app, OUTLINE)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_beat_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_story_field_definition( + app: AppHandle, + input: UpsertStoryFieldDefinitionInput, +) -> Result { + require_module_active(&app, GRID)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_field_definition_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_story_field_value( + app: AppHandle, + input: UpsertStoryFieldValueInput, +) -> Result { + require_module_active(&app, GRID)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_field_value_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_timeline_event( + app: AppHandle, + input: UpsertTimelineEventInput, +) -> Result { + require_module_active(&app, STORYWORLD)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_timeline_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn link_web_novel_scene_entity( + app: AppHandle, + input: LinkSceneEntityInput, +) -> Result { + require_module_active(&app, STORYWORLD)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || link_scene_entity_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn restore_web_novel_chapter_version( + app: AppHandle, + input: RestoreChapterVersionInput, +) -> Result { + require_module_active(&app, DELIVERY)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || restore_version_at(&database, &input.version_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? +} + +pub async fn export_web_novel_author_delivery( + app: AppHandle, + input: ExportAuthorDeliveryInput, +) -> Result, String> { + require_module_active(&app, DELIVERY)?; + let format = normalize_delivery_format(&input.format)?; + let database = super::web_novel_workspace::workspace_database(&app)?; + let title = read_work_title(&database, &input.work_id)?; + let extension = format.to_ascii_lowercase(); + let selected = app + .dialog() + .file() + .set_title("导出作者交付包") + .set_file_name(format!("{}.{}", safe_filename(&title), extension)) + .add_filter(&format, &[&extension]) + .blocking_save_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_PATH_INVALID: {error}"))?; + let work_id = input.work_id; + tauri::async_runtime::spawn_blocking(move || { + export_delivery_at(&database, &work_id, &format, &path) + }) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))? + .map(Some) +} + +fn ensure_schema(connection: &Connection) -> Result<(), String> { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS web_novel_module_installations( + module_id TEXT PRIMARY KEY NOT NULL, version TEXT NOT NULL, package_sha256 TEXT NOT NULL, + install_state TEXT NOT NULL, self_test_state TEXT NOT NULL, + installed_at_unix_ms INTEGER NOT NULL, mounted_at_unix_ms INTEGER, updated_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_module_receipts( + receipt_id TEXT PRIMARY KEY NOT NULL, module_id TEXT NOT NULL, action TEXT NOT NULL, + state TEXT NOT NULL, detail TEXT NOT NULL, previous_hash TEXT NOT NULL, + receipt_hash TEXT NOT NULL UNIQUE, created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_module_selftest(probe_id TEXT PRIMARY KEY NOT NULL, module_id TEXT NOT NULL, created_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS web_novel_scenes( + scene_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + title TEXT NOT NULL, position INTEGER NOT NULL, synopsis TEXT NOT NULL, status TEXT NOT NULL, + goal TEXT NOT NULL, conflict TEXT NOT NULL, outcome TEXT NOT NULL, hook TEXT NOT NULL, + emotional_point TEXT NOT NULL, target_words INTEGER NOT NULL, revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(chapter_id, position) + ); + CREATE TABLE IF NOT EXISTS web_novel_beats( + beat_id TEXT PRIMARY KEY NOT NULL, scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE, + position INTEGER NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL, status TEXT NOT NULL, + revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(scene_id, position) + ); + CREATE TABLE IF NOT EXISTS web_novel_story_fields( + field_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + label TEXT NOT NULL, field_type TEXT NOT NULL, options_json TEXT NOT NULL, position INTEGER NOT NULL, + revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(work_id, label) + ); + CREATE TABLE IF NOT EXISTS web_novel_story_field_values( + field_id TEXT NOT NULL REFERENCES web_novel_story_fields(field_id) ON DELETE CASCADE, + scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE, + value TEXT NOT NULL, revision INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL, + PRIMARY KEY(field_id, scene_id) + ); + CREATE TABLE IF NOT EXISTS web_novel_timeline_events( + event_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + scene_id TEXT REFERENCES web_novel_scenes(scene_id) ON DELETE SET NULL, + entity_id TEXT REFERENCES web_novel_entities(entity_id) ON DELETE SET NULL, + title TEXT NOT NULL, calendar_kind TEXT NOT NULL, story_day INTEGER, date_text TEXT NOT NULL, + time_text TEXT NOT NULL, duration_minutes INTEGER NOT NULL, description TEXT NOT NULL, + revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS web_novel_scene_entities( + link_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE, + entity_id TEXT NOT NULL REFERENCES web_novel_entities(entity_id) ON DELETE CASCADE, + role TEXT NOT NULL, note TEXT NOT NULL, created_at_unix_ms INTEGER NOT NULL, + UNIQUE(scene_id, entity_id, role) + ); + CREATE INDEX IF NOT EXISTS idx_web_novel_scenes_work ON web_novel_scenes(work_id, chapter_id, position); + CREATE INDEX IF NOT EXISTS idx_web_novel_beats_scene ON web_novel_beats(scene_id, position); + CREATE INDEX IF NOT EXISTS idx_web_novel_timeline_work ON web_novel_timeline_events(work_id, story_day, date_text, time_text);" + ).map_err(db_write) +} + +fn marketplace_at(path: &Path) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let modules = packages().into_iter().map(|package| { + let installed = connection.query_row( + "SELECT install_state,self_test_state,installed_at_unix_ms,mounted_at_unix_ms FROM web_novel_module_installations WHERE module_id=?1", + [package.module_id], |row| Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,i64>(2)?,row.get::<_,Option>(3)?)) + ).optional().map_err(db_read)?; + Ok(AuthorModuleDescriptor { + module_id: package.module_id.into(), name: package.name.into(), description: package.description.into(), version: VERSION.into(), package_sha256: package.sha256.into(), + install_state: installed.as_ref().map(|value| value.0.clone()).unwrap_or_else(|| "AVAILABLE".into()), + self_test_state: installed.as_ref().map(|value| value.1.clone()).unwrap_or_else(|| "NOT_RUN".into()), + installed_at_unix_ms: installed.as_ref().map(|value| value.2), mounted_at_unix_ms: installed.and_then(|value| value.3), + }) + }).collect::, String>>()?; + let mut statement = connection.prepare("SELECT receipt_id,module_id,action,state,detail,receipt_hash,created_at_unix_ms FROM web_novel_module_receipts ORDER BY created_at_unix_ms DESC,rowid DESC LIMIT 30").map_err(db_read)?; + let recent_receipts = statement + .query_map([], |row| { + Ok(AuthorModuleReceipt { + receipt_id: row.get(0)?, + module_id: row.get(1)?, + action: row.get(2)?, + state: row.get(3)?, + detail: row.get(4)?, + receipt_hash: row.get(5)?, + created_at_unix_ms: row.get(6)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + Ok(AuthorModuleMarketplaceSnapshot { + state: "READY", + modules, + recent_receipts, + storage: "CURRENT_ACCOUNT_SQLITE_AND_PRIVATE_MODULE_CACHE", + active_action_controller: "ONE_DESKTOP_ACTION_AT_A_TIME", + }) +} + +fn package_by_id(module_id: &str) -> Result { + packages() + .into_iter() + .find(|item| item.module_id == module_id) + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_MODULE_NOT_OFFICIAL".into()) +} + +fn install_at( + path: &Path, + cache: &Path, + module_id: &str, +) -> Result { + let package = package_by_id(module_id)?; + let actual = sha256(package.bytes); + if actual != package.sha256 { + return Err("HOLOLAKE_WEBNOVEL_MODULE_PACKAGE_HASH_MISMATCH".into()); + } + let target = module_cache_path(cache, module_id)?.join(VERSION); + fs::create_dir_all(&target).map_err(io_error)?; + set_private_permissions(&target)?; + let temp = target.join("module.json.pending"); + fs::write(&temp, package.bytes).map_err(io_error)?; + fs::rename(&temp, target.join("module.json")).map_err(io_error)?; + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let now = now_ms(); + tx.execute("INSERT INTO web_novel_module_installations(module_id,version,package_sha256,install_state,self_test_state,installed_at_unix_ms,mounted_at_unix_ms,updated_at_unix_ms) VALUES(?1,?2,?3,'INSTALLED','RUNNING',?4,NULL,?4) ON CONFLICT(module_id) DO UPDATE SET version=excluded.version,package_sha256=excluded.package_sha256,install_state='INSTALLED',self_test_state='RUNNING',updated_at_unix_ms=excluded.updated_at_unix_ms", params![module_id, VERSION, actual, now]).map_err(db_write)?; + run_self_test(&tx, module_id)?; + tx.execute("UPDATE web_novel_module_installations SET self_test_state='PASS',updated_at_unix_ms=?1 WHERE module_id=?2", params![now_ms(), module_id]).map_err(db_write)?; + append_receipt( + &tx, + module_id, + "INSTALL", + "PASS", + "不可变官方包散列核验、自检与账号缓存写入完成", + )?; + tx.commit().map_err(db_write)?; + marketplace_at(path) +} + +fn set_mount_at( + path: &Path, + module_id: &str, + mounted: bool, +) -> Result { + package_by_id(module_id)?; + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let current: Option<(String,String)> = tx.query_row("SELECT install_state,self_test_state FROM web_novel_module_installations WHERE module_id=?1", [module_id], |row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(db_read)?; + let Some((_, self_test)) = current else { + return Err("HOLOLAKE_WEBNOVEL_MODULE_NOT_INSTALLED".into()); + }; + if self_test != "PASS" { + return Err("HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_REQUIRED".into()); + } + let state = if mounted { "MOUNTED" } else { "INSTALLED" }; + let mounted_at = if mounted { Some(now_ms()) } else { None }; + tx.execute("UPDATE web_novel_module_installations SET install_state=?1,mounted_at_unix_ms=?2,updated_at_unix_ms=?3 WHERE module_id=?4", params![state,mounted_at,now_ms(),module_id]).map_err(db_write)?; + append_receipt( + &tx, + module_id, + if mounted { "MOUNT" } else { "UNMOUNT" }, + "PASS", + if mounted { + "模块已挂载到当前账号网文频道" + } else { + "模块已停止投影,用户数据保持" + }, + )?; + tx.commit().map_err(db_write)?; + marketplace_at(path) +} + +fn uninstall_at( + path: &Path, + cache: &Path, + module_id: &str, +) -> Result { + package_by_id(module_id)?; + let target = module_cache_path(cache, module_id)?; + if target.exists() { + fs::remove_dir_all(&target).map_err(io_error)?; + } + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + tx.execute( + "DELETE FROM web_novel_module_installations WHERE module_id=?1", + [module_id], + ) + .map_err(db_write)?; + append_receipt( + &tx, + module_id, + "UNINSTALL", + "PASS", + "只清除可重新取得的模块程序缓存;作品、模块数据和历史回执保持", + )?; + tx.commit().map_err(db_write)?; + marketplace_at(path) +} + +fn run_self_test(connection: &Connection, module_id: &str) -> Result<(), String> { + let required_table = match module_id { + OUTLINE => "web_novel_scenes", + GRID => "web_novel_story_fields", + STORYWORLD => "web_novel_timeline_events", + DELIVERY => "web_novel_chapter_versions", + _ => return Err("HOLOLAKE_WEBNOVEL_MODULE_NOT_OFFICIAL".into()), + }; + let exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [required_table], + |row| row.get(0), + ) + .map_err(db_read)?; + if !exists { + return Err(format!( + "HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_SCHEMA_MISSING:{required_table}" + )); + } + let probe = format!("WN-MOD-PROBE-{}", Uuid::new_v4()); + connection.execute("INSERT INTO web_novel_module_selftest(probe_id,module_id,created_at_unix_ms) VALUES(?1,?2,?3)", params![probe,module_id,now_ms()]).map_err(db_write)?; + let readback: String = connection + .query_row( + "SELECT module_id FROM web_novel_module_selftest WHERE probe_id=?1", + [&probe], + |row| row.get(0), + ) + .map_err(db_read)?; + connection + .execute( + "DELETE FROM web_novel_module_selftest WHERE probe_id=?1", + [&probe], + ) + .map_err(db_write)?; + if readback != module_id { + return Err("HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_READBACK_FAILED".into()); + } + Ok(()) +} + +fn append_receipt( + connection: &Connection, + module_id: &str, + action: &str, + state: &str, + detail: &str, +) -> Result<(), String> { + let previous: String = connection.query_row("SELECT receipt_hash FROM web_novel_module_receipts ORDER BY created_at_unix_ms DESC,rowid DESC LIMIT 1", [], |row| row.get(0)).optional().map_err(db_read)?.unwrap_or_default(); + let receipt_id = format!("WN-MOD-RCPT-{}", Uuid::new_v4()); + let now = now_ms(); + let hash = sha256( + format!("{previous}\0{receipt_id}\0{module_id}\0{action}\0{state}\0{detail}\0{now}") + .as_bytes(), + ); + connection.execute("INSERT INTO web_novel_module_receipts(receipt_id,module_id,action,state,detail,previous_hash,receipt_hash,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![receipt_id,module_id,action,state,detail,previous,hash,now]).map_err(db_write)?; + Ok(()) +} + +#[cfg(test)] +fn require_mounted(connection: &Connection, module_id: &str) -> Result<(), String> { + let state: Option = connection.query_row("SELECT install_state FROM web_novel_module_installations WHERE module_id=?1 AND self_test_state='PASS'", [module_id], |row| row.get(0)).optional().map_err(db_read)?; + if state.as_deref() == Some("MOUNTED") { + Ok(()) + } else { + Err(format!("HOLOLAKE_WEBNOVEL_MODULE_NOT_MOUNTED:{module_id}")) + } +} + +#[cfg(not(test))] +fn require_mounted(_connection: &Connection, module_id: &str) -> Result<(), String> { + if matches!(module_id, OUTLINE | GRID | STORYWORLD | DELIVERY) { + // The public adapter entry already performed an exact-number ACTIVE check + // against the shared signed package runtime. Never consult the legacy + // installation table in production. + Ok(()) + } else { + Err("HOLOLAKE_WEBNOVEL_MODULE_UNKNOWN".into()) + } +} + +fn module_cache_path(cache: &Path, module_id: &str) -> Result { + if !packages().iter().any(|item| item.module_id == module_id) + || module_id.contains('/') + || module_id.contains("..") + { + return Err("HOLOLAKE_WEBNOVEL_MODULE_ID_INVALID".into()); + } + Ok(cache.join("official").join(module_id)) +} + +fn set_private_permissions(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(io_error)?; + } + Ok(()) +} + +fn module_data_at(path: &Path, work_id: &str) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + let work_exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)", + [work_id], + |row| row.get(0), + ) + .map_err(db_read)?; + if !work_exists { + return Err("HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into()); + } + + let mut scene_statement = connection.prepare( + "SELECT scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome,hook, + emotional_point,target_words,revision,updated_at_unix_ms + FROM web_novel_scenes WHERE work_id=?1 AND archived=0 ORDER BY chapter_id,position" + ).map_err(db_read)?; + let scenes = scene_statement + .query_map([work_id], |row| { + Ok(AuthorScene { + scene_id: row.get(0)?, + work_id: row.get(1)?, + chapter_id: row.get(2)?, + title: row.get(3)?, + position: row.get(4)?, + synopsis: row.get(5)?, + status: row.get(6)?, + goal: row.get(7)?, + conflict: row.get(8)?, + outcome: row.get(9)?, + hook: row.get(10)?, + emotional_point: row.get(11)?, + target_words: row.get(12)?, + revision: row.get(13)?, + updated_at_unix_ms: row.get(14)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut beat_statement = connection.prepare( + "SELECT b.beat_id,b.scene_id,b.position,b.title,b.note,b.status,b.revision,b.updated_at_unix_ms + FROM web_novel_beats b JOIN web_novel_scenes s ON s.scene_id=b.scene_id + WHERE s.work_id=?1 AND b.archived=0 AND s.archived=0 ORDER BY s.chapter_id,s.position,b.position" + ).map_err(db_read)?; + let beats = beat_statement + .query_map([work_id], |row| { + Ok(AuthorBeat { + beat_id: row.get(0)?, + scene_id: row.get(1)?, + position: row.get(2)?, + title: row.get(3)?, + note: row.get(4)?, + status: row.get(5)?, + revision: row.get(6)?, + updated_at_unix_ms: row.get(7)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut field_statement = connection + .prepare( + "SELECT field_id,work_id,label,field_type,options_json,position,revision + FROM web_novel_story_fields WHERE work_id=?1 AND archived=0 ORDER BY position,label", + ) + .map_err(db_read)?; + let field_definitions = field_statement + .query_map([work_id], |row| { + let options_json: String = row.get(4)?; + Ok(StoryFieldDefinition { + field_id: row.get(0)?, + work_id: row.get(1)?, + label: row.get(2)?, + field_type: row.get(3)?, + options: serde_json::from_str(&options_json).unwrap_or_default(), + position: row.get(5)?, + revision: row.get(6)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut value_statement = connection.prepare( + "SELECT v.field_id,v.scene_id,v.value,v.revision FROM web_novel_story_field_values v + JOIN web_novel_story_fields f ON f.field_id=v.field_id WHERE f.work_id=?1 AND f.archived=0" + ).map_err(db_read)?; + let field_values = value_statement + .query_map([work_id], |row| { + Ok(StoryFieldValue { + field_id: row.get(0)?, + scene_id: row.get(1)?, + value: row.get(2)?, + revision: row.get(3)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut timeline_statement = connection.prepare( + "SELECT event_id,work_id,scene_id,entity_id,title,calendar_kind,story_day,date_text,time_text, + duration_minutes,description,revision FROM web_novel_timeline_events + WHERE work_id=?1 AND archived=0 ORDER BY COALESCE(story_day,9223372036854775807),date_text,time_text,title" + ).map_err(db_read)?; + let timeline_events = timeline_statement + .query_map([work_id], |row| { + Ok(StoryTimelineEvent { + event_id: row.get(0)?, + work_id: row.get(1)?, + scene_id: row.get(2)?, + entity_id: row.get(3)?, + title: row.get(4)?, + calendar_kind: row.get(5)?, + story_day: row.get(6)?, + date_text: row.get(7)?, + time_text: row.get(8)?, + duration_minutes: row.get(9)?, + description: row.get(10)?, + revision: row.get(11)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut link_statement = connection + .prepare( + "SELECT link_id,work_id,scene_id,entity_id,role,note FROM web_novel_scene_entities + WHERE work_id=?1 ORDER BY scene_id,role,entity_id", + ) + .map_err(db_read)?; + let scene_entity_links = link_statement + .query_map([work_id], |row| { + Ok(SceneEntityLink { + link_id: row.get(0)?, + work_id: row.get(1)?, + scene_id: row.get(2)?, + entity_id: row.get(3)?, + role: row.get(4)?, + note: row.get(5)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + let mut version_statement = connection.prepare( + "SELECT v.version_id,v.chapter_id,c.title,v.revision,v.word_count,v.save_reason,v.created_at_unix_ms + FROM web_novel_chapter_versions v JOIN web_novel_chapters c ON c.chapter_id=v.chapter_id + WHERE v.work_id=?1 AND c.archived=0 ORDER BY v.created_at_unix_ms DESC,v.revision DESC LIMIT 500" + ).map_err(db_read)?; + let chapter_versions = version_statement + .query_map([work_id], |row| { + Ok(ChapterVersionSummary { + version_id: row.get(0)?, + chapter_id: row.get(1)?, + chapter_title: row.get(2)?, + revision: row.get(3)?, + word_count: row.get(4)?, + save_reason: row.get(5)?, + created_at_unix_ms: row.get(6)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + + Ok(AuthorModuleData { + work_id: work_id.into(), + scenes, + beats, + field_definitions, + field_values, + timeline_events, + scene_entity_links, + chapter_versions, + }) +} + +fn upsert_scene_at(path: &Path, input: UpsertAuthorSceneInput) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, OUTLINE)?; + let title = required(&input.title, "HOLOLAKE_WEBNOVEL_SCENE_TITLE_REQUIRED")?; + let status = normalized_enum( + &input.status, + &["PLANNED", "DRAFTING", "DONE", "REVISE"], + "PLANNED", + )?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let chapter_work: Option = tx + .query_row( + "SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [&input.chapter_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + if chapter_work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_SCENE_CHAPTER_WORK_MISMATCH".into()); + } + let now = now_ms(); + let scene_id = input + .scene_id + .unwrap_or_else(|| format!("WN-SCENE-{}", Uuid::new_v4())); + let current: Option = tx + .query_row( + "SELECT revision FROM web_novel_scenes WHERE scene_id=?1", + [&scene_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + let revision = match current { + Some(value) => { + if input.expected_revision != Some(value) { + return Err("HOLOLAKE_WEBNOVEL_SCENE_REVISION_CONFLICT".into()); + } + tx.execute( + "UPDATE web_novel_scenes SET chapter_id=?1,title=?2,position=?3,synopsis=?4,status=?5,goal=?6, + conflict=?7,outcome=?8,hook=?9,emotional_point=?10,target_words=?11,revision=revision+1, + updated_at_unix_ms=?12 WHERE scene_id=?13 AND work_id=?14", + params![input.chapter_id,title,input.position.unwrap_or(0),input.synopsis,status,input.goal,input.conflict,input.outcome,input.hook,input.emotional_point,input.target_words.max(0),now,scene_id,input.work_id] + ).map_err(db_write)?; + value + 1 + } + None => { + let position = match input.position { Some(value) => value, None => tx.query_row( + "SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_scenes WHERE chapter_id=?1 AND archived=0", [&input.chapter_id], |row| row.get(0) + ).map_err(db_read)? }; + tx.execute( + "INSERT INTO web_novel_scenes(scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome, + hook,emotional_point,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,1,?14,?14,0)", + params![scene_id,input.work_id,input.chapter_id,title,position,input.synopsis,status,input.goal,input.conflict,input.outcome,input.hook,input.emotional_point,input.target_words.max(0),now] + ).map_err(db_write)?; + 1 + } + }; + let scene = tx.query_row( + "SELECT scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome,hook,emotional_point,target_words,revision,updated_at_unix_ms FROM web_novel_scenes WHERE scene_id=?1", + [&scene_id], |row| Ok(AuthorScene { scene_id: row.get(0)?, work_id: row.get(1)?, chapter_id: row.get(2)?, title: row.get(3)?, position: row.get(4)?, synopsis: row.get(5)?, status: row.get(6)?, goal: row.get(7)?, conflict: row.get(8)?, outcome: row.get(9)?, hook: row.get(10)?, emotional_point: row.get(11)?, target_words: row.get(12)?, revision: row.get(13)?, updated_at_unix_ms: row.get(14)? }) + ).map_err(db_read)?; + debug_assert_eq!(scene.revision, revision); + tx.commit().map_err(db_write)?; + Ok(scene) +} + +fn upsert_beat_at(path: &Path, input: UpsertAuthorBeatInput) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, OUTLINE)?; + let title = required(&input.title, "HOLOLAKE_WEBNOVEL_BEAT_TITLE_REQUIRED")?; + let status = normalized_enum( + &input.status, + &["PLANNED", "DRAFTING", "DONE", "REVISE"], + "PLANNED", + )?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let scene_exists: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM web_novel_scenes WHERE scene_id=?1 AND archived=0)", + [&input.scene_id], + |row| row.get(0), + ) + .map_err(db_read)?; + if !scene_exists { + return Err("HOLOLAKE_WEBNOVEL_SCENE_NOT_FOUND".into()); + } + let now = now_ms(); + let beat_id = input + .beat_id + .unwrap_or_else(|| format!("WN-BEAT-{}", Uuid::new_v4())); + let current: Option = tx + .query_row( + "SELECT revision FROM web_novel_beats WHERE beat_id=?1", + [&beat_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + match current { + Some(value) => { + if input.expected_revision != Some(value) { + return Err("HOLOLAKE_WEBNOVEL_BEAT_REVISION_CONFLICT".into()); + } + tx.execute("UPDATE web_novel_beats SET title=?1,note=?2,status=?3,position=?4,revision=revision+1,updated_at_unix_ms=?5 WHERE beat_id=?6 AND scene_id=?7", params![title,input.note,status,input.position.unwrap_or(0),now,beat_id,input.scene_id]).map_err(db_write)?; + } + None => { + let position = match input.position { Some(value) => value, None => tx.query_row("SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_beats WHERE scene_id=?1 AND archived=0", [&input.scene_id], |row| row.get(0)).map_err(db_read)? }; + tx.execute("INSERT INTO web_novel_beats(beat_id,scene_id,position,title,note,status,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,1,?7,?7,0)", params![beat_id,input.scene_id,position,title,input.note,status,now]).map_err(db_write)?; + } + } + let beat = tx.query_row("SELECT beat_id,scene_id,position,title,note,status,revision,updated_at_unix_ms FROM web_novel_beats WHERE beat_id=?1", [&beat_id], |row| Ok(AuthorBeat { beat_id: row.get(0)?, scene_id: row.get(1)?, position: row.get(2)?, title: row.get(3)?, note: row.get(4)?, status: row.get(5)?, revision: row.get(6)?, updated_at_unix_ms: row.get(7)? })).map_err(db_read)?; + tx.commit().map_err(db_write)?; + Ok(beat) +} + +fn upsert_field_definition_at( + path: &Path, + input: UpsertStoryFieldDefinitionInput, +) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, GRID)?; + let label = required(&input.label, "HOLOLAKE_WEBNOVEL_STORY_FIELD_LABEL_REQUIRED")?; + let field_type = normalized_enum( + &input.field_type, + &["TEXT", "NUMBER", "SELECT", "BOOLEAN"], + "TEXT", + )?; + let options_json = serde_json::to_string(&input.options) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_STORY_FIELD_OPTIONS_INVALID: {error}"))?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let field_id = input + .field_id + .unwrap_or_else(|| format!("WN-FIELD-{}", Uuid::new_v4())); + let current: Option = tx + .query_row( + "SELECT revision FROM web_novel_story_fields WHERE field_id=?1", + [&field_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + match current { + Some(value) => { + if input.expected_revision != Some(value) { + return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_REVISION_CONFLICT".into()); + } + tx.execute("UPDATE web_novel_story_fields SET label=?1,field_type=?2,options_json=?3,revision=revision+1,updated_at_unix_ms=?4 WHERE field_id=?5 AND work_id=?6", params![label,field_type,options_json,now_ms(),field_id,input.work_id]).map_err(db_write)?; + } + None => { + let position: i64 = tx.query_row("SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_story_fields WHERE work_id=?1 AND archived=0", [&input.work_id], |row| row.get(0)).map_err(db_read)?; + let now = now_ms(); + tx.execute("INSERT INTO web_novel_story_fields(field_id,work_id,label,field_type,options_json,position,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,1,?7,?7,0)", params![field_id,input.work_id,label,field_type,options_json,position,now]).map_err(db_write)?; + } + } + let definition = tx.query_row("SELECT field_id,work_id,label,field_type,options_json,position,revision FROM web_novel_story_fields WHERE field_id=?1", [&field_id], |row| { let value: String = row.get(4)?; Ok(StoryFieldDefinition { field_id: row.get(0)?, work_id: row.get(1)?, label: row.get(2)?, field_type: row.get(3)?, options: serde_json::from_str(&value).unwrap_or_default(), position: row.get(5)?, revision: row.get(6)? }) }).map_err(db_read)?; + tx.commit().map_err(db_write)?; + Ok(definition) +} + +fn upsert_field_value_at( + path: &Path, + input: UpsertStoryFieldValueInput, +) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, GRID)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let same_work: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM web_novel_story_fields f JOIN web_novel_scenes s ON s.work_id=f.work_id WHERE f.field_id=?1 AND s.scene_id=?2 AND f.archived=0 AND s.archived=0)", params![input.field_id,input.scene_id], |row| row.get(0)).map_err(db_read)?; + if !same_work { + return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_SCENE_MISMATCH".into()); + } + let current: Option = tx + .query_row( + "SELECT revision FROM web_novel_story_field_values WHERE field_id=?1 AND scene_id=?2", + params![input.field_id, input.scene_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + let revision = match current { + Some(value) => { + if input.expected_revision != Some(value) { + return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_VALUE_REVISION_CONFLICT".into()); + } + value + 1 + } + None => 1, + }; + tx.execute("INSERT INTO web_novel_story_field_values(field_id,scene_id,value,revision,updated_at_unix_ms) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(field_id,scene_id) DO UPDATE SET value=excluded.value,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms", params![input.field_id,input.scene_id,input.value,revision,now_ms()]).map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(StoryFieldValue { + field_id: input.field_id, + scene_id: input.scene_id, + value: input.value, + revision, + }) +} + +fn upsert_timeline_at( + path: &Path, + input: UpsertTimelineEventInput, +) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, STORYWORLD)?; + let title = required(&input.title, "HOLOLAKE_WEBNOVEL_TIMELINE_TITLE_REQUIRED")?; + let calendar_kind = normalized_enum( + &input.calendar_kind, + &["STORY_DAY", "DATE", "CUSTOM"], + "STORY_DAY", + )?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + if let Some(scene_id) = input.scene_id.as_deref() { + let work: Option = tx + .query_row( + "SELECT work_id FROM web_novel_scenes WHERE scene_id=?1 AND archived=0", + [scene_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + if work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_TIMELINE_SCENE_MISMATCH".into()); + } + } + if let Some(entity_id) = input.entity_id.as_deref() { + let work: Option = tx + .query_row( + "SELECT work_id FROM web_novel_entities WHERE entity_id=?1 AND archived=0", + [entity_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + if work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_TIMELINE_ENTITY_MISMATCH".into()); + } + } + let event_id = input + .event_id + .unwrap_or_else(|| format!("WN-EVENT-{}", Uuid::new_v4())); + let current: Option = tx + .query_row( + "SELECT revision FROM web_novel_timeline_events WHERE event_id=?1", + [&event_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)?; + let revision = match current { + Some(value) => { + if input.expected_revision != Some(value) { + return Err("HOLOLAKE_WEBNOVEL_TIMELINE_REVISION_CONFLICT".into()); + } + value + 1 + } + None => 1, + }; + let now = now_ms(); + tx.execute("INSERT INTO web_novel_timeline_events(event_id,work_id,scene_id,entity_id,title,calendar_kind,story_day,date_text,time_text,duration_minutes,description,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13,0) ON CONFLICT(event_id) DO UPDATE SET scene_id=excluded.scene_id,entity_id=excluded.entity_id,title=excluded.title,calendar_kind=excluded.calendar_kind,story_day=excluded.story_day,date_text=excluded.date_text,time_text=excluded.time_text,duration_minutes=excluded.duration_minutes,description=excluded.description,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms", params![event_id,input.work_id,input.scene_id,input.entity_id,title,calendar_kind,input.story_day,input.date_text,input.time_text,input.duration_minutes.max(0),input.description,revision,now]).map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(StoryTimelineEvent { + event_id, + work_id: input.work_id, + scene_id: input.scene_id, + entity_id: input.entity_id, + title, + calendar_kind, + story_day: input.story_day, + date_text: input.date_text, + time_text: input.time_text, + duration_minutes: input.duration_minutes.max(0), + description: input.description, + revision, + }) +} + +fn link_scene_entity_at( + path: &Path, + input: LinkSceneEntityInput, +) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, STORYWORLD)?; + let role = required(&input.role, "HOLOLAKE_WEBNOVEL_SCENE_ENTITY_ROLE_REQUIRED")?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let valid: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM web_novel_scenes s JOIN web_novel_entities e ON e.work_id=s.work_id WHERE s.scene_id=?1 AND e.entity_id=?2 AND s.work_id=?3 AND s.archived=0 AND e.archived=0)", params![input.scene_id,input.entity_id,input.work_id], |row| row.get(0)).map_err(db_read)?; + if !valid { + return Err("HOLOLAKE_WEBNOVEL_SCENE_ENTITY_WORK_MISMATCH".into()); + } + let existing: Option = tx.query_row("SELECT link_id FROM web_novel_scene_entities WHERE scene_id=?1 AND entity_id=?2 AND role=?3", params![input.scene_id,input.entity_id,role], |row| row.get(0)).optional().map_err(db_read)?; + let link_id = existing.unwrap_or_else(|| format!("WN-SCENE-ENTITY-{}", Uuid::new_v4())); + tx.execute("INSERT INTO web_novel_scene_entities(link_id,work_id,scene_id,entity_id,role,note,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(scene_id,entity_id,role) DO UPDATE SET note=excluded.note", params![link_id,input.work_id,input.scene_id,input.entity_id,role,input.note,now_ms()]).map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(SceneEntityLink { + link_id, + work_id: input.work_id, + scene_id: input.scene_id, + entity_id: input.entity_id, + role, + note: input.note, + }) +} + +fn restore_version_at(path: &Path, version_id: &str) -> Result { + let mut connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, DELIVERY)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(db_write)?; + let source: Option<(String,String,String,String,String,String,i64)> = tx.query_row( + "SELECT chapter_id,work_id,title,synopsis,content,workflow_status,word_count FROM web_novel_chapter_versions WHERE version_id=?1", + [version_id], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?)) + ).optional().map_err(db_read)?; + let Some((chapter_id, work_id, title, synopsis, content, workflow_status, word_count)) = source + else { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_VERSION_NOT_FOUND".into()); + }; + let current_revision: i64 = tx + .query_row( + "SELECT revision FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [&chapter_id], + |row| row.get(0), + ) + .map_err(db_read)?; + let revision = current_revision + 1; + let now = now_ms(); + tx.execute("UPDATE web_novel_chapters SET title=?1,synopsis=?2,content=?3,workflow_status=?4,word_count=?5,revision=?6,updated_at_unix_ms=?7 WHERE chapter_id=?8", params![title,synopsis,content,workflow_status,word_count,revision,now,chapter_id]).map_err(db_write)?; + let restored_id = format!("WN-VER-{}", Uuid::new_v4()); + tx.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,'RESTORED_FROM_VERSION',?10)", params![restored_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,now]).map_err(db_write)?; + tx.commit().map_err(db_write)?; + Ok(ChapterVersionSummary { + version_id: restored_id, + chapter_id, + chapter_title: title, + revision, + word_count, + save_reason: "RESTORED_FROM_VERSION".into(), + created_at_unix_ms: now, + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DeliveryChapter { + volume_title: String, + chapter_title: String, + synopsis: String, + content: String, + word_count: i64, + revision: i64, +} + +fn normalize_delivery_format(format: &str) -> Result { + let normalized = format.trim().to_ascii_uppercase(); + if ["TXT", "DOCX", "EPUB", "JSON"].contains(&normalized.as_str()) { + Ok(normalized) + } else { + Err("HOLOLAKE_WEBNOVEL_DELIVERY_FORMAT_UNSUPPORTED".into()) + } +} + +fn read_work_title(path: &Path, work_id: &str) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + connection + .query_row( + "SELECT title FROM web_novel_works WHERE work_id=?1 AND archived=0", + [work_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into()) +} + +fn safe_filename(value: &str) -> String { + let result: String = value + .chars() + .map(|character| { + if ['/', '\\', ':', '*', '?', '"', '<', '>', '|'].contains(&character) { + '_' + } else { + character + } + }) + .collect(); + let trimmed = result.trim().trim_end_matches('.'); + if trimmed.is_empty() { + "未命名作品".into() + } else { + trimmed.chars().take(80).collect() + } +} + +fn export_delivery_at( + path: &Path, + work_id: &str, + format: &str, + destination: &Path, +) -> Result { + let connection = super::web_novel_workspace::open_database(path)?; + ensure_schema(&connection)?; + require_mounted(&connection, DELIVERY)?; + let title: String = connection + .query_row( + "SELECT title FROM web_novel_works WHERE work_id=?1 AND archived=0", + [work_id], + |row| row.get(0), + ) + .optional() + .map_err(db_read)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".to_string())?; + let mut statement = connection.prepare( + "SELECT v.title,c.title,c.synopsis,c.content,c.word_count,c.revision FROM web_novel_chapters c + JOIN web_novel_volumes v ON v.volume_id=c.volume_id WHERE c.work_id=?1 AND c.archived=0 AND v.archived=0 + ORDER BY v.position,c.position" + ).map_err(db_read)?; + let chapters = statement + .query_map([work_id], |row| { + Ok(DeliveryChapter { + volume_title: row.get(0)?, + chapter_title: row.get(1)?, + synopsis: row.get(2)?, + content: row.get(3)?, + word_count: row.get(4)?, + revision: row.get(5)?, + }) + }) + .map_err(db_read)? + .collect::, _>>() + .map_err(db_read)?; + if chapters.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_DELIVERY_HAS_NO_CHAPTERS".into()); + } + let normalized = normalize_delivery_format(format)?; + let bytes = match normalized.as_str() { + "TXT" => render_txt(&title, &chapters).into_bytes(), + "JSON" => serde_json::to_vec_pretty(&serde_json::json!({"schema":"hololake.web-novel.delivery.v1","workId":work_id,"title":title,"chapters":chapters})).map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_JSON_FAILED: {error}"))?, + "DOCX" => render_docx(&title, &chapters)?, + "EPUB" => render_epub(&title, work_id, &chapters)?, + _ => unreachable!(), + }; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(io_error)?; + } + let pending = destination.with_extension(format!( + "{}.pending", + destination + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("export") + )); + fs::write(&pending, &bytes).map_err(io_error)?; + fs::rename(&pending, destination).map_err(io_error)?; + Ok(AuthorDeliveryReceipt { + format: normalized, + path: destination.to_string_lossy().into_owned(), + bytes: bytes.len() as u64, + chapter_count: chapters.len(), + package_sha256: sha256(&bytes), + }) +} + +fn render_txt(title: &str, chapters: &[DeliveryChapter]) -> String { + let mut text = String::new(); + text.push_str(title); + text.push_str("\n\n"); + let mut current_volume = ""; + for chapter in chapters { + if chapter.volume_title != current_volume { + current_volume = &chapter.volume_title; + text.push_str(current_volume); + text.push_str("\n\n"); + } + text.push_str(&chapter.chapter_title); + text.push_str("\n\n"); + text.push_str(&chapter.content); + text.push_str("\n\n"); + } + text +} + +fn render_docx(title: &str, chapters: &[DeliveryChapter]) -> Result, String> { + let mut paragraphs = format!( + "{}", + xml_escape(title) + ); + for chapter in chapters { + paragraphs.push_str(&format!( + "{}", + xml_escape(&chapter.chapter_title) + )); + for line in chapter.content.lines() { + paragraphs.push_str(&format!( + "{}", + xml_escape(line) + )); + } + } + let document = format!("{paragraphs}"); + zip_bytes(&[ + ("[Content_Types].xml", "".as_bytes(), true), + ("_rels/.rels", "".as_bytes(), true), + ("word/document.xml", document.as_bytes(), true), + ]) +} + +fn render_epub( + title: &str, + work_id: &str, + chapters: &[DeliveryChapter], +) -> Result, String> { + let mut entries: Vec<(String, Vec, bool)> = Vec::new(); + entries.push(("mimetype".into(), b"application/epub+zip".to_vec(), false)); + entries.push(("META-INF/container.xml".into(), b"".to_vec(), true)); + let mut manifest = String::from(""); + let mut spine = String::new(); + let mut navigation = String::new(); + for (index, chapter) in chapters.iter().enumerate() { + let id = format!("c{}", index + 1); + let href = format!("chapter-{}.xhtml", index + 1); + manifest.push_str(&format!( + "" + )); + spine.push_str(&format!("")); + navigation.push_str(&format!( + "
  • {}
  • ", + xml_escape(&chapter.chapter_title) + )); + let body = chapter + .content + .lines() + .map(|line| format!("

    {}

    ", xml_escape(line))) + .collect::(); + let xhtml = format!("{}

    {}

    {body}", xml_escape(&chapter.chapter_title), xml_escape(&chapter.chapter_title)); + entries.push((format!("OEBPS/{href}"), xhtml.into_bytes(), true)); + } + let opf = format!("{}{}zh-CN2026-08-18T00:00:00Z{manifest}{spine}", xml_escape(work_id), xml_escape(title)); + let nav = format!("{}", xml_escape(title)); + entries.push(("OEBPS/content.opf".into(), opf.into_bytes(), true)); + entries.push(("OEBPS/nav.xhtml".into(), nav.into_bytes(), true)); + let refs: Vec<(&str, &[u8], bool)> = entries + .iter() + .map(|(name, bytes, compressed)| (name.as_str(), bytes.as_slice(), *compressed)) + .collect(); + zip_bytes(&refs) +} + +fn zip_bytes(entries: &[(&str, &[u8], bool)]) -> Result, String> { + let cursor = Cursor::new(Vec::new()); + let mut archive = ZipWriter::new(cursor); + for (name, bytes, compressed) in entries { + let options = FileOptions::default().compression_method(if *compressed { + CompressionMethod::Deflated + } else { + CompressionMethod::Stored + }); + archive + .start_file(*name, options) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_ZIP_FAILED: {error}"))?; + archive.write_all(bytes).map_err(io_error)?; + } + archive + .finish() + .map(|cursor| cursor.into_inner()) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_ZIP_FAILED: {error}")) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} +fn required(value: &str, error: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + Err(error.into()) + } else { + Ok(trimmed.chars().take(200).collect()) + } +} +fn normalized_enum(value: &str, allowed: &[&str], default: &str) -> Result { + let normalized = if value.trim().is_empty() { + default.into() + } else { + value.trim().to_ascii_uppercase() + }; + if allowed.contains(&normalized.as_str()) { + Ok(normalized) + } else { + Err(format!( + "HOLOLAKE_WEBNOVEL_MODULE_ENUM_INVALID:{normalized}" + )) + } +} +fn sha256(value: &[u8]) -> String { + digest(&SHA256, value) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} +fn db_read(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_MODULE_DATABASE_READ_FAILED: {error}") +} +fn db_write(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_MODULE_DATABASE_WRITE_FAILED: {error}") +} +fn io_error(error: std::io::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_MODULE_IO_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use tempfile::tempdir; + + fn seed_work(path: &Path) -> (String, String, String) { + let connection = super::super::web_novel_workspace::open_database(path).unwrap(); + let work_id = "WN-WORK-REAL-ENGINE".to_string(); + let volume_id = "WN-VOL-REAL-ENGINE".to_string(); + let chapter_id = "WN-CH-REAL-ENGINE".to_string(); + let now = now_ms(); + connection.execute("INSERT INTO web_novel_works(work_id,title,pen_name,genre,synopsis,contract_status,copyright_status,workflow_status,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,'真实模块验收作品','冰朔','测试','用于原生引擎验收','UNSIGNED','AUTHOR_OWNED','DRAFT',100000,1,?2,?2,0)", params![work_id,now]).unwrap(); + connection.execute("INSERT INTO web_novel_volumes(volume_id,work_id,title,position,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,'第一卷',0,1,?3,?3,0)", params![volume_id,work_id,now]).unwrap(); + connection.execute("INSERT INTO web_novel_chapters(chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status,scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,'第一章 起点',0,'首章概要','当前第二版正文','DRAFT',NULL,9,2,?4,?4,0)", params![chapter_id,volume_id,work_id,now]).unwrap(); + connection.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES('WN-VER-REAL-1',?1,?2,1,'第一章 起点','首章概要','可恢复的第一版正文','DRAFT',12,'AUTOSAVE',?3)", params![chapter_id,work_id,now-100]).unwrap(); + connection.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES('WN-VER-REAL-2',?1,?2,2,'第一章 起点','首章概要','当前第二版正文','DRAFT',9,'MANUAL_SAVE',?3)", params![chapter_id,work_id,now]).unwrap(); + connection.execute("INSERT INTO web_novel_entities(entity_id,work_id,entity_type,name,aliases_json,description,first_chapter_id,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES('WN-ENTITY-REAL-1',?1,'CHARACTER','主角','[]','真实关联对象',?2,1,?3,?3,0)", params![work_id,chapter_id,now]).unwrap(); + (work_id, chapter_id, "WN-ENTITY-REAL-1".into()) + } + + #[test] + fn installs_mounts_executes_exports_and_preserves_data_on_uninstall() { + let temp = tempdir().unwrap(); + let database = temp.path().join("modules.sqlite3"); + let cache = temp.path().join("cache"); + let (work_id, chapter_id, entity_id) = seed_work(&database); + for package in packages() { + let installed = install_at(&database, &cache, package.module_id).unwrap(); + let descriptor = installed + .modules + .iter() + .find(|item| item.module_id == package.module_id) + .unwrap(); + assert_eq!(descriptor.self_test_state, "PASS"); + assert!(cache + .join("official") + .join(package.module_id) + .join(VERSION) + .join("module.json") + .is_file()); + set_mount_at(&database, package.module_id, true).unwrap(); + } + + let scene = upsert_scene_at( + &database, + UpsertAuthorSceneInput { + work_id: work_id.clone(), + scene_id: None, + chapter_id: chapter_id.clone(), + title: "真实场景".into(), + position: None, + synopsis: "主角踏入未知区域".into(), + status: "PLANNED".into(), + goal: "找到出口".into(), + conflict: "追兵封锁".into(), + outcome: "发现密道".into(), + hook: "密道中有熟悉声音".into(), + emotional_point: "紧张转希望".into(), + target_words: 1600, + expected_revision: None, + }, + ) + .unwrap(); + let beat = upsert_beat_at( + &database, + UpsertAuthorBeatInput { + scene_id: scene.scene_id.clone(), + beat_id: None, + title: "追兵逼近".into(), + note: "脚步声从转角处传来".into(), + status: "PLANNED".into(), + position: None, + expected_revision: None, + }, + ) + .unwrap(); + assert_eq!(beat.position, 0); + let field = upsert_field_definition_at( + &database, + UpsertStoryFieldDefinitionInput { + work_id: work_id.clone(), + field_id: None, + label: "视角人物".into(), + field_type: "SELECT".into(), + options: vec!["主角".into(), "反派".into()], + expected_revision: None, + }, + ) + .unwrap(); + upsert_field_value_at( + &database, + UpsertStoryFieldValueInput { + field_id: field.field_id.clone(), + scene_id: scene.scene_id.clone(), + value: "主角".into(), + expected_revision: None, + }, + ) + .unwrap(); + let timeline = upsert_timeline_at( + &database, + UpsertTimelineEventInput { + work_id: work_id.clone(), + event_id: None, + scene_id: Some(scene.scene_id.clone()), + entity_id: Some(entity_id.clone()), + title: "逃入密道".into(), + calendar_kind: "STORY_DAY".into(), + story_day: Some(1), + date_text: "".into(), + time_text: "深夜".into(), + duration_minutes: 20, + description: "改变主角路线的节点".into(), + expected_revision: None, + }, + ) + .unwrap(); + assert_eq!(timeline.story_day, Some(1)); + link_scene_entity_at( + &database, + LinkSceneEntityInput { + work_id: work_id.clone(), + scene_id: scene.scene_id.clone(), + entity_id, + role: "出场".into(), + note: "时间线主角".into(), + }, + ) + .unwrap(); + + let data = module_data_at(&database, &work_id).unwrap(); + assert_eq!( + ( + data.scenes.len(), + data.beats.len(), + data.field_definitions.len(), + data.field_values.len(), + data.timeline_events.len(), + data.scene_entity_links.len() + ), + (1, 1, 1, 1, 1, 1) + ); + let restored = restore_version_at(&database, "WN-VER-REAL-1").unwrap(); + assert_eq!(restored.revision, 3); + let connection = super::super::web_novel_workspace::open_database(&database).unwrap(); + let restored_content: String = connection + .query_row( + "SELECT content FROM web_novel_chapters WHERE chapter_id=?1", + [&chapter_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(restored_content, "可恢复的第一版正文"); + + for format in ["TXT", "JSON", "DOCX", "EPUB"] { + let destination = temp + .path() + .join(format!("delivery.{}", format.to_ascii_lowercase())); + let receipt = export_delivery_at(&database, &work_id, format, &destination).unwrap(); + assert_eq!(receipt.chapter_count, 1); + assert!(receipt.bytes > 20); + assert_eq!(receipt.package_sha256.len(), 64); + if format == "DOCX" || format == "EPUB" { + let file = fs::File::open(&destination).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + let required = if format == "DOCX" { + "word/document.xml" + } else { + "OEBPS/content.opf" + }; + let mut content = String::new(); + archive + .by_name(required) + .unwrap() + .read_to_string(&mut content) + .unwrap(); + assert!(content.contains("真实模块验收作品") || content.contains("第一章")); + } + } + + uninstall_at(&database, &cache, OUTLINE).unwrap(); + assert!(!cache.join("official").join(OUTLINE).exists()); + let preserved = module_data_at(&database, &work_id).unwrap(); + assert_eq!(preserved.scenes.len(), 1); + let marketplace = marketplace_at(&database).unwrap(); + assert!(marketplace + .recent_receipts + .iter() + .any(|receipt| receipt.action == "UNINSTALL")); + } + + #[test] + fn rejects_data_mutation_until_the_official_module_is_mounted() { + let temp = tempdir().unwrap(); + let database = temp.path().join("gate.sqlite3"); + let (work_id, chapter_id, _) = seed_work(&database); + let error = upsert_scene_at( + &database, + UpsertAuthorSceneInput { + work_id, + scene_id: None, + chapter_id, + title: "不应写入".into(), + position: None, + synopsis: "".into(), + status: "PLANNED".into(), + goal: "".into(), + conflict: "".into(), + outcome: "".into(), + hook: "".into(), + emotional_point: "".into(), + target_words: 0, + expected_revision: None, + }, + ) + .unwrap_err(); + assert!(error.contains("NOT_MOUNTED")); + } + + #[test] + #[ignore = "explicit real Desktop fixture module acceptance"] + fn imports_organizes_and_exports_real_desktop_materials() { + let fixtures = [ + ("HOLOLAKE_REAL_NOVEL", 504usize, "EPUB"), + ("HOLOLAKE_REAL_OUTLINE", 50usize, "DOCX"), + ("HOLOLAKE_REAL_SCRIPT", 75usize, "JSON"), + ]; + let temp = tempdir().unwrap(); + let database = temp.path().join("real-material-module-acceptance.sqlite3"); + let cache = temp.path().join("module-cache"); + super::super::web_novel_workspace::open_database(&database).unwrap(); + for package in packages() { + install_at(&database, &cache, package.module_id).unwrap(); + set_mount_at(&database, package.module_id, true).unwrap(); + } + let mut outline_work_id = None; + for (variable, expected, format) in fixtures { + let source = std::env::var(variable).expect("real fixture path is required"); + let preview = + super::super::web_novel_import::stage_document_at(&database, Path::new(&source)) + .unwrap(); + assert_eq!(preview.section_count, expected, "{variable}"); + let receipt = super::super::web_novel_import::commit_import_at( + &database, + super::super::web_novel_import::CommitImportInput { + import_id: preview.import_id, + target_mode: "CREATE_NEW".into(), + target_work_id: None, + title: preview.detected_title, + pen_name: preview.detected_pen_name, + genre: preview.detected_genre, + }, + ) + .unwrap(); + if variable == "HOLOLAKE_REAL_OUTLINE" { + outline_work_id = Some(receipt.work_id.clone()); + } + let connection = super::super::web_novel_workspace::open_database(&database).unwrap(); + let mut statement = connection.prepare("SELECT chapter_id,title,SUBSTR(content,1,400) FROM web_novel_chapters WHERE work_id=?1 AND archived=0 ORDER BY position").unwrap(); + let chapters = statement + .query_map([&receipt.work_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + drop(statement); + drop(connection); + assert_eq!(chapters.len(), expected); + for (chapter_id, title, synopsis) in chapters { + upsert_scene_at( + &database, + UpsertAuthorSceneInput { + work_id: receipt.work_id.clone(), + scene_id: None, + chapter_id, + title, + position: None, + synopsis, + status: "PLANNED".into(), + goal: "根据导入材料继续展开".into(), + conflict: "".into(), + outcome: "".into(), + hook: "".into(), + emotional_point: "".into(), + target_words: 1500, + expected_revision: None, + }, + ) + .unwrap(); + } + let organized = module_data_at(&database, &receipt.work_id).unwrap(); + assert_eq!(organized.scenes.len(), expected); + let first = organized.scenes.first().unwrap(); + let field = upsert_field_definition_at( + &database, + UpsertStoryFieldDefinitionInput { + work_id: receipt.work_id.clone(), + field_id: None, + label: "导入材料类型".into(), + field_type: "TEXT".into(), + options: vec![], + expected_revision: None, + }, + ) + .unwrap(); + upsert_field_value_at( + &database, + UpsertStoryFieldValueInput { + field_id: field.field_id, + scene_id: first.scene_id.clone(), + value: variable.into(), + expected_revision: None, + }, + ) + .unwrap(); + upsert_timeline_at( + &database, + UpsertTimelineEventInput { + work_id: receipt.work_id.clone(), + event_id: None, + scene_id: Some(first.scene_id.clone()), + entity_id: None, + title: "导入材料起点".into(), + calendar_kind: "STORY_DAY".into(), + story_day: Some(1), + date_text: "".into(), + time_text: "".into(), + duration_minutes: 0, + description: preview.source_filename, + expected_revision: None, + }, + ) + .unwrap(); + let destination = temp + .path() + .join(format!("{variable}.{}", format.to_ascii_lowercase())); + let delivery = + export_delivery_at(&database, &receipt.work_id, format, &destination).unwrap(); + assert_eq!(delivery.chapter_count, expected); + assert!(delivery.bytes > 100); + println!( + "{variable}: imported={}, organized={}, export={} bytes={}, sha256={}", + expected, expected, format, delivery.bytes, delivery.package_sha256 + ); + } + let txt = temp.path().join("outline.txt"); + let txt_receipt = + export_delivery_at(&database, outline_work_id.as_deref().unwrap(), "TXT", &txt) + .unwrap(); + assert_eq!(txt_receipt.chapter_count, 50); + assert!(fs::read_to_string(txt).unwrap().chars().count() > 1000); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/web_novel_workspace.rs b/product-source/hololake-native-desktop/src-tauri/src/web_novel_workspace.rs new file mode 100644 index 000000000..032af3c86 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/web_novel_workspace.rs @@ -0,0 +1,3007 @@ +//! 网文行业本地原生工作引擎。 +//! +//! 数据只进入当前已验证账号的独立 SQLite。作者、编辑与运营投影共享同一组 +//! 作品对象,不在前端复制假数据,也不自动登录或发布到第三方平台。 + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; + +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001"; +const ADAPTER: &str = "web-novel-workbench-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +const MAX_TITLE_BYTES: usize = 300; +const MAX_SYNOPSIS_BYTES: usize = 40_000; +const MAX_CHAPTER_BYTES: usize = 4_000_000; +const MAX_ENTITY_DESCRIPTION_BYTES: usize = 200_000; +const MAX_REVIEW_NOTE_BYTES: usize = 100_000; +const MAX_WORKS: i64 = 200; +const WORKFLOW_STATES: &[&str] = &[ + "DRAFT", + "SELF_REVIEW", + "EDITOR_REVIEW", + "REVISION_REQUIRED", + "APPROVED", + "SCHEDULED", + "PUBLISHED", +]; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelWorkSummary { + pub work_id: String, + pub title: String, + pub pen_name: String, + pub genre: String, + pub work_kind: String, + pub workflow_status: String, + pub target_words: i64, + pub total_words: i64, + pub chapter_count: i64, + pub published_chapter_count: i64, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelWork { + pub work_id: String, + pub title: String, + pub pen_name: String, + pub genre: String, + pub work_kind: String, + pub synopsis: String, + pub contract_status: String, + pub copyright_status: String, + pub workflow_status: String, + pub target_words: i64, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelVolume { + pub volume_id: String, + pub work_id: String, + pub title: String, + pub position: i64, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelChapterSummary { + pub chapter_id: String, + pub volume_id: String, + pub work_id: String, + pub title: String, + pub position: i64, + pub synopsis: String, + pub workflow_status: String, + pub scheduled_at_unix_ms: Option, + pub word_count: i64, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelChapter { + pub chapter_id: String, + pub volume_id: String, + pub work_id: String, + pub title: String, + pub position: i64, + pub synopsis: String, + pub content: String, + pub workflow_status: String, + pub scheduled_at_unix_ms: Option, + pub word_count: i64, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelStoryEntity { + pub entity_id: String, + pub work_id: String, + pub entity_type: String, + pub name: String, + pub aliases: Vec, + pub description: String, + pub first_chapter_id: Option, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelStoryRelation { + pub relation_id: String, + pub work_id: String, + pub source_entity_id: String, + pub target_entity_id: String, + pub relation_type: String, + pub note: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelForeshadow { + pub foreshadow_id: String, + pub work_id: String, + pub title: String, + pub setup_chapter_id: String, + pub payoff_chapter_id: Option, + pub status: String, + pub note: String, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelReviewNote { + pub note_id: String, + pub work_id: String, + pub chapter_id: String, + pub note: String, + pub status: String, + pub created_at_unix_ms: i64, + pub resolved_at_unix_ms: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelWorkflowEvent { + pub event_id: String, + pub work_id: String, + pub chapter_id: String, + pub from_status: String, + pub to_status: String, + pub note: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelCheckpointSummary { + pub checkpoint_id: String, + pub work_id: String, + pub name: String, + pub description: String, + pub chapter_count: i64, + pub word_count: i64, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WebNovelMetric { + pub metric_id: String, + pub work_id: String, + pub metric_date: String, + pub views: i64, + pub follows: i64, + pub comments: i64, + pub paid_readers: i64, + pub revenue_cents: i64, + pub source_label: String, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelOperationsSummary { + pub total_words: i64, + pub draft_chapters: i64, + pub editor_review_chapters: i64, + pub approved_chapters: i64, + pub scheduled_chapters: i64, + pub published_chapters: i64, + pub open_review_notes: i64, + pub open_foreshadows: i64, + pub latest_views: i64, + pub latest_follows: i64, + pub latest_comments: i64, + pub latest_paid_readers: i64, + pub latest_revenue_cents: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelWorkspaceSnapshot { + pub state: &'static str, + pub works: Vec, + pub work_count: usize, + pub storage: &'static str, + pub authority: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelWorkDetail { + pub work: WebNovelWork, + pub volumes: Vec, + pub chapters: Vec, + pub entities: Vec, + pub relations: Vec, + pub foreshadows: Vec, + pub review_notes: Vec, + pub workflow_events: Vec, + pub checkpoints: Vec, + pub metrics: Vec, + pub operations: WebNovelOperationsSummary, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelContinuityIssue { + pub issue_id: String, + pub severity: String, + pub code: String, + pub title: String, + pub detail: String, + pub object_id: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelContinuityAudit { + pub state: &'static str, + pub work_id: String, + pub issue_count: usize, + pub blocking_issue_count: usize, + pub warning_issue_count: usize, + pub issues: Vec, + pub checked_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebNovelExportReceipt { + pub path: String, + pub bytes: u64, + pub chapter_count: usize, + pub word_count: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelWorkInput { + pub title: String, + pub pen_name: String, + pub genre: String, + #[serde(default = "default_work_kind")] + pub work_kind: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadWebNovelWorkInput { + pub work_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveWebNovelWorkInput { + pub work_id: String, + pub title: String, + pub pen_name: String, + pub genre: String, + #[serde(default = "default_work_kind")] + pub work_kind: String, + pub synopsis: String, + pub contract_status: String, + pub copyright_status: String, + pub target_words: i64, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelVolumeInput { + pub work_id: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelChapterInput { + pub work_id: String, + pub volume_id: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadWebNovelChapterInput { + pub chapter_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveWebNovelChapterInput { + pub chapter_id: String, + pub title: String, + pub synopsis: String, + pub content: String, + pub expected_revision: i64, + pub save_reason: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TransitionWebNovelChapterInput { + pub chapter_id: String, + pub to_status: String, + pub scheduled_at_unix_ms: Option, + pub note: String, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelCheckpointInput { + pub work_id: String, + pub name: String, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RestoreWebNovelCheckpointInput { + pub checkpoint_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertWebNovelStoryEntityInput { + pub work_id: String, + pub entity_id: Option, + pub entity_type: String, + pub name: String, + pub aliases: Vec, + pub description: String, + pub first_chapter_id: Option, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelStoryRelationInput { + pub work_id: String, + pub source_entity_id: String, + pub target_entity_id: String, + pub relation_type: String, + pub note: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpsertWebNovelForeshadowInput { + pub work_id: String, + pub foreshadow_id: Option, + pub title: String, + pub setup_chapter_id: String, + pub payoff_chapter_id: Option, + pub status: String, + pub note: String, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateWebNovelReviewNoteInput { + pub work_id: String, + pub chapter_id: String, + pub note: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResolveWebNovelReviewNoteInput { + pub note_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveWebNovelMetricInput { + pub work_id: String, + pub metric_date: String, + pub views: i64, + pub follows: i64, + pub comments: i64, + pub paid_readers: i64, + pub revenue_cents: i64, + pub source_label: String, + pub expected_revision: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CheckpointChapter { + chapter_id: String, + title: String, + synopsis: String, + content: String, + workflow_status: String, + scheduled_at_unix_ms: Option, + position: i64, +} + +pub async fn get_web_novel_workspace_snapshot( + app: AppHandle, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&path)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_work( + app: AppHandle, + input: CreateWebNovelWorkInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_work_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn read_web_novel_work( + app: AppHandle, + input: ReadWebNovelWorkInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || read_work_at(&path, &input.work_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn save_web_novel_work( + app: AppHandle, + input: SaveWebNovelWorkInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_work_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_volume( + app: AppHandle, + input: CreateWebNovelVolumeInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_volume_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_chapter( + app: AppHandle, + input: CreateWebNovelChapterInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_chapter_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn read_web_novel_chapter( + app: AppHandle, + input: ReadWebNovelChapterInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || read_chapter_at(&path, &input.chapter_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn save_web_novel_chapter( + app: AppHandle, + input: SaveWebNovelChapterInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_chapter_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn transition_web_novel_chapter( + app: AppHandle, + input: TransitionWebNovelChapterInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || transition_chapter_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_checkpoint( + app: AppHandle, + input: CreateWebNovelCheckpointInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_checkpoint_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn restore_web_novel_checkpoint( + app: AppHandle, + input: RestoreWebNovelCheckpointInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || restore_checkpoint_at(&path, &input.checkpoint_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_story_entity( + app: AppHandle, + input: UpsertWebNovelStoryEntityInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_entity_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_story_relation( + app: AppHandle, + input: CreateWebNovelStoryRelationInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_relation_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn upsert_web_novel_foreshadow( + app: AppHandle, + input: UpsertWebNovelForeshadowInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || upsert_foreshadow_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn create_web_novel_review_note( + app: AppHandle, + input: CreateWebNovelReviewNoteInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_review_note_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn resolve_web_novel_review_note( + app: AppHandle, + input: ResolveWebNovelReviewNoteInput, +) -> Result<(), String> { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || resolve_review_note_at(&path, &input.note_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn save_web_novel_metric( + app: AppHandle, + input: SaveWebNovelMetricInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_metric_at(&path, input)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn run_web_novel_continuity_audit( + app: AppHandle, + input: ReadWebNovelWorkInput, +) -> Result { + require_active(&app)?; + let path = workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || continuity_audit_at(&path, &input.work_id)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? +} + +pub async fn export_web_novel_markdown( + app: AppHandle, + input: ReadWebNovelWorkInput, +) -> Result, String> { + require_active(&app)?; + let database = workspace_database(&app)?; + let detail = read_work_at(&database, &input.work_id)?; + let filename = format!("{}.md", safe_filename(&detail.work.title)); + let selected = app + .dialog() + .file() + .set_title("导出网文作品") + .set_file_name(filename) + .add_filter("Markdown", &["md"]) + .blocking_save_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_EXPORT_PATH_INVALID: {error}"))?; + tauri::async_runtime::spawn_blocking(move || export_markdown_to_path(&database, &detail, &path)) + .await + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_JOIN_FAILED: {error}"))? + .map(Some) +} + +pub(crate) fn workspace_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "web-novel-workspace-v1")?; + create_private_directory(&root)?; + let database = root.join("web-novel-workspace.sqlite3"); + migrate_acceptance_workspace_if_needed(app, &root, &database)?; + Ok(database) +} + +fn migrate_acceptance_workspace_if_needed( + app: &AppHandle, + current_namespace_root: &Path, + current_database: &Path, +) -> Result<(), String> { + if current_database.exists() { + return Ok(()); + } + let current_app_data = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?; + let Some(app_data_parent) = current_app_data.parent() else { + return Ok(()); + }; + let Some(account_root) = current_namespace_root.parent() else { + return Ok(()); + }; + let relative_account = match account_root.strip_prefix(¤t_app_data) { + Ok(value) => value, + Err(_) => return Ok(()), + }; + let legacy_account = app_data_parent + .join("world.guanghu.hololake.webnovel.acceptance") + .join(relative_account); + let legacy_database = legacy_account + .join("web-novel-workspace-v1") + .join("web-novel-workspace.sqlite3"); + if !legacy_database.is_file() { + return Ok(()); + } + let temporary = current_database.with_extension("sqlite3.migrating"); + fs::copy(&legacy_database, &temporary) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MIGRATION_COPY_FAILED: {error}"))?; + let validation = (|| -> Result<(), String> { + let connection = open_database(&temporary)?; + let integrity: String = connection + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .map_err(database_read_error)?; + if integrity != "ok" { + return Err("HOLOLAKE_WEBNOVEL_LEGACY_MIGRATION_INTEGRITY_FAILED".into()); + } + let _: i64 = connection + .query_row("SELECT COUNT(*) FROM web_novel_works", [], |row| row.get(0)) + .map_err(database_read_error)?; + Ok(()) + })(); + if let Err(error) = validation { + let _ = fs::remove_file(&temporary); + return Err(error); + } + fs::rename(&temporary, current_database) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MIGRATION_COMMIT_FAILED: {error}"))?; + + let legacy_module_cache = legacy_account.join("web-novel-modules-v1"); + let current_module_cache = account_root.join("web-novel-modules-v1"); + if legacy_module_cache.is_dir() && !current_module_cache.exists() { + copy_directory_without_overwrite(&legacy_module_cache, ¤t_module_cache)?; + } + fs::write( + account_root.join("web-novel-acceptance-migration-v1.json"), + br#"{"schema":"hololake.web-novel-acceptance-migration/v1","state":"MIGRATED_AND_SQLITE_INTEGRITY_VERIFIED","source_identifier":"world.guanghu.hololake.webnovel.acceptance"}"#, + ) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MIGRATION_RECEIPT_FAILED: {error}"))?; + Ok(()) +} + +fn copy_directory_without_overwrite(source: &Path, destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MODULE_COPY_FAILED: {error}"))?; + for entry in fs::read_dir(source) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MODULE_READ_FAILED: {error}"))? + { + let entry = entry + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MODULE_READ_FAILED: {error}"))?; + let target = destination.join(entry.file_name()); + if entry.path().is_dir() { + copy_directory_without_overwrite(&entry.path(), &target)?; + } else if !target.exists() { + fs::copy(entry.path(), target) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_LEGACY_MODULE_COPY_FAILED: {error}"))?; + } + } + Ok(()) +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_STORAGE_PERMISSION_FAILED: {error}"))?; + } + Ok(()) +} + +pub(crate) fn open_database(path: &Path) -> Result { + if let Some(parent) = path.parent() { + create_private_directory(parent)?; + } + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(database_read_error)?; + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS web_novel_meta( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO web_novel_meta(key, value) VALUES('schema_version', '1'); + CREATE TABLE IF NOT EXISTS web_novel_works( + work_id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + pen_name TEXT NOT NULL, + genre TEXT NOT NULL, + synopsis TEXT NOT NULL, + contract_status TEXT NOT NULL, + copyright_status TEXT NOT NULL, + workflow_status TEXT NOT NULL, + target_words INTEGER NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS web_novel_volumes( + volume_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + title TEXT NOT NULL, + position INTEGER NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), + UNIQUE(work_id, position) + ); + CREATE TABLE IF NOT EXISTS web_novel_chapters( + chapter_id TEXT PRIMARY KEY NOT NULL, + volume_id TEXT NOT NULL REFERENCES web_novel_volumes(volume_id) ON DELETE CASCADE, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + title TEXT NOT NULL, + position INTEGER NOT NULL, + synopsis TEXT NOT NULL, + content TEXT NOT NULL, + workflow_status TEXT NOT NULL, + scheduled_at_unix_ms INTEGER, + word_count INTEGER NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), + UNIQUE(volume_id, position) + ); + CREATE TABLE IF NOT EXISTS web_novel_chapter_versions( + version_id TEXT PRIMARY KEY NOT NULL, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + title TEXT NOT NULL, + synopsis TEXT NOT NULL, + content TEXT NOT NULL, + workflow_status TEXT NOT NULL, + word_count INTEGER NOT NULL, + save_reason TEXT NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + UNIQUE(chapter_id, revision) + ); + CREATE TABLE IF NOT EXISTS web_novel_entities( + entity_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + name TEXT NOT NULL, + aliases_json TEXT NOT NULL, + description TEXT NOT NULL, + first_chapter_id TEXT REFERENCES web_novel_chapters(chapter_id) ON DELETE SET NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS web_novel_relations( + relation_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + source_entity_id TEXT NOT NULL REFERENCES web_novel_entities(entity_id) ON DELETE CASCADE, + target_entity_id TEXT NOT NULL REFERENCES web_novel_entities(entity_id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + note TEXT NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS web_novel_foreshadows( + foreshadow_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + title TEXT NOT NULL, + setup_chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + payoff_chapter_id TEXT REFERENCES web_novel_chapters(chapter_id) ON DELETE SET NULL, + status TEXT NOT NULL, + note TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS web_novel_review_notes( + note_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + note TEXT NOT NULL, + status TEXT NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + resolved_at_unix_ms INTEGER + ); + CREATE TABLE IF NOT EXISTS web_novel_workflow_events( + event_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE, + from_status TEXT NOT NULL, + to_status TEXT NOT NULL, + note TEXT NOT NULL, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_checkpoints( + checkpoint_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL, + payload_json TEXT NOT NULL, + chapter_count INTEGER NOT NULL, + word_count INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS web_novel_metrics( + metric_id TEXT PRIMARY KEY NOT NULL, + work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE, + metric_date TEXT NOT NULL, + views INTEGER NOT NULL, + follows INTEGER NOT NULL, + comments INTEGER NOT NULL, + paid_readers INTEGER NOT NULL, + revenue_cents INTEGER NOT NULL, + source_label TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + UNIQUE(work_id, metric_date, source_label) + ); + CREATE INDEX IF NOT EXISTS idx_web_novel_volumes_work ON web_novel_volumes(work_id, position); + CREATE INDEX IF NOT EXISTS idx_web_novel_chapters_work ON web_novel_chapters(work_id, volume_id, position); + CREATE INDEX IF NOT EXISTS idx_web_novel_versions_chapter ON web_novel_chapter_versions(chapter_id, revision DESC); + CREATE INDEX IF NOT EXISTS idx_web_novel_entities_work ON web_novel_entities(work_id, entity_type, name); + CREATE INDEX IF NOT EXISTS idx_web_novel_foreshadows_work ON web_novel_foreshadows(work_id, status); + CREATE INDEX IF NOT EXISTS idx_web_novel_reviews_work ON web_novel_review_notes(work_id, status); + CREATE INDEX IF NOT EXISTS idx_web_novel_metrics_work ON web_novel_metrics(work_id, metric_date DESC);", + ) + .map_err(database_write_error)?; + ensure_column( + &connection, + "web_novel_works", + "work_kind", + "TEXT NOT NULL DEFAULT 'LONG_NOVEL'", + )?; + connection + .execute( + "UPDATE web_novel_works SET work_kind='SHORT_DRAMA' + WHERE genre LIKE '%短剧%' OR EXISTS( + SELECT 1 FROM web_novel_volumes v + WHERE v.work_id=web_novel_works.work_id AND v.title='导入·分集剧本' + )", + [], + ) + .map_err(database_write_error)?; + connection + .execute( + "UPDATE web_novel_works SET work_kind='SHORT_NOVEL' + WHERE work_kind='LONG_NOVEL' AND (genre LIKE '%短篇%' OR genre LIKE '%短故事%')", + [], + ) + .map_err(database_write_error)?; + connection + .execute( + "UPDATE web_novel_meta SET value='2' WHERE key='schema_version'", + [], + ) + .map_err(database_write_error)?; + Ok(connection) +} + +fn snapshot_at(path: &Path) -> Result { + let connection = open_database(path)?; + let mut statement = connection + .prepare( + "SELECT w.work_id, w.title, w.pen_name, w.genre, w.work_kind, w.workflow_status, + w.target_words, w.revision, w.updated_at_unix_ms, + COALESCE(SUM(CASE WHEN c.archived = 0 THEN c.word_count ELSE 0 END), 0), + COUNT(CASE WHEN c.archived = 0 THEN 1 END), + COUNT(CASE WHEN c.archived = 0 AND c.workflow_status = 'PUBLISHED' THEN 1 END) + FROM web_novel_works w + LEFT JOIN web_novel_chapters c ON c.work_id = w.work_id + WHERE w.archived = 0 + GROUP BY w.work_id + ORDER BY w.updated_at_unix_ms DESC + LIMIT ?1", + ) + .map_err(database_read_error)?; + let works = statement + .query_map([MAX_WORKS], |row| { + Ok(WebNovelWorkSummary { + work_id: row.get(0)?, + title: row.get(1)?, + pen_name: row.get(2)?, + genre: row.get(3)?, + work_kind: row.get(4)?, + workflow_status: row.get(5)?, + target_words: row.get(6)?, + revision: row.get(7)?, + updated_at_unix_ms: row.get(8)?, + total_words: row.get(9)?, + chapter_count: row.get(10)?, + published_chapter_count: row.get(11)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(WebNovelWorkspaceSnapshot { + state: "READY", + work_count: works.len(), + works, + storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE", + authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY", + }) +} + +pub(crate) fn create_work_at( + path: &Path, + input: CreateWebNovelWorkInput, +) -> Result { + let title = bounded_text(&input.title, "未命名作品", MAX_TITLE_BYTES)?; + let pen_name = bounded_text(&input.pen_name, "未设置笔名", MAX_TITLE_BYTES)?; + let genre = bounded_text(&input.genre, "未分类", MAX_TITLE_BYTES)?; + let work_kind = normalized_work_kind(&input.work_kind)?; + let mut connection = open_database(path)?; + let count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM web_novel_works WHERE archived = 0", + [], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if count >= MAX_WORKS { + return Err("HOLOLAKE_WEBNOVEL_WORK_LIMIT_REACHED".into()); + } + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let work_id = new_id("WN-WORK-"); + let volume_id = new_id("WN-VOL-"); + let now = now_ms(); + tx.execute( + "INSERT INTO web_novel_works( + work_id, title, pen_name, genre, work_kind, synopsis, contract_status, copyright_status, + workflow_status, target_words, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, ?5, '', 'UNREGISTERED', 'AUTHOR_OWNED', 'DRAFT', 0, 1, ?6, ?6, 0)", + params![work_id, title, pen_name, genre, work_kind, now], + ) + .map_err(database_write_error)?; + tx.execute( + "INSERT INTO web_novel_volumes( + volume_id, work_id, title, position, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, '第一卷', 0, 1, ?3, ?3, 0)", + params![volume_id, work_id, now], + ) + .map_err(database_write_error)?; + tx.commit().map_err(database_write_error)?; + read_work_at(path, &work_id) +} + +fn read_work_at(path: &Path, work_id: &str) -> Result { + validate_id(work_id, "WN-WORK-")?; + let connection = open_database(path)?; + read_work_with_connection(&connection, work_id) +} + +fn read_work_with_connection( + connection: &Connection, + work_id: &str, +) -> Result { + let work = connection + .query_row( + "SELECT work_id, title, pen_name, genre, work_kind, synopsis, contract_status, + copyright_status, workflow_status, target_words, revision, + created_at_unix_ms, updated_at_unix_ms + FROM web_novel_works WHERE work_id = ?1 AND archived = 0", + [work_id], + map_work, + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".to_string())?; + let volumes = query_volumes(connection, work_id)?; + let chapters = query_chapter_summaries(connection, work_id)?; + let entities = query_entities(connection, work_id)?; + let relations = query_relations(connection, work_id)?; + let foreshadows = query_foreshadows(connection, work_id)?; + let review_notes = query_review_notes(connection, work_id)?; + let workflow_events = query_workflow_events(connection, work_id)?; + let checkpoints = query_checkpoints(connection, work_id)?; + let metrics = query_metrics(connection, work_id)?; + let operations = operations_summary(connection, work_id)?; + Ok(WebNovelWorkDetail { + work, + volumes, + chapters, + entities, + relations, + foreshadows, + review_notes, + workflow_events, + checkpoints, + metrics, + operations, + }) +} + +fn save_work_at(path: &Path, input: SaveWebNovelWorkInput) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + let title = bounded_text(&input.title, "未命名作品", MAX_TITLE_BYTES)?; + let pen_name = bounded_text(&input.pen_name, "未设置笔名", MAX_TITLE_BYTES)?; + let genre = bounded_text(&input.genre, "未分类", MAX_TITLE_BYTES)?; + let work_kind = normalized_work_kind(&input.work_kind)?; + validate_optional_bytes(&input.synopsis, MAX_SYNOPSIS_BYTES, "WORK_SYNOPSIS")?; + if input.target_words < 0 || input.target_words > 100_000_000 { + return Err("HOLOLAKE_WEBNOVEL_TARGET_WORDS_INVALID".into()); + } + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE web_novel_works + SET title=?1, pen_name=?2, genre=?3, work_kind=?4, synopsis=?5, contract_status=?6, + copyright_status=?7, target_words=?8, revision=revision+1, updated_at_unix_ms=?9 + WHERE work_id=?10 AND revision=?11 AND archived=0", + params![ + title, + pen_name, + genre, + work_kind, + input.synopsis, + normalized_label(&input.contract_status, "UNREGISTERED")?, + normalized_label(&input.copyright_status, "AUTHOR_OWNED")?, + input.target_words, + now_ms(), + input.work_id, + input.expected_revision + ], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_WEBNOVEL_WORK_REVISION_CONFLICT".into()); + } + read_work_at(path, &input.work_id) +} + +fn create_volume_at( + path: &Path, + input: CreateWebNovelVolumeInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + let title = bounded_text(&input.title, "未命名分卷", MAX_TITLE_BYTES)?; + let connection = open_database(path)?; + ensure_work_exists(&connection, &input.work_id)?; + let position: i64 = connection + .query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM web_novel_volumes WHERE work_id=?1 AND archived=0", + [&input.work_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + let now = now_ms(); + connection + .execute( + "INSERT INTO web_novel_volumes( + volume_id, work_id, title, position, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, 1, ?5, ?5, 0)", + params![new_id("WN-VOL-"), input.work_id, title, position, now], + ) + .map_err(database_write_error)?; + touch_work(&connection, &input.work_id)?; + read_work_at(path, &input.work_id) +} + +pub(crate) fn create_chapter_at( + path: &Path, + input: CreateWebNovelChapterInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + validate_id(&input.volume_id, "WN-VOL-")?; + let title = bounded_text(&input.title, "未命名章节", MAX_TITLE_BYTES)?; + let mut connection = open_database(path)?; + let volume_work: Option = connection + .query_row( + "SELECT work_id FROM web_novel_volumes WHERE volume_id=?1 AND archived=0", + [&input.volume_id], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)?; + if volume_work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_VOLUME_WORK_MISMATCH".into()); + } + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let position: i64 = tx + .query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM web_novel_chapters WHERE volume_id=?1 AND archived=0", + [&input.volume_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + let chapter_id = new_id("WN-CH-"); + let now = now_ms(); + let work_kind: String = tx + .query_row( + "SELECT work_kind FROM web_novel_works WHERE work_id=?1 AND archived=0", + [&input.work_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + let initial_content = chapter_template(&work_kind, &title, position); + let initial_word_count = count_words(&initial_content); + tx.execute( + "INSERT INTO web_novel_chapters( + chapter_id, volume_id, work_id, title, position, synopsis, content, + workflow_status, scheduled_at_unix_ms, word_count, revision, + created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, ?5, '', ?6, 'DRAFT', NULL, ?7, 1, ?8, ?8, 0)", + params![ + chapter_id, + input.volume_id, + input.work_id, + title, + position, + initial_content, + initial_word_count, + now + ], + ) + .map_err(database_write_error)?; + insert_chapter_version( + &tx, + &chapter_id, + &input.work_id, + 1, + &title, + "", + &initial_content, + "DRAFT", + initial_word_count, + "CREATE", + now, + )?; + touch_work(&tx, &input.work_id)?; + tx.commit().map_err(database_write_error)?; + read_chapter_at(path, &chapter_id) +} + +fn read_chapter_at(path: &Path, chapter_id: &str) -> Result { + validate_id(chapter_id, "WN-CH-")?; + let connection = open_database(path)?; + read_chapter_with_connection(&connection, chapter_id) +} + +fn read_chapter_with_connection( + connection: &Connection, + chapter_id: &str, +) -> Result { + connection + .query_row( + "SELECT chapter_id, volume_id, work_id, title, position, synopsis, content, + workflow_status, scheduled_at_unix_ms, word_count, revision, + created_at_unix_ms, updated_at_unix_ms + FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [chapter_id], + map_chapter, + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_CHAPTER_NOT_FOUND".to_string()) +} + +pub(crate) fn save_chapter_at( + path: &Path, + input: SaveWebNovelChapterInput, +) -> Result { + validate_id(&input.chapter_id, "WN-CH-")?; + let title = bounded_text(&input.title, "未命名章节", MAX_TITLE_BYTES)?; + validate_optional_bytes(&input.synopsis, MAX_SYNOPSIS_BYTES, "CHAPTER_SYNOPSIS")?; + validate_optional_bytes(&input.content, MAX_CHAPTER_BYTES, "CHAPTER_CONTENT")?; + let word_count = count_words(&input.content); + let mut connection = open_database(path)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let current = read_chapter_with_connection(&tx, &input.chapter_id)?; + if current.revision != input.expected_revision { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT".into()); + } + if current.workflow_status == "PUBLISHED" { + return Err("HOLOLAKE_WEBNOVEL_PUBLISHED_CHAPTER_LOCKED".into()); + } + let revision = current.revision + 1; + let now = now_ms(); + let changed = tx + .execute( + "UPDATE web_novel_chapters + SET title=?1, synopsis=?2, content=?3, word_count=?4, revision=?5, updated_at_unix_ms=?6 + WHERE chapter_id=?7 AND revision=?8 AND archived=0", + params![ + title, + input.synopsis, + input.content, + word_count, + revision, + now, + input.chapter_id, + current.revision + ], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT".into()); + } + insert_chapter_version( + &tx, + ¤t.chapter_id, + ¤t.work_id, + revision, + &title, + &input.synopsis, + &input.content, + ¤t.workflow_status, + word_count, + &normalized_save_reason(input.save_reason), + now, + )?; + touch_work(&tx, ¤t.work_id)?; + tx.commit().map_err(database_write_error)?; + read_chapter_at(path, &input.chapter_id) +} + +fn transition_chapter_at( + path: &Path, + input: TransitionWebNovelChapterInput, +) -> Result { + validate_id(&input.chapter_id, "WN-CH-")?; + let to_status = normalized_workflow(&input.to_status)?; + validate_optional_bytes(&input.note, MAX_REVIEW_NOTE_BYTES, "WORKFLOW_NOTE")?; + let mut connection = open_database(path)?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let current = read_chapter_with_connection(&tx, &input.chapter_id)?; + if current.revision != input.expected_revision { + return Err("HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT".into()); + } + if !transition_allowed(¤t.workflow_status, &to_status) { + return Err(format!( + "HOLOLAKE_WEBNOVEL_WORKFLOW_TRANSITION_INVALID:{}->{}", + current.workflow_status, to_status + )); + } + if matches!(to_status.as_str(), "APPROVED" | "SCHEDULED" | "PUBLISHED") + && current.content.trim().is_empty() + { + return Err("HOLOLAKE_WEBNOVEL_EMPTY_CHAPTER_CANNOT_ADVANCE".into()); + } + if to_status == "SCHEDULED" && input.scheduled_at_unix_ms.is_none() { + return Err("HOLOLAKE_WEBNOVEL_SCHEDULE_TIME_REQUIRED".into()); + } + let scheduled = if to_status == "SCHEDULED" { + input.scheduled_at_unix_ms + } else { + None + }; + let revision = current.revision + 1; + let now = now_ms(); + tx.execute( + "UPDATE web_novel_chapters + SET workflow_status=?1, scheduled_at_unix_ms=?2, revision=?3, updated_at_unix_ms=?4 + WHERE chapter_id=?5 AND revision=?6", + params![ + to_status, + scheduled, + revision, + now, + current.chapter_id, + current.revision + ], + ) + .map_err(database_write_error)?; + tx.execute( + "INSERT INTO web_novel_workflow_events( + event_id, work_id, chapter_id, from_status, to_status, note, created_at_unix_ms + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + new_id("WN-EVENT-"), + current.work_id, + current.chapter_id, + current.workflow_status, + to_status, + input.note, + now + ], + ) + .map_err(database_write_error)?; + insert_chapter_version( + &tx, + ¤t.chapter_id, + ¤t.work_id, + revision, + ¤t.title, + ¤t.synopsis, + ¤t.content, + &to_status, + current.word_count, + "WORKFLOW_TRANSITION", + now, + )?; + touch_work(&tx, ¤t.work_id)?; + tx.commit().map_err(database_write_error)?; + read_chapter_at(path, &input.chapter_id) +} + +fn create_checkpoint_at( + path: &Path, + input: CreateWebNovelCheckpointInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + let name = bounded_text(&input.name, "手动检查点", MAX_TITLE_BYTES)?; + validate_optional_bytes( + &input.description, + MAX_REVIEW_NOTE_BYTES, + "CHECKPOINT_DESCRIPTION", + )?; + let connection = open_database(path)?; + ensure_work_exists(&connection, &input.work_id)?; + let mut statement = connection + .prepare( + "SELECT chapter_id, title, synopsis, content, workflow_status, + scheduled_at_unix_ms, position + FROM web_novel_chapters + WHERE work_id=?1 AND archived=0 + ORDER BY volume_id, position", + ) + .map_err(database_read_error)?; + let chapters = statement + .query_map([&input.work_id], |row| { + Ok(CheckpointChapter { + chapter_id: row.get(0)?, + title: row.get(1)?, + synopsis: row.get(2)?, + content: row.get(3)?, + workflow_status: row.get(4)?, + scheduled_at_unix_ms: row.get(5)?, + position: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + let payload_json = serde_json::to_string(&chapters) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_CHECKPOINT_SERIALIZE_FAILED: {error}"))?; + let checkpoint_id = new_id("WN-CP-"); + let word_count = chapters + .iter() + .map(|chapter| count_words(&chapter.content)) + .sum(); + let now = now_ms(); + connection + .execute( + "INSERT INTO web_novel_checkpoints( + checkpoint_id, work_id, name, description, payload_json, + chapter_count, word_count, created_at_unix_ms + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + checkpoint_id, + input.work_id, + name, + input.description, + payload_json, + chapters.len() as i64, + word_count, + now + ], + ) + .map_err(database_write_error)?; + Ok(WebNovelCheckpointSummary { + checkpoint_id, + work_id: input.work_id, + name, + description: input.description, + chapter_count: chapters.len() as i64, + word_count, + created_at_unix_ms: now, + }) +} + +fn restore_checkpoint_at(path: &Path, checkpoint_id: &str) -> Result { + validate_id(checkpoint_id, "WN-CP-")?; + let mut connection = open_database(path)?; + let (work_id, payload): (String, String) = connection + .query_row( + "SELECT work_id, payload_json FROM web_novel_checkpoints WHERE checkpoint_id=?1", + [checkpoint_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_WEBNOVEL_CHECKPOINT_NOT_FOUND".to_string())?; + let chapters: Vec = serde_json::from_str(&payload) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_CHECKPOINT_CORRUPT: {error}"))?; + let tx = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let now = now_ms(); + for chapter in chapters { + let current = read_chapter_with_connection(&tx, &chapter.chapter_id)?; + if current.work_id != work_id { + return Err("HOLOLAKE_WEBNOVEL_CHECKPOINT_SCOPE_MISMATCH".into()); + } + let revision = current.revision + 1; + let word_count = count_words(&chapter.content); + tx.execute( + "UPDATE web_novel_chapters + SET title=?1, synopsis=?2, content=?3, workflow_status=?4, + scheduled_at_unix_ms=?5, position=?6, word_count=?7, + revision=?8, updated_at_unix_ms=?9 + WHERE chapter_id=?10", + params![ + chapter.title, + chapter.synopsis, + chapter.content, + chapter.workflow_status, + chapter.scheduled_at_unix_ms, + chapter.position, + word_count, + revision, + now, + chapter.chapter_id + ], + ) + .map_err(database_write_error)?; + insert_chapter_version( + &tx, + &chapter.chapter_id, + &work_id, + revision, + &chapter.title, + &chapter.synopsis, + &chapter.content, + &chapter.workflow_status, + word_count, + "CHECKPOINT_RESTORE", + now, + )?; + } + touch_work(&tx, &work_id)?; + tx.commit().map_err(database_write_error)?; + read_work_at(path, &work_id) +} + +fn upsert_entity_at( + path: &Path, + input: UpsertWebNovelStoryEntityInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + let name = bounded_text(&input.name, "", MAX_TITLE_BYTES)?; + if name.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_ENTITY_NAME_REQUIRED".into()); + } + let entity_type = normalized_entity_type(&input.entity_type)?; + validate_optional_bytes( + &input.description, + MAX_ENTITY_DESCRIPTION_BYTES, + "ENTITY_DESCRIPTION", + )?; + if input.aliases.len() > 100 + || input + .aliases + .iter() + .any(|alias| alias.len() > MAX_TITLE_BYTES) + { + return Err("HOLOLAKE_WEBNOVEL_ENTITY_ALIASES_INVALID".into()); + } + if let Some(chapter_id) = &input.first_chapter_id { + validate_id(chapter_id, "WN-CH-")?; + } + let aliases_json = serde_json::to_string(&input.aliases) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_ENTITY_ALIASES_INVALID: {error}"))?; + let connection = open_database(path)?; + ensure_work_exists(&connection, &input.work_id)?; + let now = now_ms(); + let entity_id = input.entity_id.unwrap_or_else(|| new_id("WN-ENTITY-")); + let existing: Option = connection + .query_row( + "SELECT revision FROM web_novel_entities WHERE entity_id=?1 AND archived=0", + [&entity_id], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)?; + if let Some(current_revision) = existing { + if input.expected_revision != Some(current_revision) { + return Err("HOLOLAKE_WEBNOVEL_ENTITY_REVISION_CONFLICT".into()); + } + connection + .execute( + "UPDATE web_novel_entities + SET entity_type=?1, name=?2, aliases_json=?3, description=?4, + first_chapter_id=?5, revision=revision+1, updated_at_unix_ms=?6 + WHERE entity_id=?7 AND work_id=?8 AND revision=?9", + params![ + entity_type, + name, + aliases_json, + input.description, + input.first_chapter_id, + now, + entity_id, + input.work_id, + current_revision + ], + ) + .map_err(database_write_error)?; + } else { + connection + .execute( + "INSERT INTO web_novel_entities( + entity_id, work_id, entity_type, name, aliases_json, description, + first_chapter_id, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8, 0)", + params![ + entity_id, + input.work_id, + entity_type, + name, + aliases_json, + input.description, + input.first_chapter_id, + now + ], + ) + .map_err(database_write_error)?; + } + touch_work(&connection, &input.work_id)?; + query_entity(&connection, &entity_id) +} + +fn create_relation_at( + path: &Path, + input: CreateWebNovelStoryRelationInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + validate_id(&input.source_entity_id, "WN-ENTITY-")?; + validate_id(&input.target_entity_id, "WN-ENTITY-")?; + if input.source_entity_id == input.target_entity_id { + return Err("HOLOLAKE_WEBNOVEL_RELATION_SELF_LINK_INVALID".into()); + } + let relation_type = bounded_text(&input.relation_type, "", MAX_TITLE_BYTES)?; + if relation_type.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_RELATION_TYPE_REQUIRED".into()); + } + validate_optional_bytes(&input.note, MAX_REVIEW_NOTE_BYTES, "RELATION_NOTE")?; + let connection = open_database(path)?; + for entity_id in [&input.source_entity_id, &input.target_entity_id] { + let work: Option = connection + .query_row( + "SELECT work_id FROM web_novel_entities WHERE entity_id=?1 AND archived=0", + [entity_id], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)?; + if work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_RELATION_SCOPE_MISMATCH".into()); + } + } + let relation = WebNovelStoryRelation { + relation_id: new_id("WN-REL-"), + work_id: input.work_id, + source_entity_id: input.source_entity_id, + target_entity_id: input.target_entity_id, + relation_type, + note: input.note, + created_at_unix_ms: now_ms(), + }; + connection + .execute( + "INSERT INTO web_novel_relations( + relation_id, work_id, source_entity_id, target_entity_id, + relation_type, note, created_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)", + params![ + relation.relation_id, + relation.work_id, + relation.source_entity_id, + relation.target_entity_id, + relation.relation_type, + relation.note, + relation.created_at_unix_ms + ], + ) + .map_err(database_write_error)?; + touch_work(&connection, &relation.work_id)?; + Ok(relation) +} + +fn upsert_foreshadow_at( + path: &Path, + input: UpsertWebNovelForeshadowInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + validate_id(&input.setup_chapter_id, "WN-CH-")?; + if let Some(chapter_id) = &input.payoff_chapter_id { + validate_id(chapter_id, "WN-CH-")?; + } + let title = bounded_text(&input.title, "", MAX_TITLE_BYTES)?; + if title.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_FORESHADOW_TITLE_REQUIRED".into()); + } + let status = normalized_foreshadow_status(&input.status)?; + if status == "RESOLVED" && input.payoff_chapter_id.is_none() { + return Err("HOLOLAKE_WEBNOVEL_FORESHADOW_PAYOFF_REQUIRED".into()); + } + validate_optional_bytes(&input.note, MAX_REVIEW_NOTE_BYTES, "FORESHADOW_NOTE")?; + let connection = open_database(path)?; + for chapter_id in + std::iter::once(&input.setup_chapter_id).chain(input.payoff_chapter_id.as_ref()) + { + let work: Option = connection + .query_row( + "SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", + [chapter_id], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)?; + if work.as_deref() != Some(input.work_id.as_str()) { + return Err("HOLOLAKE_WEBNOVEL_FORESHADOW_SCOPE_MISMATCH".into()); + } + } + let foreshadow_id = input + .foreshadow_id + .unwrap_or_else(|| new_id("WN-FORESHADOW-")); + let existing: Option = connection + .query_row( + "SELECT revision FROM web_novel_foreshadows WHERE foreshadow_id=?1 AND archived=0", + [&foreshadow_id], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)?; + let now = now_ms(); + if let Some(revision) = existing { + if input.expected_revision != Some(revision) { + return Err("HOLOLAKE_WEBNOVEL_FORESHADOW_REVISION_CONFLICT".into()); + } + connection + .execute( + "UPDATE web_novel_foreshadows + SET title=?1, setup_chapter_id=?2, payoff_chapter_id=?3, status=?4, + note=?5, revision=revision+1, updated_at_unix_ms=?6 + WHERE foreshadow_id=?7 AND work_id=?8 AND revision=?9", + params![ + title, + input.setup_chapter_id, + input.payoff_chapter_id, + status, + input.note, + now, + foreshadow_id, + input.work_id, + revision + ], + ) + .map_err(database_write_error)?; + } else { + connection + .execute( + "INSERT INTO web_novel_foreshadows( + foreshadow_id, work_id, title, setup_chapter_id, payoff_chapter_id, + status, note, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8, 0)", + params![ + foreshadow_id, + input.work_id, + title, + input.setup_chapter_id, + input.payoff_chapter_id, + status, + input.note, + now + ], + ) + .map_err(database_write_error)?; + } + touch_work(&connection, &input.work_id)?; + query_foreshadow(&connection, &foreshadow_id) +} + +fn create_review_note_at( + path: &Path, + input: CreateWebNovelReviewNoteInput, +) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + validate_id(&input.chapter_id, "WN-CH-")?; + let note = bounded_text(&input.note, "", MAX_REVIEW_NOTE_BYTES)?; + if note.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_REVIEW_NOTE_REQUIRED".into()); + } + let connection = open_database(path)?; + let chapter = read_chapter_with_connection(&connection, &input.chapter_id)?; + if chapter.work_id != input.work_id { + return Err("HOLOLAKE_WEBNOVEL_REVIEW_SCOPE_MISMATCH".into()); + } + let result = WebNovelReviewNote { + note_id: new_id("WN-NOTE-"), + work_id: input.work_id, + chapter_id: input.chapter_id, + note, + status: "OPEN".into(), + created_at_unix_ms: now_ms(), + resolved_at_unix_ms: None, + }; + connection + .execute( + "INSERT INTO web_novel_review_notes( + note_id, work_id, chapter_id, note, status, created_at_unix_ms, resolved_at_unix_ms + ) VALUES(?1, ?2, ?3, ?4, 'OPEN', ?5, NULL)", + params![ + result.note_id, + result.work_id, + result.chapter_id, + result.note, + result.created_at_unix_ms + ], + ) + .map_err(database_write_error)?; + touch_work(&connection, &result.work_id)?; + Ok(result) +} + +fn resolve_review_note_at(path: &Path, note_id: &str) -> Result<(), String> { + validate_id(note_id, "WN-NOTE-")?; + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE web_novel_review_notes + SET status='RESOLVED', resolved_at_unix_ms=?1 + WHERE note_id=?2 AND status='OPEN'", + params![now_ms(), note_id], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_WEBNOVEL_REVIEW_NOTE_NOT_OPEN".into()); + } + Ok(()) +} + +fn save_metric_at(path: &Path, input: SaveWebNovelMetricInput) -> Result { + validate_id(&input.work_id, "WN-WORK-")?; + validate_metric_date(&input.metric_date)?; + let source_label = bounded_text(&input.source_label, "", MAX_TITLE_BYTES)?; + if source_label.is_empty() { + return Err("HOLOLAKE_WEBNOVEL_METRIC_SOURCE_REQUIRED".into()); + } + for value in [ + input.views, + input.follows, + input.comments, + input.paid_readers, + input.revenue_cents, + ] { + if value < 0 { + return Err("HOLOLAKE_WEBNOVEL_METRIC_NEGATIVE".into()); + } + } + let connection = open_database(path)?; + ensure_work_exists(&connection, &input.work_id)?; + let existing: Option<(String, i64)> = connection + .query_row( + "SELECT metric_id, revision FROM web_novel_metrics + WHERE work_id=?1 AND metric_date=?2 AND source_label=?3", + params![input.work_id, input.metric_date, source_label], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(database_read_error)?; + let now = now_ms(); + let metric_id; + if let Some((existing_id, revision)) = existing { + if input.expected_revision != Some(revision) { + return Err("HOLOLAKE_WEBNOVEL_METRIC_REVISION_CONFLICT".into()); + } + metric_id = existing_id; + connection + .execute( + "UPDATE web_novel_metrics + SET views=?1, follows=?2, comments=?3, paid_readers=?4, + revenue_cents=?5, revision=revision+1, updated_at_unix_ms=?6 + WHERE metric_id=?7 AND revision=?8", + params![ + input.views, + input.follows, + input.comments, + input.paid_readers, + input.revenue_cents, + now, + metric_id, + revision + ], + ) + .map_err(database_write_error)?; + } else { + metric_id = new_id("WN-METRIC-"); + connection + .execute( + "INSERT INTO web_novel_metrics( + metric_id, work_id, metric_date, views, follows, comments, + paid_readers, revenue_cents, source_label, revision, updated_at_unix_ms + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10)", + params![ + metric_id, + input.work_id, + input.metric_date, + input.views, + input.follows, + input.comments, + input.paid_readers, + input.revenue_cents, + source_label, + now + ], + ) + .map_err(database_write_error)?; + } + query_metric(&connection, &metric_id) +} + +fn continuity_audit_at(path: &Path, work_id: &str) -> Result { + let detail = read_work_at(path, work_id)?; + let connection = open_database(path)?; + let mut issues = Vec::new(); + for chapter in &detail.chapters { + if chapter.word_count == 0 { + issues.push(issue( + "WARNING", + "EMPTY_CHAPTER", + "章节正文为空", + format!("「{}」尚无正文。", chapter.title), + &chapter.chapter_id, + )); + } + if matches!( + chapter.workflow_status.as_str(), + "APPROVED" | "SCHEDULED" | "PUBLISHED" + ) && chapter.word_count == 0 + { + issues.push(issue( + "BLOCKING", + "ADVANCED_EMPTY_CHAPTER", + "已推进的章节没有正文", + format!("「{}」不能保持当前审稿/发布状态。", chapter.title), + &chapter.chapter_id, + )); + } + let duplicate_position: i64 = connection + .query_row( + "SELECT COUNT(*) FROM web_novel_chapters + WHERE volume_id=?1 AND position=?2 AND archived=0", + params![chapter.volume_id, chapter.position], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if duplicate_position > 1 { + issues.push(issue( + "BLOCKING", + "DUPLICATE_CHAPTER_POSITION", + "章节顺序冲突", + format!("「{}」所在分卷存在重复章节序号。", chapter.title), + &chapter.chapter_id, + )); + } + } + for entity in &detail.entities { + if entity.description.trim().is_empty() { + issues.push(issue( + "WARNING", + "ENTITY_DESCRIPTION_EMPTY", + "设定卡缺少说明", + format!("「{}」还没有人物/地点/设定说明。", entity.name), + &entity.entity_id, + )); + } + } + for foreshadow in &detail.foreshadows { + if foreshadow.status == "OPEN" { + issues.push(issue( + "WARNING", + "FORESHADOW_OPEN", + "伏笔尚未回收", + format!("「{}」仍处于待回收状态。", foreshadow.title), + &foreshadow.foreshadow_id, + )); + } + if let Some(payoff_id) = &foreshadow.payoff_chapter_id { + let setup_order = chapter_global_order(&detail, &foreshadow.setup_chapter_id); + let payoff_order = chapter_global_order(&detail, payoff_id); + if setup_order.is_some() && payoff_order.is_some() && payoff_order <= setup_order { + issues.push(issue( + "BLOCKING", + "FORESHADOW_PAYOFF_BEFORE_SETUP", + "伏笔回收早于埋设", + format!("「{}」的回收章节顺序不晚于埋设章节。", foreshadow.title), + &foreshadow.foreshadow_id, + )); + } + } + } + let mut names = std::collections::HashMap::>::new(); + for entity in &detail.entities { + names + .entry(entity.name.trim().to_lowercase()) + .or_default() + .push(entity); + } + for duplicated in names.values().filter(|items| items.len() > 1) { + issues.push(issue( + "WARNING", + "DUPLICATE_ENTITY_NAME", + "设定卡名称重复", + format!( + "「{}」存在 {} 张同名设定卡,请确认是否为同一对象。", + duplicated[0].name, + duplicated.len() + ), + &duplicated[0].entity_id, + )); + } + let blocking_issue_count = issues + .iter() + .filter(|item| item.severity == "BLOCKING") + .count(); + let warning_issue_count = issues + .iter() + .filter(|item| item.severity == "WARNING") + .count(); + Ok(WebNovelContinuityAudit { + state: if blocking_issue_count > 0 { + "BLOCKED" + } else if warning_issue_count > 0 { + "REVIEW_REQUIRED" + } else { + "PASS" + }, + work_id: work_id.to_string(), + issue_count: issues.len(), + blocking_issue_count, + warning_issue_count, + issues, + checked_at_unix_ms: now_ms(), + }) +} + +fn export_markdown_to_path( + database: &Path, + detail: &WebNovelWorkDetail, + path: &Path, +) -> Result { + let connection = open_database(database)?; + let mut output = format!( + "# {}\n\n- 笔名:{}\n- 类型:{}\n- 合同:{}\n- 版权:{}\n\n{}\n", + detail.work.title, + detail.work.pen_name, + detail.work.genre, + detail.work.contract_status, + detail.work.copyright_status, + detail.work.synopsis + ); + let mut chapter_count = 0usize; + let mut word_count = 0i64; + for volume in &detail.volumes { + output.push_str(&format!("\n# {}\n", volume.title)); + for summary in detail + .chapters + .iter() + .filter(|chapter| chapter.volume_id == volume.volume_id) + { + let chapter = read_chapter_with_connection(&connection, &summary.chapter_id)?; + output.push_str(&format!("\n## {}\n\n{}\n", chapter.title, chapter.content)); + chapter_count += 1; + word_count += chapter.word_count; + } + } + fs::write(path, output.as_bytes()) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_EXPORT_WRITE_FAILED: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("HOLOLAKE_WEBNOVEL_EXPORT_PERMISSION_FAILED: {error}"))?; + } + Ok(WebNovelExportReceipt { + path: path.to_string_lossy().to_string(), + bytes: output.len() as u64, + chapter_count, + word_count, + }) +} + +fn query_volumes(connection: &Connection, work_id: &str) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT volume_id, work_id, title, position, revision, + created_at_unix_ms, updated_at_unix_ms + FROM web_novel_volumes WHERE work_id=?1 AND archived=0 ORDER BY position", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelVolume { + volume_id: row.get(0)?, + work_id: row.get(1)?, + title: row.get(2)?, + position: row.get(3)?, + revision: row.get(4)?, + created_at_unix_ms: row.get(5)?, + updated_at_unix_ms: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_chapter_summaries( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT c.chapter_id, c.volume_id, c.work_id, c.title, c.position, c.synopsis, + c.workflow_status, c.scheduled_at_unix_ms, c.word_count, c.revision, c.updated_at_unix_ms + FROM web_novel_chapters c + JOIN web_novel_volumes v ON v.volume_id=c.volume_id + WHERE c.work_id=?1 AND c.archived=0 AND v.archived=0 + ORDER BY v.position, c.position", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelChapterSummary { + chapter_id: row.get(0)?, + volume_id: row.get(1)?, + work_id: row.get(2)?, + title: row.get(3)?, + position: row.get(4)?, + synopsis: row.get(5)?, + workflow_status: row.get(6)?, + scheduled_at_unix_ms: row.get(7)?, + word_count: row.get(8)?, + revision: row.get(9)?, + updated_at_unix_ms: row.get(10)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_entities( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT entity_id, work_id, entity_type, name, aliases_json, description, + first_chapter_id, revision, created_at_unix_ms, updated_at_unix_ms + FROM web_novel_entities WHERE work_id=?1 AND archived=0 + ORDER BY entity_type, name", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], map_entity) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_entity(connection: &Connection, entity_id: &str) -> Result { + connection + .query_row( + "SELECT entity_id, work_id, entity_type, name, aliases_json, description, + first_chapter_id, revision, created_at_unix_ms, updated_at_unix_ms + FROM web_novel_entities WHERE entity_id=?1 AND archived=0", + [entity_id], + map_entity, + ) + .map_err(database_read_error) +} + +fn query_relations( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT relation_id, work_id, source_entity_id, target_entity_id, + relation_type, note, created_at_unix_ms + FROM web_novel_relations WHERE work_id=?1 AND archived=0 + ORDER BY created_at_unix_ms DESC", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelStoryRelation { + relation_id: row.get(0)?, + work_id: row.get(1)?, + source_entity_id: row.get(2)?, + target_entity_id: row.get(3)?, + relation_type: row.get(4)?, + note: row.get(5)?, + created_at_unix_ms: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_foreshadows( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT foreshadow_id, work_id, title, setup_chapter_id, payoff_chapter_id, + status, note, revision, created_at_unix_ms, updated_at_unix_ms + FROM web_novel_foreshadows WHERE work_id=?1 AND archived=0 + ORDER BY updated_at_unix_ms DESC", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], map_foreshadow) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_foreshadow( + connection: &Connection, + foreshadow_id: &str, +) -> Result { + connection + .query_row( + "SELECT foreshadow_id, work_id, title, setup_chapter_id, payoff_chapter_id, + status, note, revision, created_at_unix_ms, updated_at_unix_ms + FROM web_novel_foreshadows WHERE foreshadow_id=?1 AND archived=0", + [foreshadow_id], + map_foreshadow, + ) + .map_err(database_read_error) +} + +fn query_review_notes( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT note_id, work_id, chapter_id, note, status, + created_at_unix_ms, resolved_at_unix_ms + FROM web_novel_review_notes WHERE work_id=?1 + ORDER BY status ASC, created_at_unix_ms DESC LIMIT 200", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelReviewNote { + note_id: row.get(0)?, + work_id: row.get(1)?, + chapter_id: row.get(2)?, + note: row.get(3)?, + status: row.get(4)?, + created_at_unix_ms: row.get(5)?, + resolved_at_unix_ms: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_workflow_events( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT event_id, work_id, chapter_id, from_status, to_status, note, created_at_unix_ms + FROM web_novel_workflow_events WHERE work_id=?1 + ORDER BY created_at_unix_ms DESC LIMIT 100", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelWorkflowEvent { + event_id: row.get(0)?, + work_id: row.get(1)?, + chapter_id: row.get(2)?, + from_status: row.get(3)?, + to_status: row.get(4)?, + note: row.get(5)?, + created_at_unix_ms: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_checkpoints( + connection: &Connection, + work_id: &str, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT checkpoint_id, work_id, name, description, chapter_count, word_count, created_at_unix_ms + FROM web_novel_checkpoints WHERE work_id=?1 + ORDER BY created_at_unix_ms DESC LIMIT 50", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], |row| { + Ok(WebNovelCheckpointSummary { + checkpoint_id: row.get(0)?, + work_id: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + chapter_count: row.get(4)?, + word_count: row.get(5)?, + created_at_unix_ms: row.get(6)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_metrics(connection: &Connection, work_id: &str) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT metric_id, work_id, metric_date, views, follows, comments, + paid_readers, revenue_cents, source_label, revision, updated_at_unix_ms + FROM web_novel_metrics WHERE work_id=?1 + ORDER BY metric_date DESC, source_label LIMIT 180", + ) + .map_err(database_read_error)?; + let result = statement + .query_map([work_id], map_metric) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(result) +} + +fn query_metric(connection: &Connection, metric_id: &str) -> Result { + connection + .query_row( + "SELECT metric_id, work_id, metric_date, views, follows, comments, + paid_readers, revenue_cents, source_label, revision, updated_at_unix_ms + FROM web_novel_metrics WHERE metric_id=?1", + [metric_id], + map_metric, + ) + .map_err(database_read_error) +} + +fn operations_summary( + connection: &Connection, + work_id: &str, +) -> Result { + let chapter_values: (i64, i64, i64, i64, i64, i64) = connection + .query_row( + "SELECT COALESCE(SUM(word_count),0), + COALESCE(SUM(CASE WHEN workflow_status IN ('DRAFT','SELF_REVIEW','REVISION_REQUIRED') THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN workflow_status='EDITOR_REVIEW' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN workflow_status='APPROVED' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN workflow_status='SCHEDULED' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN workflow_status='PUBLISHED' THEN 1 ELSE 0 END),0) + FROM web_novel_chapters WHERE work_id=?1 AND archived=0", + [work_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + ) + .map_err(database_read_error)?; + let open_review_notes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM web_novel_review_notes WHERE work_id=?1 AND status='OPEN'", + [work_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + let open_foreshadows: i64 = connection + .query_row( + "SELECT COUNT(*) FROM web_novel_foreshadows + WHERE work_id=?1 AND status='OPEN' AND archived=0", + [work_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + let latest = connection + .query_row( + "SELECT views, follows, comments, paid_readers, revenue_cents + FROM web_novel_metrics WHERE work_id=?1 + ORDER BY metric_date DESC, updated_at_unix_ms DESC LIMIT 1", + [work_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .optional() + .map_err(database_read_error)? + .unwrap_or((0, 0, 0, 0, 0)); + Ok(WebNovelOperationsSummary { + total_words: chapter_values.0, + draft_chapters: chapter_values.1, + editor_review_chapters: chapter_values.2, + approved_chapters: chapter_values.3, + scheduled_chapters: chapter_values.4, + published_chapters: chapter_values.5, + open_review_notes, + open_foreshadows, + latest_views: latest.0, + latest_follows: latest.1, + latest_comments: latest.2, + latest_paid_readers: latest.3, + latest_revenue_cents: latest.4, + }) +} + +#[allow(clippy::too_many_arguments)] +fn insert_chapter_version( + connection: &Connection, + chapter_id: &str, + work_id: &str, + revision: i64, + title: &str, + synopsis: &str, + content: &str, + workflow_status: &str, + word_count: i64, + save_reason: &str, + created_at: i64, +) -> Result<(), String> { + connection + .execute( + "INSERT INTO web_novel_chapter_versions( + version_id, chapter_id, work_id, revision, title, synopsis, content, + workflow_status, word_count, save_reason, created_at_unix_ms + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + new_id("WN-VER-"), + chapter_id, + work_id, + revision, + title, + synopsis, + content, + workflow_status, + word_count, + save_reason, + created_at + ], + ) + .map_err(database_write_error)?; + Ok(()) +} + +fn map_work(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WebNovelWork { + work_id: row.get(0)?, + title: row.get(1)?, + pen_name: row.get(2)?, + genre: row.get(3)?, + work_kind: row.get(4)?, + synopsis: row.get(5)?, + contract_status: row.get(6)?, + copyright_status: row.get(7)?, + workflow_status: row.get(8)?, + target_words: row.get(9)?, + revision: row.get(10)?, + created_at_unix_ms: row.get(11)?, + updated_at_unix_ms: row.get(12)?, + }) +} + +fn map_chapter(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WebNovelChapter { + chapter_id: row.get(0)?, + volume_id: row.get(1)?, + work_id: row.get(2)?, + title: row.get(3)?, + position: row.get(4)?, + synopsis: row.get(5)?, + content: row.get(6)?, + workflow_status: row.get(7)?, + scheduled_at_unix_ms: row.get(8)?, + word_count: row.get(9)?, + revision: row.get(10)?, + created_at_unix_ms: row.get(11)?, + updated_at_unix_ms: row.get(12)?, + }) +} + +fn map_entity(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: String = row.get(4)?; + let aliases = serde_json::from_str(&raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(WebNovelStoryEntity { + entity_id: row.get(0)?, + work_id: row.get(1)?, + entity_type: row.get(2)?, + name: row.get(3)?, + aliases, + description: row.get(5)?, + first_chapter_id: row.get(6)?, + revision: row.get(7)?, + created_at_unix_ms: row.get(8)?, + updated_at_unix_ms: row.get(9)?, + }) +} + +fn map_foreshadow(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WebNovelForeshadow { + foreshadow_id: row.get(0)?, + work_id: row.get(1)?, + title: row.get(2)?, + setup_chapter_id: row.get(3)?, + payoff_chapter_id: row.get(4)?, + status: row.get(5)?, + note: row.get(6)?, + revision: row.get(7)?, + created_at_unix_ms: row.get(8)?, + updated_at_unix_ms: row.get(9)?, + }) +} + +fn map_metric(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WebNovelMetric { + metric_id: row.get(0)?, + work_id: row.get(1)?, + metric_date: row.get(2)?, + views: row.get(3)?, + follows: row.get(4)?, + comments: row.get(5)?, + paid_readers: row.get(6)?, + revenue_cents: row.get(7)?, + source_label: row.get(8)?, + revision: row.get(9)?, + updated_at_unix_ms: row.get(10)?, + }) +} + +fn ensure_work_exists(connection: &Connection, work_id: &str) -> Result<(), String> { + let exists: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)", + [work_id], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if exists { + Ok(()) + } else { + Err("HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into()) + } +} + +fn touch_work(connection: &Connection, work_id: &str) -> Result<(), String> { + connection + .execute( + "UPDATE web_novel_works SET updated_at_unix_ms=?1 WHERE work_id=?2", + params![now_ms(), work_id], + ) + .map_err(database_write_error)?; + Ok(()) +} + +fn issue( + severity: &str, + code: &str, + title: &str, + detail: String, + object_id: &str, +) -> WebNovelContinuityIssue { + WebNovelContinuityIssue { + issue_id: new_id("WN-AUDIT-"), + severity: severity.into(), + code: code.into(), + title: title.into(), + detail, + object_id: object_id.into(), + } +} + +fn chapter_global_order(detail: &WebNovelWorkDetail, chapter_id: &str) -> Option<(i64, i64)> { + let chapter = detail + .chapters + .iter() + .find(|chapter| chapter.chapter_id == chapter_id)?; + let volume = detail + .volumes + .iter() + .find(|volume| volume.volume_id == chapter.volume_id)?; + Some((volume.position, chapter.position)) +} + +fn normalized_workflow(value: &str) -> Result { + let normalized = value.trim().to_ascii_uppercase(); + if WORKFLOW_STATES.contains(&normalized.as_str()) { + Ok(normalized) + } else { + Err("HOLOLAKE_WEBNOVEL_WORKFLOW_STATE_INVALID".into()) + } +} + +pub(crate) fn normalized_work_kind(value: &str) -> Result { + let normalized = value.trim().to_ascii_uppercase(); + if matches!( + normalized.as_str(), + "LONG_NOVEL" | "SHORT_NOVEL" | "SHORT_DRAMA" + ) { + Ok(normalized) + } else { + Err("HOLOLAKE_WEBNOVEL_WORK_KIND_INVALID".into()) + } +} + +fn default_work_kind() -> String { + "LONG_NOVEL".into() +} + +fn chapter_template(work_kind: &str, title: &str, position: i64) -> String { + if work_kind != "SHORT_DRAMA" { + return String::new(); + } + let episode = position + 1; + format!( + "{title}\n\n场次:{episode}-1 日/夜 内/外 地点\n人物:\n\n△ 动作与画面\n\n角色:对白\n\n【本场转折】\n【集尾钩子】" + ) +} + +fn ensure_column( + connection: &Connection, + table: &str, + column: &str, + definition: &str, +) -> Result<(), String> { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(database_read_error)?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + if !columns.iter().any(|value| value == column) { + connection + .execute_batch(&format!( + "ALTER TABLE {table} ADD COLUMN {column} {definition};" + )) + .map_err(database_write_error)?; + } + Ok(()) +} + +fn transition_allowed(from: &str, to: &str) -> bool { + if from == to { + return false; + } + matches!( + (from, to), + ("DRAFT", "SELF_REVIEW") + | ("SELF_REVIEW", "DRAFT") + | ("SELF_REVIEW", "EDITOR_REVIEW") + | ("EDITOR_REVIEW", "REVISION_REQUIRED") + | ("EDITOR_REVIEW", "APPROVED") + | ("REVISION_REQUIRED", "DRAFT") + | ("REVISION_REQUIRED", "EDITOR_REVIEW") + | ("APPROVED", "REVISION_REQUIRED") + | ("APPROVED", "SCHEDULED") + | ("APPROVED", "PUBLISHED") + | ("SCHEDULED", "APPROVED") + | ("SCHEDULED", "PUBLISHED") + | ("PUBLISHED", "REVISION_REQUIRED") + ) +} + +fn normalized_entity_type(value: &str) -> Result { + let normalized = value.trim().to_ascii_uppercase(); + if matches!( + normalized.as_str(), + "CHARACTER" | "LOCATION" | "WORLD_RULE" | "ITEM" | "ORGANIZATION" | "EVENT" + ) { + Ok(normalized) + } else { + Err("HOLOLAKE_WEBNOVEL_ENTITY_TYPE_INVALID".into()) + } +} + +fn normalized_foreshadow_status(value: &str) -> Result { + let normalized = value.trim().to_ascii_uppercase(); + if matches!(normalized.as_str(), "OPEN" | "RESOLVED" | "DROPPED") { + Ok(normalized) + } else { + Err("HOLOLAKE_WEBNOVEL_FORESHADOW_STATUS_INVALID".into()) + } +} + +fn normalized_label(value: &str, fallback: &str) -> Result { + let normalized = if value.trim().is_empty() { + fallback.to_string() + } else { + value.trim().to_ascii_uppercase() + }; + if normalized.len() > 80 + || !normalized + .chars() + .all(|value| value.is_ascii_uppercase() || value.is_ascii_digit() || value == '_') + { + return Err("HOLOLAKE_WEBNOVEL_STATUS_LABEL_INVALID".into()); + } + Ok(normalized) +} + +fn normalized_save_reason(value: Option) -> String { + let value = value.unwrap_or_else(|| "AUTOSAVE".into()); + let normalized = value.trim().to_ascii_uppercase(); + if normalized.is_empty() || normalized.len() > 80 { + "AUTOSAVE".into() + } else { + normalized + } +} + +fn bounded_text(value: &str, fallback: &str, max_bytes: usize) -> Result { + let normalized = if value.trim().is_empty() { + fallback.trim() + } else { + value.trim() + }; + if normalized.len() > max_bytes { + return Err("HOLOLAKE_WEBNOVEL_TEXT_BOUNDS_INVALID".into()); + } + Ok(normalized.to_string()) +} + +fn validate_optional_bytes(value: &str, max_bytes: usize, label: &str) -> Result<(), String> { + if value.len() > max_bytes { + Err(format!("HOLOLAKE_WEBNOVEL_{label}_BOUNDS_INVALID")) + } else { + Ok(()) + } +} + +fn validate_metric_date(value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + if bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + { + Ok(()) + } else { + Err("HOLOLAKE_WEBNOVEL_METRIC_DATE_INVALID".into()) + } +} + +fn validate_id(value: &str, prefix: &str) -> Result<(), String> { + if value.starts_with(prefix) + && value.len() <= prefix.len() + 64 + && value[prefix.len()..] + .chars() + .all(|value| value.is_ascii_alphanumeric() || value == '-') + { + Ok(()) + } else { + Err("HOLOLAKE_WEBNOVEL_ENTITY_ID_INVALID".into()) + } +} + +fn count_words(content: &str) -> i64 { + content + .chars() + .filter(|value| !value.is_whitespace()) + .count() as i64 +} + +fn safe_filename(value: &str) -> String { + let cleaned: String = value + .chars() + .map(|character| { + if matches!( + character, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' + ) { + '_' + } else { + character + } + }) + .collect(); + if cleaned.trim().is_empty() { + "未命名网文作品".into() + } else { + cleaned.trim().chars().take(80).collect() + } +} + +fn new_id(prefix: &str) -> String { + format!("{prefix}{}", Uuid::new_v4()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn database_read_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_DATABASE_READ_FAILED: {error}") +} + +fn database_write_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_WEBNOVEL_DATABASE_WRITE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn database_path() -> (tempfile::TempDir, PathBuf) { + let temp = tempdir().unwrap(); + let path = temp.path().join("web-novel.sqlite3"); + (temp, path) + } + + #[test] + fn chapter_save_persists_versions_and_rejects_stale_revision() { + let (_temp, path) = database_path(); + let detail = create_work_at( + &path, + CreateWebNovelWorkInput { + title: "测试作品".into(), + pen_name: "冰朔".into(), + genre: "幻想".into(), + work_kind: "LONG_NOVEL".into(), + }, + ) + .unwrap(); + let chapter = create_chapter_at( + &path, + CreateWebNovelChapterInput { + work_id: detail.work.work_id, + volume_id: detail.volumes[0].volume_id.clone(), + title: "第一章".into(), + }, + ) + .unwrap(); + let saved = save_chapter_at( + &path, + SaveWebNovelChapterInput { + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + synopsis: "开端".into(), + content: "真实正文".into(), + expected_revision: chapter.revision, + save_reason: Some("AUTOSAVE".into()), + }, + ) + .unwrap(); + assert_eq!(saved.revision, 2); + assert_eq!(saved.word_count, 4); + let stale = save_chapter_at( + &path, + SaveWebNovelChapterInput { + chapter_id: chapter.chapter_id, + title: "冲突".into(), + synopsis: String::new(), + content: "不会覆盖".into(), + expected_revision: 1, + save_reason: None, + }, + ) + .unwrap_err(); + assert_eq!(stale, "HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT"); + let connection = open_database(&path).unwrap(); + let versions: i64 = connection + .query_row( + "SELECT COUNT(*) FROM web_novel_chapter_versions", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(versions, 2); + } + + #[test] + fn workflow_requires_review_and_non_empty_content() { + assert!(transition_allowed("DRAFT", "SELF_REVIEW")); + assert!(!transition_allowed("DRAFT", "PUBLISHED")); + assert!(transition_allowed("EDITOR_REVIEW", "APPROVED")); + assert!(transition_allowed("APPROVED", "PUBLISHED")); + } + + #[test] + fn checkpoint_restores_saved_chapter_content() { + let (_temp, path) = database_path(); + let detail = create_work_at( + &path, + CreateWebNovelWorkInput { + title: "检查点作品".into(), + pen_name: "作者".into(), + genre: "悬疑".into(), + work_kind: "LONG_NOVEL".into(), + }, + ) + .unwrap(); + let chapter = create_chapter_at( + &path, + CreateWebNovelChapterInput { + work_id: detail.work.work_id.clone(), + volume_id: detail.volumes[0].volume_id.clone(), + title: "序章".into(), + }, + ) + .unwrap(); + let first = save_chapter_at( + &path, + SaveWebNovelChapterInput { + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + synopsis: String::new(), + content: "检查点正文".into(), + expected_revision: chapter.revision, + save_reason: None, + }, + ) + .unwrap(); + let checkpoint = create_checkpoint_at( + &path, + CreateWebNovelCheckpointInput { + work_id: detail.work.work_id, + name: "交稿前".into(), + description: String::new(), + }, + ) + .unwrap(); + save_chapter_at( + &path, + SaveWebNovelChapterInput { + chapter_id: chapter.chapter_id.clone(), + title: chapter.title, + synopsis: String::new(), + content: "之后的正文".into(), + expected_revision: first.revision, + save_reason: None, + }, + ) + .unwrap(); + restore_checkpoint_at(&path, &checkpoint.checkpoint_id).unwrap(); + assert_eq!( + read_chapter_at(&path, &chapter.chapter_id).unwrap().content, + "检查点正文" + ); + } +} diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 520f5a03c..4c5201fff 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -8,6 +8,7 @@ import type { ChannelWorkbenchSnapshot } from './modules/channel-workbench' const ChannelWorkbenchStudio = lazy(() => import('./modules/channel-workbench').then((module) => ({ default: module.ChannelWorkbenchStudio }))) const PersonaChannelBody = lazy(() => import('./modules/persona-channel-body').then((module) => ({ default: module.PersonaChannelBody }))) const EducationWorkspace = lazy(() => import('./modules/education-workspace').then((module) => ({ default: module.EducationWorkspace }))) +const WebNovelWorkspace = lazy(() => import('./modules/web-novel/WebNovelWorkspace').then((module) => ({ default: module.WebNovelWorkspace }))) const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] @@ -54,7 +55,7 @@ import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'education' | 'persona' | 'code' | 'receipts' | 'system' +type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'education' | 'webNovel' | 'persona' | 'code' | 'receipts' | 'system' type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool' type KnowledgeSource = 'native' | 'legacy' @@ -368,7 +369,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] -const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', education: '教育工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } +const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', education: '教育工作台', webNovel: '网文作者工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } const domainGates = [ { domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [ ['域标识', 'BRANCH_DOMAIN'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], @@ -611,6 +612,9 @@ function HoloLakeApp() { const [educationModule, setEducationModule] = useState(null) const [educationBusy, setEducationBusy] = useState(false) const [educationMessage, setEducationMessage] = useState('') + const [webNovelModule, setWebNovelModule] = useState(null) + const [webNovelBusy, setWebNovelBusy] = useState(false) + const [webNovelMessage, setWebNovelMessage] = useState('') const [expanded, setExpanded] = useState>(() => new Set(['导入'])) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -1182,6 +1186,27 @@ function HoloLakeApp() { finally { setEducationBusy(false) } } + const refreshWebNovelModule = async () => { + try { + const catalog = await invoke('get_bundled_module_catalog') + const module = catalog.find((item) => item.moduleNumber === 'HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001') || null + setWebNovelModule(module) + return module + } catch (error) { setWebNovelMessage(humanError(error, 'system')); return null } + } + const openWebNovel = () => { openWorldTool('webNovel'); void refreshWebNovelModule() } + const activateWebNovelModule = async () => { + setWebNovelBusy(true) + setWebNovelMessage('正在验证官方签名、登记网文工作台编号、确认八项边界并执行自检……') + try { + await invoke('activate_bundled_module', { input: { moduleNumber: 'HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001', humanConfirmedPermissionExpansion: true } }) + const module = await refreshWebNovelModule() + if (!module || module.installedState !== 'ACTIVE') throw new Error('HOLOLAKE_MODULE_NOT_ACTIVE') + setWebNovelMessage('网文作者工作台已通过签名、编号、权限和自检验收。') + } catch (error) { setWebNovelMessage(humanError(error, 'system')) } + finally { setWebNovelBusy(false) } + } + const cloneCodeChannel = async (event: React.FormEvent) => { event.preventDefault() if (!cloneUrl.trim()) return @@ -1596,6 +1621,20 @@ function HoloLakeApp() { ) + const renderWebNovel = () => ( +
    + {webNovelModule?.installedState === 'ACTIVE' + ?

    正在装载网文作者工作台……

    }> setWorldStage(toolReturnStage)}/>
    + :
    +

    启用网文作者工作台

    +

    基础工作台保存作品、分卷、章节、版本、设定和运营记录;大纲、多维情节表、故事世界与高级交付分别使用独立官方编号按需挂载。

    + HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001 · 8 项编号权限 + + {webNovelMessage &&

    {webNovelMessage}

    } +
    } +
    + ) + const renderCode = () => (
    } @@ -1879,7 +1919,7 @@ function HoloLakeApp() { } {worldStage === 'tool' &&
    {viewLabels[view]}{domainDisplayName(repoLogin.domain)}
    -
    {view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'education' ? renderEducation() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
    +
    {view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'education' ? renderEducation() : view === 'webNovel' ? renderWebNovel() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
    }
    光湖语言系统 · 通用人工智能操作平台GH-AIOS
    diff --git a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts index 047c3ce54..d962a1f5d 100644 --- a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts +++ b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts @@ -816,6 +816,310 @@ const ROUTES = { "moduleNumber": "HLP-NIPC-MOD-0024", "operationNumber": "HLP-NIPC-OP-0102", "targetNumber": "HLP-NIPC-TGT-0024" + }, + "get_web_novel_workspace_snapshot": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0103", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_work": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0104", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "read_web_novel_work": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0105", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "save_web_novel_work": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0106", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_volume": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0107", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_chapter": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0108", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "read_web_novel_chapter": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0109", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "save_web_novel_chapter": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0110", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "transition_web_novel_chapter": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0111", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_checkpoint": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0112", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "restore_web_novel_checkpoint": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0113", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "upsert_web_novel_story_entity": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0114", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_story_relation": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0115", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "upsert_web_novel_foreshadow": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0116", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_review_note": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0117", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "resolve_web_novel_review_note": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0118", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "save_web_novel_metric": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0119", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "run_web_novel_continuity_audit": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0120", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "export_web_novel_markdown": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0121", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "inspect_web_novel_document_from_dialog": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0122", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "commit_web_novel_document_import": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0123", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "get_web_novel_author_snapshot": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0124", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "record_web_novel_writing_activity": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0125", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "create_web_novel_inspiration": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0126", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "set_web_novel_inspiration_status": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0127", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "search_web_novel_full_text": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0128", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "format_web_novel_chapter": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0129", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "format_web_novel_work": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0130", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "upsert_web_novel_shot": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0131", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "get_web_novel_author_module_data": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0025", + "operationNumber": "HLP-NIPC-OP-0132", + "targetNumber": "HLP-NIPC-TGT-0025" + }, + "upsert_web_novel_author_scene": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0026", + "operationNumber": "HLP-NIPC-OP-0133", + "targetNumber": "HLP-NIPC-TGT-0026" + }, + "upsert_web_novel_author_beat": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0026", + "operationNumber": "HLP-NIPC-OP-0134", + "targetNumber": "HLP-NIPC-TGT-0026" + }, + "upsert_web_novel_story_field_definition": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0027", + "operationNumber": "HLP-NIPC-OP-0135", + "targetNumber": "HLP-NIPC-TGT-0027" + }, + "upsert_web_novel_story_field_value": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0027", + "operationNumber": "HLP-NIPC-OP-0136", + "targetNumber": "HLP-NIPC-TGT-0027" + }, + "upsert_web_novel_timeline_event": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0028", + "operationNumber": "HLP-NIPC-OP-0137", + "targetNumber": "HLP-NIPC-TGT-0028" + }, + "link_web_novel_scene_entity": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0028", + "operationNumber": "HLP-NIPC-OP-0138", + "targetNumber": "HLP-NIPC-TGT-0028" + }, + "restore_web_novel_chapter_version": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0029", + "operationNumber": "HLP-NIPC-OP-0139", + "targetNumber": "HLP-NIPC-TGT-0029" + }, + "export_web_novel_author_delivery": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0029", + "operationNumber": "HLP-NIPC-OP-0140", + "targetNumber": "HLP-NIPC-TGT-0029" } } as const diff --git a/product-source/hololake-native-desktop/src/modules/web-novel/AuthorModuleCenter.tsx b/product-source/hololake-native-desktop/src/modules/web-novel/AuthorModuleCenter.tsx new file mode 100644 index 000000000..698d4aa0b --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/web-novel/AuthorModuleCenter.tsx @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { numberedInvoke as invoke } from '../numbered-ipc' + +interface ChapterRef { chapterId: string; title: string } +interface EntityRef { entityId: string; name: string; entityType: string } +interface BundledModuleDescriptor { moduleNumber: string; displayName: string; version: string; adapter: string; permissions: string[]; packageSha256: string; installedState: string; signatureVerified: boolean } +interface ModuleDescriptor { moduleId: string; name: string; description: string; version: string; packageSha256: string; installState: 'AVAILABLE' | 'INSTALLED' | 'MOUNTED'; selfTestState: string } +interface ModuleReceipt { receiptId: string; moduleId: string; action: string; state: string; detail: string; receiptHash: string; createdAtUnixMs: number } +interface Marketplace { state: string; modules: ModuleDescriptor[]; recentReceipts: ModuleReceipt[] } +interface Scene { sceneId: string; workId: string; chapterId: string; title: string; position: number; synopsis: string; status: string; goal: string; conflict: string; outcome: string; hook: string; emotionalPoint: string; targetWords: number; revision: number } +interface Beat { beatId: string; sceneId: string; position: number; title: string; note: string; status: string; revision: number } +interface FieldDefinition { fieldId: string; label: string; fieldType: string; options: string[]; position: number; revision: number } +interface FieldValue { fieldId: string; sceneId: string; value: string; revision: number } +interface TimelineEvent { eventId: string; sceneId?: string; entityId?: string; title: string; calendarKind: string; storyDay?: number; dateText: string; timeText: string; durationMinutes: number; description: string; revision: number } +interface ChapterVersion { versionId: string; chapterId: string; chapterTitle: string; revision: number; wordCount: number; saveReason: string; createdAtUnixMs: number } +interface ModuleData { scenes: Scene[]; beats: Beat[]; fieldDefinitions: FieldDefinition[]; fieldValues: FieldValue[]; timelineEvents: TimelineEvent[]; chapterVersions: ChapterVersion[] } +interface DeliveryReceipt { format: string; path: string; bytes: number; chapterCount: number; packageSha256: string } + +const MODULES = { + outline: 'HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001', + grid: 'HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001', + storyworld: 'HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001', + delivery: 'HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001', +} as const + +const MODULE_DESCRIPTIONS: Record = { + [MODULES.outline]: '场景、情节拍、目标冲突结果、钩子与伏笔追踪。', + [MODULES.grid]: '由同一场景对象生成可编辑表格、自定义字段与分组情节板。', + [MODULES.storyworld]: '故事时间、场景与人物地点物品的真实关系投影。', + [MODULES.delivery]: '章节版本读回与恢复,以及 TXT、DOCX、EPUB、JSON 真实导出。', +} + +function readableError(error: unknown) { + return String(error instanceof Error ? error.message : error).replace(/^HOLOLAKE_WEBNOVEL_[A-Z_]+:?\s*/, '') +} + +export function AuthorModuleCenter({ workId, chapters, entities, onMessage, onWorkRefresh }: { + workId: string + chapters: ChapterRef[] + entities: EntityRef[] + onMessage: (message: string) => void + onWorkRefresh: () => Promise +}) { + const [marketplace, setMarketplace] = useState(null) + const [data, setData] = useState(null) + const [activeModule, setActiveModule] = useState(MODULES.outline) + const [busy, setBusy] = useState(false) + const [sceneDraft, setSceneDraft] = useState({ chapterId: chapters[0]?.chapterId || '', title: '', synopsis: '', status: 'PLANNED', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '', targetWords: '1200' }) + const [fieldDraft, setFieldDraft] = useState({ label: '', fieldType: 'TEXT', options: '' }) + const [timelineDraft, setTimelineDraft] = useState({ title: '', calendarKind: 'STORY_DAY', storyDay: '1', dateText: '', timeText: '', durationMinutes: '0', description: '', sceneId: '', entityId: '' }) + + const refresh = useCallback(async () => { + const catalog = await invoke('get_bundled_module_catalog') + const nextMarketplace: Marketplace = { + state: 'READY', + modules: catalog.filter((item) => Object.values(MODULES).includes(item.moduleNumber as typeof MODULES[keyof typeof MODULES])).map((item) => ({ + moduleId: item.moduleNumber, + name: item.displayName, + description: MODULE_DESCRIPTIONS[item.moduleNumber] || '光湖官方编号模块。', + version: item.version, + packageSha256: item.packageSha256, + installState: item.installedState === 'ACTIVE' ? 'MOUNTED' : item.installedState === 'NOT_INSTALLED' ? 'AVAILABLE' : 'INSTALLED', + selfTestState: item.installedState === 'ACTIVE' ? 'PASS' : item.signatureVerified ? 'SIGNED' : 'UNVERIFIED', + })), + recentReceipts: [], + } + const nextData = await invoke('get_web_novel_author_module_data', { input: { workId } }) + setMarketplace(nextMarketplace) + setData(nextData) + }, [workId]) + + useEffect(() => { + let cancelled = false + refresh().catch((error) => !cancelled && onMessage(`模块运行时读取失败:${readableError(error)}`)) + return () => { cancelled = true } + }, [refresh, onMessage]) + + useEffect(() => { + if (!sceneDraft.chapterId && chapters[0]) setSceneDraft((current) => ({ ...current, chapterId: chapters[0].chapterId })) + }, [chapters, sceneDraft.chapterId]) + + const run = async (action: () => Promise) => { + setBusy(true) + try { await action() } catch (error) { onMessage(readableError(error)) } finally { setBusy(false) } + } + + const moduleAction = (action: 'activate' | 'unmount', moduleId: string, success: string) => void run(async () => { + if (action === 'activate') await invoke('activate_bundled_module', { input: { moduleNumber: moduleId, humanConfirmedPermissionExpansion: true } }) + else await invoke('unmount_module', { input: { moduleNumber: moduleId } }) + await refresh() + onMessage(success) + }) + + const createScene = () => void run(async () => { + if (!sceneDraft.chapterId || !sceneDraft.title.trim()) { onMessage('请选择章节并填写场景名称。'); return } + await invoke('upsert_web_novel_author_scene', { input: { + workId, sceneId: null, chapterId: sceneDraft.chapterId, title: sceneDraft.title, + position: null, synopsis: sceneDraft.synopsis, status: sceneDraft.status, + goal: sceneDraft.goal, conflict: sceneDraft.conflict, outcome: sceneDraft.outcome, + hook: sceneDraft.hook, emotionalPoint: sceneDraft.emotionalPoint, + targetWords: Math.max(0, Number(sceneDraft.targetWords) || 0), expectedRevision: null, + } }) + setSceneDraft((current) => ({ ...current, title: '', synopsis: '', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '' })) + await refresh() + onMessage('场景已写入作品结构库。') + }) + + const editScene = (scene: Scene, patch: Partial) => void run(async () => { + await invoke('upsert_web_novel_author_scene', { input: { + workId, sceneId: scene.sceneId, chapterId: patch.chapterId ?? scene.chapterId, + title: patch.title ?? scene.title, position: patch.position ?? scene.position, + synopsis: patch.synopsis ?? scene.synopsis, status: patch.status ?? scene.status, + goal: patch.goal ?? scene.goal, conflict: patch.conflict ?? scene.conflict, + outcome: patch.outcome ?? scene.outcome, hook: patch.hook ?? scene.hook, + emotionalPoint: patch.emotionalPoint ?? scene.emotionalPoint, + targetWords: patch.targetWords ?? scene.targetWords, expectedRevision: scene.revision, + } }) + await refresh() + onMessage('情节表修订已落库。') + }) + + const addBeat = (scene: Scene) => { + const title = window.prompt('情节拍名称', '') + if (!title?.trim()) return + const note = window.prompt('这一拍发生什么?', '') || '' + void run(async () => { + await invoke('upsert_web_novel_author_beat', { input: { sceneId: scene.sceneId, beatId: null, title, note, status: 'PLANNED', position: null, expectedRevision: null } }) + await refresh(); onMessage('情节拍已追加。') + }) + } + + const addField = () => void run(async () => { + if (!fieldDraft.label.trim()) return + await invoke('upsert_web_novel_story_field_definition', { input: { workId, fieldId: null, label: fieldDraft.label, fieldType: fieldDraft.fieldType, options: fieldDraft.options.split(/[,,]/).map((value) => value.trim()).filter(Boolean), expectedRevision: null } }) + setFieldDraft({ label: '', fieldType: 'TEXT', options: '' }); await refresh(); onMessage('自定义情节维度已添加。') + }) + + const editFieldValue = (field: FieldDefinition, scene: Scene) => { + const current = data?.fieldValues.find((value) => value.fieldId === field.fieldId && value.sceneId === scene.sceneId) + const value = window.prompt(`${scene.title} · ${field.label}`, current?.value || '') + if (value === null) return + void run(async () => { + await invoke('upsert_web_novel_story_field_value', { input: { fieldId: field.fieldId, sceneId: scene.sceneId, value, expectedRevision: current?.revision ?? null } }) + await refresh(); onMessage(`已保存“${field.label}”。`) + }) + } + + const createTimeline = () => void run(async () => { + if (!timelineDraft.title.trim()) return + await invoke('upsert_web_novel_timeline_event', { input: { + workId, eventId: null, sceneId: timelineDraft.sceneId || null, entityId: timelineDraft.entityId || null, + title: timelineDraft.title, calendarKind: timelineDraft.calendarKind, + storyDay: timelineDraft.storyDay ? Number(timelineDraft.storyDay) : null, + dateText: timelineDraft.dateText, timeText: timelineDraft.timeText, + durationMinutes: Math.max(0, Number(timelineDraft.durationMinutes) || 0), + description: timelineDraft.description, expectedRevision: null, + } }) + setTimelineDraft((current) => ({ ...current, title: '', description: '' })); await refresh(); onMessage('时间线事件已写入故事世界。') + }) + + const linkEntity = (scene: Scene) => { + if (!entities.length) { onMessage('请先在“设定”里建立人物、地点或物品。'); return } + const value = window.prompt(`输入对象序号:\n${entities.map((entity, index) => `${index + 1}. ${entity.name}`).join('\n')}`, '1') + const entity = entities[(Number(value) || 0) - 1] + if (!entity) return + const role = window.prompt('该对象在场景中的作用', '出场') || '出场' + void run(async () => { + await invoke('link_web_novel_scene_entity', { input: { workId, sceneId: scene.sceneId, entityId: entity.entityId, role, note: '' } }) + await refresh(); onMessage(`已把${entity.name}关联到${scene.title}。`) + }) + } + + const restoreVersion = (version: ChapterVersion) => { + if (!window.confirm(`把“${version.chapterTitle}”恢复到修订 ${version.revision}?当前内容会先留在版本链中。`)) return + void run(async () => { + await invoke('restore_web_novel_chapter_version', { input: { versionId: version.versionId } }) + await onWorkRefresh(); await refresh(); onMessage('历史版本已恢复,并生成了新的恢复版本。') + }) + } + + const exportDelivery = (format: string) => void run(async () => { + const receipt = await invoke('export_web_novel_author_delivery', { input: { workId, format } }) + if (receipt) onMessage(`${format} 已真实导出 ${receipt.chapterCount} 章、${receipt.bytes.toLocaleString()} 字节;校验 ${receipt.packageSha256.slice(0, 12)}。`) + }) + + const chapterName = useMemo(() => new Map(chapters.map((chapter) => [chapter.chapterId, chapter.title])), [chapters]) + const mounted = (moduleId: string) => marketplace?.modules.find((module) => module.moduleId === moduleId)?.installState === 'MOUNTED' + + return
    +
    OFFICIAL HOT-PLUG MODULES

    作者模块中心

    轻量码字内置;完整能力通过官方包校验、自检后挂载到当前频道。

    +
    + {marketplace?.modules.map((module) =>
    setActiveModule(module.moduleId)}> +
    {module.moduleId}{module.name}

    {module.description}

    {module.installState === 'MOUNTED' ? '已挂载' : module.installState === 'INSTALLED' ? '已安装' : '可安装'} · 自检 {module.selfTestState}
    +
    + {module.installState === 'AVAILABLE' && } + {module.installState === 'INSTALLED' && } + {module.installState === 'MOUNTED' && } +
    +
    )} +
    + + {activeModule === MODULES.outline &&
    +
    {MODULES.outline}

    作品结构与大纲追踪

    {mounted(MODULES.outline) ? '引擎已挂载' : '先安装并挂载'}
    +
    { event.preventDefault(); createScene() }}> setSceneDraft({ ...sceneDraft, title: event.target.value })}/> setSceneDraft({ ...sceneDraft, synopsis: event.target.value })}/> setSceneDraft({ ...sceneDraft, goal: event.target.value })}/> setSceneDraft({ ...sceneDraft, conflict: event.target.value })}/> setSceneDraft({ ...sceneDraft, outcome: event.target.value })}/> setSceneDraft({ ...sceneDraft, hook: event.target.value })}/> setSceneDraft({ ...sceneDraft, emotionalPoint: event.target.value })}/>
    +
    {data?.scenes.map((scene) =>
    {chapterName.get(scene.chapterId)} · {scene.status}{scene.title}

    {scene.goal || '待填目标'} → {scene.conflict || '待填冲突'} → {scene.outcome || '待填结果'}

    钩子:{scene.hook || '未设置'} · 修订 {scene.revision}
      {data.beats.filter((beat) => beat.sceneId === scene.sceneId).map((beat) =>
    1. {beat.title}{beat.note}
    2. )}
    )}
    +
    } + + {activeModule === MODULES.grid &&
    +
    {MODULES.grid}

    多维情节表

    {mounted(MODULES.grid) ? '可编辑' : '先安装并挂载'}
    +
    { event.preventDefault(); addField() }}> setFieldDraft({ ...fieldDraft, label: event.target.value })}/> setFieldDraft({ ...fieldDraft, options: event.target.value })}/>
    +
    {data?.fieldDefinitions.map((field) => )}{data?.scenes.map((scene) => {data.fieldDefinitions.map((field) => { const value = data.fieldValues.find((item) => item.fieldId === field.fieldId && item.sceneId === scene.sceneId)?.value; return })})}
    章节场景状态目标冲突结果钩子{field.label}
    {chapterName.get(scene.chapterId)}{scene.title}{scene.goal}{scene.conflict}{scene.outcome}{scene.hook}
    +
    } + + {activeModule === MODULES.storyworld &&
    +
    {MODULES.storyworld}

    时间线与故事资料库

    {mounted(MODULES.storyworld) ? '引擎已挂载' : '先安装并挂载'}
    +
    { event.preventDefault(); createTimeline() }}> setTimelineDraft({ ...timelineDraft, title: event.target.value })}/> setTimelineDraft({ ...timelineDraft, storyDay: event.target.value })}/> setTimelineDraft({ ...timelineDraft, dateText: event.target.value })}/> setTimelineDraft({ ...timelineDraft, timeText: event.target.value })}/>