feat: admit signed web novel module family

This commit is contained in:
冰朔 2026-08-19 03:51:18 +08:00
commit eb7223ea4c
41 changed files with 10452 additions and 21 deletions

View file

@ -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]]

View file

@ -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"

View file

@ -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;

View file

@ -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() {

View file

@ -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

View file

@ -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);
}

View file

@ -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?,

File diff suppressed because it is too large Load diff

View file

@ -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<ImportSectionPreview>,
}
#[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<String>,
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<ImportSection>,
}
pub async fn inspect_web_novel_document_from_dialog(
app: AppHandle,
) -> Result<Option<WebNovelImportPreview>, 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<WebNovelImportReceipt, String> {
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<WebNovelImportPreview, String> {
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<WebNovelImportReceipt, String> {
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<ImportSection> = 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 &sections {
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(&section.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<ParsedDocument, String> {
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<String, String> {
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<ImportSection>), String> {
let heading = if family == "SCRIPT" {
heading_regex("")
} else {
heading_regex("")
};
let mut preface = Vec::new();
let mut sections: Vec<ImportSection> = Vec::new();
let mut current_title: Option<String> = None;
let mut current_body: Vec<String> = 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, &current_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, &current_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<ImportSection>, 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-9-9一二三四五六七八九十百千万零〇两]{{1,12}}{}(?:\s*[:]?\s*.*)?$",
unit
))
.expect("valid heading regex")
}
fn scene_regex() -> Regex {
Regex::new(r"(?m)^\s*[0-9-]{1,3}-[0-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::<Vec<_>>()
.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<String> = 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::<Result<Vec<_>, _>>().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());
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff