2004 lines
85 KiB
Rust
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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
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);
|
|
}
|
|
}
|