feat(hololake): converge industry modules and dynamic world UI
This commit is contained in:
parent
ef94ef462e
commit
a8fe571b5d
40 changed files with 9816 additions and 80 deletions
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", [¤t.work_id], |row| row.get(0))
|
||||
.map_err(db_read)?;
|
||||
let volume_title: String = connection
|
||||
.query_row("SELECT title FROM web_novel_volumes WHERE volume_id=?1", [¤t.volume_id], |row| row.get(0))
|
||||
.map_err(db_read)?;
|
||||
drop(connection);
|
||||
let requested = input.preset.as_deref().unwrap_or("AUTO");
|
||||
let inferred = if requested == "AUTO" && volume_title.contains("细纲") { "OUTLINE" } else { requested };
|
||||
let content = format_content(¤t.content, &work_kind, inferred);
|
||||
super::web_novel_workspace::save_chapter_at(
|
||||
path,
|
||||
super::web_novel_workspace::SaveWebNovelChapterInput {
|
||||
chapter_id: current.chapter_id,
|
||||
title: current.title,
|
||||
synopsis: current.synopsis,
|
||||
content,
|
||||
expected_revision: current.revision,
|
||||
save_reason: Some("ONE_CLICK_FORMAT".into()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn format_work_at(path: &Path, input: FormatWorkInput) -> Result<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 §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
|
|
@ -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}¤t=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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue