zhuyuan-ice-gl-zy001/ZCODE-DEV-20260826/public-spine/shell-frozen-src/src-tauri/src/web_novel_modules.rs

2004 lines
85 KiB
Rust

//! 网文作者官方模块数据适配器。
//!
//! 生产安装、签名验证、挂载、自检和回执只由 `module_package_runtime` 执行。
//! 本文件保留 0.4.1 安装器作为迁移回归测试夹具;它没有编号 IPC 路由,不能成为
//! 第二套生产生命周期权威。
#![allow(dead_code)]
use ring::digest::{digest, SHA256};
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Cursor, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
use uuid::Uuid;
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipWriter};
const BASE: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-WORKBENCH-0001";
const ADAPTER: &str = "web-novel-workbench-v1";
fn require_module_active(app: &AppHandle, module_number: &str) -> Result<(), String> {
crate::module_package_runtime::require_active_module_adapter(app, module_number, ADAPTER)
}
const OUTLINE: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001";
const GRID: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001";
const STORYWORLD: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001";
const DELIVERY: &str = "HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001";
const VERSION: &str = "1.0.0";
struct Package {
module_id: &'static str,
name: &'static str,
description: &'static str,
sha256: &'static str,
bytes: &'static [u8],
}
fn packages() -> [Package; 4] {
[
Package {
module_id: OUTLINE,
name: "作品结构与大纲追踪",
description: "场景、情节拍、目标冲突结果、钩子与伏笔追踪。",
sha256: "6d31d3f530862000bd84a8f5dc73bcb9cf0d5738219f7a287562d4f4bcfa5318",
bytes: include_bytes!(
"../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-OUTLINE-001.json"
),
},
Package {
module_id: GRID,
name: "多维情节表与情节板",
description: "由同一场景对象生成可编辑表格、自定义字段与分组情节板。",
sha256: "2e34e0337c51dbbdd1efdd807be71e69a0cb2a1745624e1f51b452b7b54f1553",
bytes: include_bytes!(
"../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-GRID-002.json"
),
},
Package {
module_id: STORYWORLD,
name: "时间线与故事资料库",
description: "故事时间、场景与人物地点物品的真实关系投影。",
sha256: "d486719500ddfc08054d9c9bb6d3683afe5547a2b24c9b04d0f899ac66094d2f",
bytes: include_bytes!(
"../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-STORYWORLD-003.json"
),
},
Package {
module_id: DELIVERY,
name: "高级版本与交付",
description: "章节版本读回与恢复,以及 TXT、DOCX、EPUB、JSON 真实导出。",
sha256: "4af1d4610826edd29759bd0dc12b4b0c04fd2ebebb4ebc362d47d762810989e8",
bytes: include_bytes!(
"../../fixtures/web-novel-legacy-0.4.1/HL-MOD-WEBNOVEL-DELIVERY-004.json"
),
},
]
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorModuleDescriptor {
pub module_id: String,
pub name: String,
pub description: String,
pub version: String,
pub package_sha256: String,
pub install_state: String,
pub self_test_state: String,
pub installed_at_unix_ms: Option<i64>,
pub mounted_at_unix_ms: Option<i64>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorModuleReceipt {
pub receipt_id: String,
pub module_id: String,
pub action: String,
pub state: String,
pub detail: String,
pub receipt_hash: String,
pub created_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorModuleMarketplaceSnapshot {
pub state: &'static str,
pub modules: Vec<AuthorModuleDescriptor>,
pub recent_receipts: Vec<AuthorModuleReceipt>,
pub storage: &'static str,
pub active_action_controller: &'static str,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleActionInput {
pub module_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReadAuthorModuleDataInput {
pub work_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthorScene {
pub scene_id: String,
pub work_id: String,
pub chapter_id: String,
pub title: String,
pub position: i64,
pub synopsis: String,
pub status: String,
pub goal: String,
pub conflict: String,
pub outcome: String,
pub hook: String,
pub emotional_point: String,
pub target_words: i64,
pub revision: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthorBeat {
pub beat_id: String,
pub scene_id: String,
pub position: i64,
pub title: String,
pub note: String,
pub status: String,
pub revision: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StoryFieldDefinition {
pub field_id: String,
pub work_id: String,
pub label: String,
pub field_type: String,
pub options: Vec<String>,
pub position: i64,
pub revision: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StoryFieldValue {
pub field_id: String,
pub scene_id: String,
pub value: String,
pub revision: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StoryTimelineEvent {
pub event_id: String,
pub work_id: String,
pub scene_id: Option<String>,
pub entity_id: Option<String>,
pub title: String,
pub calendar_kind: String,
pub story_day: Option<i64>,
pub date_text: String,
pub time_text: String,
pub duration_minutes: i64,
pub description: String,
pub revision: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SceneEntityLink {
pub link_id: String,
pub work_id: String,
pub scene_id: String,
pub entity_id: String,
pub role: String,
pub note: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChapterVersionSummary {
pub version_id: String,
pub chapter_id: String,
pub chapter_title: String,
pub revision: i64,
pub word_count: i64,
pub save_reason: String,
pub created_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorModuleData {
pub work_id: String,
pub scenes: Vec<AuthorScene>,
pub beats: Vec<AuthorBeat>,
pub field_definitions: Vec<StoryFieldDefinition>,
pub field_values: Vec<StoryFieldValue>,
pub timeline_events: Vec<StoryTimelineEvent>,
pub scene_entity_links: Vec<SceneEntityLink>,
pub chapter_versions: Vec<ChapterVersionSummary>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpsertAuthorSceneInput {
pub work_id: String,
pub scene_id: Option<String>,
pub chapter_id: String,
pub title: String,
pub position: Option<i64>,
pub synopsis: String,
pub status: String,
pub goal: String,
pub conflict: String,
pub outcome: String,
pub hook: String,
pub emotional_point: String,
pub target_words: i64,
pub expected_revision: Option<i64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpsertAuthorBeatInput {
pub scene_id: String,
pub beat_id: Option<String>,
pub title: String,
pub note: String,
pub status: String,
pub position: Option<i64>,
pub expected_revision: Option<i64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpsertStoryFieldDefinitionInput {
pub work_id: String,
pub field_id: Option<String>,
pub label: String,
pub field_type: String,
pub options: Vec<String>,
pub expected_revision: Option<i64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpsertStoryFieldValueInput {
pub field_id: String,
pub scene_id: String,
pub value: String,
pub expected_revision: Option<i64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpsertTimelineEventInput {
pub work_id: String,
pub event_id: Option<String>,
pub scene_id: Option<String>,
pub entity_id: Option<String>,
pub title: String,
pub calendar_kind: String,
pub story_day: Option<i64>,
pub date_text: String,
pub time_text: String,
pub duration_minutes: i64,
pub description: String,
pub expected_revision: Option<i64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LinkSceneEntityInput {
pub work_id: String,
pub scene_id: String,
pub entity_id: String,
pub role: String,
pub note: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RestoreChapterVersionInput {
pub version_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExportAuthorDeliveryInput {
pub work_id: String,
pub format: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorDeliveryReceipt {
pub format: String,
pub path: String,
pub bytes: u64,
pub chapter_count: usize,
pub package_sha256: String,
}
pub async fn get_web_novel_author_module_marketplace(
app: AppHandle,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || marketplace_at(&database))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn install_web_novel_author_module(
app: AppHandle,
input: ModuleActionInput,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
let cache = crate::authenticated_storage::account_storage_root(&app, "web-novel-modules-v1")?;
tauri::async_runtime::spawn_blocking(move || install_at(&database, &cache, &input.module_id))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn mount_web_novel_author_module(
app: AppHandle,
input: ModuleActionInput,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || set_mount_at(&database, &input.module_id, true))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn unmount_web_novel_author_module(
app: AppHandle,
input: ModuleActionInput,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || set_mount_at(&database, &input.module_id, false))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn uninstall_web_novel_author_module(
app: AppHandle,
input: ModuleActionInput,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
let cache = crate::authenticated_storage::account_storage_root(&app, "web-novel-modules-v1")?;
tauri::async_runtime::spawn_blocking(move || uninstall_at(&database, &cache, &input.module_id))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn get_web_novel_author_module_data(
app: AppHandle,
input: ReadAuthorModuleDataInput,
) -> Result<AuthorModuleData, String> {
require_module_active(&app, BASE)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || module_data_at(&database, &input.work_id))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn upsert_web_novel_author_scene(
app: AppHandle,
input: UpsertAuthorSceneInput,
) -> Result<AuthorScene, String> {
require_module_active(&app, OUTLINE)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_scene_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn upsert_web_novel_author_beat(
app: AppHandle,
input: UpsertAuthorBeatInput,
) -> Result<AuthorBeat, String> {
require_module_active(&app, OUTLINE)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_beat_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn upsert_web_novel_story_field_definition(
app: AppHandle,
input: UpsertStoryFieldDefinitionInput,
) -> Result<StoryFieldDefinition, String> {
require_module_active(&app, GRID)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_field_definition_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn upsert_web_novel_story_field_value(
app: AppHandle,
input: UpsertStoryFieldValueInput,
) -> Result<StoryFieldValue, String> {
require_module_active(&app, GRID)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_field_value_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn upsert_web_novel_timeline_event(
app: AppHandle,
input: UpsertTimelineEventInput,
) -> Result<StoryTimelineEvent, String> {
require_module_active(&app, STORYWORLD)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_timeline_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn link_web_novel_scene_entity(
app: AppHandle,
input: LinkSceneEntityInput,
) -> Result<SceneEntityLink, String> {
require_module_active(&app, STORYWORLD)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || link_scene_entity_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn restore_web_novel_chapter_version(
app: AppHandle,
input: RestoreChapterVersionInput,
) -> Result<ChapterVersionSummary, String> {
require_module_active(&app, DELIVERY)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || restore_version_at(&database, &input.version_id))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
}
pub async fn export_web_novel_author_delivery(
app: AppHandle,
input: ExportAuthorDeliveryInput,
) -> Result<Option<AuthorDeliveryReceipt>, String> {
require_module_active(&app, DELIVERY)?;
let format = normalize_delivery_format(&input.format)?;
let database = super::web_novel_workspace::workspace_database(&app)?;
let title = read_work_title(&database, &input.work_id)?;
let extension = format.to_ascii_lowercase();
let selected = app
.dialog()
.file()
.set_title("导出作者交付包")
.set_file_name(format!("{}.{}", safe_filename(&title), extension))
.add_filter(&format, &[&extension])
.blocking_save_file();
let Some(selected) = selected else {
return Ok(None);
};
let path = selected
.into_path()
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_PATH_INVALID: {error}"))?;
let work_id = input.work_id;
tauri::async_runtime::spawn_blocking(move || {
export_delivery_at(&database, &work_id, &format, &path)
})
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_MODULE_JOIN_FAILED: {error}"))?
.map(Some)
}
fn ensure_schema(connection: &Connection) -> Result<(), String> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS web_novel_module_installations(
module_id TEXT PRIMARY KEY NOT NULL, version TEXT NOT NULL, package_sha256 TEXT NOT NULL,
install_state TEXT NOT NULL, self_test_state TEXT NOT NULL,
installed_at_unix_ms INTEGER NOT NULL, mounted_at_unix_ms INTEGER, updated_at_unix_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS web_novel_module_receipts(
receipt_id TEXT PRIMARY KEY NOT NULL, module_id TEXT NOT NULL, action TEXT NOT NULL,
state TEXT NOT NULL, detail TEXT NOT NULL, previous_hash TEXT NOT NULL,
receipt_hash TEXT NOT NULL UNIQUE, created_at_unix_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS web_novel_module_selftest(probe_id TEXT PRIMARY KEY NOT NULL, module_id TEXT NOT NULL, created_at_unix_ms INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS web_novel_scenes(
scene_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE,
title TEXT NOT NULL, position INTEGER NOT NULL, synopsis TEXT NOT NULL, status TEXT NOT NULL,
goal TEXT NOT NULL, conflict TEXT NOT NULL, outcome TEXT NOT NULL, hook TEXT NOT NULL,
emotional_point TEXT NOT NULL, target_words INTEGER NOT NULL, revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(chapter_id, position)
);
CREATE TABLE IF NOT EXISTS web_novel_beats(
beat_id TEXT PRIMARY KEY NOT NULL, scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE,
position INTEGER NOT NULL, title TEXT NOT NULL, note TEXT NOT NULL, status TEXT NOT NULL,
revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(scene_id, position)
);
CREATE TABLE IF NOT EXISTS web_novel_story_fields(
field_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
label TEXT NOT NULL, field_type TEXT NOT NULL, options_json TEXT NOT NULL, position INTEGER NOT NULL,
revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)), UNIQUE(work_id, label)
);
CREATE TABLE IF NOT EXISTS web_novel_story_field_values(
field_id TEXT NOT NULL REFERENCES web_novel_story_fields(field_id) ON DELETE CASCADE,
scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE,
value TEXT NOT NULL, revision INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
PRIMARY KEY(field_id, scene_id)
);
CREATE TABLE IF NOT EXISTS web_novel_timeline_events(
event_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
scene_id TEXT REFERENCES web_novel_scenes(scene_id) ON DELETE SET NULL,
entity_id TEXT REFERENCES web_novel_entities(entity_id) ON DELETE SET NULL,
title TEXT NOT NULL, calendar_kind TEXT NOT NULL, story_day INTEGER, date_text TEXT NOT NULL,
time_text TEXT NOT NULL, duration_minutes INTEGER NOT NULL, description TEXT NOT NULL,
revision INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1))
);
CREATE TABLE IF NOT EXISTS web_novel_scene_entities(
link_id TEXT PRIMARY KEY NOT NULL, work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
scene_id TEXT NOT NULL REFERENCES web_novel_scenes(scene_id) ON DELETE CASCADE,
entity_id TEXT NOT NULL REFERENCES web_novel_entities(entity_id) ON DELETE CASCADE,
role TEXT NOT NULL, note TEXT NOT NULL, created_at_unix_ms INTEGER NOT NULL,
UNIQUE(scene_id, entity_id, role)
);
CREATE INDEX IF NOT EXISTS idx_web_novel_scenes_work ON web_novel_scenes(work_id, chapter_id, position);
CREATE INDEX IF NOT EXISTS idx_web_novel_beats_scene ON web_novel_beats(scene_id, position);
CREATE INDEX IF NOT EXISTS idx_web_novel_timeline_work ON web_novel_timeline_events(work_id, story_day, date_text, time_text);"
).map_err(db_write)
}
fn marketplace_at(path: &Path) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let modules = packages().into_iter().map(|package| {
let installed = connection.query_row(
"SELECT install_state,self_test_state,installed_at_unix_ms,mounted_at_unix_ms FROM web_novel_module_installations WHERE module_id=?1",
[package.module_id], |row| Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,i64>(2)?,row.get::<_,Option<i64>>(3)?))
).optional().map_err(db_read)?;
Ok(AuthorModuleDescriptor {
module_id: package.module_id.into(), name: package.name.into(), description: package.description.into(), version: VERSION.into(), package_sha256: package.sha256.into(),
install_state: installed.as_ref().map(|value| value.0.clone()).unwrap_or_else(|| "AVAILABLE".into()),
self_test_state: installed.as_ref().map(|value| value.1.clone()).unwrap_or_else(|| "NOT_RUN".into()),
installed_at_unix_ms: installed.as_ref().map(|value| value.2), mounted_at_unix_ms: installed.and_then(|value| value.3),
})
}).collect::<Result<Vec<_>, String>>()?;
let mut statement = connection.prepare("SELECT receipt_id,module_id,action,state,detail,receipt_hash,created_at_unix_ms FROM web_novel_module_receipts ORDER BY created_at_unix_ms DESC,rowid DESC LIMIT 30").map_err(db_read)?;
let recent_receipts = statement
.query_map([], |row| {
Ok(AuthorModuleReceipt {
receipt_id: row.get(0)?,
module_id: row.get(1)?,
action: row.get(2)?,
state: row.get(3)?,
detail: row.get(4)?,
receipt_hash: row.get(5)?,
created_at_unix_ms: row.get(6)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
Ok(AuthorModuleMarketplaceSnapshot {
state: "READY",
modules,
recent_receipts,
storage: "CURRENT_ACCOUNT_SQLITE_AND_PRIVATE_MODULE_CACHE",
active_action_controller: "ONE_DESKTOP_ACTION_AT_A_TIME",
})
}
fn package_by_id(module_id: &str) -> Result<Package, String> {
packages()
.into_iter()
.find(|item| item.module_id == module_id)
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_MODULE_NOT_OFFICIAL".into())
}
fn install_at(
path: &Path,
cache: &Path,
module_id: &str,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
let package = package_by_id(module_id)?;
let actual = sha256(package.bytes);
if actual != package.sha256 {
return Err("HOLOLAKE_WEBNOVEL_MODULE_PACKAGE_HASH_MISMATCH".into());
}
let target = module_cache_path(cache, module_id)?.join(VERSION);
fs::create_dir_all(&target).map_err(io_error)?;
set_private_permissions(&target)?;
let temp = target.join("module.json.pending");
fs::write(&temp, package.bytes).map_err(io_error)?;
fs::rename(&temp, target.join("module.json")).map_err(io_error)?;
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let now = now_ms();
tx.execute("INSERT INTO web_novel_module_installations(module_id,version,package_sha256,install_state,self_test_state,installed_at_unix_ms,mounted_at_unix_ms,updated_at_unix_ms) VALUES(?1,?2,?3,'INSTALLED','RUNNING',?4,NULL,?4) ON CONFLICT(module_id) DO UPDATE SET version=excluded.version,package_sha256=excluded.package_sha256,install_state='INSTALLED',self_test_state='RUNNING',updated_at_unix_ms=excluded.updated_at_unix_ms", params![module_id, VERSION, actual, now]).map_err(db_write)?;
run_self_test(&tx, module_id)?;
tx.execute("UPDATE web_novel_module_installations SET self_test_state='PASS',updated_at_unix_ms=?1 WHERE module_id=?2", params![now_ms(), module_id]).map_err(db_write)?;
append_receipt(
&tx,
module_id,
"INSTALL",
"PASS",
"不可变官方包散列核验、自检与账号缓存写入完成",
)?;
tx.commit().map_err(db_write)?;
marketplace_at(path)
}
fn set_mount_at(
path: &Path,
module_id: &str,
mounted: bool,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
package_by_id(module_id)?;
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let current: Option<(String,String)> = tx.query_row("SELECT install_state,self_test_state FROM web_novel_module_installations WHERE module_id=?1", [module_id], |row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(db_read)?;
let Some((_, self_test)) = current else {
return Err("HOLOLAKE_WEBNOVEL_MODULE_NOT_INSTALLED".into());
};
if self_test != "PASS" {
return Err("HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_REQUIRED".into());
}
let state = if mounted { "MOUNTED" } else { "INSTALLED" };
let mounted_at = if mounted { Some(now_ms()) } else { None };
tx.execute("UPDATE web_novel_module_installations SET install_state=?1,mounted_at_unix_ms=?2,updated_at_unix_ms=?3 WHERE module_id=?4", params![state,mounted_at,now_ms(),module_id]).map_err(db_write)?;
append_receipt(
&tx,
module_id,
if mounted { "MOUNT" } else { "UNMOUNT" },
"PASS",
if mounted {
"模块已挂载到当前账号网文频道"
} else {
"模块已停止投影,用户数据保持"
},
)?;
tx.commit().map_err(db_write)?;
marketplace_at(path)
}
fn uninstall_at(
path: &Path,
cache: &Path,
module_id: &str,
) -> Result<AuthorModuleMarketplaceSnapshot, String> {
package_by_id(module_id)?;
let target = module_cache_path(cache, module_id)?;
if target.exists() {
fs::remove_dir_all(&target).map_err(io_error)?;
}
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
tx.execute(
"DELETE FROM web_novel_module_installations WHERE module_id=?1",
[module_id],
)
.map_err(db_write)?;
append_receipt(
&tx,
module_id,
"UNINSTALL",
"PASS",
"只清除可重新取得的模块程序缓存;作品、模块数据和历史回执保持",
)?;
tx.commit().map_err(db_write)?;
marketplace_at(path)
}
fn run_self_test(connection: &Connection, module_id: &str) -> Result<(), String> {
let required_table = match module_id {
OUTLINE => "web_novel_scenes",
GRID => "web_novel_story_fields",
STORYWORLD => "web_novel_timeline_events",
DELIVERY => "web_novel_chapter_versions",
_ => return Err("HOLOLAKE_WEBNOVEL_MODULE_NOT_OFFICIAL".into()),
};
let exists: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
[required_table],
|row| row.get(0),
)
.map_err(db_read)?;
if !exists {
return Err(format!(
"HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_SCHEMA_MISSING:{required_table}"
));
}
let probe = format!("WN-MOD-PROBE-{}", Uuid::new_v4());
connection.execute("INSERT INTO web_novel_module_selftest(probe_id,module_id,created_at_unix_ms) VALUES(?1,?2,?3)", params![probe,module_id,now_ms()]).map_err(db_write)?;
let readback: String = connection
.query_row(
"SELECT module_id FROM web_novel_module_selftest WHERE probe_id=?1",
[&probe],
|row| row.get(0),
)
.map_err(db_read)?;
connection
.execute(
"DELETE FROM web_novel_module_selftest WHERE probe_id=?1",
[&probe],
)
.map_err(db_write)?;
if readback != module_id {
return Err("HOLOLAKE_WEBNOVEL_MODULE_SELF_TEST_READBACK_FAILED".into());
}
Ok(())
}
fn append_receipt(
connection: &Connection,
module_id: &str,
action: &str,
state: &str,
detail: &str,
) -> Result<(), String> {
let previous: String = connection.query_row("SELECT receipt_hash FROM web_novel_module_receipts ORDER BY created_at_unix_ms DESC,rowid DESC LIMIT 1", [], |row| row.get(0)).optional().map_err(db_read)?.unwrap_or_default();
let receipt_id = format!("WN-MOD-RCPT-{}", Uuid::new_v4());
let now = now_ms();
let hash = sha256(
format!("{previous}\0{receipt_id}\0{module_id}\0{action}\0{state}\0{detail}\0{now}")
.as_bytes(),
);
connection.execute("INSERT INTO web_novel_module_receipts(receipt_id,module_id,action,state,detail,previous_hash,receipt_hash,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![receipt_id,module_id,action,state,detail,previous,hash,now]).map_err(db_write)?;
Ok(())
}
#[cfg(test)]
fn require_mounted(connection: &Connection, module_id: &str) -> Result<(), String> {
let state: Option<String> = connection.query_row("SELECT install_state FROM web_novel_module_installations WHERE module_id=?1 AND self_test_state='PASS'", [module_id], |row| row.get(0)).optional().map_err(db_read)?;
if state.as_deref() == Some("MOUNTED") {
Ok(())
} else {
Err(format!("HOLOLAKE_WEBNOVEL_MODULE_NOT_MOUNTED:{module_id}"))
}
}
#[cfg(not(test))]
fn require_mounted(_connection: &Connection, module_id: &str) -> Result<(), String> {
if matches!(module_id, OUTLINE | GRID | STORYWORLD | DELIVERY) {
// The public adapter entry already performed an exact-number ACTIVE check
// against the shared signed package runtime. Never consult the legacy
// installation table in production.
Ok(())
} else {
Err("HOLOLAKE_WEBNOVEL_MODULE_UNKNOWN".into())
}
}
fn module_cache_path(cache: &Path, module_id: &str) -> Result<PathBuf, String> {
if !packages().iter().any(|item| item.module_id == module_id)
|| module_id.contains('/')
|| module_id.contains("..")
{
return Err("HOLOLAKE_WEBNOVEL_MODULE_ID_INVALID".into());
}
Ok(cache.join("official").join(module_id))
}
fn set_private_permissions(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(io_error)?;
}
Ok(())
}
fn module_data_at(path: &Path, work_id: &str) -> Result<AuthorModuleData, String> {
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let work_exists: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)",
[work_id],
|row| row.get(0),
)
.map_err(db_read)?;
if !work_exists {
return Err("HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into());
}
let mut scene_statement = connection.prepare(
"SELECT scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome,hook,
emotional_point,target_words,revision,updated_at_unix_ms
FROM web_novel_scenes WHERE work_id=?1 AND archived=0 ORDER BY chapter_id,position"
).map_err(db_read)?;
let scenes = scene_statement
.query_map([work_id], |row| {
Ok(AuthorScene {
scene_id: row.get(0)?,
work_id: row.get(1)?,
chapter_id: row.get(2)?,
title: row.get(3)?,
position: row.get(4)?,
synopsis: row.get(5)?,
status: row.get(6)?,
goal: row.get(7)?,
conflict: row.get(8)?,
outcome: row.get(9)?,
hook: row.get(10)?,
emotional_point: row.get(11)?,
target_words: row.get(12)?,
revision: row.get(13)?,
updated_at_unix_ms: row.get(14)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut beat_statement = connection.prepare(
"SELECT b.beat_id,b.scene_id,b.position,b.title,b.note,b.status,b.revision,b.updated_at_unix_ms
FROM web_novel_beats b JOIN web_novel_scenes s ON s.scene_id=b.scene_id
WHERE s.work_id=?1 AND b.archived=0 AND s.archived=0 ORDER BY s.chapter_id,s.position,b.position"
).map_err(db_read)?;
let beats = beat_statement
.query_map([work_id], |row| {
Ok(AuthorBeat {
beat_id: row.get(0)?,
scene_id: row.get(1)?,
position: row.get(2)?,
title: row.get(3)?,
note: row.get(4)?,
status: row.get(5)?,
revision: row.get(6)?,
updated_at_unix_ms: row.get(7)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut field_statement = connection
.prepare(
"SELECT field_id,work_id,label,field_type,options_json,position,revision
FROM web_novel_story_fields WHERE work_id=?1 AND archived=0 ORDER BY position,label",
)
.map_err(db_read)?;
let field_definitions = field_statement
.query_map([work_id], |row| {
let options_json: String = row.get(4)?;
Ok(StoryFieldDefinition {
field_id: row.get(0)?,
work_id: row.get(1)?,
label: row.get(2)?,
field_type: row.get(3)?,
options: serde_json::from_str(&options_json).unwrap_or_default(),
position: row.get(5)?,
revision: row.get(6)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut value_statement = connection.prepare(
"SELECT v.field_id,v.scene_id,v.value,v.revision FROM web_novel_story_field_values v
JOIN web_novel_story_fields f ON f.field_id=v.field_id WHERE f.work_id=?1 AND f.archived=0"
).map_err(db_read)?;
let field_values = value_statement
.query_map([work_id], |row| {
Ok(StoryFieldValue {
field_id: row.get(0)?,
scene_id: row.get(1)?,
value: row.get(2)?,
revision: row.get(3)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut timeline_statement = connection.prepare(
"SELECT event_id,work_id,scene_id,entity_id,title,calendar_kind,story_day,date_text,time_text,
duration_minutes,description,revision FROM web_novel_timeline_events
WHERE work_id=?1 AND archived=0 ORDER BY COALESCE(story_day,9223372036854775807),date_text,time_text,title"
).map_err(db_read)?;
let timeline_events = timeline_statement
.query_map([work_id], |row| {
Ok(StoryTimelineEvent {
event_id: row.get(0)?,
work_id: row.get(1)?,
scene_id: row.get(2)?,
entity_id: row.get(3)?,
title: row.get(4)?,
calendar_kind: row.get(5)?,
story_day: row.get(6)?,
date_text: row.get(7)?,
time_text: row.get(8)?,
duration_minutes: row.get(9)?,
description: row.get(10)?,
revision: row.get(11)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut link_statement = connection
.prepare(
"SELECT link_id,work_id,scene_id,entity_id,role,note FROM web_novel_scene_entities
WHERE work_id=?1 ORDER BY scene_id,role,entity_id",
)
.map_err(db_read)?;
let scene_entity_links = link_statement
.query_map([work_id], |row| {
Ok(SceneEntityLink {
link_id: row.get(0)?,
work_id: row.get(1)?,
scene_id: row.get(2)?,
entity_id: row.get(3)?,
role: row.get(4)?,
note: row.get(5)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut version_statement = connection.prepare(
"SELECT v.version_id,v.chapter_id,c.title,v.revision,v.word_count,v.save_reason,v.created_at_unix_ms
FROM web_novel_chapter_versions v JOIN web_novel_chapters c ON c.chapter_id=v.chapter_id
WHERE v.work_id=?1 AND c.archived=0 ORDER BY v.created_at_unix_ms DESC,v.revision DESC LIMIT 500"
).map_err(db_read)?;
let chapter_versions = version_statement
.query_map([work_id], |row| {
Ok(ChapterVersionSummary {
version_id: row.get(0)?,
chapter_id: row.get(1)?,
chapter_title: row.get(2)?,
revision: row.get(3)?,
word_count: row.get(4)?,
save_reason: row.get(5)?,
created_at_unix_ms: row.get(6)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
Ok(AuthorModuleData {
work_id: work_id.into(),
scenes,
beats,
field_definitions,
field_values,
timeline_events,
scene_entity_links,
chapter_versions,
})
}
fn upsert_scene_at(path: &Path, input: UpsertAuthorSceneInput) -> Result<AuthorScene, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, OUTLINE)?;
let title = required(&input.title, "HOLOLAKE_WEBNOVEL_SCENE_TITLE_REQUIRED")?;
let status = normalized_enum(
&input.status,
&["PLANNED", "DRAFTING", "DONE", "REVISE"],
"PLANNED",
)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let chapter_work: Option<String> = tx
.query_row(
"SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0",
[&input.chapter_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
if chapter_work.as_deref() != Some(input.work_id.as_str()) {
return Err("HOLOLAKE_WEBNOVEL_SCENE_CHAPTER_WORK_MISMATCH".into());
}
let now = now_ms();
let scene_id = input
.scene_id
.unwrap_or_else(|| format!("WN-SCENE-{}", Uuid::new_v4()));
let current: Option<i64> = tx
.query_row(
"SELECT revision FROM web_novel_scenes WHERE scene_id=?1",
[&scene_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
let revision = match current {
Some(value) => {
if input.expected_revision != Some(value) {
return Err("HOLOLAKE_WEBNOVEL_SCENE_REVISION_CONFLICT".into());
}
tx.execute(
"UPDATE web_novel_scenes SET chapter_id=?1,title=?2,position=?3,synopsis=?4,status=?5,goal=?6,
conflict=?7,outcome=?8,hook=?9,emotional_point=?10,target_words=?11,revision=revision+1,
updated_at_unix_ms=?12 WHERE scene_id=?13 AND work_id=?14",
params![input.chapter_id,title,input.position.unwrap_or(0),input.synopsis,status,input.goal,input.conflict,input.outcome,input.hook,input.emotional_point,input.target_words.max(0),now,scene_id,input.work_id]
).map_err(db_write)?;
value + 1
}
None => {
let position = match input.position { Some(value) => value, None => tx.query_row(
"SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_scenes WHERE chapter_id=?1 AND archived=0", [&input.chapter_id], |row| row.get(0)
).map_err(db_read)? };
tx.execute(
"INSERT INTO web_novel_scenes(scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome,
hook,emotional_point,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,1,?14,?14,0)",
params![scene_id,input.work_id,input.chapter_id,title,position,input.synopsis,status,input.goal,input.conflict,input.outcome,input.hook,input.emotional_point,input.target_words.max(0),now]
).map_err(db_write)?;
1
}
};
let scene = tx.query_row(
"SELECT scene_id,work_id,chapter_id,title,position,synopsis,status,goal,conflict,outcome,hook,emotional_point,target_words,revision,updated_at_unix_ms FROM web_novel_scenes WHERE scene_id=?1",
[&scene_id], |row| Ok(AuthorScene { scene_id: row.get(0)?, work_id: row.get(1)?, chapter_id: row.get(2)?, title: row.get(3)?, position: row.get(4)?, synopsis: row.get(5)?, status: row.get(6)?, goal: row.get(7)?, conflict: row.get(8)?, outcome: row.get(9)?, hook: row.get(10)?, emotional_point: row.get(11)?, target_words: row.get(12)?, revision: row.get(13)?, updated_at_unix_ms: row.get(14)? })
).map_err(db_read)?;
debug_assert_eq!(scene.revision, revision);
tx.commit().map_err(db_write)?;
Ok(scene)
}
fn upsert_beat_at(path: &Path, input: UpsertAuthorBeatInput) -> Result<AuthorBeat, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, OUTLINE)?;
let title = required(&input.title, "HOLOLAKE_WEBNOVEL_BEAT_TITLE_REQUIRED")?;
let status = normalized_enum(
&input.status,
&["PLANNED", "DRAFTING", "DONE", "REVISE"],
"PLANNED",
)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let scene_exists: bool = tx
.query_row(
"SELECT EXISTS(SELECT 1 FROM web_novel_scenes WHERE scene_id=?1 AND archived=0)",
[&input.scene_id],
|row| row.get(0),
)
.map_err(db_read)?;
if !scene_exists {
return Err("HOLOLAKE_WEBNOVEL_SCENE_NOT_FOUND".into());
}
let now = now_ms();
let beat_id = input
.beat_id
.unwrap_or_else(|| format!("WN-BEAT-{}", Uuid::new_v4()));
let current: Option<i64> = tx
.query_row(
"SELECT revision FROM web_novel_beats WHERE beat_id=?1",
[&beat_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
match current {
Some(value) => {
if input.expected_revision != Some(value) {
return Err("HOLOLAKE_WEBNOVEL_BEAT_REVISION_CONFLICT".into());
}
tx.execute("UPDATE web_novel_beats SET title=?1,note=?2,status=?3,position=?4,revision=revision+1,updated_at_unix_ms=?5 WHERE beat_id=?6 AND scene_id=?7", params![title,input.note,status,input.position.unwrap_or(0),now,beat_id,input.scene_id]).map_err(db_write)?;
}
None => {
let position = match input.position { Some(value) => value, None => tx.query_row("SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_beats WHERE scene_id=?1 AND archived=0", [&input.scene_id], |row| row.get(0)).map_err(db_read)? };
tx.execute("INSERT INTO web_novel_beats(beat_id,scene_id,position,title,note,status,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,1,?7,?7,0)", params![beat_id,input.scene_id,position,title,input.note,status,now]).map_err(db_write)?;
}
}
let beat = tx.query_row("SELECT beat_id,scene_id,position,title,note,status,revision,updated_at_unix_ms FROM web_novel_beats WHERE beat_id=?1", [&beat_id], |row| Ok(AuthorBeat { beat_id: row.get(0)?, scene_id: row.get(1)?, position: row.get(2)?, title: row.get(3)?, note: row.get(4)?, status: row.get(5)?, revision: row.get(6)?, updated_at_unix_ms: row.get(7)? })).map_err(db_read)?;
tx.commit().map_err(db_write)?;
Ok(beat)
}
fn upsert_field_definition_at(
path: &Path,
input: UpsertStoryFieldDefinitionInput,
) -> Result<StoryFieldDefinition, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, GRID)?;
let label = required(&input.label, "HOLOLAKE_WEBNOVEL_STORY_FIELD_LABEL_REQUIRED")?;
let field_type = normalized_enum(
&input.field_type,
&["TEXT", "NUMBER", "SELECT", "BOOLEAN"],
"TEXT",
)?;
let options_json = serde_json::to_string(&input.options)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_STORY_FIELD_OPTIONS_INVALID: {error}"))?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let field_id = input
.field_id
.unwrap_or_else(|| format!("WN-FIELD-{}", Uuid::new_v4()));
let current: Option<i64> = tx
.query_row(
"SELECT revision FROM web_novel_story_fields WHERE field_id=?1",
[&field_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
match current {
Some(value) => {
if input.expected_revision != Some(value) {
return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_REVISION_CONFLICT".into());
}
tx.execute("UPDATE web_novel_story_fields SET label=?1,field_type=?2,options_json=?3,revision=revision+1,updated_at_unix_ms=?4 WHERE field_id=?5 AND work_id=?6", params![label,field_type,options_json,now_ms(),field_id,input.work_id]).map_err(db_write)?;
}
None => {
let position: i64 = tx.query_row("SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_story_fields WHERE work_id=?1 AND archived=0", [&input.work_id], |row| row.get(0)).map_err(db_read)?;
let now = now_ms();
tx.execute("INSERT INTO web_novel_story_fields(field_id,work_id,label,field_type,options_json,position,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,1,?7,?7,0)", params![field_id,input.work_id,label,field_type,options_json,position,now]).map_err(db_write)?;
}
}
let definition = tx.query_row("SELECT field_id,work_id,label,field_type,options_json,position,revision FROM web_novel_story_fields WHERE field_id=?1", [&field_id], |row| { let value: String = row.get(4)?; Ok(StoryFieldDefinition { field_id: row.get(0)?, work_id: row.get(1)?, label: row.get(2)?, field_type: row.get(3)?, options: serde_json::from_str(&value).unwrap_or_default(), position: row.get(5)?, revision: row.get(6)? }) }).map_err(db_read)?;
tx.commit().map_err(db_write)?;
Ok(definition)
}
fn upsert_field_value_at(
path: &Path,
input: UpsertStoryFieldValueInput,
) -> Result<StoryFieldValue, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, GRID)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let same_work: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM web_novel_story_fields f JOIN web_novel_scenes s ON s.work_id=f.work_id WHERE f.field_id=?1 AND s.scene_id=?2 AND f.archived=0 AND s.archived=0)", params![input.field_id,input.scene_id], |row| row.get(0)).map_err(db_read)?;
if !same_work {
return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_SCENE_MISMATCH".into());
}
let current: Option<i64> = tx
.query_row(
"SELECT revision FROM web_novel_story_field_values WHERE field_id=?1 AND scene_id=?2",
params![input.field_id, input.scene_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
let revision = match current {
Some(value) => {
if input.expected_revision != Some(value) {
return Err("HOLOLAKE_WEBNOVEL_STORY_FIELD_VALUE_REVISION_CONFLICT".into());
}
value + 1
}
None => 1,
};
tx.execute("INSERT INTO web_novel_story_field_values(field_id,scene_id,value,revision,updated_at_unix_ms) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(field_id,scene_id) DO UPDATE SET value=excluded.value,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms", params![input.field_id,input.scene_id,input.value,revision,now_ms()]).map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(StoryFieldValue {
field_id: input.field_id,
scene_id: input.scene_id,
value: input.value,
revision,
})
}
fn upsert_timeline_at(
path: &Path,
input: UpsertTimelineEventInput,
) -> Result<StoryTimelineEvent, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, STORYWORLD)?;
let title = required(&input.title, "HOLOLAKE_WEBNOVEL_TIMELINE_TITLE_REQUIRED")?;
let calendar_kind = normalized_enum(
&input.calendar_kind,
&["STORY_DAY", "DATE", "CUSTOM"],
"STORY_DAY",
)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
if let Some(scene_id) = input.scene_id.as_deref() {
let work: Option<String> = tx
.query_row(
"SELECT work_id FROM web_novel_scenes WHERE scene_id=?1 AND archived=0",
[scene_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
if work.as_deref() != Some(input.work_id.as_str()) {
return Err("HOLOLAKE_WEBNOVEL_TIMELINE_SCENE_MISMATCH".into());
}
}
if let Some(entity_id) = input.entity_id.as_deref() {
let work: Option<String> = tx
.query_row(
"SELECT work_id FROM web_novel_entities WHERE entity_id=?1 AND archived=0",
[entity_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
if work.as_deref() != Some(input.work_id.as_str()) {
return Err("HOLOLAKE_WEBNOVEL_TIMELINE_ENTITY_MISMATCH".into());
}
}
let event_id = input
.event_id
.unwrap_or_else(|| format!("WN-EVENT-{}", Uuid::new_v4()));
let current: Option<i64> = tx
.query_row(
"SELECT revision FROM web_novel_timeline_events WHERE event_id=?1",
[&event_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
let revision = match current {
Some(value) => {
if input.expected_revision != Some(value) {
return Err("HOLOLAKE_WEBNOVEL_TIMELINE_REVISION_CONFLICT".into());
}
value + 1
}
None => 1,
};
let now = now_ms();
tx.execute("INSERT INTO web_novel_timeline_events(event_id,work_id,scene_id,entity_id,title,calendar_kind,story_day,date_text,time_text,duration_minutes,description,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13,0) ON CONFLICT(event_id) DO UPDATE SET scene_id=excluded.scene_id,entity_id=excluded.entity_id,title=excluded.title,calendar_kind=excluded.calendar_kind,story_day=excluded.story_day,date_text=excluded.date_text,time_text=excluded.time_text,duration_minutes=excluded.duration_minutes,description=excluded.description,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms", params![event_id,input.work_id,input.scene_id,input.entity_id,title,calendar_kind,input.story_day,input.date_text,input.time_text,input.duration_minutes.max(0),input.description,revision,now]).map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(StoryTimelineEvent {
event_id,
work_id: input.work_id,
scene_id: input.scene_id,
entity_id: input.entity_id,
title,
calendar_kind,
story_day: input.story_day,
date_text: input.date_text,
time_text: input.time_text,
duration_minutes: input.duration_minutes.max(0),
description: input.description,
revision,
})
}
fn link_scene_entity_at(
path: &Path,
input: LinkSceneEntityInput,
) -> Result<SceneEntityLink, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, STORYWORLD)?;
let role = required(&input.role, "HOLOLAKE_WEBNOVEL_SCENE_ENTITY_ROLE_REQUIRED")?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let valid: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM web_novel_scenes s JOIN web_novel_entities e ON e.work_id=s.work_id WHERE s.scene_id=?1 AND e.entity_id=?2 AND s.work_id=?3 AND s.archived=0 AND e.archived=0)", params![input.scene_id,input.entity_id,input.work_id], |row| row.get(0)).map_err(db_read)?;
if !valid {
return Err("HOLOLAKE_WEBNOVEL_SCENE_ENTITY_WORK_MISMATCH".into());
}
let existing: Option<String> = tx.query_row("SELECT link_id FROM web_novel_scene_entities WHERE scene_id=?1 AND entity_id=?2 AND role=?3", params![input.scene_id,input.entity_id,role], |row| row.get(0)).optional().map_err(db_read)?;
let link_id = existing.unwrap_or_else(|| format!("WN-SCENE-ENTITY-{}", Uuid::new_v4()));
tx.execute("INSERT INTO web_novel_scene_entities(link_id,work_id,scene_id,entity_id,role,note,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(scene_id,entity_id,role) DO UPDATE SET note=excluded.note", params![link_id,input.work_id,input.scene_id,input.entity_id,role,input.note,now_ms()]).map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(SceneEntityLink {
link_id,
work_id: input.work_id,
scene_id: input.scene_id,
entity_id: input.entity_id,
role,
note: input.note,
})
}
fn restore_version_at(path: &Path, version_id: &str) -> Result<ChapterVersionSummary, String> {
let mut connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, DELIVERY)?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let source: Option<(String,String,String,String,String,String,i64)> = tx.query_row(
"SELECT chapter_id,work_id,title,synopsis,content,workflow_status,word_count FROM web_novel_chapter_versions WHERE version_id=?1",
[version_id], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?))
).optional().map_err(db_read)?;
let Some((chapter_id, work_id, title, synopsis, content, workflow_status, word_count)) = source
else {
return Err("HOLOLAKE_WEBNOVEL_CHAPTER_VERSION_NOT_FOUND".into());
};
let current_revision: i64 = tx
.query_row(
"SELECT revision FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0",
[&chapter_id],
|row| row.get(0),
)
.map_err(db_read)?;
let revision = current_revision + 1;
let now = now_ms();
tx.execute("UPDATE web_novel_chapters SET title=?1,synopsis=?2,content=?3,workflow_status=?4,word_count=?5,revision=?6,updated_at_unix_ms=?7 WHERE chapter_id=?8", params![title,synopsis,content,workflow_status,word_count,revision,now,chapter_id]).map_err(db_write)?;
let restored_id = format!("WN-VER-{}", Uuid::new_v4());
tx.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,'RESTORED_FROM_VERSION',?10)", params![restored_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,now]).map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(ChapterVersionSummary {
version_id: restored_id,
chapter_id,
chapter_title: title,
revision,
word_count,
save_reason: "RESTORED_FROM_VERSION".into(),
created_at_unix_ms: now,
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DeliveryChapter {
volume_title: String,
chapter_title: String,
synopsis: String,
content: String,
word_count: i64,
revision: i64,
}
fn normalize_delivery_format(format: &str) -> Result<String, String> {
let normalized = format.trim().to_ascii_uppercase();
if ["TXT", "DOCX", "EPUB", "JSON"].contains(&normalized.as_str()) {
Ok(normalized)
} else {
Err("HOLOLAKE_WEBNOVEL_DELIVERY_FORMAT_UNSUPPORTED".into())
}
}
fn read_work_title(path: &Path, work_id: &str) -> Result<String, String> {
let connection = super::web_novel_workspace::open_database(path)?;
connection
.query_row(
"SELECT title FROM web_novel_works WHERE work_id=?1 AND archived=0",
[work_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into())
}
fn safe_filename(value: &str) -> String {
let result: String = value
.chars()
.map(|character| {
if ['/', '\\', ':', '*', '?', '"', '<', '>', '|'].contains(&character) {
'_'
} else {
character
}
})
.collect();
let trimmed = result.trim().trim_end_matches('.');
if trimmed.is_empty() {
"未命名作品".into()
} else {
trimmed.chars().take(80).collect()
}
}
fn export_delivery_at(
path: &Path,
work_id: &str,
format: &str,
destination: &Path,
) -> Result<AuthorDeliveryReceipt, String> {
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
require_mounted(&connection, DELIVERY)?;
let title: String = connection
.query_row(
"SELECT title FROM web_novel_works WHERE work_id=?1 AND archived=0",
[work_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".to_string())?;
let mut statement = connection.prepare(
"SELECT v.title,c.title,c.synopsis,c.content,c.word_count,c.revision FROM web_novel_chapters c
JOIN web_novel_volumes v ON v.volume_id=c.volume_id WHERE c.work_id=?1 AND c.archived=0 AND v.archived=0
ORDER BY v.position,c.position"
).map_err(db_read)?;
let chapters = statement
.query_map([work_id], |row| {
Ok(DeliveryChapter {
volume_title: row.get(0)?,
chapter_title: row.get(1)?,
synopsis: row.get(2)?,
content: row.get(3)?,
word_count: row.get(4)?,
revision: row.get(5)?,
})
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
if chapters.is_empty() {
return Err("HOLOLAKE_WEBNOVEL_DELIVERY_HAS_NO_CHAPTERS".into());
}
let normalized = normalize_delivery_format(format)?;
let bytes = match normalized.as_str() {
"TXT" => render_txt(&title, &chapters).into_bytes(),
"JSON" => serde_json::to_vec_pretty(&serde_json::json!({"schema":"hololake.web-novel.delivery.v1","workId":work_id,"title":title,"chapters":chapters})).map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_JSON_FAILED: {error}"))?,
"DOCX" => render_docx(&title, &chapters)?,
"EPUB" => render_epub(&title, work_id, &chapters)?,
_ => unreachable!(),
};
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(io_error)?;
}
let pending = destination.with_extension(format!(
"{}.pending",
destination
.extension()
.and_then(|value| value.to_str())
.unwrap_or("export")
));
fs::write(&pending, &bytes).map_err(io_error)?;
fs::rename(&pending, destination).map_err(io_error)?;
Ok(AuthorDeliveryReceipt {
format: normalized,
path: destination.to_string_lossy().into_owned(),
bytes: bytes.len() as u64,
chapter_count: chapters.len(),
package_sha256: sha256(&bytes),
})
}
fn render_txt(title: &str, chapters: &[DeliveryChapter]) -> String {
let mut text = String::new();
text.push_str(title);
text.push_str("\n\n");
let mut current_volume = "";
for chapter in chapters {
if chapter.volume_title != current_volume {
current_volume = &chapter.volume_title;
text.push_str(current_volume);
text.push_str("\n\n");
}
text.push_str(&chapter.chapter_title);
text.push_str("\n\n");
text.push_str(&chapter.content);
text.push_str("\n\n");
}
text
}
fn render_docx(title: &str, chapters: &[DeliveryChapter]) -> Result<Vec<u8>, String> {
let mut paragraphs = format!(
"<w:p><w:pPr><w:pStyle w:val=\"Title\"/></w:pPr><w:r><w:t>{}</w:t></w:r></w:p>",
xml_escape(title)
);
for chapter in chapters {
paragraphs.push_str(&format!(
"<w:p><w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr><w:r><w:t>{}</w:t></w:r></w:p>",
xml_escape(&chapter.chapter_title)
));
for line in chapter.content.lines() {
paragraphs.push_str(&format!(
"<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
xml_escape(line)
));
}
}
let document = format!("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>{paragraphs}<w:sectPr/></w:body></w:document>");
zip_bytes(&[
("[Content_Types].xml", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>".as_bytes(), true),
("_rels/.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/></Relationships>".as_bytes(), true),
("word/document.xml", document.as_bytes(), true),
])
}
fn render_epub(
title: &str,
work_id: &str,
chapters: &[DeliveryChapter],
) -> Result<Vec<u8>, String> {
let mut entries: Vec<(String, Vec<u8>, bool)> = Vec::new();
entries.push(("mimetype".into(), b"application/epub+zip".to_vec(), false));
entries.push(("META-INF/container.xml".into(), b"<?xml version=\"1.0\"?><container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>".to_vec(), true));
let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>");
let mut spine = String::new();
let mut navigation = String::new();
for (index, chapter) in chapters.iter().enumerate() {
let id = format!("c{}", index + 1);
let href = format!("chapter-{}.xhtml", index + 1);
manifest.push_str(&format!(
"<item id=\"{id}\" href=\"{href}\" media-type=\"application/xhtml+xml\"/>"
));
spine.push_str(&format!("<itemref idref=\"{id}\"/>"));
navigation.push_str(&format!(
"<li><a href=\"{href}\">{}</a></li>",
xml_escape(&chapter.chapter_title)
));
let body = chapter
.content
.lines()
.map(|line| format!("<p>{}</p>", xml_escape(line)))
.collect::<String>();
let xhtml = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"zh-CN\"><head><title>{}</title></head><body><h1>{}</h1>{body}</body></html>", xml_escape(&chapter.chapter_title), xml_escape(&chapter.chapter_title));
entries.push((format!("OEBPS/{href}"), xhtml.into_bytes(), true));
}
let opf = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\"><metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:identifier id=\"bookid\">{}</dc:identifier><dc:title>{}</dc:title><dc:language>zh-CN</dc:language><meta property=\"dcterms:modified\">2026-08-18T00:00:00Z</meta></metadata><manifest>{manifest}</manifest><spine>{spine}</spine></package>", xml_escape(work_id), xml_escape(title));
let nav = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\"><head><title>{}</title></head><body><nav epub:type=\"toc\"><h1>目录</h1><ol>{navigation}</ol></nav></body></html>", xml_escape(title));
entries.push(("OEBPS/content.opf".into(), opf.into_bytes(), true));
entries.push(("OEBPS/nav.xhtml".into(), nav.into_bytes(), true));
let refs: Vec<(&str, &[u8], bool)> = entries
.iter()
.map(|(name, bytes, compressed)| (name.as_str(), bytes.as_slice(), *compressed))
.collect();
zip_bytes(&refs)
}
fn zip_bytes(entries: &[(&str, &[u8], bool)]) -> Result<Vec<u8>, String> {
let cursor = Cursor::new(Vec::new());
let mut archive = ZipWriter::new(cursor);
for (name, bytes, compressed) in entries {
let options = FileOptions::default().compression_method(if *compressed {
CompressionMethod::Deflated
} else {
CompressionMethod::Stored
});
archive
.start_file(*name, options)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_ZIP_FAILED: {error}"))?;
archive.write_all(bytes).map_err(io_error)?;
}
archive
.finish()
.map(|cursor| cursor.into_inner())
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_DELIVERY_ZIP_FAILED: {error}"))
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn required(value: &str, error: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(error.into())
} else {
Ok(trimmed.chars().take(200).collect())
}
}
fn normalized_enum(value: &str, allowed: &[&str], default: &str) -> Result<String, String> {
let normalized = if value.trim().is_empty() {
default.into()
} else {
value.trim().to_ascii_uppercase()
};
if allowed.contains(&normalized.as_str()) {
Ok(normalized)
} else {
Err(format!(
"HOLOLAKE_WEBNOVEL_MODULE_ENUM_INVALID:{normalized}"
))
}
}
fn sha256(value: &[u8]) -> String {
digest(&SHA256, value)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn db_read(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_MODULE_DATABASE_READ_FAILED: {error}")
}
fn db_write(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_MODULE_DATABASE_WRITE_FAILED: {error}")
}
fn io_error(error: std::io::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_MODULE_IO_FAILED: {error}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use tempfile::tempdir;
fn seed_work(path: &Path) -> (String, String, String) {
let connection = super::super::web_novel_workspace::open_database(path).unwrap();
let work_id = "WN-WORK-REAL-ENGINE".to_string();
let volume_id = "WN-VOL-REAL-ENGINE".to_string();
let chapter_id = "WN-CH-REAL-ENGINE".to_string();
let now = now_ms();
connection.execute("INSERT INTO web_novel_works(work_id,title,pen_name,genre,synopsis,contract_status,copyright_status,workflow_status,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,'真实模块验收作品','冰朔','测试','用于原生引擎验收','UNSIGNED','AUTHOR_OWNED','DRAFT',100000,1,?2,?2,0)", params![work_id,now]).unwrap();
connection.execute("INSERT INTO web_novel_volumes(volume_id,work_id,title,position,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,'第一卷',0,1,?3,?3,0)", params![volume_id,work_id,now]).unwrap();
connection.execute("INSERT INTO web_novel_chapters(chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status,scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES(?1,?2,?3,'第一章 起点',0,'首章概要','当前第二版正文','DRAFT',NULL,9,2,?4,?4,0)", params![chapter_id,volume_id,work_id,now]).unwrap();
connection.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES('WN-VER-REAL-1',?1,?2,1,'第一章 起点','首章概要','可恢复的第一版正文','DRAFT',12,'AUTOSAVE',?3)", params![chapter_id,work_id,now-100]).unwrap();
connection.execute("INSERT INTO web_novel_chapter_versions(version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms) VALUES('WN-VER-REAL-2',?1,?2,2,'第一章 起点','首章概要','当前第二版正文','DRAFT',9,'MANUAL_SAVE',?3)", params![chapter_id,work_id,now]).unwrap();
connection.execute("INSERT INTO web_novel_entities(entity_id,work_id,entity_type,name,aliases_json,description,first_chapter_id,revision,created_at_unix_ms,updated_at_unix_ms,archived) VALUES('WN-ENTITY-REAL-1',?1,'CHARACTER','主角','[]','真实关联对象',?2,1,?3,?3,0)", params![work_id,chapter_id,now]).unwrap();
(work_id, chapter_id, "WN-ENTITY-REAL-1".into())
}
#[test]
fn installs_mounts_executes_exports_and_preserves_data_on_uninstall() {
let temp = tempdir().unwrap();
let database = temp.path().join("modules.sqlite3");
let cache = temp.path().join("cache");
let (work_id, chapter_id, entity_id) = seed_work(&database);
for package in packages() {
let installed = install_at(&database, &cache, package.module_id).unwrap();
let descriptor = installed
.modules
.iter()
.find(|item| item.module_id == package.module_id)
.unwrap();
assert_eq!(descriptor.self_test_state, "PASS");
assert!(cache
.join("official")
.join(package.module_id)
.join(VERSION)
.join("module.json")
.is_file());
set_mount_at(&database, package.module_id, true).unwrap();
}
let scene = upsert_scene_at(
&database,
UpsertAuthorSceneInput {
work_id: work_id.clone(),
scene_id: None,
chapter_id: chapter_id.clone(),
title: "真实场景".into(),
position: None,
synopsis: "主角踏入未知区域".into(),
status: "PLANNED".into(),
goal: "找到出口".into(),
conflict: "追兵封锁".into(),
outcome: "发现密道".into(),
hook: "密道中有熟悉声音".into(),
emotional_point: "紧张转希望".into(),
target_words: 1600,
expected_revision: None,
},
)
.unwrap();
let beat = upsert_beat_at(
&database,
UpsertAuthorBeatInput {
scene_id: scene.scene_id.clone(),
beat_id: None,
title: "追兵逼近".into(),
note: "脚步声从转角处传来".into(),
status: "PLANNED".into(),
position: None,
expected_revision: None,
},
)
.unwrap();
assert_eq!(beat.position, 0);
let field = upsert_field_definition_at(
&database,
UpsertStoryFieldDefinitionInput {
work_id: work_id.clone(),
field_id: None,
label: "视角人物".into(),
field_type: "SELECT".into(),
options: vec!["主角".into(), "反派".into()],
expected_revision: None,
},
)
.unwrap();
upsert_field_value_at(
&database,
UpsertStoryFieldValueInput {
field_id: field.field_id.clone(),
scene_id: scene.scene_id.clone(),
value: "主角".into(),
expected_revision: None,
},
)
.unwrap();
let timeline = upsert_timeline_at(
&database,
UpsertTimelineEventInput {
work_id: work_id.clone(),
event_id: None,
scene_id: Some(scene.scene_id.clone()),
entity_id: Some(entity_id.clone()),
title: "逃入密道".into(),
calendar_kind: "STORY_DAY".into(),
story_day: Some(1),
date_text: "".into(),
time_text: "深夜".into(),
duration_minutes: 20,
description: "改变主角路线的节点".into(),
expected_revision: None,
},
)
.unwrap();
assert_eq!(timeline.story_day, Some(1));
link_scene_entity_at(
&database,
LinkSceneEntityInput {
work_id: work_id.clone(),
scene_id: scene.scene_id.clone(),
entity_id,
role: "出场".into(),
note: "时间线主角".into(),
},
)
.unwrap();
let data = module_data_at(&database, &work_id).unwrap();
assert_eq!(
(
data.scenes.len(),
data.beats.len(),
data.field_definitions.len(),
data.field_values.len(),
data.timeline_events.len(),
data.scene_entity_links.len()
),
(1, 1, 1, 1, 1, 1)
);
let restored = restore_version_at(&database, "WN-VER-REAL-1").unwrap();
assert_eq!(restored.revision, 3);
let connection = super::super::web_novel_workspace::open_database(&database).unwrap();
let restored_content: String = connection
.query_row(
"SELECT content FROM web_novel_chapters WHERE chapter_id=?1",
[&chapter_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(restored_content, "可恢复的第一版正文");
for format in ["TXT", "JSON", "DOCX", "EPUB"] {
let destination = temp
.path()
.join(format!("delivery.{}", format.to_ascii_lowercase()));
let receipt = export_delivery_at(&database, &work_id, format, &destination).unwrap();
assert_eq!(receipt.chapter_count, 1);
assert!(receipt.bytes > 20);
assert_eq!(receipt.package_sha256.len(), 64);
if format == "DOCX" || format == "EPUB" {
let file = fs::File::open(&destination).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let required = if format == "DOCX" {
"word/document.xml"
} else {
"OEBPS/content.opf"
};
let mut content = String::new();
archive
.by_name(required)
.unwrap()
.read_to_string(&mut content)
.unwrap();
assert!(content.contains("真实模块验收作品") || content.contains("第一章"));
}
}
uninstall_at(&database, &cache, OUTLINE).unwrap();
assert!(!cache.join("official").join(OUTLINE).exists());
let preserved = module_data_at(&database, &work_id).unwrap();
assert_eq!(preserved.scenes.len(), 1);
let marketplace = marketplace_at(&database).unwrap();
assert!(marketplace
.recent_receipts
.iter()
.any(|receipt| receipt.action == "UNINSTALL"));
}
#[test]
fn rejects_data_mutation_until_the_official_module_is_mounted() {
let temp = tempdir().unwrap();
let database = temp.path().join("gate.sqlite3");
let (work_id, chapter_id, _) = seed_work(&database);
let error = upsert_scene_at(
&database,
UpsertAuthorSceneInput {
work_id,
scene_id: None,
chapter_id,
title: "不应写入".into(),
position: None,
synopsis: "".into(),
status: "PLANNED".into(),
goal: "".into(),
conflict: "".into(),
outcome: "".into(),
hook: "".into(),
emotional_point: "".into(),
target_words: 0,
expected_revision: None,
},
)
.unwrap_err();
assert!(error.contains("NOT_MOUNTED"));
}
#[test]
#[ignore = "explicit real Desktop fixture module acceptance"]
fn imports_organizes_and_exports_real_desktop_materials() {
let fixtures = [
("HOLOLAKE_REAL_NOVEL", 504usize, "EPUB"),
("HOLOLAKE_REAL_OUTLINE", 50usize, "DOCX"),
("HOLOLAKE_REAL_SCRIPT", 75usize, "JSON"),
];
let temp = tempdir().unwrap();
let database = temp.path().join("real-material-module-acceptance.sqlite3");
let cache = temp.path().join("module-cache");
super::super::web_novel_workspace::open_database(&database).unwrap();
for package in packages() {
install_at(&database, &cache, package.module_id).unwrap();
set_mount_at(&database, package.module_id, true).unwrap();
}
let mut outline_work_id = None;
for (variable, expected, format) in fixtures {
let source = std::env::var(variable).expect("real fixture path is required");
let preview =
super::super::web_novel_import::stage_document_at(&database, Path::new(&source))
.unwrap();
assert_eq!(preview.section_count, expected, "{variable}");
let receipt = super::super::web_novel_import::commit_import_at(
&database,
super::super::web_novel_import::CommitImportInput {
import_id: preview.import_id,
target_mode: "CREATE_NEW".into(),
target_work_id: None,
title: preview.detected_title,
pen_name: preview.detected_pen_name,
genre: preview.detected_genre,
},
)
.unwrap();
if variable == "HOLOLAKE_REAL_OUTLINE" {
outline_work_id = Some(receipt.work_id.clone());
}
let connection = super::super::web_novel_workspace::open_database(&database).unwrap();
let mut statement = connection.prepare("SELECT chapter_id,title,SUBSTR(content,1,400) FROM web_novel_chapters WHERE work_id=?1 AND archived=0 ORDER BY position").unwrap();
let chapters = statement
.query_map([&receipt.work_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
drop(statement);
drop(connection);
assert_eq!(chapters.len(), expected);
for (chapter_id, title, synopsis) in chapters {
upsert_scene_at(
&database,
UpsertAuthorSceneInput {
work_id: receipt.work_id.clone(),
scene_id: None,
chapter_id,
title,
position: None,
synopsis,
status: "PLANNED".into(),
goal: "根据导入材料继续展开".into(),
conflict: "".into(),
outcome: "".into(),
hook: "".into(),
emotional_point: "".into(),
target_words: 1500,
expected_revision: None,
},
)
.unwrap();
}
let organized = module_data_at(&database, &receipt.work_id).unwrap();
assert_eq!(organized.scenes.len(), expected);
let first = organized.scenes.first().unwrap();
let field = upsert_field_definition_at(
&database,
UpsertStoryFieldDefinitionInput {
work_id: receipt.work_id.clone(),
field_id: None,
label: "导入材料类型".into(),
field_type: "TEXT".into(),
options: vec![],
expected_revision: None,
},
)
.unwrap();
upsert_field_value_at(
&database,
UpsertStoryFieldValueInput {
field_id: field.field_id,
scene_id: first.scene_id.clone(),
value: variable.into(),
expected_revision: None,
},
)
.unwrap();
upsert_timeline_at(
&database,
UpsertTimelineEventInput {
work_id: receipt.work_id.clone(),
event_id: None,
scene_id: Some(first.scene_id.clone()),
entity_id: None,
title: "导入材料起点".into(),
calendar_kind: "STORY_DAY".into(),
story_day: Some(1),
date_text: "".into(),
time_text: "".into(),
duration_minutes: 0,
description: preview.source_filename,
expected_revision: None,
},
)
.unwrap();
let destination = temp
.path()
.join(format!("{variable}.{}", format.to_ascii_lowercase()));
let delivery =
export_delivery_at(&database, &receipt.work_id, format, &destination).unwrap();
assert_eq!(delivery.chapter_count, expected);
assert!(delivery.bytes > 100);
println!(
"{variable}: imported={}, organized={}, export={} bytes={}, sha256={}",
expected, expected, format, delivery.bytes, delivery.package_sha256
);
}
let txt = temp.path().join("outline.txt");
let txt_receipt =
export_delivery_at(&database, outline_work_id.as_deref().unwrap(), "TXT", &txt)
.unwrap();
assert_eq!(txt_receipt.chapter_count, 50);
assert!(fs::read_to_string(txt).unwrap().chars().count() > 1000);
}
}