feat(hololake): converge industry modules and dynamic world UI

This commit is contained in:
冰朔 2026-08-18 19:09:29 +08:00
commit a8fe571b5d
40 changed files with 9816 additions and 80 deletions

View file

@ -1557,6 +1557,8 @@ dependencies = [
"fs2",
"futures-util",
"interprocess",
"quick-xml 0.31.0",
"regex",
"reqwest",
"ring",
"rusqlite",
@ -1574,6 +1576,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"] }
@ -37,6 +39,7 @@ reqwest = { version = "0.13.2", default-features = false, features = ["cookies",
tokio = { version = "1", features = ["time"] }
futures-util = "0.3"
rust_xlsxwriter = "=0.64.2"
zip = { version = "=0.6.6", default-features = false, features = ["deflate"] }
[target.'cfg(windows)'.dependencies]
widestring = "1"

View file

@ -559,7 +559,7 @@ fn connect_endpoint(path: &Path) -> std::io::Result<LocalSocketStream> {
#[cfg(unix)]
{
let name = path.to_fs_name::<GenericFilePath>()?;
return LocalSocketStream::connect(name);
LocalSocketStream::connect(name)
}
#[cfg(windows)]
{

View file

@ -254,7 +254,7 @@ pub async fn import_education_tables_from_dialog(
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || match import_tables_from_path(&database, &path) {
Ok(receipt) => Ok(EducationImportOutcome {
state: "IMPORTED_TO_NATIVE".into(),
state: "STAGED_UNASSIGNED".into(),
import_receipt: Some(receipt),
assistance_receipt: None,
}),
@ -379,7 +379,7 @@ fn import_tables_from_path(
.collect();
Ok(EducationTableImportReceipt {
schema: TRANSLATOR_SCHEMA,
state: "IMPORTED_TO_NATIVE",
state: "STAGED_UNASSIGNED",
adapter_id: IMPORT_ADAPTER,
import_id,
source_format: outcome.source_format,
@ -607,12 +607,12 @@ fn outcome_from_parsed(
"NONE".into()
},
target_module: if page.state == "READY_TO_TRANSLATE" {
"NATIVE_TABLE_DATA".into()
"UNASSIGNED_CHANNEL_STAGING".into()
} else {
"NONE".into()
},
decision: if page.state == "READY_TO_TRANSLATE" {
"PROFILED_THEN_REGISTERED".into()
"PROFILED_THEN_STAGED_PENDING_HUMAN_ROUTE".into()
} else {
"EMPTY_PAGE_SKIPPED_WITH_RECORD".into()
},

View file

@ -53,6 +53,16 @@ pub struct EducationTableSummary {
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnassignedTableSummary {
pub table_id: String,
pub title: String,
pub column_count: usize,
pub row_count: usize,
pub imported_at_unix_ms: i64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationTableColumn {
@ -115,9 +125,11 @@ pub struct EducationWorkspaceSnapshot {
pub automation_rules: Vec<EducationAutomationRule>,
pub recent_automation_runs: Vec<EducationAutomationRunReceipt>,
pub recent_imports: Vec<EducationImportRegistrySummary>,
pub unassigned_tables: Vec<UnassignedTableSummary>,
pub document_count: usize,
pub table_count: usize,
pub automation_rule_count: usize,
pub unassigned_count: usize,
pub storage: &'static str,
pub authority: &'static str,
}
@ -367,6 +379,19 @@ pub async fn archive_education_table(
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn assign_imported_table_to_education(
app: AppHandle,
input: ReadEducationTableInput,
) -> Result<EducationTable, String> {
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
assign_imported_table_to_education_at(&database, &input.table_id)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn create_education_automation_rule(
app: AppHandle,
@ -479,6 +504,7 @@ fn open_database(path: &Path) -> Result<Connection, String> {
revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED')),
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
);
CREATE TABLE IF NOT EXISTS education_automation_rules (
@ -517,6 +543,39 @@ fn open_database(path: &Path) -> Result<Connection, String> {
);",
)
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_MIGRATION_FAILED: {error}"))?;
if !table_has_column(&connection, "education_tables", "assignment_scope")? {
connection
.execute(
"ALTER TABLE education_tables ADD COLUMN assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED'))",
[],
)
.map_err(database_write_error)?;
let imported_table_ids = {
let mut statement = connection
.prepare("SELECT table_ids_json FROM education_import_registry")
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(database_read_error)?;
rows.filter_map(Result::ok)
.flat_map(|value| serde_json::from_str::<Vec<String>>(&value).unwrap_or_default())
.collect::<Vec<_>>()
};
for table_id in imported_table_ids {
connection
.execute(
"UPDATE education_tables SET assignment_scope = 'UNASSIGNED' WHERE table_id = ?1 AND archived = 0",
[table_id],
)
.map_err(database_write_error)?;
}
connection
.execute(
"INSERT INTO workspace_meta(key, value) VALUES ('schema_version', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[],
)
.map_err(database_write_error)?;
}
#[cfg(unix)]
if path.exists() {
use std::os::unix::fs::PermissionsExt;
@ -553,7 +612,7 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
let mut statement = connection
.prepare(
"SELECT table_id, title, columns_json, rows_json, revision, updated_at_unix_ms
FROM education_tables WHERE archived = 0
FROM education_tables WHERE archived = 0 AND assignment_scope = 'EDUCATION'
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
@ -581,6 +640,7 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
let automation_rules = read_automation_rules_with_connection(&connection)?;
let recent_automation_runs = read_recent_automation_runs_with_connection(&connection)?;
let recent_imports = read_recent_imports_with_connection(&connection)?;
let unassigned_tables = read_unassigned_tables_with_connection(&connection)?;
Ok(EducationWorkspaceSnapshot {
schema: WORKSPACE_SCHEMA,
state: "READY",
@ -592,11 +652,42 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
automation_rules,
recent_automation_runs,
recent_imports,
unassigned_count: unassigned_tables.len(),
unassigned_tables,
storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE",
authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY",
})
}
fn read_unassigned_tables_with_connection(
connection: &Connection,
) -> Result<Vec<UnassignedTableSummary>, String> {
let mut statement = connection
.prepare(
"SELECT table_id, title, columns_json, rows_json, updated_at_unix_ms
FROM education_tables
WHERE archived = 0 AND assignment_scope = 'UNASSIGNED'
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let columns_json: String = row.get(2)?;
let rows_json: String = row.get(3)?;
let columns: Vec<EducationTableColumn> = serde_json::from_str(&columns_json).unwrap_or_default();
let rows: Vec<EducationTableRow> = serde_json::from_str(&rows_json).unwrap_or_default();
Ok(UnassignedTableSummary {
table_id: row.get(0)?,
title: row.get(1)?,
column_count: columns.len(),
row_count: rows.len(),
imported_at_unix_ms: row.get(4)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>().map_err(database_read_error)
}
fn read_recent_imports_with_connection(
connection: &Connection,
) -> Result<Vec<EducationImportRegistrySummary>, String> {
@ -796,8 +887,8 @@ fn create_table_at(path: &Path, input: CreateEducationItemInput) -> Result<Educa
.execute(
"INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 0)",
created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'EDUCATION', 0)",
params![
table_id,
title,
@ -838,8 +929,8 @@ pub(crate) fn import_table_batch_at(
.execute(
"INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 0)",
created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'UNASSIGNED', 0)",
params![
table_id,
title,
@ -877,6 +968,37 @@ pub(crate) fn import_table_batch_at(
Ok((import_id, tables))
}
fn assign_imported_table_to_education_at(
path: &Path,
table_id: &str,
) -> Result<EducationTable, String> {
validate_entity_id(table_id, "EDU-TABLE-")?;
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_tables
SET assignment_scope = 'EDUCATION', updated_at_unix_ms = ?1
WHERE table_id = ?2 AND assignment_scope = 'UNASSIGNED' AND archived = 0",
params![now_ms(), table_id],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_IMPORT_ASSIGNMENT_NOT_FOUND".into());
}
read_table_with_connection(&connection, table_id)
}
fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool, String> {
let mut statement = connection
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(1))
.map_err(database_read_error)?;
let found = rows.filter_map(Result::ok).any(|name| name == column);
Ok(found)
}
fn read_table_with_connection(
connection: &Connection,
table_id: &str,
@ -1539,6 +1661,45 @@ mod tests {
assert_eq!(archived, 1);
}
#[test]
fn imported_tables_stay_unassigned_until_a_human_routes_them_to_education() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let imported = ImportedEducationTable {
title: "待识别业务数据".into(),
columns: vec![EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "业务字段".into(),
}],
rows: vec![EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["尚未归属教育".into()],
}],
};
let (_, tables) = import_table_batch_at(
&database,
vec![imported],
EducationImportRegistration {
source_filename: "业务数据.xlsx".into(),
source_format: "XLSX".into(),
source_sha256: "0".repeat(64),
profile_json: "{}".into(),
imported_at_unix_ms: now_ms(),
},
)
.unwrap();
let table_id = tables[0].table_id.clone();
let quarantined = snapshot_at(&database).unwrap();
assert_eq!(quarantined.table_count, 0);
assert_eq!(quarantined.unassigned_count, 1);
assert_eq!(quarantined.unassigned_tables[0].table_id, table_id);
assign_imported_table_to_education_at(&database, &table_id).unwrap();
let assigned = snapshot_at(&database).unwrap();
assert_eq!(assigned.table_count, 1);
assert_eq!(assigned.unassigned_count, 0);
}
#[test]
fn table_bounds_are_enforced() {
let columns = (0..=MAX_TABLE_COLUMNS)

View file

@ -27,6 +27,11 @@ 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 world_climate;
mod zero_core_numbering;
mod zero_point;
@ -87,6 +92,7 @@ pub fn run() {
education_workspace::read_education_table,
education_workspace::save_education_table,
education_workspace::archive_education_table,
education_workspace::assign_imported_table_to_education,
education_translation::import_education_tables_from_dialog,
education_translation::export_education_table_to_dialog,
education_translation::get_education_recognition_capability,
@ -95,6 +101,50 @@ pub fn run() {
education_workspace::archive_education_automation_rule,
education_workspace::preview_education_automation_rule,
education_workspace::execute_education_automation_rule,
web_novel_workspace::get_web_novel_workspace_snapshot,
world_climate::get_world_climate,
web_novel_workspace::create_web_novel_work,
web_novel_workspace::read_web_novel_work,
web_novel_workspace::save_web_novel_work,
web_novel_workspace::create_web_novel_volume,
web_novel_workspace::create_web_novel_chapter,
web_novel_workspace::read_web_novel_chapter,
web_novel_workspace::save_web_novel_chapter,
web_novel_workspace::transition_web_novel_chapter,
web_novel_workspace::create_web_novel_checkpoint,
web_novel_workspace::restore_web_novel_checkpoint,
web_novel_workspace::upsert_web_novel_story_entity,
web_novel_workspace::create_web_novel_story_relation,
web_novel_workspace::upsert_web_novel_foreshadow,
web_novel_workspace::create_web_novel_review_note,
web_novel_workspace::resolve_web_novel_review_note,
web_novel_workspace::save_web_novel_metric,
web_novel_workspace::run_web_novel_continuity_audit,
web_novel_workspace::export_web_novel_markdown,
web_novel_import::inspect_web_novel_document_from_dialog,
web_novel_import::commit_web_novel_document_import,
web_novel_author::get_web_novel_author_snapshot,
web_novel_author::record_web_novel_writing_activity,
web_novel_author::create_web_novel_inspiration,
web_novel_author::set_web_novel_inspiration_status,
web_novel_author::search_web_novel_full_text,
web_novel_author::format_web_novel_chapter,
web_novel_author::format_web_novel_work,
web_novel_author::upsert_web_novel_shot,
web_novel_modules::get_web_novel_author_module_marketplace,
web_novel_modules::install_web_novel_author_module,
web_novel_modules::mount_web_novel_author_module,
web_novel_modules::unmount_web_novel_author_module,
web_novel_modules::uninstall_web_novel_author_module,
web_novel_modules::get_web_novel_author_module_data,
web_novel_modules::upsert_web_novel_author_scene,
web_novel_modules::upsert_web_novel_author_beat,
web_novel_modules::upsert_web_novel_story_field_definition,
web_novel_modules::upsert_web_novel_story_field_value,
web_novel_modules::upsert_web_novel_timeline_event,
web_novel_modules::link_web_novel_scene_entity,
web_novel_modules::restore_web_novel_chapter_version,
web_novel_modules::export_web_novel_author_delivery,
knowledge_base::get_knowledge_snapshot,
knowledge_base::read_knowledge_document,
knowledge_base::search_knowledge,

View file

@ -0,0 +1,803 @@
//! 作者频道内置写作底座。
//!
//! 这里承载三种作品形态共用的真实能力:写作活动回执、即时灵感、
//! 全文检索、一键排版,以及短剧分镜和生成提示词。数据与正文共用当前
//! 已验证账号的 SQLite模块商城不是这些基础能力的前置条件。
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::AppHandle;
use uuid::Uuid;
const MAX_INSPIRATION_BYTES: usize = 100_000;
const MAX_SHOT_TEXT_BYTES: usize = 200_000;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelInspiration {
pub inspiration_id: String,
pub work_id: String,
pub chapter_id: Option<String>,
pub content: String,
pub tags: Vec<String>,
pub status: String,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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<WebNovelInspiration>,
pub shots: Vec<WebNovelShot>,
}
#[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<WebNovelSearchHit>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorSnapshotInput {
pub work_id: String,
pub local_date: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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")]
pub struct CreateInspirationInput {
pub work_id: String,
pub chapter_id: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetInspirationStatusInput {
pub inspiration_id: String,
pub status: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFullTextInput {
pub work_id: String,
pub query: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatChapterInput {
pub chapter_id: String,
pub expected_revision: i64,
pub preset: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatWorkInput {
pub work_id: String,
pub preset: Option<String>,
}
#[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")]
pub struct UpsertShotInput {
pub work_id: String,
pub chapter_id: String,
pub shot_id: Option<String>,
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<i64>,
}
#[tauri::command]
pub async fn get_web_novel_author_snapshot(
app: AppHandle,
input: AuthorSnapshotInput,
) -> Result<WebNovelAuthorSnapshot, String> {
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}"))?
}
#[tauri::command]
pub async fn record_web_novel_writing_activity(
app: AppHandle,
input: RecordWritingActivityInput,
) -> Result<WebNovelAuthorSnapshot, String> {
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}"))?
}
#[tauri::command]
pub async fn create_web_novel_inspiration(
app: AppHandle,
input: CreateInspirationInput,
) -> Result<WebNovelInspiration, String> {
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}"))?
}
#[tauri::command]
pub async fn set_web_novel_inspiration_status(
app: AppHandle,
input: SetInspirationStatusInput,
) -> Result<WebNovelInspiration, String> {
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}"))?
}
#[tauri::command]
pub async fn search_web_novel_full_text(
app: AppHandle,
input: SearchFullTextInput,
) -> Result<WebNovelSearchResult, String> {
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}"))?
}
#[tauri::command]
pub async fn format_web_novel_chapter(
app: AppHandle,
input: FormatChapterInput,
) -> Result<super::web_novel_workspace::WebNovelChapter, String> {
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}"))?
}
#[tauri::command]
pub async fn format_web_novel_work(
app: AppHandle,
input: FormatWorkInput,
) -> Result<FormatWorkReceipt, String> {
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}"))?
}
#[tauri::command]
pub async fn upsert_web_novel_shot(
app: AppHandle,
input: UpsertShotInput,
) -> Result<WebNovelShot, String> {
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<WebNovelAuthorSnapshot, String> {
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<WebNovelAuthorSnapshot, String> {
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<String> = 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<WebNovelInspiration, String> {
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<WebNovelInspiration, String> {
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<WebNovelSearchResult, String> {
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::<Result<Vec<_>, _>>()
.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<super::web_novel_workspace::WebNovelChapter, String> {
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", [&current.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", [&current.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(&current.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<FormatWorkReceipt, String> {
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::<Result<Vec<_>, _>>()
.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<WebNovelShot, String> {
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<Vec<WebNovelInspiration>, 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::<Result<Vec<_>, _>>().map_err(db_read)?;
Ok(rows)
}
fn query_inspiration(connection: &Connection, inspiration_id: &str) -> Result<WebNovelInspiration, String> {
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<WebNovelInspiration> {
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<Vec<WebNovelShot>, 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::<Result<Vec<_>, _>>().map_err(db_read)?;
Ok(rows)
}
fn query_shot(connection: &Connection, shot_id: &str) -> Result<WebNovelShot, String> {
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<WebNovelShot> {
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<String> = 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<String> = 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<char> = source.chars().collect();
let needle: Vec<char> = 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);
}
}

View file

@ -0,0 +1,805 @@
//! 网文真实文档导入器。
//!
//! 文档在 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 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")]
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")]
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")]
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")]
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")]
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>,
}
#[tauri::command]
pub async fn inspect_web_novel_document_from_dialog(
app: AppHandle,
) -> Result<Option<WebNovelImportPreview>, String> {
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)
}
#[tauri::command]
pub async fn commit_web_novel_document_import(
app: AppHandle,
input: CommitImportInput,
) -> Result<WebNovelImportReceipt, String> {
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

View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//! 语言世界首页的动态气候投影。
//! 固定域坐标只用于取公开天气;返回值不包含城市或经纬度,界面也不把现实城市冒充世界地名。
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const SOURCE: &str = "OPEN_METEO_FORECAST_API";
#[derive(Debug, Deserialize)]
struct OpenMeteoResponse {
current: OpenMeteoCurrent,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoCurrent {
weather_code: i64,
is_day: i64,
precipitation: f64,
cloud_cover: f64,
wind_speed_10m: f64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorldClimateSnapshot {
pub state: &'static str,
pub active_domain: &'static str,
pub time_phase: &'static str,
pub weather_kind: &'static str,
pub motion_intensity: &'static str,
pub source: &'static str,
pub city_exposed: bool,
pub observed_at_unix_ms: i64,
}
#[tauri::command]
pub async fn get_world_climate() -> Result<WorldClimateSnapshot, String> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("HOLOLAKE_WORLD_CLIMATE_CLOCK_INVALID: {error}"))?
.as_secs() as i64;
let beijing_seconds = seconds + 8 * 60 * 60;
let day_index = beijing_seconds.div_euclid(86_400);
let hour = beijing_seconds.rem_euclid(86_400) / 3_600;
let domains = ["FIFTH_DOMAIN", "ZERO_SENSE_DOMAIN", "MAIN_DOMAIN", "BRANCH_DOMAIN", "ZERO_DOMAIN"];
let active_domain = domains[day_index.rem_euclid(domains.len() as i64) as usize];
let (latitude, longitude) = coordinate_for(active_domain, day_index);
let time_phase = match hour {
5..=7 => "DAWN",
8..=16 => "DAY",
17..=19 => "DUSK",
_ => "NIGHT",
};
let observed_at_unix_ms = seconds.saturating_mul(1_000);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("HOLOLAKE_WORLD_CLIMATE_CLIENT_FAILED: {error}"))?;
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&current=weather_code,is_day,precipitation,cloud_cover,wind_speed_10m&timezone=Asia%2FShanghai&forecast_days=1"
);
let response = match client
.get(url)
.header(reqwest::header::USER_AGENT, "HoloLake/0.4.1 world-climate")
.send()
.await
{
Ok(response) if response.status().is_success() => response,
_ => return Ok(fallback(active_domain, time_phase, observed_at_unix_ms)),
};
let current = match response.json::<OpenMeteoResponse>().await {
Ok(payload) => payload.current,
Err(_) => return Ok(fallback(active_domain, time_phase, observed_at_unix_ms)),
};
let weather_kind = weather_kind(current.weather_code, current.precipitation, current.cloud_cover);
let motion_intensity = if current.wind_speed_10m >= 35.0 || weather_kind == "STORM" {
"ACTIVE"
} else if current.wind_speed_10m >= 15.0 || matches!(weather_kind, "RAIN" | "SNOW") {
"GENTLE"
} else {
"CALM"
};
let effective_phase = if current.is_day == 0 && time_phase == "DAY" { "NIGHT" } else { time_phase };
Ok(WorldClimateSnapshot {
state: "VERIFIED_LIVE",
active_domain,
time_phase: effective_phase,
weather_kind,
motion_intensity,
source: SOURCE,
city_exposed: false,
observed_at_unix_ms,
})
}
fn coordinate_for(domain: &str, day_index: i64) -> (f64, f64) {
match domain {
"FIFTH_DOMAIN" => (34.3416, 108.9398),
"ZERO_DOMAIN" => (23.1291, 113.2644),
"MAIN_DOMAIN" | "ZERO_SENSE_DOMAIN" => (39.9042, 116.4074),
"BRANCH_DOMAIN" => {
let roaming = [
(31.2304, 121.4737), (30.5728, 104.0668), (30.5928, 114.3055),
(30.2741, 120.1551), (32.0603, 118.7969), (29.5630, 106.5516),
];
roaming[day_index.rem_euclid(roaming.len() as i64) as usize]
}
_ => (39.9042, 116.4074),
}
}
fn weather_kind(code: i64, precipitation: f64, cloud_cover: f64) -> &'static str {
if matches!(code, 95 | 96 | 99) { "STORM" }
else if matches!(code, 71..=77 | 85 | 86) { "SNOW" }
else if precipitation > 0.0 || matches!(code, 51..=67 | 80..=82) { "RAIN" }
else if matches!(code, 45 | 48) { "FOG" }
else if matches!(code, 1..=3) || cloud_cover >= 35.0 { "CLOUD" }
else { "CLEAR" }
}
fn fallback(active_domain: &'static str, time_phase: &'static str, observed_at_unix_ms: i64) -> WorldClimateSnapshot {
WorldClimateSnapshot {
state: "TIME_ONLY_WEATHER_UNAVAILABLE",
active_domain,
time_phase,
weather_kind: "UNAVAILABLE",
motion_intensity: "CALM",
source: SOURCE,
city_exposed: false,
observed_at_unix_ms,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weather_codes_map_to_visual_families_without_city_data() {
assert_eq!(weather_kind(0, 0.0, 3.0), "CLEAR");
assert_eq!(weather_kind(61, 0.3, 80.0), "RAIN");
assert_eq!(weather_kind(95, 4.0, 100.0), "STORM");
assert_eq!(coordinate_for("FIFTH_DOMAIN", 1), (34.3416, 108.9398));
}
}