feat: admit signed web novel module family
This commit is contained in:
parent
df5e8f8e3c
commit
eb7223ea4c
41 changed files with 10452 additions and 21 deletions
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 §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<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, ¤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<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-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::<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
Loading…
Reference in a new issue