diff --git a/Cargo.lock b/Cargo.lock index 53701141e..227b704e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4744,6 +4744,7 @@ dependencies = [ "gpui_widgets", "image", "oakengine", + "serde_json", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index 54c1d60ab..f94c2a1b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,9 @@ smallvec = "1" # Editable-text widget (used by the file / export dialogs' path fields, the # same gpui-elements crate gpui_widgets builds on). gpui_elements = { path = "gpui/crates/gpui_elements" } +# The library list crosses the facade C ABI as JSON +# (`oakengine_library_list`, M13 D4); Value-only parsing, no derive. +serde_json = "1" [build-dependencies] # The real engine is NOT linked as an rlib: the app binds only the frozen diff --git a/crates/oakaudio/src/manager.rs b/crates/oakaudio/src/manager.rs index 8c7088fa5..68d9d1ee1 100644 --- a/crates/oakaudio/src/manager.rs +++ b/crates/oakaudio/src/manager.rs @@ -454,10 +454,43 @@ mod tests { fn output_callback_consumes_pushed_samples() { // Skip when the audio system cannot actually run a stream: open a // silent stream and require at least one callback within 2 s. A - // device existing is not enough — headless sessions report - // is_active=true while delivering zero callbacks. + // device existing is not enough — headless sessions report the + // stream running while delivering zero callbacks. use std::sync::atomic::AtomicI64 as A; + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; static PROBE: A = A::new(0); + PROBE.store(0, Ordering::Relaxed); + let can_play = (|| { + let host = cpal::default_host(); + let device = host.default_output_device()?; + let config = device.default_output_config().ok()?; + let cb = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + PROBE.fetch_add(data.len() as i64, Ordering::Relaxed); + for s in data.iter_mut() { + *s = 0.0; + } + }; + let stream = device + .build_output_stream( + config.config(), + cb, + |_| {}, + None, + ) + .ok()?; + stream.play().ok()?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let got = PROBE.load(Ordering::Relaxed) > 0; + drop(stream); + Some(got) + })(); + if can_play != Some(true) { + eprintln!("audio session cannot deliver callbacks; skipping"); + return; + } DESTROYED.store(false, Ordering::SeqCst); let manager = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default())); diff --git a/crates/oakengine/src/lib.rs b/crates/oakengine/src/lib.rs index 5fdedd46c..0278d7109 100644 --- a/crates/oakengine/src/lib.rs +++ b/crates/oakengine/src/lib.rs @@ -74,6 +74,7 @@ pub mod handle; pub mod ipc; #[cfg(not(test))] pub mod linkage; +pub mod library; pub mod node; pub mod plugin; pub mod pods; diff --git a/crates/oakengine/src/library.rs b/crates/oakengine/src/library.rs new file mode 100644 index 000000000..e134ef4e1 --- /dev/null +++ b/crates/oakengine/src/library.rs @@ -0,0 +1,368 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The project-library manager C ABI (plan M13 §4): list / create / open / +//! rename / duplicate / delete / import / export over the oakstorage +//! database backend the write-through binds to ([`crate::storage`]). +//! +//! These exports are additive (D4): the app talks to the engine dylib only +//! through the frozen `oakengine_*` surface, so the manager's data source +//! crosses the boundary here instead of linking oakstorage directly (which +//! would give the app a second copy of the handle/serializer types). +//! +//! All operations address the configured default library (the same +//! `Storage/Backend` + `Storage/SqlitePath` configuration the write-through +//! uses); with storage disabled every call fails with `OAKENGINE_E_STATE` +//! except [`oakengine_library_list`], which reports an empty library +//! (`"[]"`) so the manager window can still open. + +use std::ffi::{c_char, c_int}; + +use oakstorage::backend::StorageBackend; +use oakstorage::uri::StorageUri; + +use crate::error::{Error, Result}; +use crate::handle::{guard, guard_int, read_cstr, write_string, OakEngineProject}; +use crate::stubs::node as n; + +/// One library row as the project manager shows it: the project metadata +/// plus the stats derived from the head state (plan §4). +#[derive(serde::Serialize)] +struct LibraryRow { + /// Library row uuid (the open/duplicate/export selector). + uuid: String, + /// Display name. + name: String, + /// Row creation time (unix seconds, UTC). + created_at: i64, + /// Last-write time (unix seconds, UTC; the manager sort key). + modified_at: i64, + /// Longest sequence duration, milliseconds. + duration_ms: i64, + /// Total tracks across all sequences. + track_count: i32, + /// Total clip blocks. + clip_count: i32, + /// Total footage nodes. + footage_count: i32, +} + +/// Map an oakstorage error onto the facade error space (the context string +/// is log-only per the error contract). +fn map_err(e: oakstorage::error::Error) -> Error { + use oakstorage::error::Error as E; + match e { + E::Invalid => Error::Invalid, + E::State => Error::State, + E::NotFound => Error::NotFound, + E::NoMem => Error::NoMem, + other => Error::Failed(other.to_string()), + } +} + +/// The configured default library as a parsed URI; [`Error::State`] when +/// the write-through backend is disabled or the path does not resolve. +fn library() -> Result { + if !crate::storage::storage_enabled() { + return Err(Error::State); + } + let uri = crate::storage::library_uri().ok_or(Error::State)?; + StorageUri::parse(&uri).map_err(map_err) +} + +/// The library URI selecting one row (`…?project=`). +fn project_uri(uuid: &str) -> Result { + let uri = library()?; + StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string())).map_err(map_err) +} + +/// Load one library row as an owned project handle (refcount 1). +fn load_handle(uuid: &str) -> Result { + let uri = project_uri(uuid)?; + let result = crate::storage::backend().load(&uri).map_err(map_err)?; + if result.project.is_null() { + return Err(Error::Failed(format!( + "library load of {uuid} returned no project (info code {})", + result.version_info + ))); + } + Ok(result.project) +} + +/// Release an owned handle (refcount 1). +fn release(h: crate::handle::CHandle) { + if let Some(release) = h.release { + unsafe { release(h.ctx) }; + } +} + +/// `oakengine_library_list` — the library rows as a JSON array (buf/size +/// convention), most recently modified first. Each row carries the manager +/// stats derived from the head state; a row whose stats fail to replay +/// degrades to zeros instead of failing the whole list. With storage +/// disabled the result is the empty array (`"[]"`), not an error. +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int { + guard_int(|| unsafe { + if !crate::storage::storage_enabled() { + return Ok(write_string("[]", buf, buf_size)); + } + let uri = library()?; + let infos = crate::storage::backend() + .list_projects(&uri) + .map_err(map_err)?; + let mut rows = Vec::with_capacity(infos.len()); + for info in infos { + let stats = crate::storage::backend() + .project_stats(&uri, &info.uuid) + .unwrap_or_default(); + rows.push(LibraryRow { + uuid: info.uuid, + name: info.name, + created_at: info.created_at.and_utc().timestamp(), + modified_at: info.modified_at.and_utc().timestamp(), + duration_ms: stats.duration_ms, + track_count: stats.track_count, + clip_count: stats.clip_count, + footage_count: stats.footage_count, + }); + } + let json = serde_json::to_string(&rows) + .map_err(|e| Error::Failed(format!("library list encode: {e}")))?; + Ok(write_string(&json, buf, buf_size)) + }) +} + +/// `oakengine_library_create` — create a blank project named `name` as a +/// new library row and report its uuid (buf/size convention on +/// `out_uuid`; the return value is the uuid length, negative on error). +/// The row lands immediately (one `kind='import'` command), so the +/// manager list shows it before the first edit. +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_create( + name: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, +) -> c_int { + guard_int(|| unsafe { + if name.is_null() { + return Err(Error::Invalid); + } + let name = read_cstr(name); + if name.trim().is_empty() { + return Err(Error::Invalid); + } + let uri = library()?; + + let mut h = n::oaknode_project_init(); + if h.is_null() { + return Err(Error::NoMem); + } + let outcome = (|| -> Result { + Error::from_module(n::oaknode_project_initialize(h))?; + let uuid = { + let arc = crate::handle::domain::project_of(&h).ok_or(Error::Invalid)?; + let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner()); + guard.settings.insert("projectname".to_string(), name); + guard.uuid.clone() + }; + crate::storage::backend().save(h, &uri, 0).map_err(map_err)?; + Ok(uuid) + })(); + n::oaknode_project_free(&mut h); + let uuid = outcome?; + Ok(write_string(&uuid, out_uuid, out_size)) + }) +} + +/// `oakengine_library_delete` — delete the library row `uuid` (cascades +/// settings / snapshots / journal; `OAKENGINE_E_NOT_FOUND` when absent). +/// The manager confirms with the user before calling. +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_delete(uuid: *const c_char) -> c_int { + guard(|| unsafe { + if uuid.is_null() { + return Err(Error::Invalid); + } + let uuid = read_cstr(uuid); + if uuid.is_empty() { + return Err(Error::Invalid); + } + crate::storage::backend() + .delete_project(&library()?, &uuid) + .map_err(map_err) + }) +} + +/// `oakengine_library_rename` — rename the library row `uuid` (the +/// manager's list name; the in-project `projectname` setting is +/// untouched). +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_rename( + uuid: *const c_char, + name: *const c_char, +) -> c_int { + guard(|| unsafe { + if uuid.is_null() || name.is_null() { + return Err(Error::Invalid); + } + let (uuid, name) = (read_cstr(uuid), read_cstr(name)); + if uuid.is_empty() || name.trim().is_empty() { + return Err(Error::Invalid); + } + crate::storage::backend() + .rename_project(&library()?, &uuid, name.trim()) + .map_err(map_err) + }) +} + +/// `oakengine_library_duplicate` — copy the library row `uuid` (settings, +/// snapshots and the full journal history included) under a fresh uuid, +/// reporting the new row's uuid (buf/size convention on `out_uuid`; the +/// return value is the uuid length, negative on error). +/// `name` is the copy's display name; NULL/empty defaults to +/// ` (copy)`. +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_duplicate( + uuid: *const c_char, + name: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, +) -> c_int { + guard_int(|| unsafe { + if uuid.is_null() { + return Err(Error::Invalid); + } + let uuid = read_cstr(uuid); + if uuid.is_empty() { + return Err(Error::Invalid); + } + let name = read_cstr(name); + let name = match name.trim() { + "" => None, + trimmed => Some(trimmed), + }; + let info = crate::storage::backend() + .duplicate_project(&library()?, &uuid, name) + .map_err(map_err)?; + Ok(write_string(&info.uuid, out_uuid, out_size)) + }) +} + +/// `oakengine_library_import` — import a `.ove` / `.otio` / `.fcpxml` +/// project file as a new library row (the file backend parses it, a fresh +/// uuid is assigned, and the first save journals the whole project as one +/// `kind='import'` command). Reports the new row's uuid (buf/size +/// convention on `out_uuid`; the return value is the uuid length, +/// negative on error). +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_import( + path: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, +) -> c_int { + guard_int(|| unsafe { + if path.is_null() { + return Err(Error::Invalid); + } + let path = read_cstr(path); + if path.is_empty() { + return Err(Error::Invalid); + } + let file_uri = StorageUri::parse(&path).map_err(map_err)?; + let uuid = crate::storage::backend() + .import_from_file(&library()?, &file_uri) + .map_err(map_err)?; + Ok(write_string(&uuid, out_uuid, out_size)) + }) +} + +/// `oakengine_library_export` — export the library row `uuid` to the file +/// `path`; the format is dispatched by extension through the oakstorage +/// registry (`.ove` / `.ovexml` → ove-xml, `.otio` / `.fcpxml` → the +/// interchange backend). Nothing is written back to the library. +#[no_mangle] +pub unsafe extern "C" fn oakengine_library_export( + uuid: *const c_char, + path: *const c_char, +) -> c_int { + guard(|| unsafe { + if uuid.is_null() || path.is_null() { + return Err(Error::Invalid); + } + let (uuid, path) = (read_cstr(uuid), read_cstr(path)); + if uuid.is_empty() || path.is_empty() { + return Err(Error::Invalid); + } + let file_uri = StorageUri::parse(&path).map_err(map_err)?; + if file_uri.scheme != "file" { + return Err(Error::Invalid); + } + let handle = load_handle(&uuid)?; + let backend = oakstorage::registry::Registry::global() + .resolve(&file_uri) + .map_err(map_err)?; + let result = backend.save(handle, &file_uri, 0).map_err(map_err); + release(handle); + result + }) +} + +/// `oakengine_project_load_library` — load the library row `uuid` into a +/// fresh project shell (same contract as `oakengine_project_load`: the +/// shell must carry no content). On success the undo stack is cleared, the +/// modified flag is reset, and the project is bound to the library session +/// (the write-through continues the row's journal from its head seq). +#[no_mangle] +pub unsafe extern "C" fn oakengine_project_load_library( + self_: *mut OakEngineProject, + uuid: *const c_char, + err: *mut c_char, + err_size: c_int, +) -> c_int { + guard(|| unsafe { + if self_.is_null() || uuid.is_null() { + return Err(Error::Invalid); + } + let h = crate::handle::unbox(self_)?; + if !n::oaknode_project_root(h).is_null() { + return Err(Error::State); + } + let uuid = read_cstr(uuid); + if uuid.is_empty() { + return Err(Error::Invalid); + } + let loaded = match load_handle(&uuid) { + Ok(handle) => handle, + Err(e) => { + write_string(&e.to_string(), err, err_size); + return Err(e); + } + }; + // Swap the loaded content into the caller's shell box, releasing + // the empty shell handle the box was created with. + let mut old = (*self_).handle; + (*self_).handle = loaded; + n::oaknode_project_free(&mut old); + crate::undo::oakengine_undo_clear(); + Error::from_module(n::oaknode_project_set_modified(loaded, 0))?; + crate::storage::bind_project(loaded); + if !err.is_null() && err_size > 0 { + *err = 0; + } + Ok(()) + }) +} diff --git a/crates/oakengine/src/storage.rs b/crates/oakengine/src/storage.rs index be5c8b45a..720d35dcc 100644 --- a/crates/oakengine/src/storage.rs +++ b/crates/oakengine/src/storage.rs @@ -41,9 +41,9 @@ //! The library is selected from the `Storage` config group (all defaults //! are config-driven, plan §5): //! -//! - `Storage/Backend` — `"sqlite"` (the documented default value) or -//! `"database"` enable the write-through; any other value (e.g. -//! `"off"`) disables it. When the key is absent, no library is +//! - `Storage/Backend` — `"sqlite"` (the documented default value), +//! `"database"` or `"pg"` enable the write-through; any other value +//! (e.g. `"off"`) disables it. When the key is absent, no library is //! configured: projects stay unbound and the undo path runs without //! touching a database (graceful degradation — this is what keeps //! headless consumers and the test suite from writing to the user's @@ -52,6 +52,12 @@ //! storage is enabled) `/library.db` (the same //! location `FileFunctions::get_configuration_location` derives, //! honoring `OAK_CONFIG_DIR` and portable mode). +//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used +//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname` +//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is +//! accepted and stripped). The resolved library URI is +//! `oakdb+pg://`. When `Backend = "pg"` but `PgUrl` is absent +//! or empty, no library is configured (same graceful degradation). //! //! ## Snapshot thread and exit flush //! @@ -101,9 +107,9 @@ struct Binding { } /// The process-wide oakstorage backend (shared by the UI thread's -/// write-throughs and the snapshot thread; the backend serializes its own -/// operations). -fn backend() -> &'static DatabaseBackend { +/// write-throughs, the snapshot thread and the library manager exports in +/// [`crate::library`]; the backend serializes its own operations). +pub(crate) fn backend() -> &'static DatabaseBackend { static BACKEND: OnceLock = OnceLock::new(); BACKEND.get_or_init(DatabaseBackend::new) } @@ -291,18 +297,18 @@ fn record_error(key: usize, message: &str) { // --------------------------------------------------------------------------- /// Whether the write-through backend is enabled (config-driven, plan §5): -/// `Storage/Backend` set to `"sqlite"` or `"database"` enables it; any -/// other explicit value (e.g. `"off"`) disables it. When NO `Storage` -/// configuration is present the backend is NOT enabled — "no library -/// configured" degrades gracefully to plain unbound projects, which keeps -/// headless consumers (the CLI) and the test suite from ever writing to -/// the user's default library. The documented default *values* are -/// `Backend = "sqlite"` and `SqlitePath = /library.db` +/// `Storage/Backend` set to `"sqlite"`, `"database"` or `"pg"` enables +/// it; any other explicit value (e.g. `"off"`) disables it. When NO +/// `Storage` configuration is present the backend is NOT enabled — "no +/// library configured" degrades gracefully to plain unbound projects, +/// which keeps headless consumers (the CLI) and the test suite from ever +/// writing to the user's default library. The documented default *values* +/// are `Backend = "sqlite"` and `SqlitePath = /library.db` /// (used once storage is enabled without an explicit path). pub(crate) fn storage_enabled() -> bool { let store = oakcommon::configstore::ConfigStore::instance(); match store.get(Some("Storage"), "Backend") { - Ok(b) => b == "sqlite" || b == "database", + Ok(b) => b == "sqlite" || b == "database" || b == "pg", Err(_) => false, } } @@ -316,6 +322,29 @@ fn configured_sqlite_path() -> Option { } } +/// The configured PostgreSQL connection string (`Storage/PgUrl`; empty +/// value = not configured). +fn configured_pg_url() -> Option { + let store = oakcommon::configstore::ConfigStore::instance(); + match store.get(Some("Storage"), "PgUrl") { + Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()), + _ => None, + } +} + +/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None +/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://` +/// scheme on the config value is stripped — the oakdb uri body is the +/// bare connection string (`user:pass@host:5432/dbname`). +fn pg_library_uri() -> Option { + let url = configured_pg_url()?; + let body = url + .strip_prefix("postgres://") + .or_else(|| url.strip_prefix("postgresql://")) + .unwrap_or(&url); + Some(format!("oakdb+pg://{body}")) +} + /// The default library file: `/library.db`, where /// the data directory is the standard per-user location /// (`FileFunctions::get_configuration_location`: macOS Application @@ -327,9 +356,14 @@ pub(crate) fn default_library_path() -> String { format!("{}/library.db", dir) } -/// The `oakdb+sqlite://…` uri of the configured library (None when the -/// path cannot be made absolute). -fn library_uri() -> Option { +/// The `oakdb+…` uri of the configured library (None when the path +/// cannot be made absolute, or the PG url is missing). Shared with the +/// library manager exports in [`crate::library`]. +pub(crate) fn library_uri() -> Option { + let store = oakcommon::configstore::ConfigStore::instance(); + if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") { + return pg_library_uri(); + } let path = match configured_sqlite_path() { Some(p) => p, None => default_library_path(), @@ -580,4 +614,47 @@ mod tests { assert!(!storage_enabled()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn library_uri_resolves_pg_config() { + use oakcommon::configstore::ConfigStore; + let store = ConfigStore::instance(); + let _g = crate::tests::common::STORAGE_CONFIG_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + + // Backend = "pg" yields an oakdb+pg uri from Storage/PgUrl; a + // postgres:// scheme on the config value is stripped. + store.set(Some("Storage"), "Backend", "pg"); + store.set( + Some("Storage"), + "PgUrl", + "postgres://user:pass@host:5432/oak", + ); + assert!(storage_enabled()); + assert_eq!( + library_uri().as_deref(), + Some("oakdb+pg://user:pass@host:5432/oak") + ); + + // postgresql:// is accepted too. + store.set( + Some("Storage"), + "PgUrl", + "postgresql://u@h/db?sslmode=disable", + ); + assert_eq!( + library_uri().as_deref(), + Some("oakdb+pg://u@h/db?sslmode=disable") + ); + + // Backend = "pg" with no PgUrl = no library (graceful + // degradation, same as an absent sqlite path). + store.set(Some("Storage"), "PgUrl", ""); + assert_eq!(library_uri(), None); + + // Leave the store in a safe state. + store.set(Some("Storage"), "Backend", "off"); + assert!(!storage_enabled()); + } } diff --git a/crates/oakengine/src/test_support/it_library.rs b/crates/oakengine/src/test_support/it_library.rs new file mode 100644 index 000000000..b5985ae1c --- /dev/null +++ b/crates/oakengine/src/test_support/it_library.rs @@ -0,0 +1,426 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! D4 integration tests: the project-library manager C ABI +//! (`src/library.rs`, plan M13 §4). +//! +//! End-to-end against real SQLite library files in temp directories, +//! driving the facade exactly like the app's project manager: create lands +//! a row immediately, list reports it with the derived stats, open +//! (`oakengine_project_load_library`) binds the loaded project to the +//! library session (the next undoable edit write-throughs onto the row's +//! journal), rename / duplicate / delete / import / export round-trip, and +//! the disabled-backend configuration degrades to an empty list + error +//! codes. +//! +//! Every test holds the shared undo-stack lock (the facade's stack is +//! process-wide, same as the it_undo / it_storage families) and the +//! storage-config lock, so the suite never races on either singleton. + +use std::ffi::CString; +use std::path::{Path, PathBuf}; + +use super::common; +use super::it_undo::GLOBAL_STACK_LOCK; + +use crate::error::{OAKENGINE_OK, OAKENGINE_E_INVALID, OAKENGINE_E_NOT_FOUND, OAKENGINE_E_STATE}; +use crate::handle::OakEngineProject; + +/// The math node type id (a factory type the tests add as an undoable +/// edit; footage is not factory-creatable, so the write-through is +/// verified on the journal rows directly). +const MATH: &str = "org.olivevideoeditor.Olive.math"; + +/// The journal row count of a library row (direct sea-orm read, the same +/// pattern as the it_storage tests). +fn journal_rows(db: &Path, uuid: &str) -> usize { + use sea_orm::entity::prelude::*; + use oakstorage::backends::database::entities::{journal, project}; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let conn = sea_orm::Database::connect(format!("sqlite://{}?mode=ro", db.display())) + .await + .unwrap(); + let model = project::Entity::find() + .filter(project::Column::Uuid.eq(uuid)) + .one(&conn) + .await + .unwrap() + .expect("the row exists"); + journal::Entity::find() + .filter(journal::Column::ProjectId.eq(model.id)) + .all(&conn) + .await + .unwrap() + .len() + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Serialize a library test: hold the process-global undo-stack lock AND +/// the storage-config lock for the whole body, then point the library at a +/// temp SQLite file. +fn with_library(db: &Path, f: impl FnOnce() -> R) -> R { + let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _config = common::STORAGE_CONFIG_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let store = oakcommon::configstore::ConfigStore::instance(); + store.set(Some("Storage"), "Backend", "sqlite"); + store.set(Some("Storage"), "SqlitePath", &db.to_string_lossy()); + f() +} + +/// A fresh, unique temp directory for one test. +fn temp_dir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("oakengine_library_{}_{}", std::process::id(), tag)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Two-stage string read over a facade `(buf, size)` getter. +fn read_string(f: impl Fn(*mut std::ffi::c_char, i32) -> i32) -> String { + let needed = f(std::ptr::null_mut(), 0); + if needed <= 0 { + return String::new(); + } + let mut buf = vec![0 as std::ffi::c_char; needed as usize + 1]; + f(buf.as_mut_ptr(), needed + 1); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// The library list JSON. +fn list_json() -> String { + read_string(|buf, size| unsafe { crate::library::oakengine_library_list(buf, size) }) +} + +/// Create a library row; returns its uuid. The create export has a side +/// effect, so it is called ONCE with a stack buffer (never two-stage). +fn create(name: &str) -> String { + let name = CString::new(name).unwrap(); + let mut buf = [0 as std::ffi::c_char; 256]; + let rc = unsafe { crate::library::oakengine_library_create(name.as_ptr(), buf.as_mut_ptr(), 256) }; + assert!(rc > 0, "create {name:?} rc={rc}"); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// Duplicate a library row (single call with a stack buffer, see +/// [`create`]). +fn duplicate(uuid: &str) -> String { + let uuid = CString::new(uuid).unwrap(); + let mut buf = [0 as std::ffi::c_char; 256]; + let rc = unsafe { + crate::library::oakengine_library_duplicate( + uuid.as_ptr(), + std::ptr::null(), + buf.as_mut_ptr(), + 256, + ) + }; + assert!(rc > 0, "duplicate rc={rc}"); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// The uuids in the library list JSON (order preserved). +fn list_uuids(json: &str) -> Vec { + serde_json::from_str::(json) + .expect("list is JSON") + .as_array() + .expect("list is an array") + .iter() + .map(|row| { + row.get("uuid") + .and_then(|v| v.as_str()) + .expect("row uuid") + .to_string() + }) + .collect() +} + +/// One row of the library list JSON by uuid. +fn list_row<'a>(json: &'a str, uuid: &str) -> Option { + serde_json::from_str::(json) + .expect("list is JSON") + .as_array() + .expect("list is an array") + .iter() + .find(|row| row.get("uuid").and_then(|v| v.as_str()) == Some(uuid)) + .cloned() +} + +/// Open a library row into a fresh facade project shell. +fn open(uuid: &str) -> *mut OakEngineProject { + let project = unsafe { crate::node::oakengine_project_create() }; + assert!(!project.is_null()); + let uuid_c = CString::new(uuid).unwrap(); + let mut err = [0 as std::ffi::c_char; 4096]; + let rc = unsafe { + crate::library::oakengine_project_load_library( + project, + uuid_c.as_ptr(), + err.as_mut_ptr(), + err.len() as i32, + ) + }; + assert_eq!(rc, OAKENGINE_OK, "open {uuid}"); + project +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Create lands a row immediately; list reports it with the metadata and +/// the (zero) stats. +#[test] +fn create_then_list_shows_the_row() { + let dir = temp_dir("create"); + with_library(&dir.join("lib.db"), || { + let uuid = create("Demo Reel"); + assert!(!uuid.is_empty(), "create reports the new uuid"); + + let json = list_json(); + let row = list_row(&json, &uuid).expect("the created row is listed"); + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Demo Reel")); + assert!(row.get("modified_at").and_then(|v| v.as_i64()).unwrap() > 0); + assert_eq!(row.get("track_count").and_then(|v| v.as_i64()), Some(0)); + assert_eq!(row.get("footage_count").and_then(|v| v.as_i64()), Some(0)); + + // A second create adds a second row. + let other = create("Second"); + assert_ne!(uuid, other); + assert_eq!(list_uuids(&list_json()).len(), 2); + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Opening a library row binds the project to the library session: the +/// next undoable edit write-throughs onto the row's journal. +#[test] +fn open_binds_and_write_through_advances_the_row() { + let dir = temp_dir("open"); + let db = dir.join("lib.db"); + with_library(&db, || { + let uuid = create("Editable"); + let before = journal_rows(&db, &uuid); + let project = open(&uuid); + assert_eq!( + unsafe { crate::storage::oakengine_storage_is_bound(project) }, + 1, + "the library-opened project is bound" + ); + + // An undoable edit write-throughs. + let node = unsafe { + crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr()) + }; + assert!(!node.is_null()); + unsafe { crate::node::oakengine_node_free(node) }; + + let after = journal_rows(&db, &uuid); + assert!( + after > before, + "the edit journaled new rows ({before} -> {after})" + ); + + // The row name comes from the projectname setting (the facade's + // project name is filename-derived, so a library project displays + // "(untitled)"; the app overrides it with the row name). + let row = list_row(&list_json(), &uuid).expect("row after the edit"); + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Editable")); + + unsafe { crate::node::oakengine_project_free(project) }; + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Rename and duplicate keep the list coherent; delete removes the row and +/// opening it afterwards fails. +#[test] +fn rename_duplicate_delete() { + let dir = temp_dir("rdd"); + with_library(&dir.join("lib.db"), || { + let uuid = create("Original"); + + // Rename. + let rc = unsafe { + crate::library::oakengine_library_rename( + CString::new(uuid.clone()).unwrap().as_ptr(), + CString::new("Renamed").unwrap().as_ptr(), + ) + }; + assert_eq!(rc, OAKENGINE_OK); + let row = list_row(&list_json(), &uuid).expect("renamed row"); + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Renamed")); + + // Duplicate (default " (copy)" name). + let copy = duplicate(&uuid); + assert_ne!(copy, uuid); + let row = list_row(&list_json(), ©).expect("the copy is listed"); + assert_eq!( + row.get("name").and_then(|v| v.as_str()), + Some("Renamed (copy)") + ); + + // The copy opens (its journal history came along). + let project = open(©); + unsafe { crate::node::oakengine_project_free(project) }; + + // Delete the copy; opening it afterwards fails. + let rc = unsafe { + crate::library::oakengine_library_delete(CString::new(copy.clone()).unwrap().as_ptr()) + }; + assert_eq!(rc, OAKENGINE_OK); + assert!(!list_uuids(&list_json()).contains(©)); + let shell = unsafe { crate::node::oakengine_project_create() }; + let rc = unsafe { + crate::library::oakengine_project_load_library( + shell, + CString::new(copy).unwrap().as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + assert_eq!(rc, OAKENGINE_E_NOT_FOUND, "a deleted row does not open"); + unsafe { crate::node::oakengine_project_free(shell) }; + + // Unknown uuids are E_NOT_FOUND; empty arguments E_INVALID. + let rc = unsafe { + crate::library::oakengine_library_delete(CString::new("{no-such}").unwrap().as_ptr()) + }; + assert_eq!(rc, OAKENGINE_E_NOT_FOUND); + let rc = unsafe { crate::library::oakengine_library_delete(c"".as_ptr()) }; + assert_eq!(rc, OAKENGINE_E_INVALID); + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Export writes the row's head state to a file (dispatched by extension), +/// and import brings a file back as a new library row that opens. +#[test] +fn export_then_import_round_trip() { + let dir = temp_dir("xport"); + with_library(&dir.join("lib.db"), || { + // A row with one node, so the exported file has content. + let uuid = create("Exchange"); + let project = open(&uuid); + let node = unsafe { + crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr()) + }; + assert!(!node.is_null()); + unsafe { crate::node::oakengine_node_free(node) }; + unsafe { crate::node::oakengine_project_free(project) }; + + // Export as .ove and as .otio. + let ove = dir.join("out.ove"); + let otio = dir.join("out.otio"); + for path in [&ove, &otio] { + let rc = unsafe { + crate::library::oakengine_library_export( + CString::new(uuid.clone()).unwrap().as_ptr(), + CString::new(path.to_string_lossy().into_owned()).unwrap().as_ptr(), + ) + }; + assert_eq!(rc, OAKENGINE_OK, "export {}", path.display()); + assert!(path.exists(), "{} exists", path.display()); + } + let xml = std::fs::read_to_string(&ove).unwrap(); + assert!(xml.contains(" 0, "import rc={rc}"); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + let imported = + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned(); + assert!(!imported.is_empty(), "import reports the new uuid"); + assert_ne!(imported, uuid, "import assigns a fresh uuid"); + let project = open(&imported); + // The projectname setting round-trips into the imported row's name. + let row = list_row(&list_json(), &imported).expect("the imported row is listed"); + assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Exchange")); + unsafe { crate::node::oakengine_project_free(project) }; + + // Importing a missing file fails. + let rc = unsafe { + crate::library::oakengine_library_import( + CString::new(dir.join("nope.ove").to_string_lossy().into_owned()) + .unwrap() + .as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + assert!(rc < 0, "a missing file does not import"); + }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With the backend disabled the list is empty (not an error) and every +/// mutating call fails with E_STATE. +#[test] +fn disabled_backend_degrades_gracefully() { + let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _off = common::storage_off_guard(); + + assert_eq!(list_json(), "[]", "no library configured reads as empty"); + + let rc = unsafe { + crate::library::oakengine_library_create( + c"Nope".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + assert_eq!(rc, OAKENGINE_E_STATE); + let rc = unsafe { crate::library::oakengine_library_delete(c"{x}".as_ptr()) }; + assert_eq!(rc, OAKENGINE_E_STATE); + let rc = unsafe { crate::library::oakengine_library_export(c"{x}".as_ptr(), c"/tmp/x.ove".as_ptr()) }; + assert_eq!(rc, OAKENGINE_E_STATE); + + let shell = unsafe { crate::node::oakengine_project_create() }; + let rc = unsafe { + crate::library::oakengine_project_load_library( + shell, + c"{x}".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + assert_eq!(rc, OAKENGINE_E_STATE); + unsafe { crate::node::oakengine_project_free(shell) }; +} diff --git a/crates/oakengine/src/test_support/mod.rs b/crates/oakengine/src/test_support/mod.rs index da26b2297..da42b101e 100644 --- a/crates/oakengine/src/test_support/mod.rs +++ b/crates/oakengine/src/test_support/mod.rs @@ -41,6 +41,7 @@ mod it_audio; mod it_codec; mod it_common; mod it_export; +mod it_library; mod it_plugin; mod it_storage; mod it_task; diff --git a/crates/oakstorage/src/backends/database/entities/mod.rs b/crates/oakstorage/src/backends/database/entities/mod.rs index 7dee44324..562b5e8b2 100644 --- a/crates/oakstorage/src/backends/database/entities/mod.rs +++ b/crates/oakstorage/src/backends/database/entities/mod.rs @@ -18,10 +18,10 @@ //! //! One module per entity: the `DeriveEntityModel` macro generates //! `Entity`/`Column`/`Model`/`ActiveModel` in the defining module, so -//! each table gets its own scope. The same definitions serve SQLite -//! today and PostgreSQL (D3) — the column types (`i64`, `String`, -//! `Option`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on -//! both. +//! each table gets its own scope. The same definitions serve SQLite and +//! PostgreSQL — the column types (`i64`, `String`, `Option`, +//! `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on both, so the shared +//! save/replay logic never branches on the dialect. pub mod journal; pub mod project; diff --git a/crates/oakstorage/src/backends/database/migration.rs b/crates/oakstorage/src/backends/database/migration.rs index 7d27bd09b..3e3bcf1f8 100644 --- a/crates/oakstorage/src/backends/database/migration.rs +++ b/crates/oakstorage/src/backends/database/migration.rs @@ -60,22 +60,53 @@ const SQLITE_DDL: &[&str] = &[ )", ]; -/// Apply the schema (idempotent; safe to run on every connection open). -/// -/// PostgreSQL (D3) gets its own `BIGSERIAL` DDL — same shape, currently -/// unreachable because the backend rejects `oakdb+pg://` before any -/// connection is opened. +/// PostgreSQL DDL for the four tables — the same shape with the PG +/// dialect differences (plan D3: BIGSERIAL for the surrogate key, +/// BIGINT for the FKs; `TEXT` everywhere, matching the SQLite TEXT +/// decision — no BYTEA). `CREATE TABLE IF NOT EXISTS` makes it +/// idempotent, exactly like the SQLite side. +const POSTGRES_DDL: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + schema_ver INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + modified_at TIMESTAMP NOT NULL, + command_seq BIGINT NOT NULL DEFAULT 0 + )", + "CREATE TABLE IF NOT EXISTS settings ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (project_id, key) + )", + "CREATE TABLE IF NOT EXISTS snapshots ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + command_seq BIGINT NOT NULL, + payload TEXT NOT NULL, + written_at TIMESTAMP NOT NULL, + PRIMARY KEY (project_id, command_seq) + )", + "CREATE TABLE IF NOT EXISTS journal ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + seq BIGINT NOT NULL, + node_identity BIGINT NOT NULL, + kind TEXT NOT NULL, + old_xml TEXT, + new_xml TEXT, + at TIMESTAMP NOT NULL, + PRIMARY KEY (project_id, seq, node_identity) + )", +]; + +/// Apply the schema for the connection's dialect (idempotent; safe to +/// run on every connection open). pub async fn migrate(db: &impl ConnectionTrait) -> crate::error::Result<()> { let backend = db.get_database_backend(); let statements: &[&str] = match backend { DatabaseBackend::Sqlite => SQLITE_DDL, - // D3: `BIGSERIAL PRIMARY KEY` for projects.id, `BIGINT` for the - // FKs — the rest of the column set is identical. - DatabaseBackend::Postgres => { - return Err(crate::error::Error::Failed( - "oakdb+pg is a D3 milestone; the PostgreSQL schema is not wired yet".to_string(), - )); - } + DatabaseBackend::Postgres => POSTGRES_DDL, other => { return Err(crate::error::Error::Failed(format!( "unsupported database backend {other:?}" diff --git a/crates/oakstorage/src/backends/database/mod.rs b/crates/oakstorage/src/backends/database/mod.rs index ca023dc62..f02ec3a45 100644 --- a/crates/oakstorage/src/backends/database/mod.rs +++ b/crates/oakstorage/src/backends/database/mod.rs @@ -14,16 +14,20 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The database backend (plan M13 §1/§2), SQLite first. +//! The database backend (plan M13 §1/§2): SQLite and PostgreSQL share +//! one code path — the same sea-orm entities, diff/save/replay logic and +//! migrations, with the dialect differences (BIGSERIAL vs AUTOINCREMENT, +//! `IF NOT EXISTS` DDL) converged in the connection and migration layer. //! //! URI forms: //! - `oakdb+sqlite:///absolute/path.db[?project=]` — local file //! database; the optional `project` query selects the library row to //! load (default: the most recently modified project). -//! - `oakdb+pg://user:pass@host:5432/db[?project=]` — PostgreSQL -//! (D3). The target is parsed into [`DbTarget::Pg`] today, but every -//! operation rejects it with [`crate::error::Error::NoBackend`] until -//! the D3 client lands. +//! - `oakdb+pg://user:pass@host:5432/db[?project=]` — PostgreSQL; +//! the body is the libpq connection string without a scheme. A `?…` +//! part is the `?project=` selector only when it carries a `project=` +//! key — a bare query (e.g. `?sslmode=disable`) belongs to the +//! connection string and is passed through untouched. //! //! The async sea-orm API is driven by a private current-thread tokio //! runtime so the public surface stays synchronous (plan §6: "后端内嵌 @@ -104,9 +108,11 @@ pub(crate) enum DbTarget { /// `?project=` uuid; `None` = most recently modified row. project: Option, }, - /// PostgreSQL (parsed for D3; rejected until then). + /// PostgreSQL server. Pg { - /// Connection string (`user:pass@host:port/db`). + /// Connection string body (`user:pass@host:port/db`, without a + /// scheme; may carry its own `?param=…` query when it does not + /// contain a `project=` key). conn: String, /// `?project=` uuid; `None` = most recently modified row. project: Option, @@ -114,20 +120,6 @@ pub(crate) enum DbTarget { } impl DbTarget { - /// True for the PostgreSQL variant (all ops return E_NO_BACKEND - /// until D3). - fn is_pg(&self) -> bool { - matches!(self, DbTarget::Pg { .. }) - } - - /// The SQLite path (`None` for PG). - fn sqlite_path(&self) -> Option<&str> { - match self { - DbTarget::Sqlite { path, .. } => Some(path), - DbTarget::Pg { .. } => None, - } - } - /// The `?project=` selector. fn project(&self) -> Option<&str> { match self { @@ -138,12 +130,23 @@ impl DbTarget { } } +/// The connection-cache key of a parsed target (the database location — +/// an absolute SQLite path or a PostgreSQL connection string body). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum DbKey { + /// SQLite file database at an absolute path. + Sqlite(String), + /// PostgreSQL server (connection string body, see [`DbTarget::Pg`]). + Pg(String), +} + /// Parse a storage URI into a [`DbTarget`]. /// /// `oakdb+sqlite://` requires an absolute path (E_INVALID otherwise); /// the body may carry `?project=` (no percent-decoding — uuid -/// values contain only hex/braces/hyphens). Unknown `oakdb` variants -/// are E_NO_BACKEND. +/// values contain only hex/braces/hyphens). `oakdb+pg://` requires a +/// non-empty, parseable connection string (E_INVALID otherwise). Unknown +/// `oakdb` variants are E_NO_BACKEND. pub(crate) fn parse_target(uri: &StorageUri) -> Result { match uri.scheme.as_str() { "oakdb+sqlite" => { @@ -157,16 +160,37 @@ pub(crate) fn parse_target(uri: &StorageUri) -> Result { }) } "oakdb+pg" => { - let (conn, query) = split_query(&uri.body); - Ok(DbTarget::Pg { - conn: conn.to_string(), - project: query_param(query, "project"), - }) + let (base, query) = split_query(&uri.body); + // A `?…` part is the `?project=` selector only when it carries + // a `project=` key; otherwise (e.g. `?sslmode=disable`) it + // belongs to the connection string and the whole body passes + // through. + let project = match query { + Some(q) if query_param(Some(q), "project").is_some() => { + query_param(Some(q), "project") + } + _ => None, + }; + let conn = match &project { + Some(_) => base.to_string(), + None => uri.body.clone(), + }; + if conn.is_empty() || !valid_pg_conn(&conn) { + return Err(Error::Invalid); + } + Ok(DbTarget::Pg { conn, project }) } _ => Err(Error::NoBackend), } } +/// Whether `conn` parses as a PostgreSQL connection string (the libpq +/// URL form, `user:pass@host:port/db[?params]`, with a scheme prepended). +fn valid_pg_conn(conn: &str) -> bool { + use std::str::FromStr; + sea_orm::sqlx::postgres::PgConnectOptions::from_str(&format!("postgres://{conn}")).is_ok() +} + /// Split `body` at the first `?` (the query part, if any). fn split_query(body: &str) -> (&str, Option<&str>) { match body.split_once('?') { @@ -310,8 +334,8 @@ pub struct DatabaseBackend { runtime: OnceLock, /// Serializes all database operations of this backend. op_lock: Mutex<()>, - /// Open connections by database path. - connections: Mutex>, + /// Open connections by database location (path or PG conn string). + connections: Mutex>, } impl DatabaseBackend { @@ -332,10 +356,11 @@ impl DatabaseBackend { .expect("tokio current_thread runtime") } - /// Run `f` against the database at `key` (an absolute SQLite path). - /// The op lock is held for the whole call; the connection is opened - /// and migrated on first use, then cached. - fn run(&self, key: &str, f: F) -> Result + /// Run `f` against the database at `key` (an absolute SQLite path or a + /// PostgreSQL connection string). The op lock is held for the whole + /// call; the connection is opened and migrated on first use, then + /// cached. + fn run(&self, key: DbKey, f: F) -> Result where F: FnOnce(DatabaseConnection) -> Fut, Fut: Future>, @@ -343,33 +368,36 @@ impl DatabaseBackend { let _guard = self.op_lock.lock().unwrap_or_else(|e| e.into_inner()); let runtime = self.runtime.get_or_init(Self::build_runtime); runtime.block_on(async { - let conn = self.connect_cached(key).await?; + let conn = self.connect_cached(&key).await?; f(conn).await }) } - /// Open (and migrate) the SQLite database at `path`, caching the - /// connection. + /// Open (and migrate) the database at `key`, caching the connection. /// - /// Two writers racing on a fresh file surface SQLite's `database is + /// Two writers racing on a fresh SQLite file surface `database is /// locked` (SQLITE_BUSY) — most often when a fresh connection's WAL /// mode switch collides with the other's, which `busy_timeout` cannot - /// wait out — so connect+ migrate are retried a few times (plan §6: - /// single-writer is the contract; a clean error beats a silent - /// failure). - async fn connect_cached(&self, path: &str) -> Result { + /// wait out — so the SQLite connect + migrate are retried a few times + /// (plan §6: single-writer is the contract; a clean error beats a + /// silent failure). PostgreSQL connections are never retried (their + /// errors are not retryable by [`retryable`]). + async fn connect_cached(&self, key: &DbKey) -> Result { if let Some(conn) = self .connections .lock() .unwrap_or_else(|e| e.into_inner()) - .get(path) + .get(key) { return Ok(conn.clone()); } let mut attempt = 0; let conn = loop { let result = async { - let conn = connect_sqlite(path).await?; + let conn = match key { + DbKey::Sqlite(path) => connect_sqlite(path).await?, + DbKey::Pg(conn) => connect_pg(conn).await?, + }; migration::migrate(&conn).await?; Ok::<_, Error>(conn) } @@ -386,7 +414,7 @@ impl DatabaseBackend { self.connections .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(path.to_string(), conn.clone()); + .insert(key.clone(), conn.clone()); Ok(conn) } @@ -396,8 +424,8 @@ impl DatabaseBackend { /// node graph). pub fn list_projects(&self, uri: &StorageUri) -> Result> { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; - self.run(path, |conn| async move { + let key = db_key_of(&target); + self.run(key, |conn| async move { let models = project::Entity::find() .order_by_desc(project::Column::ModifiedAt) .all(&conn) @@ -422,9 +450,9 @@ impl DatabaseBackend { /// the project's data is gone — the manager confirms before calling). pub fn delete_project(&self, uri: &StorageUri, uuid: &str) -> Result<()> { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let tx = conn.begin().await.map_err(db_err)?; let res = project::Entity::delete_many() .filter(project::Column::Uuid.eq(&uuid)) @@ -449,10 +477,10 @@ impl DatabaseBackend { new_name: Option<&str>, ) -> Result { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); let new_name = new_name.map(str::to_string); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let tx = conn.begin().await.map_err(db_err)?; let src = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) @@ -556,10 +584,10 @@ impl DatabaseBackend { /// in-app project name (`settings["projectname"]`) is untouched. pub fn rename_project(&self, uri: &StorageUri, uuid: &str, new_name: &str) -> Result<()> { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); let new_name = new_name.to_string(); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let now = chrono::Utc::now().naive_utc(); let tx = conn.begin().await.map_err(db_err)?; let res = project::Entity::update_many() @@ -633,9 +661,9 @@ impl DatabaseBackend { /// the head seq. pub fn snapshot(&self, uri: &StorageUri, uuid: &str) -> Result<()> { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let model = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -657,9 +685,9 @@ impl DatabaseBackend { /// the empty project. E_INVALID when `seq` is out of range. pub fn load_at(&self, uri: &StorageUri, uuid: &str, seq: i64) -> Result { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let model = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -695,9 +723,9 @@ impl DatabaseBackend { /// handle (the head state). fn load_handle_by_uuid(&self, uri: &StorageUri, uuid: &str) -> Result { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let uuid = uuid.to_string(); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let model = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -736,9 +764,9 @@ impl StorageBackend for DatabaseBackend { fn load(&self, uri: &StorageUri) -> Result { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); let project = target.project().map(str::to_string); - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { let model = pick_project(&conn, project.as_deref()).await?; let xml = assemble_at(&conn, model.id, &model.uuid, model.command_seq).await?; let handle = @@ -749,7 +777,7 @@ impl StorageBackend for DatabaseBackend { fn save(&self, project: CHandle, uri: &StorageUri, _options: u32) -> Result<()> { let target = parse_target(uri)?; - let path = sqlite_path_of(&target)?; + let key = db_key_of(&target); // Serialize under the project lock (the same per-node writer the // `.ove` backend uses — one serialization truth). let arc = unsafe { crate::nodeutil::project_arc(&project)? }; @@ -760,21 +788,19 @@ impl StorageBackend for DatabaseBackend { let (nodes, settings_xml, settings_map) = serialize_project_state(&guard)?; (uuid, name, nodes, settings_xml, settings_map) }; - self.run(path, move |conn| async move { + self.run(key, move |conn| async move { save_tx(&conn, &uuid, &name, &nodes, &settings_xml, &settings_map).await })?; Ok(()) } } -/// The SQLite path of a target, rejecting PG with E_NO_BACKEND (D3). -fn sqlite_path_of(target: &DbTarget) -> Result<&str> { - if target.is_pg() { - // D3: `oakdb+pg://` is parsed (see parse_target) but every - // operation refuses it until the PostgreSQL client is wired. - return Err(Error::NoBackend); +/// The connection-cache key of a target (SQLite path or PG conn string). +fn db_key_of(target: &DbTarget) -> DbKey { + match target { + DbTarget::Sqlite { path, .. } => DbKey::Sqlite(path.clone()), + DbTarget::Pg { conn, .. } => DbKey::Pg(conn.clone()), } - Ok(target.sqlite_path().expect("pg rejected above")) } /// Display name for a project on first entry: the `projectname` setting @@ -804,6 +830,34 @@ async fn connect_sqlite(path: &str) -> Result { Ok(DatabaseConnection::from(pool)) } +/// Open the PostgreSQL database at `conn` (a connection string body, +/// e.g. `user:pass@host:5432/db`; a scheme is prepended for sqlx). +/// +/// The server is first probed with a single, non-retrying connection +/// attempt: the pool retries refused connections with backoff for the +/// whole acquire timeout, which would hold a dead server for seconds. +/// A single `connect()` surfaces the refusal immediately, so a dead / +/// unreachable host fails fast with a clean E_IO. +async fn connect_pg(conn: &str) -> Result { + use std::str::FromStr; + use sea_orm::sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use sea_orm::sqlx::ConnectOptions; + let url = format!("postgres://{conn}"); + let options = PgConnectOptions::from_str(&url) + .map_err(|e| Error::Io(format!("invalid postgres connection string: {e}")))?; + options + .connect() + .await + .map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?; + let pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(options) + .await + .map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?; + Ok(DatabaseConnection::from(pool)) +} + /// Whether an error is worth retrying (SQLite BUSY — plan §6 note in /// [`DatabaseBackend::run`]). fn retryable(e: &Error) -> bool { @@ -1450,7 +1504,7 @@ mod tests { } #[test] - fn parse_target_pg_kept_for_d3() { + fn parse_target_pg_conn_string() { let t = parse_target(&uri("oakdb+pg://user:pass@host:5432/db")).unwrap(); assert_eq!( t, @@ -1459,6 +1513,47 @@ mod tests { project: None } ); + // A `?project=` query is the row selector, not a conn param. + let t = parse_target(&uri("oakdb+pg://user@host/db?project={abc-123}")).unwrap(); + assert_eq!( + t, + DbTarget::Pg { + conn: "user@host/db".to_string(), + project: Some("{abc-123}".to_string()) + } + ); + // A query without a `project=` key belongs to the conn string + // (e.g. `?sslmode=disable` passes through untouched). + let t = parse_target(&uri("oakdb+pg://user@host/db?sslmode=disable")).unwrap(); + assert_eq!( + t, + DbTarget::Pg { + conn: "user@host/db?sslmode=disable".to_string(), + project: None + } + ); + } + + #[test] + fn parse_target_pg_invalid_rejected() { + // Empty / unparseable connection strings are E_INVALID (clean, no + // network touched), matching the sqlite relative-path rule. + assert!(matches!( + parse_target(&uri("oakdb+pg://")), + Err(Error::Invalid) + )); + assert!(matches!( + parse_target(&uri("oakdb+pg://?project={x}")), + Err(Error::Invalid) + )); + assert!(matches!( + parse_target(&uri("oakdb+pg://user@")), + Err(Error::Invalid) + )); + assert!(matches!( + parse_target(&uri("oakdb+pg://not a url")), + Err(Error::Invalid) + )); } #[test] diff --git a/crates/oakstorage/tests/common/mod.rs b/crates/oakstorage/tests/common/mod.rs new file mode 100644 index 000000000..b7d24385f --- /dev/null +++ b/crates/oakstorage/tests/common/mod.rs @@ -0,0 +1,525 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Shared fixtures and helpers for the database-backend integration +//! tests (plan M13 D1/D3). +//! +//! Everything here is dialect-agnostic: the test bodies run against a +//! `oakdb+sqlite://…` or `oakdb+pg://…` uri string, and the helpers that +//! inspect rows behind the backend's back open a raw sea-orm connection +//! ([`inspect_sqlite`] / [`inspect_pg`]). `tests/database_test.rs` runs +//! the SQLite suite; `tests/database_pg_test.rs` runs the same behaviors +//! against a real PostgreSQL server gated on `OAK_TEST_PG_URL`. + +// The module is compiled into two test binaries, each of which uses only +// the helpers for its own dialect, so the other dialect's helpers look +// dead to one binary while the other uses them. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use sea_orm::entity::prelude::*; +use oakcore_rs::Rational; +use oaknode::block::ClipBlockBehavior; +use oaknode::footage::FootageBehavior; +use oaknode::id::NodeId; +use oaknode::keyframe::{Interpolation, Keyframe}; +use oaknode::node::NodeCore; +use oaknode::project::Project; +use oaknode::sequence::SequenceBehavior; +use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType}; +use oaknode::value::NodeValue; +use oakstorage::backend::StorageBackend; +use oakstorage::backends::database::entities::settings; +use oakstorage::backends::database::DatabaseBackend; +use oakstorage::error::OAKSTORAGE_OK; +use oakstorage::handle::CHandle; +use oakstorage::nodeutil::{make_project_owned, project_arc}; +use oakstorage::uri::StorageUri; + +// --------------------------------------------------------------------------- +// URIs and paths +// --------------------------------------------------------------------------- + +/// A fresh, unique temp directory for one test. +pub(crate) fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `oakdb+sqlite:///…` URI for a database file. +pub(crate) fn db_uri(path: &Path) -> String { + format!("oakdb+sqlite://{}", path.display()) +} + +/// `oakdb+sqlite:///…?project=` URI selecting one library row. +pub(crate) fn project_uri(db: &str, uuid: &str) -> String { + format!("{db}?project={uuid}") +} + +/// `file://…` URI for a plain file. +pub(crate) fn file_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +// --------------------------------------------------------------------------- +// Save / load through the backend +// --------------------------------------------------------------------------- + +/// Release an owned handle (refcount 1). +pub(crate) fn release(h: CHandle) { + if let Some(release) = h.release { + unsafe { release(h.ctx) }; + } +} + +/// Save an `Arc>` through the database backend. +pub(crate) fn save_project( + backend: &DatabaseBackend, + project: &Arc>, + uri: &str, +) -> oakstorage::error::Result<()> { + let parsed = StorageUri::parse(uri).unwrap(); + let handle = make_project_owned(project.clone()); + let result = backend.save(handle, &parsed, 0); + release(handle); + result +} + +/// Load a project through a *new* database backend session (a fresh +/// connection pool = a fresh session), returning `(uuid, loaded)`. +pub(crate) fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc>) { + let parsed = StorageUri::parse(uri).unwrap(); + let result = backend.load(&parsed).unwrap(); + assert_eq!(result.version_info, OAKSTORAGE_OK); + let handle = result.project; + let loaded = unsafe { project_arc(&handle) }.unwrap(); + let uuid = loaded.lock().unwrap().uuid.clone(); + release(handle); + (uuid, loaded) +} + +/// Load the state at `seq` (undo to any point). +pub(crate) fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc> { + let parsed = StorageUri::parse(uri).unwrap(); + let handle = backend.load_at(&parsed, uuid, seq).unwrap(); + let loaded = unsafe { project_arc(&handle) }.unwrap(); + release(handle); + loaded +} + +pub(crate) fn r_to_f(r: Rational) -> f64 { + r.numerator() as f64 / r.denominator() as f64 +} + +pub(crate) fn assert_close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "expected {a} close to {b}"); +} + +// --------------------------------------------------------------------------- +// Row inspection (raw sea-orm connections behind the backend's back) +// --------------------------------------------------------------------------- + +/// Open a raw sea-orm SQLite connection to `path` and drive one future +/// against it on a private current-thread runtime. +pub(crate) fn inspect_sqlite(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R +where + Fut: std::future::Future, +{ + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)) + .foreign_keys(true); + let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + f(sea_orm::DatabaseConnection::from(pool)).await + }) +} + +/// Open a raw sea-orm PostgreSQL connection to `url` and drive one +/// future against it on a private current-thread runtime. +pub(crate) fn inspect_pg(url: &str, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R +where + Fut: std::future::Future, +{ + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let pool = sea_orm::sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(url) + .await + .unwrap(); + f(sea_orm::DatabaseConnection::from(pool)).await + }) +} + +/// The settings mirror rows of a project (SQLite). +pub(crate) fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> { + inspect_sqlite(path, |conn| async move { + settings::Entity::find() + .filter(settings::Column::ProjectId.eq(project_id)) + .all(&conn) + .await + .unwrap() + .into_iter() + .map(|s| (s.key, s.value)) + .collect() + }) +} + +/// The settings mirror rows of a project (PostgreSQL). +pub(crate) fn settings_rows_pg(url: &str, project_id: i64) -> Vec<(String, String)> { + inspect_pg(url, |conn| async move { + settings::Entity::find() + .filter(settings::Column::ProjectId.eq(project_id)) + .all(&conn) + .await + .unwrap() + .into_iter() + .map(|s| (s.key, s.value)) + .collect() + }) +} + +/// The project uuid. +pub(crate) fn uuid_of(project: &Arc>) -> String { + project.lock().unwrap().uuid.clone() +} + +/// Read a config value for the duration of a test (the config store is +/// process-global, so restore it afterwards). +pub(crate) fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) { + let store = oakcommon::configstore::ConfigStore::instance(); + let before = store.get_int(Some(group), key, 0); + store.set_int(Some(group), key, value); + f(); + store.set_int(Some(group), key, before); +} + +// --------------------------------------------------------------------------- +// Full-feature fixture (footage / sequence / track / clip / effect / +// keyframes) — the union of the ove round-trip and timeline fixtures. +// --------------------------------------------------------------------------- + +pub(crate) const MATH: &str = "org.olivevideoeditor.Olive.math"; + +/// Build the round-trip fixture: root folder + two math nodes with +/// values/keyframes/label/color/link/connection + a sequence "My Seq" +/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4). +pub(crate) fn build_full_project() -> Arc> { + let project = Project::new(); + let mut p = project.lock().unwrap(); + p.initialize().unwrap(); + + let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); + let a = p.graph.add_node(core, behavior); + { + let e = p.graph.get_mut(a).unwrap(); + e.core.label = "Math A".to_string(); + e.core.override_color = 2; + e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5)); + e.core + .keyframe_track_mut("param_a_in", -1) + .set_key(Keyframe { + time: Rational::new(0, 1), + value: NodeValue::Float(1.0), + interpolation: Interpolation::Linear, + bezier_in: (0.0, 0.0), + bezier_out: (0.0, 0.0), + }); + e.core + .keyframe_track_mut("param_a_in", -1) + .set_key(Keyframe { + time: Rational::new(1, 1), + value: NodeValue::Float(3.0), + interpolation: Interpolation::Bezier, + bezier_in: (0.1, 0.2), + bezier_out: (0.3, 0.4), + }); + } + let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); + let b = p.graph.add_node(core, behavior); + p.graph + .get_mut(b) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(4.0)); + p.graph.connect(a, b, "param_b_in", -1).unwrap(); + p.graph.link(a, b); + + // Timeline: sequence + video track + two clips with footage. + let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph); + p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string(); + + let mut tb = TrackBehavior::new(TrackType::Video); + tb.track_list = Some(lists[0]); + let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb)); + + let foot1 = p + .graph + .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4"))); + let clip1 = { + let (core, mut behavior) = oaknode::block::clip_create(); + let clip = behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap(); + clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25)); + clip.core.media_in = Rational::new(0, 1); + clip.core.track = Some(track_id); + clip.footage = Some(foot1); + p.graph.add_node(core, behavior) + }; + + let foot2 = p + .graph + .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4"))); + let clip2 = { + let (core, mut behavior) = oaknode::block::clip_create(); + let clip = behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap(); + clip.core.range = + oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25)); + clip.core.media_in = Rational::new(10, 25); + clip.core.track = Some(track_id); + clip.footage = Some(foot2); + p.graph.add_node(core, behavior) + }; + + if let Some(entry) = p.graph.get_mut(track_id) { + entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap() + .blocks = vec![clip1, clip2]; + } + if let Some(entry) = p.graph.get_mut(lists[0]) { + entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + .unwrap() + .tracks = vec![track_id]; + } + + p.settings + .insert("projectname".to_string(), "full-fixture".to_string()); + drop(p); + project +} + +/// Field-by-field comparison of the full fixture after a round-trip +/// (uuid included). +pub(crate) fn assert_full_fields(orig: &Project, loaded: &Project) { + assert_eq!(loaded.uuid, orig.uuid, "uuid"); + assert_full_state(orig, loaded); +} + +/// Field-by-field comparison that ignores the uuid (a duplicated row +/// carries a fresh uuid by design). +pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) { + assert_eq!(loaded.settings, orig.settings, "settings"); + + // Node set: count, type order (slot order preserved by the writer). + let o_ids = orig.graph.node_ids(); + let l_ids = loaded.graph.node_ids(); + assert_eq!(l_ids.len(), o_ids.len(), "node count"); + let o_types: Vec<&str> = o_ids + .iter() + .map(|id| orig.graph.get(*id).unwrap().behavior.type_id()) + .collect(); + let l_types: Vec<&str> = l_ids + .iter() + .map(|id| loaded.graph.get(*id).unwrap().behavior.type_id()) + .collect(); + assert_eq!(l_types, o_types, "node types"); + + // Math A: label, color, value, keyframes. + let (a_o, a_l) = (o_ids[1], l_ids[1]); + assert_eq!( + loaded.graph.get(a_l).unwrap().core.label, + orig.graph.get(a_o).unwrap().core.label, + "label" + ); + assert_eq!( + loaded.graph.get(a_l).unwrap().core.override_color, + orig.graph.get(a_o).unwrap().core.override_color, + "color" + ); + assert_eq!( + loaded + .graph + .get(a_l) + .unwrap() + .core + .standard_value("param_a_in", -1), + orig.graph + .get(a_o) + .unwrap() + .core + .standard_value("param_a_in", -1), + "value a" + ); + let keys_o = orig + .graph + .get(a_o) + .unwrap() + .core + .keyframe_track("param_a_in", -1) + .unwrap() + .keys() + .to_vec(); + let keys_l = loaded + .graph + .get(a_l) + .unwrap() + .core + .keyframe_track("param_a_in", -1) + .unwrap() + .keys() + .to_vec(); + assert_eq!(keys_l.len(), keys_o.len(), "keyframe count"); + for (ko, kl) in keys_o.iter().zip(&keys_l) { + assert_eq!(kl.time, ko.time, "key time"); + assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value"); + assert_eq!(kl.interpolation, ko.interpolation, "key interpolation"); + assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in"); + assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out"); + } + + // Connection a -> b.param_b_in and the link. + assert_eq!( + loaded.graph.connected_output(l_ids[2], "param_b_in", -1), + Some(a_l), + "connection" + ); + assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link"); + + // Timeline: sequence, track lists, track, clips, footage. + let mut seq: Option<(NodeId, Vec)> = None; + for id in loaded.graph.node_ids() { + let entry = loaded.graph.get(id).unwrap(); + if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" { + let s = entry + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + seq = Some((id, s.track_lists.clone())); + break; + } + } + let (_seq_id, lists) = seq.expect("sequence present"); + assert_eq!(lists.len(), 3, "video/audio/subtitle lists"); + let list = loaded + .graph + .get(lists[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(list.kind, TrackType::Video); + assert_eq!(list.array_base, 0); + assert_eq!(list.tracks.len(), 1); + let track = loaded + .graph + .get(list.tracks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(track.kind, TrackType::Video); + assert_eq!(track.blocks.len(), 2); + let c1 = loaded + .graph + .get(track.blocks[0]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_close(r_to_f(c1.core.length()), 4.0); + assert_close(r_to_f(c1.core.media_in), 0.0); + let f1 = loaded + .graph + .get(c1.footage.unwrap()) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(f1.filename, "/a/b.mp4"); + let c2 = loaded + .graph + .get(track.blocks[1]) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_close(r_to_f(c2.core.length()), 2.0); + assert_close(r_to_f(c2.core.media_in), 0.4); + let f2 = loaded + .graph + .get(c2.footage.unwrap()) + .unwrap() + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .unwrap(); + assert_eq!(f2.filename, "/a/c.mp4"); +} + +/// Build a small project with a `projectname` setting (fast saves). +pub(crate) fn build_named_project(name: &str) -> Arc> { + let project = Project::new(); + { + let mut p = project.lock().unwrap(); + p.initialize().unwrap(); + p.settings.insert("projectname".to_string(), name.to_string()); + } + project +} + +/// Save a fresh named project, returning its uuid. +pub(crate) fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String { + let project = build_named_project(name); + let uuid = uuid_of(&project); + save_project(backend, &project, uri).unwrap(); + uuid +} diff --git a/crates/oakstorage/tests/database_pg_test.rs b/crates/oakstorage/tests/database_pg_test.rs new file mode 100644 index 000000000..38437e51a --- /dev/null +++ b/crates/oakstorage/tests/database_pg_test.rs @@ -0,0 +1,996 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! PostgreSQL variants of the database-backend integration tests (plan +//! M13 D3): the same behaviors `database_test.rs` runs against SQLite, +//! driven by the shared fixtures in `tests/common/mod.rs`, against a +//! real PostgreSQL server. +//! +//! Gating: when `OAK_TEST_PG_URL` (a libpq URL such as +//! `postgres://user:pass@host:5432/db`) is set, every test connects to +//! that server and runs for real; when it is absent the tests skip with +//! a note on stderr, so `cargo test -p oakstorage` is green without a +//! PostgreSQL server (CI included). The URL should point at a dedicated +//! test database: each test resets the four schema tables (DROP + the +//! migration recreates them on connect), and the tests run serialized +//! against the shared database. + +mod common; + +use std::sync::Mutex; + +use sea_orm::entity::prelude::*; +use sea_orm::{ConnectionTrait, QueryOrder}; +use oaknode::id::NodeId; +use oaknode::value::NodeValue; +use oakstorage::backend::StorageBackend; +use oakstorage::backends::database::entities::{journal, project, snapshot}; +use oakstorage::backends::database::{ + derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP, +}; +use oakstorage::error::{ + OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK, +}; +use oakstorage::nodeutil::project_arc; +use oakstorage::registry::Registry; +use oakstorage::uri::StorageUri; + +use common::*; + +// --------------------------------------------------------------------------- +// Gating and isolation +// --------------------------------------------------------------------------- + +/// The `OAK_TEST_PG_URL` connection string (None when not configured). +fn pg_url() -> Option { + std::env::var("OAK_TEST_PG_URL") + .ok() + .filter(|s| !s.trim().is_empty()) +} + +/// Strip the `postgres://` / `postgresql://` scheme from a connection +/// URL to get the `oakdb+pg://` uri body. +fn pg_body(url: &str) -> &str { + url.strip_prefix("postgres://") + .or_else(|| url.strip_prefix("postgresql://")) + .unwrap_or(url) +} + +/// Reset the four schema tables (the migration recreates them on the +/// next connection). Serialized with the other PG tests. +fn reset_tables(url: &str) { + inspect_pg(url, |conn| async move { + for table in ["journal", "snapshots", "settings", "projects"] { + conn.execute_raw(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + format!("DROP TABLE IF EXISTS {table} CASCADE"), + )) + .await + .unwrap(); + } + }); +} + +/// Run `f` with `(pg_url, oakdb+pg uri)` for an isolated test session. +/// Skips (with a note) when `OAK_TEST_PG_URL` is absent; otherwise the +/// tables are reset and the test runs against the shared database (the +/// tests share one server and one schema, so they run one at a time). +fn with_pg_uri(tag: &str, f: impl FnOnce(&str, &str)) { + let Some(url) = pg_url() else { + eprintln!("OAK_TEST_PG_URL is not set — skipping the PostgreSQL variant of '{tag}'"); + return; + }; + static PG_LOCK: Mutex<()> = Mutex::new(()); + let _g = PG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_tables(&url); + let uri = format!("oakdb+pg://{}", pg_body(&url)); + f(&url, &uri); +} + +// --------------------------------------------------------------------------- +// Round-trip through a fresh session +// --------------------------------------------------------------------------- + +#[test] +fn pg_roundtrip_field_by_field() { + with_pg_uri("roundtrip_field_by_field", |url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + + // A new backend instance = a new session; the library row is + // selected explicitly by uuid. + let session = DatabaseBackend::new(); + let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, &uuid)); + assert_eq!(loaded_uuid, uuid); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_full_fields(&o, &l); + } + + // The default (no ?project=) pick returns the same single row. + let session = DatabaseBackend::new(); + let (loaded_uuid, _) = load_project(&session, uri); + assert_eq!(loaded_uuid, uuid); + + // The settings mirror carries the current keys. + let uuid_c = uuid.clone(); + let row = inspect_pg(url, move |conn| async move { + project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap() + .id + }); + let mirrored = settings_rows_pg(url, row); + assert!( + mirrored.contains(&("projectname".to_string(), "full-fixture".to_string())), + "{mirrored:?}" + ); + }); +} + +// --------------------------------------------------------------------------- +// Journal semantics: import, diff, no-op save +// --------------------------------------------------------------------------- + +/// First save is one `kind='import'` command carrying every node plus +/// the settings pseudo-node (plan §2); the project row advances to seq 1. +#[test] +fn pg_first_save_is_an_import_command() { + with_pg_uri("first_save_is_an_import_command", |url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + let node_count = project.lock().unwrap().graph.node_count(); + save_project(&backend, &project, uri).unwrap(); + + let uuid_c = uuid.clone(); + let rows = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + let rows = journal::Entity::find() + .filter(journal::Column::ProjectId.eq(proj.id)) + .order_by_asc(journal::Column::Seq) + .order_by_asc(journal::Column::NodeIdentity) + .all(&conn) + .await + .unwrap(); + (proj.id, proj.command_seq, rows) + }); + let (pid, command_seq, rows) = rows; + assert_eq!(command_seq, 1); + assert_eq!( + rows.len(), + node_count + 1, + "every node + the settings pseudo-node" + ); + assert!(rows.iter().all(|r| r.seq == 1 && r.kind == KIND_IMPORT), "one import command"); + assert!(rows.iter().all(|r| r.old_xml.is_none()), "import has no before images"); + assert!(rows.iter().all(|r| r.new_xml.is_some()), "import has after images"); + // The settings pseudo-node (identity 0) is present. + assert!(rows.iter().any(|r| r.node_identity == 0), "settings row present"); + assert_eq!( + rows.iter().filter(|r| r.node_identity == 0).count(), + 1, + "exactly one settings row" + ); + // The real-node rows use the +1 offset (settings 0 reserved). + assert!(rows.iter().all(|r| r.node_identity >= 1 || r.node_identity == 0)); + // The node fragments round-trip: the settings row holds the settings + // element. + let settings_row = rows.iter().find(|r| r.node_identity == 0).unwrap(); + assert!(settings_row.new_xml.as_deref().unwrap().starts_with("")); + let _ = pid; + }); +} + +/// A later save is a diff: only the changed nodes land as `kind='redo'` +/// rows with both before and after images (plan §0). +#[test] +fn pg_later_saves_are_diffs() { + with_pg_uri("later_saves_are_diffs", |url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + + // Change one math node's value and one setting key. + { + let mut p = project.lock().unwrap(); + let math_ids: Vec = p + .graph + .node_ids() + .into_iter() + .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + let a = math_ids[0]; + p.graph + .get_mut(a) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(9.5)); + p.settings.insert("projectname".to_string(), "diffed".to_string()); + } + save_project(&backend, &project, uri).unwrap(); + + let uuid_c = uuid.clone(); + let rows = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + let rows = journal::Entity::find() + .filter(journal::Column::ProjectId.eq(proj.id)) + .all(&conn) + .await + .unwrap(); + (proj.id, proj.command_seq, rows) + }); + let (pid, command_seq, rows) = rows; + assert_eq!(command_seq, 2); + let seq2: Vec<&journal::Model> = rows.iter().filter(|r| r.seq == 2).collect(); + assert_eq!(seq2.len(), 2, "one changed node + the settings row, nothing else"); + assert!(seq2.iter().all(|r| r.kind == KIND_REDO)); + assert!( + seq2.iter().all(|r| r.old_xml.is_some() && r.new_xml.is_some()), + "diff carries both images" + ); + let settings_row = seq2.iter().find(|r| r.node_identity == 0).unwrap(); + assert!(settings_row.new_xml.as_deref().unwrap().contains(">diffed<")); + let _ = pid; + + // Head state after the diff reflects both changes. + let session = DatabaseBackend::new(); + let (_, loaded) = load_project(&session, &project_uri(uri, &uuid)); + { + let l = loaded.lock().unwrap(); + let math_ids: Vec = l + .graph + .node_ids() + .into_iter() + .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + assert_eq!( + l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), + NodeValue::Float(9.5) + ); + assert_eq!( + l.settings.get("projectname").cloned(), + Some("diffed".to_string()) + ); + } + }); +} + +/// A save that changes nothing bumps no command seq and writes no rows. +#[test] +fn pg_no_op_save_is_a_touch_only() { + with_pg_uri("no_op_save_is_a_touch_only", |url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + save_project(&backend, &project, uri).unwrap(); + + let uuid_c = uuid.clone(); + let (head, count) = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + let n = journal::Entity::find() + .filter(journal::Column::ProjectId.eq(proj.id)) + .count(&conn) + .await + .unwrap(); + (proj.command_seq, n) + }); + assert_eq!(head, 1, "no-op save keeps the head seq"); + // The import wrote 12 nodes + 1 settings row; the no-op added none. + assert_eq!(count, 13, "only the import rows remain"); + }); +} + +// --------------------------------------------------------------------------- +// Snapshot + journal replay and undo to any point +// --------------------------------------------------------------------------- + +/// With `Storage/SnapshotIntervalSec` ≤ 0 every dirty save writes a +/// snapshot; the newest snapshot is the replay base and the journal rows +/// after it are applied on top. Deleting every snapshot still recovers +/// the same state from an empty base plus the full journal (plan §0). +#[test] +fn pg_snapshot_and_journal_replay() { + with_pg_uri("snapshot_and_journal_replay", |url, uri| { + let backend = DatabaseBackend::new(); + + with_config("Storage", "SnapshotIntervalSec", 0, || { + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + // Second command: one value change. + { + let mut p = project.lock().unwrap(); + let math_ids: Vec = p + .graph + .node_ids() + .into_iter() + .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + p.graph + .get_mut(math_ids[0]) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(7.25)); + } + save_project(&backend, &project, uri).unwrap(); + + // Both saves produced snapshots (head seq 2). + let uuid_c = uuid.clone(); + let snaps = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + snapshot::Entity::find() + .filter(snapshot::Column::ProjectId.eq(proj.id)) + .all(&conn) + .await + .unwrap() + }); + assert_eq!(snaps.len(), 2, "one snapshot per dirty save"); + + // Load (head): snapshot seq 2 is the base, nothing after it. + let session = DatabaseBackend::new(); + let (_, loaded) = load_project(&session, &project_uri(uri, &uuid)); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_full_fields(&o, &l); + } + + // Destroy the snapshots: replay degrades to empty base + full + // journal and still reconstructs the head. + let uuid_c = uuid.clone(); + inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + snapshot::Entity::delete_many() + .filter(snapshot::Column::ProjectId.eq(proj.id)) + .exec(&conn) + .await + .unwrap(); + }); + let session = DatabaseBackend::new(); + let (_, loaded) = load_project(&session, &project_uri(uri, &uuid)); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_full_fields(&o, &l); + } + }); + }); +} + +/// The journal is the persistent undo history: `load_at(seq)` replays to +/// any point (plan §0 "撤销到任意点"). +#[test] +fn pg_undo_to_any_point() { + with_pg_uri("undo_to_any_point", |_url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + let math_a = { + let p = project.lock().unwrap(); + p.graph + .node_ids() + .into_iter() + .find(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .unwrap() + }; + // Command 1: value 2.5 (fixture default). + save_project(&backend, &project, uri).unwrap(); + // Command 2: value 6.0. + { + let mut p = project.lock().unwrap(); + p.graph + .get_mut(math_a) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(6.0)); + } + save_project(&backend, &project, uri).unwrap(); + // Command 3: add a node. + let (new_id, value) = { + let mut p = project.lock().unwrap(); + let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); + let id = p.graph.add_node(core, behavior); + p.graph + .get_mut(id) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(11.0)); + let v = p + .graph + .get(id) + .unwrap() + .core + .standard_value("param_a_in", -1); + (id, v) + }; + save_project(&backend, &project, uri).unwrap(); + + // Head (seq 3): the extra node exists. + let session = DatabaseBackend::new(); + let (_, head) = load_project(&session, &project_uri(uri, &uuid)); + { + let h = head.lock().unwrap(); + assert!(h.graph.is_valid(new_id), "node added in command 3 is live"); + assert_eq!( + h.graph.get(new_id).unwrap().core.standard_value("param_a_in", -1), + value + ); + } + + // Undo to seq 2: the node is gone, the value is 6.0. + let session = DatabaseBackend::new(); + let at2 = load_at(&session, uri, &uuid, 2); + { + let l = at2.lock().unwrap(); + assert!(!l.graph.is_valid(new_id), "command 3 rolled back"); + let id = l + .graph + .node_ids() + .into_iter() + .find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) + .unwrap(); + assert_eq!( + l.graph.get(id).unwrap().core.standard_value("param_a_in", -1), + NodeValue::Float(6.0) + ); + } + + // Undo to seq 1: the value is back to the fixture default. + let session = DatabaseBackend::new(); + let at1 = load_at(&session, uri, &uuid, 1); + { + let l = at1.lock().unwrap(); + assert_eq!(l.graph.node_count(), 12, "fixture node count"); + let id = l + .graph + .node_ids() + .into_iter() + .find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) + .unwrap(); + assert_eq!( + l.graph.get(id).unwrap().core.standard_value("param_a_in", -1), + NodeValue::Float(2.5) + ); + } + + // Undo to seq 0: an empty project. + let session = DatabaseBackend::new(); + let at0 = load_at(&session, uri, &uuid, 0); + { + let l = at0.lock().unwrap(); + assert_eq!(l.graph.node_count(), 0, "empty project at seq 0"); + } + + // Out of range -> E_INVALID. + let session = DatabaseBackend::new(); + assert_eq!( + session + .load_at(&StorageUri::parse(uri).unwrap(), &uuid, 99) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_INVALID + ); + }); +} + +/// Snapshot pruning keeps the newest [`SNAPSHOT_KEEP`] copies. +#[test] +fn pg_snapshot_pruning_keeps_three() { + with_pg_uri("snapshot_pruning_keeps_three", |url, uri| { + let backend = DatabaseBackend::new(); + + with_config("Storage", "SnapshotIntervalSec", 0, || { + let project = build_full_project(); + let uuid = uuid_of(&project); + // 6 commands, each dirty-snapshotted. + for i in 0..6i64 { + { + let mut p = project.lock().unwrap(); + let id = p.graph.node_ids()[0]; + p.graph.get_mut(id).unwrap().core.label = format!("step {i}"); + } + save_project(&backend, &project, uri).unwrap(); + } + let uuid_c = uuid.clone(); + let seqs = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + snapshot::Entity::find() + .filter(snapshot::Column::ProjectId.eq(proj.id)) + .order_by_desc(snapshot::Column::CommandSeq) + .all(&conn) + .await + .unwrap() + .into_iter() + .map(|s| s.command_seq) + .collect::>() + }); + assert_eq!(seqs.len() as u64, SNAPSHOT_KEEP, "only the newest {SNAPSHOT_KEEP}"); + assert_eq!(seqs[0], 6, "the newest survives"); + }); + }); +} + +/// Journal retention (`Storage/JournalRetentionDays`): rows older than +/// the window and covered by the newest snapshot are dropped; the head +/// stays reconstructible (snapshot + remaining rows). +#[test] +fn pg_journal_retention_truncation() { + with_pg_uri("journal_retention_truncation", |url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + // Command 1 (import) + command 2 (a value change). + save_project(&backend, &project, uri).unwrap(); + { + let mut p = project.lock().unwrap(); + let math_ids: Vec = p + .graph + .node_ids() + .into_iter() + .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + p.graph + .get_mut(math_ids[0]) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(3.25)); + } + save_project(&backend, &project, uri).unwrap(); + // Snapshot at the head so rows ≤ 2 are covered. + backend + .snapshot(&StorageUri::parse(uri).unwrap(), &uuid) + .unwrap(); + + // Backdate every journal row two days, then save a third command + // with a 1-day retention window. + inspect_pg(url, |conn| async move { + let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2); + journal::Entity::update_many() + .col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old)) + .exec(&conn) + .await + .unwrap(); + }); + with_config("Storage", "JournalRetentionDays", 1, || { + { + let mut p = project.lock().unwrap(); + let math_ids: Vec = p + .graph + .node_ids() + .into_iter() + .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + p.graph + .get_mut(math_ids[0]) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(4.5)); + } + save_project(&backend, &project, uri).unwrap(); + }); + + // Only the third command's rows survive. + let uuid_c = uuid.clone(); + let (pid, rows) = inspect_pg(url, move |conn| async move { + let proj = project::Entity::find() + .filter(project::Column::Uuid.eq(&uuid_c)) + .one(&conn) + .await + .unwrap() + .unwrap(); + let rows = journal::Entity::find() + .filter(journal::Column::ProjectId.eq(proj.id)) + .all(&conn) + .await + .unwrap(); + (proj.id, rows) + }); + let seqs: Vec = rows.iter().map(|r| r.seq).collect(); + assert!(!seqs.contains(&1) && !seqs.contains(&2), "old commands pruned: {seqs:?}"); + assert_eq!(seqs, vec![3], "only the fresh command remains"); + let _ = pid; + + // The head state is still correct (snapshot at seq 2 + command 3). + let session = DatabaseBackend::new(); + let (_, loaded) = load_project(&session, &project_uri(uri, &uuid)); + { + let l = loaded.lock().unwrap(); + let math_ids: Vec = l + .graph + .node_ids() + .into_iter() + .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + assert_eq!( + l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), + NodeValue::Float(4.5) + ); + assert_eq!(l.graph.node_count(), 12); + } + }); +} + +// --------------------------------------------------------------------------- +// Project-manager API surface +// --------------------------------------------------------------------------- + +#[test] +fn pg_list_delete_duplicate_rename() { + with_pg_uri("list_delete_duplicate_rename", |_url, uri| { + let backend = DatabaseBackend::new(); + + // Empty library lists nothing. + assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty()); + + let alpha = save_named_project(&backend, uri, "Alpha"); + let beta = save_named_project(&backend, uri, "Beta"); + + let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap(); + assert_eq!(list.len(), 2, "two rows"); + // Most recently modified first. + assert_eq!(list[0].name, "Beta"); + assert_eq!(list[1].name, "Alpha"); + assert_eq!(list[0].command_seq, 1); + assert_eq!(list[0].schema_ver, oaknode::serializer::CURRENT_VERSION.0 as i32); + + // Rename (library metadata). + backend + .rename_project(&StorageUri::parse(uri).unwrap(), &alpha, "Alpha Renamed") + .unwrap(); + let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap(); + assert!(list.iter().any(|p| p.name == "Alpha Renamed"), "{list:?}"); + assert_eq!( + backend + .rename_project(&StorageUri::parse(uri).unwrap(), "{missing}", "X") + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + + // Duplicate: fresh uuid, default "(copy)" name, history copied. + let copy = backend + .duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, None) + .unwrap(); + assert_ne!(copy.uuid, beta, "fresh uuid"); + assert_eq!(copy.name, "Beta (copy)"); + assert_eq!(copy.command_seq, 1); + let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap(); + assert_eq!(list.len(), 3); + let renamed_copy = backend + .duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, Some("Beta Clone")) + .unwrap(); + assert_eq!(renamed_copy.name, "Beta Clone"); + assert_eq!( + backend + .duplicate_project(&StorageUri::parse(uri).unwrap(), "{missing}", None) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + + // Delete. + for uuid in [&alpha, &beta, ©.uuid, &renamed_copy.uuid] { + backend + .delete_project(&StorageUri::parse(uri).unwrap(), uuid) + .unwrap(); + } + assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty()); + assert_eq!( + backend + .delete_project(&StorageUri::parse(uri).unwrap(), "{missing}") + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + }); +} + +/// Duplicating a full-feature project copies the whole history: the copy +/// loads identically under its own uuid and keeps the undo history. +#[test] +fn pg_duplicate_preserves_history() { + with_pg_uri("duplicate_preserves_history", |_url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + { + let mut p = project.lock().unwrap(); + let math_ids: Vec = p + .graph + .node_ids() + .into_iter() + .filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + p.graph + .get_mut(math_ids[0]) + .unwrap() + .core + .set_standard_value("param_a_in", -1, NodeValue::Float(6.0)); + } + save_project(&backend, &project, uri).unwrap(); + + let copy = backend + .duplicate_project(&StorageUri::parse(uri).unwrap(), &uuid, None) + .unwrap(); + assert_eq!(copy.command_seq, 2); + + // The copy loads through a fresh session, field-for-field (uuid is + // fresh by design). + let session = DatabaseBackend::new(); + let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, ©.uuid)); + assert_eq!(loaded_uuid, copy.uuid); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_full_state(&o, &l); + } + + // Undo history travels with the copy: load_at(1) on the copy gives + // the pre-change state. + let session = DatabaseBackend::new(); + let at1 = load_at(&session, uri, ©.uuid, 1); + { + let l = at1.lock().unwrap(); + let math_ids: Vec = l + .graph + .node_ids() + .into_iter() + .filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH) + .collect(); + assert_eq!( + l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1), + NodeValue::Float(2.5) + ); + } + }); +} + +/// Manager stats are derived from the node graph, not stored (plan §4). +#[test] +fn pg_project_stats_derived_from_graph() { + with_pg_uri("project_stats_derived_from_graph", |_url, uri| { + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + + let stats = backend + .project_stats(&StorageUri::parse(uri).unwrap(), &uuid) + .unwrap(); + assert_eq!( + stats, + ProjectStats { + duration_ms: 6000, + track_count: 1, + clip_count: 2, + footage_count: 2, + } + ); + // Same numbers derive_stats yields directly on the live project. + let guard = project.lock().unwrap(); + assert_eq!(derive_stats(&guard), stats); + assert_eq!( + backend + .project_stats(&StorageUri::parse(uri).unwrap(), "{missing}") + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + }); +} + +#[test] +fn pg_export_and_import_round_trip() { + with_pg_uri("export_and_import_round_trip", |_url, uri| { + let dir = temp_dir("pg_xi"); + let backend = DatabaseBackend::new(); + + let project = build_full_project(); + let uuid = uuid_of(&project); + save_project(&backend, &project, uri).unwrap(); + + // Export: .ove written from an in-memory assembly; the file backend + // re-imports it byte-for-byte. + let out = dir.join("exported.ove"); + let file = file_uri(&out); + backend + .export_to_file( + &StorageUri::parse(uri).unwrap(), + &uuid, + &StorageUri::parse(&file).unwrap(), + ) + .unwrap(); + assert!(out.exists(), ".ove written"); + let text = std::fs::read_to_string(&out).unwrap(); + assert!(text.starts_with(""), "{text}"); + let file_backend = Registry::global() + .resolve(&StorageUri::parse(&file).unwrap()) + .unwrap(); + let result = file_backend + .load(&StorageUri::parse(&file).unwrap()) + .unwrap(); + assert_eq!(result.version_info, OAKSTORAGE_OK); + let loaded = unsafe { project_arc(&result.project) }.unwrap(); + release(result.project); + { + let o = project.lock().unwrap(); + let l = loaded.lock().unwrap(); + assert_full_fields(&o, &l); + } + + // Import: the .ove lands as a new library row under a fresh uuid; + // importing it again yields a distinct row. + let imported = backend + .import_from_file( + &StorageUri::parse(uri).unwrap(), + &StorageUri::parse(&file).unwrap(), + ) + .unwrap(); + assert_ne!(imported, uuid, "import gets a fresh uuid"); + let imported2 = backend + .import_from_file( + &StorageUri::parse(uri).unwrap(), + &StorageUri::parse(&file).unwrap(), + ) + .unwrap(); + assert_ne!(imported2, imported, "repeat imports are new rows"); + let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap(); + assert_eq!(list.len(), 3, "original + two imports"); + let session = DatabaseBackend::new(); + let (imported_uuid, imported_proj) = load_project(&session, &project_uri(uri, &imported)); + assert_eq!(imported_uuid, imported); + { + let l = imported_proj.lock().unwrap(); + assert_eq!(l.graph.node_count(), 12); + assert_eq!( + l.settings.get("projectname").cloned(), + Some("full-fixture".to_string()) + ); + } + + // Error paths: unknown project on export; non-file target; corrupt + // file on import. + assert_eq!( + backend + .export_to_file( + &StorageUri::parse(uri).unwrap(), + "{missing}", + &StorageUri::parse(&file).unwrap(), + ) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + assert_eq!( + backend + .export_to_file( + &StorageUri::parse(uri).unwrap(), + &uuid, + &StorageUri::parse("oakdb+sqlite:///tmp/x.db").unwrap(), + ) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_INVALID + ); + let corrupt = dir.join("corrupt.ove"); + std::fs::write(&corrupt, "").unwrap(); + assert_eq!( + backend + .import_from_file( + &StorageUri::parse(uri).unwrap(), + &StorageUri::parse(&file_uri(&corrupt)).unwrap(), + ) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_FORMAT + ); + }); +} + +// --------------------------------------------------------------------------- +// Project selection (`?project=` vs default) +// --------------------------------------------------------------------------- + +#[test] +fn pg_project_selection_via_query() { + with_pg_uri("project_selection_via_query", |_url, uri| { + let backend = DatabaseBackend::new(); + + let a = save_named_project(&backend, uri, "A"); + let b = save_named_project(&backend, uri, "B"); + + // Explicit uuid picks the right row regardless of recency. + let session = DatabaseBackend::new(); + let (loaded_uuid, _) = load_project(&session, &project_uri(uri, &a)); + assert_eq!(loaded_uuid, a); + + // Default pick = most recently modified (B was written last). + let session = DatabaseBackend::new(); + let (loaded_uuid, loaded) = load_project(&session, uri); + assert_eq!(loaded_uuid, b); + assert_eq!( + loaded.lock().unwrap().settings.get("projectname").cloned(), + Some("B".to_string()) + ); + + // Unknown uuid -> E_NOT_FOUND; empty library -> E_NOT_FOUND. + let session = DatabaseBackend::new(); + assert_eq!( + session + .load(&StorageUri::parse(&project_uri(uri, "{missing}")).unwrap()) + .err() + .unwrap() + .code(), + OAKSTORAGE_E_NOT_FOUND + ); + }); +} diff --git a/crates/oakstorage/tests/database_test.rs b/crates/oakstorage/tests/database_test.rs index 04cabe0f4..7861316ef 100644 --- a/crates/oakstorage/tests/database_test.rs +++ b/crates/oakstorage/tests/database_test.rs @@ -22,459 +22,33 @@ //! journal (import + diff), snapshot + journal replay, undo to any //! point, snapshot pruning and journal retention, the project-manager //! API surface (list/delete/duplicate/rename/import/export) and the -//! error/URI matrices. Nothing touches a real library. +//! error/URI matrices. Nothing touches a real library. The same +//! behaviors run against real PostgreSQL in `database_pg_test.rs` +//! (gated on `OAK_TEST_PG_URL`); both suites share the fixtures and +//! helpers in `tests/common/mod.rs`. -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +mod common; + +use std::path::PathBuf; use sea_orm::entity::prelude::*; use sea_orm::QueryOrder; -use oakcore_rs::Rational; -use oaknode::block::ClipBlockBehavior; -use oaknode::footage::FootageBehavior; use oaknode::id::NodeId; -use oaknode::keyframe::{Interpolation, Keyframe}; -use oaknode::node::NodeCore; -use oaknode::project::Project; -use oaknode::sequence::SequenceBehavior; -use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType}; use oaknode::value::NodeValue; use oakstorage::backend::StorageBackend; -use oakstorage::backends::database::entities::{journal, project, settings, snapshot}; -use oakstorage::backends::database::{derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP}; +use oakstorage::backends::database::entities::{journal, project, snapshot}; +use oakstorage::backends::database::{ + derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP, +}; use oakstorage::error::{ OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND, OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK, }; -use oakstorage::handle::CHandle; -use oakstorage::nodeutil::{make_project_owned, project_arc}; +use oakstorage::nodeutil::project_arc; use oakstorage::registry::Registry; use oakstorage::uri::StorageUri; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// A fresh, unique temp directory for one test. -fn temp_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -/// `oakdb+sqlite:///…` URI for a database file. -fn db_uri(path: &Path) -> String { - format!("oakdb+sqlite://{}", path.display()) -} - -/// `oakdb+sqlite:///…?project=` URI selecting one library row. -fn project_uri(db: &str, uuid: &str) -> String { - format!("{db}?project={uuid}") -} - -/// `file://…` URI for a plain file. -fn file_uri(path: &Path) -> String { - format!("file://{}", path.display()) -} - -/// Release an owned handle (refcount 1). -fn release(h: CHandle) { - if let Some(release) = h.release { - unsafe { release(h.ctx) }; - } -} - -/// Save an `Arc>` through the database backend. -fn save_project( - backend: &DatabaseBackend, - project: &Arc>, - uri: &str, -) -> oakstorage::error::Result<()> { - let parsed = StorageUri::parse(uri).unwrap(); - let handle = make_project_owned(project.clone()); - let result = backend.save(handle, &parsed, 0); - release(handle); - result -} - -/// Load a project through a *new* database backend session (a fresh -/// connection pool = a fresh session), returning `(uuid, loaded)`. -fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc>) { - let parsed = StorageUri::parse(uri).unwrap(); - let result = backend.load(&parsed).unwrap(); - assert_eq!(result.version_info, OAKSTORAGE_OK); - let handle = result.project; - let loaded = unsafe { project_arc(&handle) }.unwrap(); - let uuid = loaded.lock().unwrap().uuid.clone(); - release(handle); - (uuid, loaded) -} - -/// Load the state at `seq` (undo to any point). -fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc> { - let parsed = StorageUri::parse(uri).unwrap(); - let handle = backend.load_at(&parsed, uuid, seq).unwrap(); - let loaded = unsafe { project_arc(&handle) }.unwrap(); - release(handle); - loaded -} - -fn r_to_f(r: Rational) -> f64 { - r.numerator() as f64 / r.denominator() as f64 -} - -fn assert_close(a: f64, b: f64) { - assert!((a - b).abs() < 1e-6, "expected {a} close to {b}"); -} - -/// Open a raw sea-orm connection to the database file (for inspecting -/// and injecting rows behind the backend's back) and drive one future -/// against it on a private current-thread runtime. The connection is -/// handed to `f` by value (a clone of the pool). -fn inspect_db(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R -where - Fut: std::future::Future, -{ - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new() - .filename(path) - .create_if_missing(true) - .journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal) - .busy_timeout(Duration::from_secs(5)) - .foreign_keys(true); - let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .unwrap(); - f(sea_orm::DatabaseConnection::from(pool)).await - }) -} - -/// The settings mirror rows of a project. -fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> { - inspect_db(path, |conn| async move { - settings::Entity::find() - .filter(settings::Column::ProjectId.eq(project_id)) - .all(&conn) - .await - .unwrap() - .into_iter() - .map(|s| (s.key, s.value)) - .collect() - }) -} - -/// The project uuid. -fn uuid_of(project: &Arc>) -> String { - project.lock().unwrap().uuid.clone() -} - -/// Read a config value for the duration of a test (the config store is -/// process-global, so restore it afterwards). -fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) { - let store = oakcommon::configstore::ConfigStore::instance(); - let before = store.get_int(Some(group), key, 0); - store.set_int(Some(group), key, value); - f(); - store.set_int(Some(group), key, before); -} - -// --------------------------------------------------------------------------- -// Full-feature fixture (footage / sequence / track / clip / effect / -// keyframes) — the union of the ove round-trip and timeline fixtures. -// --------------------------------------------------------------------------- - -const MATH: &str = "org.olivevideoeditor.Olive.math"; - -/// Build the round-trip fixture: root folder + two math nodes with -/// values/keyframes/label/color/link/connection + a sequence "My Seq" -/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4). -fn build_full_project() -> Arc> { - let project = Project::new(); - let mut p = project.lock().unwrap(); - p.initialize().unwrap(); - - let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); - let a = p.graph.add_node(core, behavior); - { - let e = p.graph.get_mut(a).unwrap(); - e.core.label = "Math A".to_string(); - e.core.override_color = 2; - e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5)); - e.core - .keyframe_track_mut("param_a_in", -1) - .set_key(Keyframe { - time: Rational::new(0, 1), - value: NodeValue::Float(1.0), - interpolation: Interpolation::Linear, - bezier_in: (0.0, 0.0), - bezier_out: (0.0, 0.0), - }); - e.core - .keyframe_track_mut("param_a_in", -1) - .set_key(Keyframe { - time: Rational::new(1, 1), - value: NodeValue::Float(3.0), - interpolation: Interpolation::Bezier, - bezier_in: (0.1, 0.2), - bezier_out: (0.3, 0.4), - }); - } - let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)(); - let b = p.graph.add_node(core, behavior); - p.graph - .get_mut(b) - .unwrap() - .core - .set_standard_value("param_a_in", -1, NodeValue::Float(4.0)); - p.graph.connect(a, b, "param_b_in", -1).unwrap(); - p.graph.link(a, b); - - // Timeline: sequence + video track + two clips with footage. - let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph); - p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string(); - - let mut tb = TrackBehavior::new(TrackType::Video); - tb.track_list = Some(lists[0]); - let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb)); - - let foot1 = p - .graph - .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4"))); - let clip1 = { - let (core, mut behavior) = oaknode::block::clip_create(); - let clip = behavior - .as_any_mut() - .and_then(|a| a.downcast_mut::()) - .unwrap(); - clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25)); - clip.core.media_in = Rational::new(0, 1); - clip.core.track = Some(track_id); - clip.footage = Some(foot1); - p.graph.add_node(core, behavior) - }; - - let foot2 = p - .graph - .add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4"))); - let clip2 = { - let (core, mut behavior) = oaknode::block::clip_create(); - let clip = behavior - .as_any_mut() - .and_then(|a| a.downcast_mut::()) - .unwrap(); - clip.core.range = - oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25)); - clip.core.media_in = Rational::new(10, 25); - clip.core.track = Some(track_id); - clip.footage = Some(foot2); - p.graph.add_node(core, behavior) - }; - - if let Some(entry) = p.graph.get_mut(track_id) { - entry - .behavior - .as_any_mut() - .and_then(|a| a.downcast_mut::()) - .unwrap() - .blocks = vec![clip1, clip2]; - } - if let Some(entry) = p.graph.get_mut(lists[0]) { - entry - .behavior - .as_any_mut() - .and_then(|a| a.downcast_mut::()) - .unwrap() - .tracks = vec![track_id]; - } - - p.settings - .insert("projectname".to_string(), "full-fixture".to_string()); - drop(p); - project -} - -/// Field-by-field comparison of the full fixture after a round-trip -/// (uuid included). -fn assert_full_fields(orig: &Project, loaded: &Project) { - assert_eq!(loaded.uuid, orig.uuid, "uuid"); - assert_full_state(orig, loaded); -} - -/// Field-by-field comparison that ignores the uuid (a duplicated row -/// carries a fresh uuid by design). -fn assert_full_state(orig: &Project, loaded: &Project) { - assert_eq!(loaded.settings, orig.settings, "settings"); - - // Node set: count, type order (slot order preserved by the writer). - let o_ids = orig.graph.node_ids(); - let l_ids = loaded.graph.node_ids(); - assert_eq!(l_ids.len(), o_ids.len(), "node count"); - let o_types: Vec<&str> = o_ids - .iter() - .map(|id| orig.graph.get(*id).unwrap().behavior.type_id()) - .collect(); - let l_types: Vec<&str> = l_ids - .iter() - .map(|id| loaded.graph.get(*id).unwrap().behavior.type_id()) - .collect(); - assert_eq!(l_types, o_types, "node types"); - - // Math A: label, color, value, keyframes. - let (a_o, a_l) = (o_ids[1], l_ids[1]); - assert_eq!( - loaded.graph.get(a_l).unwrap().core.label, - orig.graph.get(a_o).unwrap().core.label, - "label" - ); - assert_eq!( - loaded.graph.get(a_l).unwrap().core.override_color, - orig.graph.get(a_o).unwrap().core.override_color, - "color" - ); - assert_eq!( - loaded - .graph - .get(a_l) - .unwrap() - .core - .standard_value("param_a_in", -1), - orig.graph - .get(a_o) - .unwrap() - .core - .standard_value("param_a_in", -1), - "value a" - ); - let keys_o = orig - .graph - .get(a_o) - .unwrap() - .core - .keyframe_track("param_a_in", -1) - .unwrap() - .keys() - .to_vec(); - let keys_l = loaded - .graph - .get(a_l) - .unwrap() - .core - .keyframe_track("param_a_in", -1) - .unwrap() - .keys() - .to_vec(); - assert_eq!(keys_l.len(), keys_o.len(), "keyframe count"); - for (ko, kl) in keys_o.iter().zip(&keys_l) { - assert_eq!(kl.time, ko.time, "key time"); - assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value"); - assert_eq!(kl.interpolation, ko.interpolation, "key interpolation"); - assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in"); - assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out"); - } - - // Connection a -> b.param_b_in and the link. - assert_eq!( - loaded.graph.connected_output(l_ids[2], "param_b_in", -1), - Some(a_l), - "connection" - ); - assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link"); - - // Timeline: sequence, track lists, track, clips, footage. - let mut seq: Option<(NodeId, Vec)> = None; - for id in loaded.graph.node_ids() { - let entry = loaded.graph.get(id).unwrap(); - if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" { - let s = entry - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - seq = Some((id, s.track_lists.clone())); - break; - } - } - let (_seq_id, lists) = seq.expect("sequence present"); - assert_eq!(lists.len(), 3, "video/audio/subtitle lists"); - let list = loaded - .graph - .get(lists[0]) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_eq!(list.kind, TrackType::Video); - assert_eq!(list.array_base, 0); - assert_eq!(list.tracks.len(), 1); - let track = loaded - .graph - .get(list.tracks[0]) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_eq!(track.kind, TrackType::Video); - assert_eq!(track.blocks.len(), 2); - let c1 = loaded - .graph - .get(track.blocks[0]) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_close(r_to_f(c1.core.length()), 4.0); - assert_close(r_to_f(c1.core.media_in), 0.0); - let f1 = loaded - .graph - .get(c1.footage.unwrap()) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_eq!(f1.filename, "/a/b.mp4"); - let c2 = loaded - .graph - .get(track.blocks[1]) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_close(r_to_f(c2.core.length()), 2.0); - assert_close(r_to_f(c2.core.media_in), 0.4); - let f2 = loaded - .graph - .get(c2.footage.unwrap()) - .unwrap() - .behavior - .as_any() - .and_then(|a| a.downcast_ref::()) - .unwrap(); - assert_eq!(f2.filename, "/a/c.mp4"); -} - -/// Build a small project with a `projectname` setting (fast saves). -fn build_named_project(name: &str) -> Arc> { - let project = Project::new(); - { - let mut p = project.lock().unwrap(); - p.initialize().unwrap(); - p.settings.insert("projectname".to_string(), name.to_string()); - } - project -} +use common::*; // --------------------------------------------------------------------------- // Round-trip through a fresh session @@ -508,7 +82,7 @@ fn roundtrip_field_by_field() { assert_eq!(loaded_uuid, uuid); // The settings mirror carries the current keys. - let row = inspect_db(&db, |conn| async move { + let row = inspect_sqlite(&db, |conn| async move { project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -566,7 +140,7 @@ fn first_save_is_an_import_command() { let node_count = project.lock().unwrap().graph.node_count(); save_project(&backend, &project, &uri).unwrap(); - let rows = inspect_db(&db, |conn| async move { + let rows = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -637,7 +211,7 @@ fn later_saves_are_diffs() { save_project(&backend, &project, &uri).unwrap(); let uuid_q = uuid.clone(); - let rows = inspect_db(&db, move |conn| async move { + let rows = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) @@ -693,7 +267,7 @@ fn no_op_save_is_a_touch_only() { save_project(&backend, &project, &uri).unwrap(); save_project(&backend, &project, &uri).unwrap(); - let (head, count) = inspect_db(&db, |conn| async move { + let (head, count) = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -751,7 +325,7 @@ fn snapshot_and_journal_replay() { // Both saves produced snapshots (head seq 2). let uuid_q = uuid.clone(); - let snaps = inspect_db(&db, move |conn| async move { + let snaps = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) @@ -778,7 +352,7 @@ fn snapshot_and_journal_replay() { // Destroy the snapshots: replay degrades to empty base + full // journal and still reconstructs the head. let uuid_q = uuid.clone(); - inspect_db(&db, move |conn| async move { + inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) @@ -944,7 +518,7 @@ fn snapshot_pruning_keeps_three() { } save_project(&backend, &project, &uri).unwrap(); } - let seqs = inspect_db(&db, |conn| async move { + let seqs = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid)) .one(&conn) @@ -1000,7 +574,7 @@ fn journal_retention_truncation() { // Backdate every journal row two days, then save a third command // with a 1-day retention window. - inspect_db(&db, |conn| async move { + inspect_sqlite(&db, |conn| async move { let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2); journal::Entity::update_many() .col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old)) @@ -1028,7 +602,7 @@ fn journal_retention_truncation() { // Only the third command's rows survive. let uuid_q = uuid.clone(); - let (pid, rows) = inspect_db(&db, move |conn| async move { + let (pid, rows) = inspect_sqlite(&db, move |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&uuid_q)) .one(&conn) @@ -1070,13 +644,6 @@ fn journal_retention_truncation() { // Project-manager API surface // --------------------------------------------------------------------------- -fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String { - let project = build_named_project(name); - let uuid = uuid_of(&project); - save_project(backend, &project, uri).unwrap(); - uuid -} - #[test] fn list_delete_duplicate_rename() { let dir = temp_dir("mgr"); @@ -1404,8 +971,13 @@ fn project_selection_via_query() { // Error paths, locking, URI matrix // --------------------------------------------------------------------------- +/// PG targets are now live: malformed connection strings fail cleanly at +/// parse time (E_INVALID, no network touched) and an unreachable server +/// fails cleanly at connect (E_IO, no panic). Both run without a real +/// PostgreSQL server — the connection-refused case uses port 1 on +/// loopback, which nothing listens on. #[test] -fn pg_target_rejected_until_d3() { +fn pg_invalid_and_unreachable_targets_error_cleanly() { let dir = temp_dir("pg"); let db = dir.join("lib.db"); let uri = db_uri(&db); @@ -1414,27 +986,45 @@ fn pg_target_rejected_until_d3() { save_project(&backend, &project, &uri).unwrap(); let uuid = uuid_of(&project); - let pg = "oakdb+pg://user:pass@host:5432/db"; + // Invalid connection strings -> E_INVALID at parse (no connection). + for bad in ["oakdb+pg://", "oakdb+pg://?project={x}", "oakdb+pg://user@"] { + let err = backend + .list_projects(&StorageUri::parse(bad).unwrap()) + .err() + .unwrap(); + assert_eq!(err.code(), OAKSTORAGE_E_INVALID, "{bad}"); + } + + // Unreachable server (port 1 on loopback) -> E_IO at connect, and the + // error is clean (no panic) across every operation. The connect probe + // fails on the refused TCP connection without waiting out the pool's + // acquire timeout. + let dead = "oakdb+pg://user@127.0.0.1:1/db"; for call in [ - backend.load(&StorageUri::parse(pg).unwrap()).map(|_| ()), - backend.save(CHandle::null(), &StorageUri::parse(pg).unwrap(), 0), - backend.list_projects(&StorageUri::parse(pg).unwrap()).map(|_| ()), - backend.delete_project(&StorageUri::parse(pg).unwrap(), &uuid), - backend.rename_project(&StorageUri::parse(pg).unwrap(), &uuid, "X"), + backend.load(&StorageUri::parse(dead).unwrap()).map(|_| ()), + save_project(&backend, &project, dead), + backend.list_projects(&StorageUri::parse(dead).unwrap()).map(|_| ()), + backend.delete_project(&StorageUri::parse(dead).unwrap(), &uuid), + backend.rename_project(&StorageUri::parse(dead).unwrap(), &uuid, "X"), backend - .duplicate_project(&StorageUri::parse(pg).unwrap(), &uuid, None) + .duplicate_project(&StorageUri::parse(dead).unwrap(), &uuid, None) .map(|_| ()), backend .export_to_file( - &StorageUri::parse(pg).unwrap(), + &StorageUri::parse(dead).unwrap(), &uuid, &StorageUri::parse("file:///tmp/x.ove").unwrap(), ), - backend.snapshot(&StorageUri::parse(pg).unwrap(), &uuid), - backend.load_at(&StorageUri::parse(pg).unwrap(), &uuid, 1).map(|_| ()), + backend.snapshot(&StorageUri::parse(dead).unwrap(), &uuid), + backend.load_at(&StorageUri::parse(dead).unwrap(), &uuid, 1).map(|_| ()), ] { - assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_NO_BACKEND, "pg target"); + assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_IO, "dead pg target"); } + + // The sqlite library next to it is untouched by all of the above. + let session = DatabaseBackend::new(); + let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &uuid)); + assert_eq!(loaded_uuid, uuid); } #[test] @@ -1606,7 +1196,7 @@ fn concurrent_writers_are_serialized() { let session = DatabaseBackend::new(); let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &same)); assert_eq!(loaded_uuid, same, "library consistent after races"); - let (head, seqs) = inspect_db(&db, |conn| async move { + let (head, seqs) = inspect_sqlite(&db, |conn| async move { let proj = project::Entity::find() .filter(project::Column::Uuid.eq(&same)) .one(&conn) diff --git a/docs/project-storage.md b/docs/project-storage.md index 6eaeeca6a..7578b0ce4 100644 --- a/docs/project-storage.md +++ b/docs/project-storage.md @@ -91,12 +91,39 @@ removed clips. or otio backend; nothing is read from or written to the database beyond the current state. +## Configuration + +Library selection and behavior are driven by the `Storage` config group +(read by `crates/oakengine/src/storage.rs`): + +- `Storage/Backend` — `"sqlite"` (the documented default), `"database"` + or `"pg"` enables write-through; any other value (e.g. `"off"`) + disables it. When the key is absent no library is configured and + projects stay unbound (headless consumers and the test suite never + touch the user's real library). +- `Storage/SqlitePath` — the SQLite library file; default + `/library.db` (honoring `OAK_CONFIG_DIR`). +- `Storage/PgUrl` — the PostgreSQL connection string, used when + `Backend = "pg"`: `user:pass@host:5432/dbname` (libpq URL form; an + optional `postgres://`/`postgresql://` scheme is stripped). The + resolved library URI is `oakdb+pg://`. +- `Storage/SnapshotIntervalSec` (default 600) and + `Storage/JournalRetentionDays` (default 0 = keep forever) — see above. + ## Multi-writer and platforms v1 assumes a single writer per database (SQLite `busy_timeout`, PG row locks). Multi-writer collaboration is future work (M14). The default -database is a single user-level SQLite file; PostgreSQL is selected -with an `oakdb+pg://` URI. +database is a single user-level SQLite file; PostgreSQL is selected with +`Storage/Backend = "pg"` + `Storage/PgUrl`, or directly with an +`oakdb+pg://` URI. + +Database tests: `cargo test -p oakstorage` is green without PostgreSQL — +the SQLite suite always runs; the PG suite (`tests/database_pg_test.rs`) +connects to a real server when `OAK_TEST_PG_URL` is set (e.g. +`postgres://user:pass@host:5432/db`) and skips with a note otherwise. +The URL should point at a dedicated test database: each test resets the +four tables. See also: [M10 oakstorage manual](plans/riir/M10-oakstorage.md), [M13 write-through plan](plans/riir/M13-storage-live.md), diff --git a/docs/screenshot-manager-en.png b/docs/screenshot-manager-en.png new file mode 100644 index 000000000..5d0dee27a Binary files /dev/null and b/docs/screenshot-manager-en.png differ diff --git a/docs/screenshot-manager.png b/docs/screenshot-manager.png new file mode 100644 index 000000000..846379da7 Binary files /dev/null and b/docs/screenshot-manager.png differ diff --git a/docs/screenshot-window-en.png b/docs/screenshot-window-en.png index 68a085143..47ece0b6b 100644 Binary files a/docs/screenshot-window-en.png and b/docs/screenshot-window-en.png differ diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png index 8da9555e0..376cc9287 100644 Binary files a/docs/screenshot-window.png and b/docs/screenshot-window.png differ diff --git a/docs/zh/plans/riir/M13-storage-live.md b/docs/zh/plans/riir/M13-storage-live.md index 298dd122b..5dfbb9471 100644 --- a/docs/zh/plans/riir/M13-storage-live.md +++ b/docs/zh/plans/riir/M13-storage-live.md @@ -109,6 +109,24 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end` > 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅 > 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写 > 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。 +> +> D3 落地记录(2026-08):`oakdb+pg://` 全量走通(load/save/load_at/ +> snapshot/list/delete/duplicate/rename/export/import)。方言差异收敛在 +> 连接与 migration 两层:`crates/oakstorage/src/backends/database/ +> migration.rs` 按 `DatabaseBackend` 选 SQLite/PG DDL(PG 仅 `BIGSERIAL` +> PK + `BIGINT` FK,其余同构;`CREATE TABLE IF NOT EXISTS` 幂等,每次 +> 连接时执行;payload 维持 TEXT,不做 BYTEA);sea-orm 实体与 save/replay +> 逻辑两库共用零分支。`connect_pg` 先单次直连探测(pool 对被拒连接会 +> 退避重试到 acquire 超时,死库会挂起数秒),失败映射干净 E_IO。 +> **配置**:`Storage/Backend = "pg"` 启用,`Storage/PgUrl` 给连接串 +> (`user:pass@host:5432/dbname`,容忍 `postgres://` 前缀,oakdb uri +> 剥掉);URI 的 `?project=` 选择器只在 query 含 `project=` 键时生效, +> 否则整段(含 `?sslmode=…`)视为连接串。**测试矩阵**:SQLite 18 个 +> 常驻;PG 13 个变体(round-trip/journal 语义/快照重放/撤销/清理/管理 +> API/导入导出/选择)门控 `OAK_TEST_PG_URL`,未设置则早退打印说明 +> (CI 无 PG 也绿);非法连接串(E_INVALID,parse 期)+ 连不上(E_IO, +> 单次探测)为常驻错误路径测试,无需真实 PG。共享 fixture 抽到 +> `tests/common/mod.rs`(建 fixture/保存加载/行检查 sqlite+pg 双探针)。 ## 4. 项目管理器窗口(app) @@ -119,6 +137,30 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end` - 导入 .ove/.otio/.fcpxml 为新库行;选中工程导出为 .ove/.otio/.fcpxml。 - 数据源走 oakstorage 会话 API(Rust 直调;需要 C ABI 时 facade 只增)。 +> D4 落地记录(2026-08):app 只链接 `liboakengine` dylib,故数据源走 +> 新增 C ABI(`crates/oakengine/src/library.rs`,只增): +> `oakengine_library_list`(JSON 行:uuid/name/created/modified + 派生 +> 统计)/ `_create` / `_delete` / `_rename` / `_duplicate` / `_import` +> / `_export`(按扩展名走 oakstorage registry 分发 ove-xml 或 otio 后端) +> / `oakengine_project_load_library`(载入并**绑定**写穿会话,与 +> `project_load` 同契约)。有副作用的 create/duplicate/import 返回 uuid +> 用单次定长缓冲调用(不能两段式 measure-then-read——会在 C 侧执行两次)。 +> app 侧:`src/manager.rs`(`ProjectManager` 内容视图 + `ManagerEvent` +> 请求枚举 + `NamePrompt`/`ConfirmContent` 子对话框;时间/时长格式化为 +> 无依赖纯函数)。`src/app.rs`:无 `--project` 启动时开管理器(驱动根 +> entity,不能走 `WindowHandle::update`——`spawn_modal` 的 +> `update_window` 会重入失败);`run_with` 启动时 +> `real::configure_storage()`(仅在 `Storage/Backend` 未配置时设 +> `sqlite`,路径用 facade 默认 `<数据目录>/library.db`),退出前 +> `storage_flush()`。菜单语义(提前并入 D5):保存/另存为 → +> 导出工程文件…(⌘S,扩展名分发 ove/otio/fcpxml);打开 → 从库中打开… +> (= 管理器)+ 打开工程文件…;新增 项目管理器…。状态栏自动保存段 → +> 库写入状态(`storage_bound` / `last_error`,失败红色)。MockEngine 带 +> 内存假库(3 行种子 + 全操作),app gpui 测试覆盖建/删/复制/重命名/ +> 导入/导出/打开;facade 侧 `it_library.rs` 5 测试(建/列/打开绑定写穿/ +> 重命名复制删除/导出导入 round-trip/禁用降级)。截图 +> docs/screenshot-manager.png + -en.png。 + ## 5. 分期与判据 | 期 | 内容 | 完成判据 | diff --git a/docs/zh/project-storage.md b/docs/zh/project-storage.md index 32f3e2011..4f66aec4b 100644 --- a/docs/zh/project-storage.md +++ b/docs/zh/project-storage.md @@ -74,11 +74,35 @@ clip 行自带时间线区间(``)、媒体偏移(`/library.db`(尊重 `OAK_CONFIG_DIR`)。 +- `Storage/PgUrl` — PostgreSQL 连接串,`Backend = "pg"` 时使用: + `user:pass@host:5432/dbname`(libpq URL 形式,带 + `postgres://`/`postgresql://` 前缀也会被剥掉)。解析出的库 URI 为 + `oakdb+pg://`。 +- `Storage/SnapshotIntervalSec`(默认 600)、`Storage/JournalRetentionDays` + (默认 0 = 全保留)见上。 + ## 多写者与平台 v1 假设单写者(SQLite `busy_timeout`,PG 行锁);多写者协作是后续 -工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL 用 -`oakdb+pg://` 连接串选择。 +工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL 通过 +`Storage/Backend = "pg"` + `Storage/PgUrl` 选择,或直接用 +`oakdb+pg://` 连接串 URI。 + +数据库测试:`cargo test -p oakstorage` 全绿无需 PostgreSQL——SQLite +套件常驻运行;PG 套件(`tests/database_pg_test.rs`)在设置了 +`OAK_TEST_PG_URL`(如 `postgres://user:pass@host:5432/db`)时连接真实 +PG 全量运行,未设置则跳过并打印说明。该 URL 应指向专用测试库:每个 +测试会重置四张表。 另见:[M10 oakstorage 手册](plans/riir/M10-oakstorage.md)、 [M13 写穿计划](plans/riir/M13-storage-live.md)、 diff --git a/examples/screenshot.rs b/examples/screenshot.rs index 78db647a0..863b705aa 100644 --- a/examples/screenshot.rs +++ b/examples/screenshot.rs @@ -19,9 +19,10 @@ //! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an //! offscreen macOS window and writes the PNGs to //! `docs/screenshot-window.png` (zh-CN) and `docs/screenshot-window-en.png` -//! (en-US), using the same [`VisualTestAppContext`] machinery the gpui visual -//! tests use. The window is created at `(-10000, -10000)` so nothing -//! flickers on screen. +//! (en-US), then opens the project manager (M13 D4) on the same shell and +//! captures it to `docs/screenshot-manager.png` / `-en.png`, using the same +//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window +//! is created at `(-10000, -10000)` so nothing flickers on screen. //! //! Unlike the real app's startup ([`oakapp::app::run`]) the example must //! initialize the i18n layer itself, or every menu renders in the en-US @@ -37,7 +38,7 @@ //! cargo run --example screenshot -- 1100 900 # any size (same filenames) //! ``` -use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext}; +use gpui::{px, size, AnyWindowHandle, AppContext, Entity, Result, VisualTestAppContext}; use gpui_platform::current_platform; use oakapp::app::OakApp; use oakapp::i18n::{self, Language}; @@ -47,6 +48,8 @@ const DEFAULT_WIDTH: f32 = 1600.0; const DEFAULT_HEIGHT: f32 = 900.0; const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png"); const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png"); +const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png"); +const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png"); /// Logical y of the timeline toolbar row, which sits at the top of the /// bottom dock panel in the default layout: the dock starts at y 27.5 (the @@ -77,47 +80,62 @@ fn main() -> Result<()> { let original = i18n::language(); i18n::set_language(Language::ZhCN); { - let handle = open_shell(&mut cx, width, height); - let image = cx.capture_screenshot(handle)?; + let (handle, root) = open_shell(&mut cx, width, height); + let image = cx.capture_screenshot(handle.into())?; std::fs::create_dir_all(std::path::Path::new(OUT_ZH).parent().unwrap())?; image.save(OUT_ZH)?; println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height()); assert_toolbar(&image, "zh-CN"); + capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?; } i18n::set_language(Language::EnUs); { - let handle = open_shell(&mut cx, width, height); - let image = cx.capture_screenshot(handle)?; + let (handle, root) = open_shell(&mut cx, width, height); + let image = cx.capture_screenshot(handle.into())?; std::fs::create_dir_all(std::path::Path::new(OUT_EN).parent().unwrap())?; image.save(OUT_EN)?; println!("wrote {OUT_EN} ({}×{})", image.width(), image.height()); assert_toolbar(&image, "en-US"); + capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?; } i18n::set_language(original); Ok(()) } -/// Opens the app shell offscreen and draws enough frames for the layout to -/// settle and the async toolbar-icon assets to decode: the node editor fits -/// its graph once the canvas size is known, the viewers upload their first -/// CPU frame, and the PNG toolbar icons load through the background executor -/// on the frame after the asset future resolves. +/// Opens the app shell offscreen and lets the layout settle (see +/// [`settle`]). Returns the typed window handle and the root entity so the +/// caller can still drive the shell (the manager capture) after the plain +/// screenshot. fn open_shell( cx: &mut VisualTestAppContext, width: f32, height: f32, -) -> AnyWindowHandle { +) -> ( + gpui::WindowHandle>, + Entity>, +) { + let mut root_slot = None; let window = cx .open_offscreen_window(size(px(width), px(height)), |window, cx| { // Compact pro-app text metrics, matching the real app's startup // (`src/app.rs run_with` sets rem 14px; gpui's default is 16px). window.set_rem_size(px(14.0)); - cx.new(|cx| OakApp::::new(window, None, cx)) + let root = cx.new(|cx| OakApp::::new(window, None, cx)); + root_slot = Some(root.clone()); + root }) .expect("offscreen window opens"); - let handle: AnyWindowHandle = window.into(); + settle(cx, window.into()); + (window, root_slot.expect("root entity")) +} +/// Draws enough frames for the layout to settle and the async toolbar-icon +/// assets to decode: the node editor fits its graph once the canvas size is +/// known, the viewers upload their first CPU frame, and the PNG toolbar +/// icons load through the background executor on the frame after the asset +/// future resolves. +fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) { for _ in 0..16 { cx.run_until_parked(); cx.update_window(handle, |_root, window, app| { @@ -135,7 +153,24 @@ fn open_shell( .expect("window still open"); } cx.run_until_parked(); - handle +} + +/// Opens the project manager on the shell (M13 D4) and captures it: the +/// modal card lists the mock library with its per-project stats. Drives the +/// root ENTITY (not the window handle — a window update borrows the window, +/// and building the modal inside it would re-enter it). +fn capture_manager( + cx: &mut VisualTestAppContext, + handle: gpui::WindowHandle>, + root: &Entity>, + out: &str, +) -> Result<()> { + root.update(cx, |app, cx| app.show_project_manager(cx)); + settle(cx, handle.into()); + let image = cx.capture_screenshot(handle.into())?; + image.save(out)?; + println!("wrote {out} ({}×{})", image.width(), image.height()); + Ok(()) } /// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) must diff --git a/src/app.rs b/src/app.rs index 061efa184..8f1800dbe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -31,7 +31,7 @@ //! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录 //! │ (vertical split) 时间线 (full width, 31px toolbar on top) //! ├───────────────────────────────────────────────────── -//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎 +//! └ status bar: 就绪 | 缓存 | 代理 | 库写入状态 || 时间码/时长 | 帧率 | 分辨率 | 引擎 //! ``` use std::path::PathBuf; @@ -69,12 +69,13 @@ use crate::panels::timeline::TimelinePanel; mod menu_ids { pub const NEW_PROJECT: usize = 101; pub const OPEN_PROJECT: usize = 102; - pub const SAVE: usize = 103; - pub const SAVE_AS: usize = 104; + pub const EXPORT_PROJECT: usize = 103; pub const CLOSE: usize = 105; pub const EXPORT: usize = 106; pub const QUIT: usize = 107; pub const IMPORT_FOOTAGE: usize = 108; + pub const PROJECT_MANAGER: usize = 109; + pub const OPEN_FROM_LIBRARY: usize = 110; pub const UNDO: usize = 201; pub const REDO: usize = 202; @@ -116,6 +117,9 @@ mod modal_ids { pub const PREFERENCES: usize = 3; pub const EXPORT: usize = 4; pub const EXPORT_PROGRESS: usize = 5; + pub const MANAGER: usize = 6; + pub const MANAGER_RENAME: usize = 7; + pub const MANAGER_DELETE: usize = 8; } /// What a picked platform-dialog path should do. @@ -123,11 +127,18 @@ mod modal_ids { enum FileAction { ImportFootage, Open, + /// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`, + /// dispatched by extension). SaveAs, + /// Import a project file into the library (the manager's 导入). + ImportProject, + /// Export the selected library project (the manager's 导出; the row's + /// uuid is stashed in [`OakApp::pending_export`]). + ExportProject, } /// The modal currently layered on top of the shell, if any. -enum ModalState { +enum ModalState { None, Preferences { modal: Entity, @@ -141,6 +152,19 @@ enum ModalState { modal: Entity, content: Entity, }, + /// The project manager (M13 D4). + Manager { + modal: Entity, + content: Entity>, + }, + /// The manager's rename prompt. + ManagerRename { + modal: Entity, + content: Entity, + uuid: String, + }, + /// The manager's delete confirmation. + ManagerDelete { modal: Entity, uuid: String }, } /// A running export: the session the tick loop drains for progress. @@ -148,14 +172,17 @@ struct ExportRun { session: ExportSession, } -impl ModalState { +impl ModalState { /// The modal entity currently shown, if any. fn modal_entity(&self) -> Option> { match self { ModalState::None => None, ModalState::Preferences { modal, .. } | ModalState::Export { modal, .. } - | ModalState::Progress { modal, .. } => Some(modal.clone()), + | ModalState::Progress { modal, .. } + | ModalState::Manager { modal, .. } + | ModalState::ManagerRename { modal, .. } + | ModalState::ManagerDelete { modal, .. } => Some(modal.clone()), } } } @@ -255,9 +282,11 @@ pub struct OakApp { /// Whether the dark theme is active (toggles via 视图 → 主题). dark: bool, /// The modal currently shown on top of the shell, if any. - modal: ModalState, + modal: ModalState, /// The running export session, if any. export: Option, + /// The library row pending an export save dialog (manager 导出). + pending_export: Option, } impl OakApp { @@ -459,6 +488,7 @@ impl OakApp { dark: true, modal: ModalState::None, export: None, + pending_export: None, }; // Open the CLI-provided project once the shell is up. @@ -491,11 +521,11 @@ impl OakApp { use menu_ids::*; match item { // --- File ------------------------------------------------------ - NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)), + NEW_PROJECT => self.new_project(cx), OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx), + OPEN_FROM_LIBRARY | PROJECT_MANAGER => self.show_project_manager(cx), IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx), - SAVE => self.save_project(None, cx), - SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx), + EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx), CLOSE => self .engine .update(cx, |engine, cx| engine.close_project(cx)), @@ -580,7 +610,8 @@ impl OakApp { } } - /// Saves the project (to its own filename, or the given `path`). + /// Exports the project to a file (the 导出工程文件… action's target; the + /// format is dispatched by the picked path's extension). fn save_project(&mut self, path: Option, cx: &mut Context) { let result = self .engine @@ -590,6 +621,20 @@ impl OakApp { } } + /// 新建项目: creates a blank project in the library and opens it (the + /// write-through persists it from the first edit). Falls back to the + /// engine's plain new-project path when the library is unavailable. + fn new_project(&mut self, cx: &mut Context) { + let name = crate::i18n::tr("manager.new.default_name").to_string(); + let result = self + .engine + .update(cx, |engine, cx| engine.library_create_project(&name, cx)); + if let Err(err) = result { + println!("[file] library create failed ({err}); plain new project"); + self.engine.update(cx, |engine, cx| engine.new_project(cx)); + } + } + /// Deletes the timeline's selected clips (ripple or gap) through the /// engine's edit commands. fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context) { @@ -660,6 +705,228 @@ impl OakApp { .detach(); } + // ----------------------------------------------------------------------- + // Project manager (M13 D4) + // ----------------------------------------------------------------------- + + /// Opens the project manager (startup without a project argument, and + /// 文件 → 项目管理器 / 从库中打开…). Re-entrant: an already-open + /// manager just reloads its list. + pub fn show_project_manager(&mut self, cx: &mut Context) { + if let ModalState::Manager { content, .. } = &self.modal { + let content = content.clone(); + content.update(cx, |manager, cx| manager.reload(cx)); + return; + } + let engine = self.engine.clone(); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| crate::manager::ProjectManager::new(engine, window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::MANAGER, + ModalOptions::new(crate::i18n::tr("manager.title"), px(880.0)) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.close"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Manager { modal, content } + }); + // The content's requests (open / create / rename / ...) route here. + if let ModalState::Manager { content, .. } = &self.modal { + let content = content.clone(); + cx.subscribe( + &content, + |this, _content, event: &crate::manager::ManagerEvent, cx| { + this.on_manager_event(event, cx); + }, + ) + .detach(); + } + } + + /// Routes a project-manager request through the engine. Open / Create + /// close the dialog on success; the mutating actions reload the list; + /// failures land in the dialog's status line. + fn on_manager_event(&mut self, event: &crate::manager::ManagerEvent, cx: &mut Context) { + use crate::manager::ManagerEvent as E; + match event { + E::Create => { + let name = crate::i18n::tr("manager.new.default_name").to_string(); + let result = self + .engine + .update(cx, |engine, cx| engine.library_create_project(&name, cx)); + match result { + Ok(()) => self.close_modal(cx), + Err(err) => self.manager_status(err, cx), + } + } + E::Open(uuid) => { + let uuid = uuid.clone(); + let result = self + .engine + .update(cx, |engine, cx| engine.library_open_project(&uuid, cx)); + match result { + Ok(()) => self.close_modal(cx), + Err(err) => self.manager_status(err, cx), + } + } + E::Rename(uuid) => self.open_manager_rename(uuid.clone(), cx), + E::Duplicate(uuid) => { + let result = self + .engine + .update(cx, |engine, _cx| engine.library_duplicate_project(uuid)); + match result { + Ok(()) => self.reload_manager(cx), + Err(err) => self.manager_status(err, cx), + } + } + E::Delete(uuid) => self.open_manager_delete(uuid.clone(), cx), + E::Import => self.open_file_dialog(FileAction::ImportProject, cx), + E::Export(uuid) => self.open_manager_export(uuid.clone(), cx), + } + } + + /// Reloads the open manager's list (after a library mutation). + fn reload_manager(&mut self, cx: &mut Context) { + if let ModalState::Manager { content, .. } = &self.modal { + let content = content.clone(); + content.update(cx, |manager, cx| manager.reload(cx)); + } + } + + /// Shows an engine error in the open manager's status line (or logs it + /// when the manager is gone). + fn manager_status(&mut self, message: String, cx: &mut Context) { + if let ModalState::Manager { content, .. } = &self.modal { + let content = content.clone(); + content.update(cx, |manager, cx| manager.set_status(Some(message), cx)); + } else { + println!("[manager] {message}"); + } + } + + /// The selected row's display name in the open manager (used to seed + /// the rename prompt / the delete confirmation / the export filename). + fn manager_selected_name(&self, cx: &App) -> Option { + match &self.modal { + ModalState::Manager { content, .. } => content.read(cx).selected_name(), + _ => None, + } + } + + /// Swaps the manager for the rename prompt of row `uuid`. + fn open_manager_rename(&mut self, uuid: String, cx: &mut Context) { + let initial = self.manager_selected_name(cx).unwrap_or_default(); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| crate::manager::NamePrompt::new(&initial, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::MANAGER_RENAME, + ModalOptions::new(crate::i18n::tr("manager.rename.title"), px(380.0)) + .with_button(DialogButton::primary(crate::i18n::tr( + "manager.rename.title", + ))) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::ManagerRename { modal, content, uuid } + }); + } + + /// Swaps the manager for the delete confirmation of row `uuid`. + fn open_manager_delete(&mut self, uuid: String, cx: &mut Context) { + let name = self.manager_selected_name(cx).unwrap_or_default(); + let text = crate::i18n::tr("manager.delete.confirm").replace("{name}", &name); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|_cx| crate::manager::ConfirmContent::new(text)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::MANAGER_DELETE, + ModalOptions::new(crate::i18n::tr("manager.delete.title"), px(420.0)) + .with_button(DialogButton::primary(crate::i18n::tr("manager.delete"))) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::ManagerDelete { modal, uuid } + }); + } + + /// Confirms the rename prompt (button 0). + fn confirm_manager_rename(&mut self, cx: &mut Context) { + let ModalState::ManagerRename { content, uuid, .. } = &self.modal else { + return; + }; + let name = content.read(cx).value(cx); + let uuid = uuid.clone(); + if name.is_empty() { + self.back_to_manager(cx); + return; + } + let result = self + .engine + .update(cx, |engine, _cx| engine.library_rename_project(&uuid, &name)); + match result { + Ok(()) => self.back_to_manager(cx), + Err(err) => { + self.back_to_manager(cx); + self.manager_status(err, cx); + } + } + } + + /// Confirms the delete confirmation (button 0). + fn confirm_manager_delete(&mut self, cx: &mut Context) { + let ModalState::ManagerDelete { uuid, .. } = &self.modal else { + return; + }; + let uuid = uuid.clone(); + let result = self + .engine + .update(cx, |engine, _cx| engine.library_delete_project(&uuid)); + match result { + Ok(()) => self.back_to_manager(cx), + Err(err) => { + self.back_to_manager(cx); + self.manager_status(err, cx); + } + } + } + + /// Returns from a manager sub-dialog (rename / delete) to the manager. + fn back_to_manager(&mut self, cx: &mut Context) { + self.modal = ModalState::None; + self.show_project_manager(cx); + } + + /// Opens the platform save dialog for exporting the library row `uuid` + /// (the suggested name is `.ove`; the format follows the + /// extension the user picks). + fn open_manager_export(&mut self, uuid: String, cx: &mut Context) { + let name = self + .manager_selected_name(cx) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "project".to_string()); + self.pending_export = Some(uuid); + let receiver = + cx.prompt_for_new_path(&PathBuf::from("."), Some(&format!("{name}.ove"))); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(path))) = receiver.await { + this.update(cx, |this, cx| { + this.on_file_paths(FileAction::ExportProject, vec![path], cx); + }); + } + }) + .detach(); + } + // ----------------------------------------------------------------------- // Modal dialogs // ----------------------------------------------------------------------- @@ -679,10 +946,15 @@ impl OakApp { /// through a weak handle *inside* the window callback would re-enter this /// entity while it is already being updated (the crash seen when opening /// Preferences from a menu action). + /// + /// The caller must NOT be inside a window update itself (e.g. + /// `WindowHandle::update`): the nested `update_window` below would fail + /// and the modal would silently not open. Drive the root entity instead + /// (menu actions and entity updates are fine). fn spawn_modal( &mut self, cx: &mut Context, - build: impl FnOnce(&mut Window, &mut App) -> ModalState, + build: impl FnOnce(&mut Window, &mut App) -> ModalState, ) { let windows = cx.windows(); let Some(handle) = windows.first() else { @@ -704,14 +976,15 @@ impl OakApp { /// Opens the platform file dialog for `action` and routes the picked /// path(s) through the engine. Open / Import use the path picker (import - /// allows multiple files); Save As asks for a new path next to the current - /// project. The picker resolves asynchronously, so the chosen path is - /// applied in a spawned task via [`Self::on_file_paths`]. + /// footage allows multiple files); Save As and the manager's export ask + /// for a new path. The picker resolves asynchronously, so the chosen + /// path is applied in a spawned task via [`Self::on_file_paths`]. fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context) { match action { - FileAction::Open | FileAction::ImportFootage => { + FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => { let prompt = match action { FileAction::Open => crate::i18n::tr("file.open.title"), + FileAction::ImportProject => crate::i18n::tr("manager.import.title"), _ => crate::i18n::tr("file.import_footage.title"), }; let receiver = cx.prompt_for_paths(PathPromptOptions { @@ -757,12 +1030,49 @@ impl OakApp { }) .detach(); } + // The manager's export prompts in `open_manager_export` (it needs + // the selection's suggested filename). + FileAction::ExportProject => {} } } /// Applies paths picked in the platform dialog through the engine, using - /// the action's routing (open / import / save-as). + /// the action's routing (open / import / export). fn on_file_paths(&mut self, action: FileAction, paths: Vec, cx: &mut Context) { + // The manager's library import/export refresh the open manager and + // report failures in its status line. + match action { + FileAction::ImportProject => { + let Some(path) = paths.first() else { + return; + }; + let result = self + .engine + .update(cx, |engine, _cx| engine.library_import_project(path.clone())); + match result { + Ok(uuid) => { + println!("[manager] imported \"{}\" as {uuid}", path.display()); + self.reload_manager(cx); + } + Err(err) => self.manager_status(err, cx), + } + return; + } + FileAction::ExportProject => { + let (Some(uuid), Some(path)) = (self.pending_export.take(), paths.first()) else { + return; + }; + let result = self + .engine + .update(cx, |engine, _cx| engine.library_export_project(&uuid, path.clone())); + match result { + Ok(()) => println!("[manager] exported to \"{}\"", path.display()), + Err(err) => self.manager_status(err, cx), + } + return; + } + _ => {} + } let result = self.engine.update(cx, |engine, cx| match action { FileAction::Open => match paths.first() { Some(path) => engine.open_project_path(path.clone(), cx), @@ -786,6 +1096,8 @@ impl OakApp { None => Ok(()), } } + // Handled above (the manager's library import/export). + FileAction::ImportProject | FileAction::ExportProject => Ok(()), }); if let Err(err) = result { println!("[file] {action:?} failed: {err}"); @@ -963,6 +1275,21 @@ impl OakApp { } } modal_ids::PREFERENCES => self.close_modal(cx), + modal_ids::MANAGER => self.close_modal(cx), + modal_ids::MANAGER_RENAME => { + if *button == 0 { + self.confirm_manager_rename(cx); + } else { + self.back_to_manager(cx); + } + } + modal_ids::MANAGER_DELETE => { + if *button == 0 { + self.confirm_manager_delete(cx); + } else { + self.back_to_manager(cx); + } + } _ => {} }, ModalEvent::Dismissed { control } => match *control { @@ -971,6 +1298,10 @@ impl OakApp { self.cancel_export(cx); self.close_modal(cx); } + // Escape from a manager sub-dialog returns to the manager. + modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => { + self.back_to_manager(cx); + } _ => self.close_modal(cx), }, } @@ -1020,11 +1351,12 @@ fn make_menus(dark: bool) -> Vec { tr("menu.file"), Menu::new(vec![ MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"), + MenuItem::new(OPEN_FROM_LIBRARY, tr("menu.file.open_library")), MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"), + MenuItem::new(PROJECT_MANAGER, tr("menu.file.project_manager")).separated(), MenuItem::new(IMPORT_FOOTAGE, tr("menu.file.import_footage")), - MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"), - MenuItem::new(SAVE_AS, tr("menu.file.save_as")) - .with_shortcut("⇧⌘S") + MenuItem::new(EXPORT_PROJECT, tr("menu.file.export_project")) + .with_shortcut("⌘S") .separated(), MenuItem::new(CLOSE, tr("menu.file.close")), MenuItem::new(EXPORT, tr("menu.file.export")) @@ -1174,28 +1506,53 @@ fn run_with(args: AppArgs) { // Restore the persisted UI language (config `Language` key) before the // first window renders. crate::i18n::init(); + // M13 D4: enable the write-through project library (SQLite at the + // default location) unless the user configured the backend + // explicitly. + crate::oakui::real::configure_storage(); cx.init_colors(); let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx); let initial = initial.clone(); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |window, cx| { - // Compact pro-app text metrics: gpui's default rem is - // 16px (desktop-app large); 14px matches the design's - // density. All rem-based text scales; px spacing is - // unaffected. - window.set_rem_size(px(14.0)); - build_root::(window, initial, cx) - }, - ) - .expect("failed to open the main window"); + let show_manager = initial.is_none(); + let mut root_slot = None; + let window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| { + // Compact pro-app text metrics: gpui's default rem is + // 16px (desktop-app large); 14px matches the design's + // density. All rem-based text scales; px spacing is + // unaffected. + window.set_rem_size(px(14.0)); + let root = build_root::(window, initial, cx); + root_slot = Some(root.clone()); + root + }, + ) + .expect("failed to open the main window"); + let _ = window; + + // No project on the command line: the DaVinci-style project manager + // greets instead of an empty shell. Drives the ROOT ENTITY (not the + // window handle): a window update borrows the window, and building a + // modal inside it would re-enter it (spawn_modal needs a free + // `update_window`). + if show_manager { + if let Some(root) = &root_slot { + root.update(cx, |app, cx| app.show_project_manager(cx)); + } + } cx.activate(true); cx.on_window_closed(|cx, _| { if cx.windows().is_empty() { + // Exit path (plan M13 §2): drain the write-through backlog + // (save + snapshot of every still-bound project) and stop + // the facade's snapshot thread before quitting. + crate::oakui::real::storage_flush(); cx.quit(); } }) @@ -1206,6 +1563,7 @@ fn run_with(args: AppArgs) { #[cfg(test)] mod tests { use super::*; + use crate::oakui::EngineGateway as _; use gpui::{px, size, TestAppContext}; /// The 视图/View menu carries a 语言/Language submenu whose items are @@ -1299,9 +1657,10 @@ mod tests { assert_eq!(dark_item(false).checked, Some(false)); } - /// The File menu exposes the full project lifecycle actions (open / - /// save / save-as / close / export) and the Edit menu the undo stack - /// plus the delete variants, across both languages. + /// The File menu exposes the full project lifecycle actions (new / + /// open-from-library / open-file / manager / export-project / close / + /// export) and the Edit menu the undo stack plus the delete variants, + /// across both languages. #[test] fn file_and_edit_menus_cover_the_project_lifecycle() { let _guard = crate::i18n::lang_test_lock().lock().unwrap(); @@ -1317,9 +1676,10 @@ mod tests { let file = entry("File(F)"); for id in [ menu_ids::NEW_PROJECT, + menu_ids::OPEN_FROM_LIBRARY, menu_ids::OPEN_PROJECT, - menu_ids::SAVE, - menu_ids::SAVE_AS, + menu_ids::PROJECT_MANAGER, + menu_ids::EXPORT_PROJECT, menu_ids::CLOSE, menu_ids::EXPORT, menu_ids::QUIT, @@ -1349,7 +1709,7 @@ mod tests { .menu .items .iter() - .any(|item| item.id == menu_ids::SAVE_AS)); + .any(|item| item.id == menu_ids::EXPORT_PROJECT)); } /// Opening 视图 → Preferences… must not crash: the dialog content and the @@ -1463,4 +1823,244 @@ mod tests { assert!(d.project.is_none()); assert!(!d.mock); } + + // ------------------------------------------------------------------- + // Project manager (M13 D4) + // ------------------------------------------------------------------- + + /// A running app shell on the mock engine (en-US), plus its root. The + /// caller holds the language lock (the tests flip the process-global + /// language). + fn mock_shell( + cx: &mut TestAppContext, + ) -> ( + gpui::WindowHandle>, + Entity>, + ) { + crate::i18n::set_language(crate::i18n::Language::EnUs); + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| { + OakApp::::new(window, None, cx) + }); + cx.run_until_parked(); + let root = window.root(cx).expect("app root"); + (window, root) + } + + /// Opens the manager and returns its content entity. + fn open_manager( + cx: &mut TestAppContext, + root: &Entity>, + ) -> Entity> { + cx.update(|app| root.update(app, |app, cx| app.show_project_manager(cx))); + cx.run_until_parked(); + let content = cx.read(|app| match &root.read(app).modal { + ModalState::Manager { content, .. } => content.clone(), + _ => panic!("the manager modal should be open"), + }); + content + } + + /// The manager's listed rows. + fn manager_rows( + cx: &mut TestAppContext, + content: &Entity>, + ) -> Vec { + cx.read(|app| content.read(app).rows().to_vec()) + } + + /// The manager lists the mock library; opening a row (the double-click + /// / 打开 route) drives the engine's library open and closes the dialog. + #[gpui::test] + async fn manager_lists_and_opens(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (_window, root) = mock_shell(cx); + let content = open_manager(cx, &root); + let rows = manager_rows(cx, &content); + assert_eq!(rows.len(), 3, "the mock library seeds three rows"); + assert_eq!(rows[0].name, "第一稿", "most recently modified first"); + assert!(rows[0].track_count > 0 && rows[0].footage_count > 0); + + // Select the second row and open it. + let uuid = rows[1].uuid.clone(); + cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx))); + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Open(uuid.clone()), cx) + }) + }); + cx.run_until_parked(); + + let opened = cx.read(|app| root.read(app).engine.read(app).library_opened().to_vec()); + assert_eq!(opened, vec![uuid], "the engine opened the selected row"); + let name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone()); + assert_eq!(name, "宣传片 v3"); + let modal_none = cx.read(|app| matches!(root.read(app).modal, ModalState::None)); + assert!(modal_none, "a successful open closes the manager"); + } + + /// Create / rename / duplicate / delete round-trip through the manager + /// and its sub-dialogs. + #[gpui::test] + async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (_window, root) = mock_shell(cx); + let content = open_manager(cx, &root); + + // Create: a new row appears and the project opens (dialog closes). + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Create, cx) + }) + }); + cx.run_until_parked(); + let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap()); + assert_eq!(rows.len(), 4); + let created = rows + .iter() + .find(|row| row.name == "Untitled Project") + .expect("the created row") + .clone(); + let project_name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone()); + assert_eq!(project_name, "Untitled Project", "create opens the new project"); + + // Reopen the manager, select the created row, rename it. + let content = open_manager(cx, &root); + cx.update(|app| content.update(app, |m, cx| m.select(&created.uuid, cx))); + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Rename(created.uuid.clone()), cx) + }) + }); + cx.run_until_parked(); + let prompt = cx.read(|app| match &root.read(app).modal { + ModalState::ManagerRename { content, uuid, .. } => { + assert_eq!(uuid, &created.uuid); + content.clone() + } + _ => panic!("the rename prompt should be open"), + }); + cx.update(|app| prompt.update(app, |p, cx| p.set_value("改名为正稿", cx))); + cx.update(|app| { + root.update(app, |app, cx| { + app.on_modal( + &ModalEvent::ButtonClicked { + control: modal_ids::MANAGER_RENAME, + button: 0, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + // The confirmation swaps in a FRESH manager (the captured content is + // stale from here on); assert against the engine's library instead. + let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap()); + let renamed = rows.iter().find(|row| row.uuid == created.uuid).unwrap(); + assert_eq!(renamed.name, "改名为正稿"); + let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. })); + assert!(back, "a confirmed rename returns to the manager"); + + // Duplicate the renamed row. + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event( + &crate::manager::ManagerEvent::Duplicate(created.uuid.clone()), + cx, + ) + }) + }); + cx.run_until_parked(); + let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap()); + assert_eq!(rows.len(), 5); + assert!( + rows.iter().any(|row| row.name == "改名为正稿 (copy)"), + "the copy is named ' (copy)': {rows:?}" + ); + + // Delete the original through the confirmation dialog. + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Delete(created.uuid.clone()), cx) + }) + }); + cx.run_until_parked(); + let confirming = cx.read(|app| matches!(root.read(app).modal, ModalState::ManagerDelete { .. })); + assert!(confirming, "the delete confirmation should be open"); + cx.update(|app| { + root.update(app, |app, cx| { + app.on_modal( + &ModalEvent::ButtonClicked { + control: modal_ids::MANAGER_DELETE, + button: 0, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap()); + assert_eq!(rows.len(), 4); + assert!(!rows.iter().any(|row| row.uuid == created.uuid)); + let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. })); + assert!(back, "a confirmed delete returns to the manager"); + } + + /// The manager's 导入 opens the platform path picker and lands the file + /// as a new row; 导出 asks for a new path and routes it to the engine. + #[gpui::test] + async fn manager_import_and_export_route_through_the_platform_dialogs( + cx: &mut TestAppContext, + ) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (_window, root) = mock_shell(cx); + let content = open_manager(cx, &root); + + // Import. + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Import, cx) + }) + }); + cx.run_until_parked(); + assert!(cx.did_prompt_for_paths(), "import shows the path picker"); + cx.simulate_path_prompt_response(|options| { + assert!(!options.multiple, "project import is single-file"); + Some(vec![PathBuf::from("/library/先导片.ove")]) + }); + cx.run_until_parked(); + let rows = manager_rows(cx, &content); + assert_eq!(rows.len(), 4); + assert!( + rows.iter().any(|row| row.name == "先导片"), + "the imported file becomes a row named by its stem: {rows:?}" + ); + + // Export the imported row. + let uuid = rows + .iter() + .find(|row| row.name == "先导片") + .unwrap() + .uuid + .clone(); + cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx))); + cx.update(|app| { + root.update(app, |app, cx| { + app.on_manager_event(&crate::manager::ManagerEvent::Export(uuid.clone()), cx) + }) + }); + cx.run_until_parked(); + assert!( + cx.did_prompt_for_new_path(), + "export shows the save dialog" + ); + cx.simulate_new_path_selection(|_dir| Some(PathBuf::from("/library/先导片.otio"))); + cx.run_until_parked(); + let exported = cx.read(|app| root.read(app).engine.read(app).library_exported().to_vec()); + assert_eq!( + exported, + vec![(uuid, PathBuf::from("/library/先导片.otio"))], + "the picked path routes to the engine's library export" + ); + } } diff --git a/src/i18n.rs b/src/i18n.rs index 04929800c..15be26e41 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -180,10 +180,11 @@ const EN: &[(&str, &str)] = &[ ("menu.help", "Help(H)"), // --- File --- ("menu.file.new_project", "New Project…"), - ("menu.file.open_project", "Open Project…"), + ("menu.file.open_project", "Open Project File…"), + ("menu.file.open_library", "Open from Library…"), + ("menu.file.project_manager", "Project Manager…"), ("menu.file.import_footage", "Import Footage…"), - ("menu.file.save", "Save"), - ("menu.file.save_as", "Save As…"), + ("menu.file.export_project", "Export Project File…"), ("menu.file.close", "Close Project"), ("menu.file.export", "Export…"), ("menu.file.quit", "Quit"), @@ -240,9 +241,34 @@ const EN: &[(&str, &str)] = &[ ("status.ready", "Ready"), ("status.cache", "Cache: Enabled"), ("status.proxy", "Proxy: Off"), - ("status.autosave", "Autosave: 3 min ago"), + ("status.storage.written", "Library: written"), + ("status.storage.unbound", "Library: off"), + ("status.storage.error", "Library: write failed"), ("status.untitled", "Untitled Project"), ("status.backend", "Engine:"), + // --- project manager --- + ("manager.title", "Project Manager"), + ("manager.new", "New Project"), + ("manager.new.default_name", "Untitled Project"), + ("manager.open", "Open"), + ("manager.rename", "Rename…"), + ("manager.rename.title", "Rename Project"), + ("manager.rename.label", "New name"), + ("manager.duplicate", "Duplicate"), + ("manager.delete", "Delete"), + ("manager.delete.title", "Delete Project"), + ("manager.delete.confirm", "Delete project \"{name}\" from the library? This cannot be undone."), + ("manager.import", "Import…"), + ("manager.import.title", "Import Project"), + ("manager.export", "Export…"), + ("manager.export.title", "Export Project"), + ("manager.col.name", "Name"), + ("manager.col.modified", "Modified"), + ("manager.col.duration", "Duration"), + ("manager.col.tracks", "Tracks"), + ("manager.col.clips", "Clips"), + ("manager.col.footage", "Footage"), + ("manager.empty", "No projects in the library yet."), // --- timeline toolbar --- ("timeline.tool.select", "Select"), ("timeline.tool.razor", "Razor"), @@ -327,10 +353,11 @@ const ZH: &[(&str, &str)] = &[ ("menu.help", "帮助(H)"), // --- File --- ("menu.file.new_project", "新建项目…"), - ("menu.file.open_project", "打开项目…"), + ("menu.file.open_project", "打开工程文件…"), + ("menu.file.open_library", "从库中打开…"), + ("menu.file.project_manager", "项目管理器…"), ("menu.file.import_footage", "导入素材…"), - ("menu.file.save", "保存"), - ("menu.file.save_as", "另存为…"), + ("menu.file.export_project", "导出工程文件…"), ("menu.file.close", "关闭项目"), ("menu.file.export", "导出…"), ("menu.file.quit", "退出"), @@ -387,9 +414,37 @@ const ZH: &[(&str, &str)] = &[ ("status.ready", "就绪"), ("status.cache", "缓存:已启用"), ("status.proxy", "代理:关"), - ("status.autosave", "自动保存:3分钟前"), + ("status.storage.written", "库:已写入"), + ("status.storage.unbound", "库:未启用"), + ("status.storage.error", "库:写入失败"), ("status.untitled", "未命名项目"), ("status.backend", "引擎:"), + // --- project manager --- + ("manager.title", "项目管理器"), + ("manager.new", "新建项目"), + ("manager.new.default_name", "未命名项目"), + ("manager.open", "打开"), + ("manager.rename", "重命名…"), + ("manager.rename.title", "重命名工程"), + ("manager.rename.label", "新名称"), + ("manager.duplicate", "复制"), + ("manager.delete", "删除"), + ("manager.delete.title", "删除工程"), + ( + "manager.delete.confirm", + "从库中删除工程“{name}”?此操作不可撤销。", + ), + ("manager.import", "导入…"), + ("manager.import.title", "导入工程"), + ("manager.export", "导出…"), + ("manager.export.title", "导出工程"), + ("manager.col.name", "名称"), + ("manager.col.modified", "修改时间"), + ("manager.col.duration", "时长"), + ("manager.col.tracks", "轨道"), + ("manager.col.clips", "片段"), + ("manager.col.footage", "素材"), + ("manager.empty", "库中还没有工程。"), // --- timeline toolbar --- ("timeline.tool.select", "选择"), ("timeline.tool.razor", "剃刀"), @@ -594,11 +649,11 @@ mod tests { fn switching_flips_a_sample_string() { let _guard = lang_lock().lock().unwrap(); set_language(Language::EnUs); - assert_eq!(tr("menu.file.save"), "Save"); + assert_eq!(tr("menu.file.export_project"), "Export Project File…"); set_language(Language::ZhCN); - assert_eq!(tr("menu.file.save"), "保存"); + assert_eq!(tr("menu.file.export_project"), "导出工程文件…"); set_language(Language::EnUs); - assert_eq!(tr("menu.file.save"), "Save"); + assert_eq!(tr("menu.file.export_project"), "Export Project File…"); } /// `sync_widgets` installs the active language's strings into the widget diff --git a/src/lib.rs b/src/lib.rs index 351f13c89..6396c7a54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,8 @@ //! * [`app`] — the window shell: menu bar, dock layout, status bar, modal //! dialogs (file open/save-as, preferences, export), tick loop. //! * [`dialogs`] — the preferences and export dialog content views. +//! * [`manager`] — the project manager window (M13 D4): the library browser +//! with new / open / rename / duplicate / delete / import / export. //! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...). //! * [`oakui`] — the engine gateway trait, the mock + real implementations, //! and the pure view-state logic (timecode, transport). @@ -51,6 +53,7 @@ pub mod app; pub mod dialogs; pub mod i18n; +pub mod manager; pub mod oakui; pub mod panels; diff --git a/src/manager.rs b/src/manager.rs new file mode 100644 index 000000000..f2640dbe5 --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,538 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The project manager (M13 D4): the DaVinci-style library browser the app +//! shows at startup (no `--project` argument) and from 文件 → 项目管理器. +//! +//! The view is a modal content view ([`ProjectManager`]) hosted by +//! `crate::app::OakApp` inside the standard `Modal` card: a toolbar +//! (新建 / 导入), a column list of the library rows (name, modified time, +//! duration, tracks, clips, footage — the backend-derived stats), and an +//! action row (打开 / 重命名 / 复制 / 删除 / 导出). The view emits +//! [`ManagerEvent`] requests; the app routes them through the engine +//! ([`AppEngine`]'s library surface) and swaps in the rename / delete +//! confirmation modals. +//! +//! Pure formatting helpers ([`format_modified`], [`format_duration_ms`]) +//! are unit tested here; the app-level flows are covered by the +//! `OakApp` tests in `crate::app`. + +use gpui::colors::DefaultColors; +use gpui::prelude::*; +use gpui::{ + div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString, + Window, +}; +use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage}; + +use crate::i18n; +use crate::oakui::{AppEngine, LibraryProject}; + +/// A request the manager view emits for the host (the app shell) to route +/// through the engine. +#[derive(Debug, Clone, PartialEq)] +pub enum ManagerEvent { + /// Open the project (double-click / the 打开 button). + Open(String), + /// Create a new blank project and open it. + Create, + /// Rename the project (the host prompts for the new name). + Rename(String), + /// Duplicate the project (history included). + Duplicate(String), + /// Delete the project (the host confirms first). + Delete(String), + /// Import a `.ove` / `.otio` / `.fcpxml` file as a new library row. + Import, + /// Export the project to a file. + Export(String), +} + +/// The project manager content view: the library list plus its toolbars. +pub struct ProjectManager { + engine: Entity, + /// The listed rows (most recently modified first, as the engine + /// reports them). + rows: Vec, + /// The selected row index. + selected: Option, + /// The last operation error (shown under the list). + status: Option, +} + +impl ProjectManager { + /// Builds the view and loads the library. + pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { + let mut this = Self { + engine, + rows: Vec::new(), + selected: None, + status: None, + }; + this.reload(cx); + this + } + + /// Reloads the library from the engine, keeping the selection on the + /// same row (by uuid) when it still exists. + pub fn reload(&mut self, cx: &mut Context) { + let selected_uuid = self.selected_uuid(); + match self.engine.read(cx).library_projects() { + Ok(rows) => { + self.rows = rows; + self.selected = selected_uuid + .and_then(|uuid| self.rows.iter().position(|row| row.uuid == uuid)); + self.status = None; + } + Err(err) => { + self.rows = Vec::new(); + self.selected = None; + self.status = Some(err); + } + } + cx.notify(); + } + + /// The selected row's uuid, if any. + pub fn selected_uuid(&self) -> Option { + self.selected + .and_then(|index| self.rows.get(index)) + .map(|row| row.uuid.clone()) + } + + /// The selected row's display name, if any. + pub fn selected_name(&self) -> Option { + self.selected + .and_then(|index| self.rows.get(index)) + .map(|row| row.name.clone()) + } + + /// Shows an operation error under the list (the host reports engine + /// failures here so a failed action is visible in the dialog). + pub fn set_status(&mut self, status: Option, cx: &mut Context) { + self.status = status; + cx.notify(); + } + + /// The listed rows (tests). + #[cfg(test)] + pub fn rows(&self) -> &[LibraryProject] { + &self.rows + } + + /// Selects the row with `uuid` (tests and the host's post-action + /// reselection). + pub fn select(&mut self, uuid: &str, cx: &mut Context) { + self.selected = self.rows.iter().position(|row| row.uuid == uuid); + cx.notify(); + } + + /// Emits the event through the view's subscribers. + fn emit(&mut self, event: ManagerEvent, cx: &mut Context) { + cx.emit(event); + cx.notify(); + } + + /// A click on row `index`: select, or open on a double click. + fn row_clicked(&mut self, index: usize, clicks: usize, cx: &mut Context) { + if index >= self.rows.len() { + return; + } + self.selected = Some(index); + if clicks >= 2 { + let uuid = self.rows[index].uuid.clone(); + self.emit(ManagerEvent::Open(uuid), cx); + } else { + cx.notify(); + } + } + + /// An action-row button targeting the selection. + fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context) { + if let Some(uuid) = self.selected_uuid() { + self.emit(action(uuid), cx); + } + } +} + +impl EventEmitter for ProjectManager {} + +impl Render for ProjectManager { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + + // Toolbar buttons (新建 / 导入) and the selection-targeting action + // row (打开 / 重命名 / 复制 / 删除 / 导出). + let tool = |id: &'static str, label: &'static str| -> gpui::Stateful { + div() + .id(id) + .px_3() + .py_1() + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .text_color(colors.text) + .cursor_pointer() + .child(label) + }; + let has_selection = self.selected.is_some(); + let action = |id: &'static str, label: &'static str| -> gpui::Stateful { + let mut b = div().id(id).px_3().py_1().rounded_md(); + if has_selection { + b = b + .bg(colors.background) + .border_1() + .border_color(colors.border) + .text_color(colors.text) + .cursor_pointer(); + } else { + b = b.text_color(colors.disabled); + } + b.child(label) + }; + + // The column header. + let header_cell = |label: &'static str, width: f32| -> gpui::Div { + let mut cell = div() + .px_2() + .text_xs() + .text_color(colors.disabled) + .whitespace_nowrap() + .child(label); + if width <= 0.0 { + cell = cell.flex_1(); + } else { + cell = cell.w(gpui::px(width)).text_right(); + } + cell + }; + + let list = div() + .h(gpui::px(340.0)) + .flex() + .flex_col() + .border_1() + .border_color(colors.border) + .rounded_md() + .bg(colors.background) + .child( + div() + .flex() + .items_center() + .gap_2() + .py_1() + .border_b_1() + .border_color(colors.border) + .child(header_cell(i18n::tr("manager.col.name"), 0.0)) + .child(header_cell(i18n::tr("manager.col.modified"), 128.0)) + .child(header_cell(i18n::tr("manager.col.duration"), 72.0)) + .child(header_cell(i18n::tr("manager.col.tracks"), 56.0)) + .child(header_cell(i18n::tr("manager.col.clips"), 56.0)) + .child(header_cell(i18n::tr("manager.col.footage"), 56.0)), + ) + .child( + div() + .id("manager-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .children(if self.rows.is_empty() { + vec![ + div() + .p_4() + .text_color(colors.disabled) + .child(i18n::tr("manager.empty")) + .into_any_element(), + ] + } else { + self.rows + .iter() + .enumerate() + .map(|(index, row)| { + let selected = self.selected == Some(index); + let mut line = div() + .id(ElementId::Name(format!("manager-row-{index}").into())) + .flex() + .items_center() + .gap_2() + .py_1() + .cursor_pointer() + .on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| { + this.row_clicked(index, event.click_count(), cx); + })); + if selected { + line = line.bg(colors.selected).text_color(colors.selected_text); + } else { + line = line.text_color(colors.text); + } + let cell = |text: String, width: f32| -> gpui::Div { + let mut c = div().px_2().whitespace_nowrap().child(text); + if width <= 0.0 { + c = c.flex_1(); + } else { + c = c.w(gpui::px(width)).text_right(); + } + c + }; + line.child(cell(row.name.clone(), 0.0)) + .child(cell(format_modified(row.modified_at), 128.0)) + .child(cell(format_duration_ms(row.duration_ms), 72.0)) + .child(cell(row.track_count.to_string(), 56.0)) + .child(cell(row.clip_count.to_string(), 56.0)) + .child(cell(row.footage_count.to_string(), 56.0)) + .into_any_element() + }) + .collect() + }), + ); + + let mut root = div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child( + div() + .flex() + .gap_2() + .child( + tool("manager-new", i18n::tr("manager.new")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.emit(ManagerEvent::Create, cx); + }), + ), + ) + .child( + tool("manager-import", i18n::tr("manager.import")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.emit(ManagerEvent::Import, cx); + }), + ), + ), + ) + .child(list) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + action("manager-open", i18n::tr("manager.open")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.selected_action(ManagerEvent::Open, cx); + }), + ), + ) + .child( + action("manager-rename", i18n::tr("manager.rename")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.selected_action(ManagerEvent::Rename, cx); + }), + ), + ) + .child( + action("manager-duplicate", i18n::tr("manager.duplicate")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.selected_action(ManagerEvent::Duplicate, cx); + }), + ), + ) + .child( + action("manager-delete", i18n::tr("manager.delete")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.selected_action(ManagerEvent::Delete, cx); + }), + ), + ) + .child( + action("manager-export", i18n::tr("manager.export")).on_click( + cx.listener(|this, _e: &ClickEvent, _w, cx| { + this.selected_action(ManagerEvent::Export, cx); + }), + ), + ), + ); + if let Some(status) = &self.status { + root = root.child( + div() + .text_xs() + .text_color(Hsla { + h: 0.0, + s: 0.6, + l: 0.55, + a: 1.0, + }) + .child(status.clone()), + ); + } + root + } +} + +// --------------------------------------------------------------------------- +// Rename prompt / delete confirmation contents +// --------------------------------------------------------------------------- + +/// The rename prompt's content: one text field with the new name. +pub struct NamePrompt { + editor: Entity, +} + +impl NamePrompt { + /// Builds the prompt seeded with `initial`. + pub fn new(initial: &str, cx: &mut Context) -> Self { + let editor = cx.new(|cx| { + let editor = EditableTextState::new(StringStorage::default(), cx); + editor + }); + editor.update(cx, |editor, cx| editor.emplace(initial, cx)); + Self { editor } + } + + /// The name currently entered (trimmed). + pub fn value(&self, app: &App) -> String { + self.editor.read(app).as_str().trim().to_string() + } + + /// Replaces the entered name (tests / prefill). + pub fn set_value(&mut self, value: &str, cx: &mut Context) { + self.editor.update(cx, |editor, cx| editor.emplace(value, cx)); + cx.notify(); + } +} + +impl Render for NamePrompt { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.editor.downgrade(); + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .child( + div() + .text_color(colors.text) + .child(i18n::tr("manager.rename.label")), + ) + .child( + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child( + text_input("gpui-widgets-rename-field") + .state(weak) + .accepts_input(true), + ), + ) + } +} + +/// The delete confirmation's content: the warning text. +pub struct ConfirmContent { + text: SharedString, +} + +impl ConfirmContent { + /// Builds the confirmation with `text`. + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +impl Render for ConfirmContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div().text_color(colors.text).child(self.text.clone()) + } +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/// Formats a unix timestamp (UTC) as `YYYY-MM-DD HH:MM` for the modified +/// column. +pub fn format_modified(unix_secs: i64) -> String { + if unix_secs <= 0 { + return "—".to_string(); + } + let days = unix_secs.div_euclid(86_400); + let secs = unix_secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02}", + secs / 3600, + (secs % 3600) / 60 + ) +} + +/// Days since the unix epoch → (year, month, day), UTC (Howard Hinnant's +/// civil-from-days algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + let year = if month <= 2 { year + 1 } else { year }; + (year, month, day) +} + +/// Formats a duration in milliseconds as `H:MM:SS` for the duration +/// column. +pub fn format_duration_ms(ms: i64) -> String { + if ms <= 0 { + return "—".to_string(); + } + let secs = ms / 1000; + format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The civil-date conversion matches known dates. + #[test] + fn format_modified_known_dates() { + assert_eq!(format_modified(0), "—"); + assert_eq!(format_modified(-5), "—"); + // 2026-08-16 00:54:01 UTC. + assert_eq!(format_modified(1_786_841_641), "2026-08-16 00:54"); + // 1970-01-01 00:00 UTC. + assert_eq!(format_modified(1), "1970-01-01 00:00"); + // 2000-02-29 (a leap day) 12:34 UTC. + assert_eq!(format_modified(951_827_640), "2000-02-29 12:34"); + } + + /// Durations format as H:MM:SS with a dash for empty projects. + #[test] + fn format_duration_ms_shapes() { + assert_eq!(format_duration_ms(0), "—"); + assert_eq!(format_duration_ms(61_500), "0:01:01"); + assert_eq!(format_duration_ms(3_725_000), "1:02:05"); + } +} diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index cd0439f82..ebb2ceb36 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -93,6 +93,30 @@ pub struct Project { pub path: PathBuf, } +/// A project-library row, as the project manager lists it (M13 D4). The +/// stats are derived from the row's head state by the backend (they are +/// never stored in the library). +#[derive(Debug, Clone, PartialEq)] +pub struct LibraryProject { + /// The library row uuid (the open / rename / duplicate / delete / + /// export selector). + pub uuid: String, + /// The row's display name. + pub name: String, + /// Row creation time (unix seconds, UTC). + pub created_at: i64, + /// Last-write time (unix seconds, UTC; the manager sort key). + pub modified_at: i64, + /// Longest sequence duration in milliseconds. + pub duration_ms: i64, + /// Total tracks across all sequences. + pub track_count: i32, + /// Total clip blocks. + pub clip_count: i32, + /// Total footage nodes. + pub footage_count: i32, +} + /// The sequence currently open in the project. #[derive(Debug, Clone, PartialEq)] pub struct Sequence { @@ -293,6 +317,73 @@ pub trait AppEngine: /// Closes the current project, leaving the app with no sequence. fn close_project(&mut self, cx: &mut Context); + + // ------------------------------------------------------------------- + // Project library (M13 D4: the write-through database the manager + // window browses). Default: unsupported (empty list / error strings). + // ------------------------------------------------------------------- + + /// Whether the open project is bound to the library write-through + /// session (the status bar's write state). + fn storage_bound(&self) -> bool { + false + } + + /// The last write-through / snapshot error of the open project, if any. + fn storage_last_error(&self) -> Option { + None + } + + /// Lists the project library, most recently modified first (the + /// project manager's data source). + fn library_projects(&self) -> Result, String> { + Err("project library not supported".into()) + } + + /// Creates a blank project named `name` in the library and opens it. + fn library_create_project(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { + let _ = (name, cx); + Err("project library not supported".into()) + } + + /// Opens the library project `uuid` (closing the current project). + fn library_open_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { + let _ = (uuid, cx); + Err("project library not supported".into()) + } + + /// Deletes the library project `uuid` (the manager confirms first). + fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> { + let _ = uuid; + Err("project library not supported".into()) + } + + /// Renames the library project `uuid` (the manager's list name). + fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> { + let _ = (uuid, name); + Err("project library not supported".into()) + } + + /// Duplicates the library project `uuid` (history included) under a + /// fresh uuid. + fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> { + let _ = uuid; + Err("project library not supported".into()) + } + + /// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library + /// as a new row; returns the new row's uuid. + fn library_import_project(&mut self, path: PathBuf) -> Result { + let _ = path; + Err("project library not supported".into()) + } + + /// Exports the library project `uuid` to `path`; the format is + /// dispatched by extension (`.ove` / `.otio` / `.fcpxml`). + fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> { + let _ = (uuid, path); + Err("project library not supported".into()) + } /// The timeline waveform cache (M12 P4); `None` when the backend /// does not provide waveforms. fn waveform_cache(&self) -> Option> { diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index 3cede6720..0f73d33c4 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -281,6 +281,67 @@ unsafe extern "C" { /// `oakengine_config_set_string` — write a string value. pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int; + // -- oakengine::storage (write-through session state) -- + + /// `oakengine_storage_flush` — flush every bound project and stop the + /// snapshot thread (the app calls it on exit). + pub fn oakengine_storage_flush() -> c_int; + /// `oakengine_storage_is_bound` — 1 when the project is bound to a + /// library session. + pub fn oakengine_storage_is_bound(project: *mut OakEngineProject) -> c_int; + /// `oakengine_storage_last_error` — the last write-through / snapshot + /// error (buf/size; empty when none or not bound). + pub fn oakengine_storage_last_error( + project: *mut OakEngineProject, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + + // -- oakengine::library (project manager, M13 D4) -- + + /// `oakengine_library_list` — the library rows as a JSON array + /// (buf/size), most recently modified first; `"[]"` with storage off. + pub fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_library_create` — create a blank project row; reports its + /// uuid (buf/size on `out_uuid`; the return value is the uuid length, + /// negative on error). + pub fn oakengine_library_create( + name: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, + ) -> c_int; + /// `oakengine_library_delete` — delete a row by uuid. + pub fn oakengine_library_delete(uuid: *const c_char) -> c_int; + /// `oakengine_library_rename` — rename a row. + pub fn oakengine_library_rename(uuid: *const c_char, name: *const c_char) -> c_int; + /// `oakengine_library_duplicate` — copy a row (history included); + /// reports the new uuid like `oakengine_library_create`. + pub fn oakengine_library_duplicate( + uuid: *const c_char, + name: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, + ) -> c_int; + /// `oakengine_library_import` — import a `.ove`/`.otio`/`.fcpxml` file + /// as a new row; reports the new uuid like `oakengine_library_create`. + pub fn oakengine_library_import( + path: *const c_char, + out_uuid: *mut c_char, + out_size: c_int, + ) -> c_int; + /// `oakengine_library_export` — export a row to `path` (format by + /// extension). + pub fn oakengine_library_export(uuid: *const c_char, path: *const c_char) -> c_int; + /// `oakengine_project_load_library` — load a library row into a fresh + /// project shell (same contract as `oakengine_project_load`); binds the + /// project to the library session. + pub fn oakengine_project_load_library( + self_: *mut OakEngineProject, + uuid: *const c_char, + err: *mut c_char, + err_size: c_int, + ) -> c_int; + // -- oakengine::node (project) -- /// `oakengine_project_create` — owned project box (no content yet). diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index ce6bde0d7..9cd165a57 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -62,14 +62,60 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; use super::engine::{ - AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence, - VideoFormat, + AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project, + ScopeData, Sequence, VideoFormat, }; use super::transport::TransportState; /// The demo sequence length: 00:04:18:18 at 25 fps. const SEQUENCE_LENGTH: i64 = 6468; +/// The current unix time in seconds (0 on clock failure). +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// The demo library rows the project manager opens with (M13 D4): three +/// projects with plausible stats, most recently modified first. +fn demo_library() -> Vec { + let now = now_unix(); + vec![ + LibraryProject { + uuid: "mock-1".into(), + name: "第一稿".into(), + created_at: now - 86400, + modified_at: now - 300, + duration_ms: SEQUENCE_LENGTH * 1000 / 25, + track_count: 4, + clip_count: 5, + footage_count: 3, + }, + LibraryProject { + uuid: "mock-2".into(), + name: "宣传片 v3".into(), + created_at: now - 3 * 86400, + modified_at: now - 86400, + duration_ms: 95_000, + track_count: 6, + clip_count: 14, + footage_count: 8, + }, + LibraryProject { + uuid: "mock-3".into(), + name: "采访粗剪".into(), + created_at: now - 9 * 86400, + modified_at: now - 7 * 86400, + duration_ms: 612_000, + track_count: 3, + clip_count: 22, + footage_count: 5, + }, + ] +} + // --------------------------------------------------------------------------- // Clocks // --------------------------------------------------------------------------- @@ -431,6 +477,19 @@ pub struct MockEngine { /// has no media pipeline, so it just records them (drives app-level tests /// of the import flow). imported_footage: Vec, + /// The fake project library the project manager browses (M13 D4): an + /// in-memory row set the library trait methods operate on, so the app + /// flow (list / open / create / rename / duplicate / delete / import / + /// export) is testable without a database. + library: Vec, + /// Id allocator for library rows created at runtime. + next_library_id: u64, + /// The uuids handed to [`AppEngine::library_open_project`] (test + /// observability). + library_opened: Vec, + /// (uuid, path) pairs handed to [`AppEngine::library_export_project`] + /// (test observability). + library_exported: Vec<(String, PathBuf)>, } impl MockEngine { @@ -674,6 +733,10 @@ impl MockEngine { node_selection: BTreeSet::new(), cpu_frame_cache: Mutex::new(HashMap::new()), imported_footage: Vec::new(), + library: demo_library(), + next_library_id: 100, + library_opened: Vec::new(), + library_exported: Vec::new(), }; // The demo graph is born connected: derive every port's `connected` // flag from the edge list. @@ -1274,6 +1337,114 @@ impl AppEngine for MockEngine { cx.notify(); } + // --- project library (M13 D4, in-memory fake) ------------------------- + + fn storage_bound(&self) -> bool { + // The mock pretends the demo project lives in the library. + true + } + + fn library_projects(&self) -> Result, String> { + let mut rows = self.library.clone(); + rows.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); + Ok(rows) + } + + fn library_create_project(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { + let uuid = format!("mock-{}", self.next_library_id); + self.next_library_id += 1; + let now = now_unix(); + self.library.push(LibraryProject { + uuid, + name: name.to_string(), + created_at: now, + modified_at: now, + duration_ms: 0, + track_count: 0, + clip_count: 0, + footage_count: 0, + }); + self.project.name = name.to_string(); + self.project.path = PathBuf::new(); + cx.notify(); + Ok(()) + } + + fn library_open_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { + let Some(row) = self.library.iter().find(|row| row.uuid == uuid) else { + return Err(format!("library project {uuid} not found")); + }; + self.project.name = row.name.clone(); + self.project.path = PathBuf::new(); + self.library_opened.push(uuid.to_string()); + cx.notify(); + Ok(()) + } + + fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> { + let before = self.library.len(); + self.library.retain(|row| row.uuid != uuid); + if self.library.len() == before { + return Err(format!("library project {uuid} not found")); + } + Ok(()) + } + + fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> { + let Some(row) = self.library.iter_mut().find(|row| row.uuid == uuid) else { + return Err(format!("library project {uuid} not found")); + }; + row.name = name.to_string(); + row.modified_at = now_unix(); + Ok(()) + } + + fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> { + let Some(row) = self.library.iter().find(|row| row.uuid == uuid).cloned() else { + return Err(format!("library project {uuid} not found")); + }; + let now = now_unix(); + self.library.push(LibraryProject { + uuid: format!("mock-{}", self.next_library_id), + name: format!("{} (copy)", row.name), + created_at: now, + modified_at: now, + ..row + }); + self.next_library_id += 1; + Ok(()) + } + + fn library_import_project(&mut self, path: PathBuf) -> Result { + let name = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("invalid project file \"{}\"", path.display()))?; + let uuid = format!("mock-{}", self.next_library_id); + self.next_library_id += 1; + let now = now_unix(); + self.library.push(LibraryProject { + uuid: uuid.clone(), + name, + created_at: now, + modified_at: now, + duration_ms: 0, + track_count: 0, + clip_count: 0, + footage_count: 0, + }); + Ok(uuid) + } + + fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> { + if !self.library.iter().any(|row| row.uuid == uuid) { + return Err(format!("library project {uuid} not found")); + } + self.library_exported.push((uuid.to_string(), path)); + Ok(()) + } + fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result { // Mock export: fake progress on a background thread, no file. let (tx, rx) = mpsc::channel::(); @@ -1421,6 +1592,18 @@ impl MockEngine { &self.imported_footage } + /// The uuids opened via [`AppEngine::library_open_project`] so far + /// (mock state; drives app-level tests of the manager's open flow). + pub fn library_opened(&self) -> &[String] { + &self.library_opened + } + + /// The (uuid, path) pairs exported via + /// [`AppEngine::library_export_project`] so far (mock state). + pub fn library_exported(&self) -> &[(String, PathBuf)] { + &self.library_exported + } + /// The selected material-bin entry id (demo state). pub fn selected_item(&self) -> Option { self.selected_item diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index dd69791a0..c7709afc1 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -53,8 +53,8 @@ pub mod transport; pub mod waveform; pub use engine::{ - AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, - Sequence, VideoFormat, + AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, + Project, ScopeData, Sequence, VideoFormat, }; pub use mock::{MockClock, MockEngine}; pub use real::{RealClock, RealEngine}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 34224f269..ebdf0ed7d 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -101,8 +101,8 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; use super::engine::{ - AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence, - VideoFormat, + AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project, + ScopeData, Sequence, VideoFormat, }; use super::ffi::*; use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples}; @@ -2230,6 +2230,112 @@ impl AppEngine for RealEngine { cx.notify(); } + // --- project library (M13 D4) -------------------------------------- + + fn storage_bound(&self) -> bool { + self.project_ptr() + .map(|p| unsafe { oakengine_storage_is_bound(p) } != 0) + .unwrap_or(false) + } + + fn storage_last_error(&self) -> Option { + let project = self.project_ptr()?; + let message = read_string(|buf, size| unsafe { + oakengine_storage_last_error(project, buf, size) + }); + if message.is_empty() { + None + } else { + Some(message) + } + } + + fn library_projects(&self) -> Result, String> { + library_list() + } + + fn library_create_project(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { + let uuid = library_create(name)?; + self.open_library_project(&uuid, cx) + } + + fn library_open_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { + self.open_library_project(uuid, cx) + } + + fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> { + let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; + let rc = unsafe { oakengine_library_delete(uuid_c.as_ptr()) }; + if rc != 0 { + return Err(format!("failed to delete the project (error {rc})")); + } + Ok(()) + } + + fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> { + let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; + let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?; + let rc = unsafe { oakengine_library_rename(uuid_c.as_ptr(), name_c.as_ptr()) }; + if rc != 0 { + return Err(format!("failed to rename the project (error {rc})")); + } + Ok(()) + } + + fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> { + let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; + let mut buf = [0 as c_char; 256]; + // Single call with a stack buffer: the duplicate has a side effect, + // so the two-stage (measure-then-read) pattern must not be used. + let rc = unsafe { + oakengine_library_duplicate( + uuid_c.as_ptr(), + std::ptr::null(), + buf.as_mut_ptr(), + buf.len() as c_int, + ) + }; + if rc < 0 { + return Err(format!("failed to duplicate the project (error {rc})")); + } + Ok(()) + } + + fn library_import_project(&mut self, path: PathBuf) -> Result { + let path_c = cstr_path(&path).ok_or("invalid import path")?; + let mut buf = [0 as c_char; 256]; + // Single call with a stack buffer (side effect; see duplicate). + let rc = unsafe { + oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) + }; + if rc < 0 { + return Err(format!( + "failed to import \"{}\" (error {rc})", + path.display() + )); + } + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Ok( + String::from_utf8_lossy(unsafe { + std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) + }) + .into_owned(), + ) + } + + fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> { + let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; + let path_c = cstr_path(&path).ok_or("invalid export path")?; + let rc = unsafe { oakengine_library_export(uuid_c.as_ptr(), path_c.as_ptr()) }; + if rc != 0 { + return Err(format!( + "failed to export the project to \"{}\" (error {rc})", + path.display() + )); + } + Ok(()) + } + fn start_export(&mut self, format: i32, path: PathBuf) -> Result { let Some(seq) = self.seq_ptr() else { return Err("no sequence open".into()); @@ -2589,6 +2695,121 @@ pub fn renderer_backends() -> Vec<&'static str> { vec!["opengl", "metal", "vulkan", "none"] } +// --------------------------------------------------------------------------- +// Project library (M13 D4: the write-through database the manager browses) +// --------------------------------------------------------------------------- + +/// The config key selecting the storage backend (see +/// `crates/oakengine/src/storage.rs`). +pub const CONFIG_KEY_STORAGE_BACKEND: &str = "Storage/Backend"; + +/// Enables the SQLite write-through library unless the user configured the +/// backend explicitly (any existing value — including "off" — wins over the +/// app's default). The library path defaults facade-side to +/// `/library.db`. +pub fn configure_storage() { + if config_get_string(CONFIG_KEY_STORAGE_BACKEND).is_empty() { + config_set_string(CONFIG_KEY_STORAGE_BACKEND, "sqlite"); + } +} + +/// Flushes every bound project (write-through + snapshot) and stops the +/// facade's snapshot thread. The app calls this on exit. +pub fn storage_flush() { + unsafe { + oakengine_storage_flush(); + } +} + +/// The library rows, most recently modified first (the project manager's +/// data source; JSON over the facade's `oakengine_library_list`). +pub fn library_list() -> Result, String> { + let needed = unsafe { oakengine_library_list(std::ptr::null_mut(), 0) }; + if needed < 0 { + return Err(format!("failed to list the library (error {needed})")); + } + let mut buf = vec![0 as c_char; needed as usize + 1]; + unsafe { oakengine_library_list(buf.as_mut_ptr(), needed + 1) }; + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + let json = + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned(); + let rows: serde_json::Value = + serde_json::from_str(&json).map_err(|e| format!("malformed library list: {e}"))?; + let Some(rows) = rows.as_array() else { + return Err("malformed library list (not an array)".into()); + }; + Ok(rows + .iter() + .map(|row| { + let s = |key: &str| row.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let n = |key: &str| row.get(key).and_then(|v| v.as_i64()).unwrap_or(0); + LibraryProject { + uuid: s("uuid"), + name: s("name"), + created_at: n("created_at"), + modified_at: n("modified_at"), + duration_ms: n("duration_ms"), + track_count: n("track_count") as i32, + clip_count: n("clip_count") as i32, + footage_count: n("footage_count") as i32, + } + }) + .collect()) +} + +/// Creates a blank project row in the library; returns its uuid. Single +/// call with a stack buffer: the create has a side effect, so the +/// two-stage (measure-then-read) pattern must not be used. +fn library_create(name: &str) -> Result { + let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?; + let mut buf = [0 as c_char; 256]; + let rc = unsafe { oakengine_library_create(name_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) }; + if rc < 0 { + return Err(format!("failed to create the project (error {rc})")); + } + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Ok( + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned(), + ) +} + +impl RealEngine { + /// Opens the library row `uuid` through the facade's library-load path + /// (which binds the project to the write-through session) and adopts it. + fn open_library_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { + let project = unsafe { oakengine_project_create() }; + if project.is_null() { + return Err("failed to create a project".into()); + } + let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; + let mut err = [0 as c_char; 4096]; + let rc = unsafe { + oakengine_project_load_library( + project, + uuid_c.as_ptr(), + err.as_mut_ptr(), + err.len() as c_int, + ) + }; + if rc != 0 { + let message = load_error(&mut err); + unsafe { oakengine_project_free(project) }; + return Err(format!("failed to open the library project: {message}")); + } + self.adopt_project(project, cx); + // The facade's project name is filename-derived ("(untitled)" for a + // library row); display the library row name instead. + if let Ok(rows) = library_list() { + if let Some(row) = rows.iter().find(|row| row.uuid == uuid) { + self.project_info.name = row.name.clone(); + } + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index ae7a41452..589129de2 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -14,9 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The global status bar (状态栏): ready state, cache, proxy and autosave -//! info on the left; current timecode / duration, frame rate and resolution -//! on the right. +//! The global status bar (状态栏): ready state, cache, proxy and the +//! library write state (M13 D4: the write-through replaces the manual save, +//! so the old autosave hint becomes "written to the library / library off / +//! write failed") on the left; current timecode / duration, frame rate and +//! resolution on the right. use gpui::colors::DefaultColors; use gpui::timeline::Frame; @@ -66,6 +68,25 @@ impl Render for StatusBar { div().px_2().py_1().text_color(colors.text).child(text) }; + // The write-through state (M13 D4): a bound project with no recorded + // error is written through; an error turns the segment red. + let (storage_text, storage_color) = if engine.storage_last_error().is_some() { + ( + crate::i18n::tr("status.storage.error"), + gpui::rgba(0xcc6666ff), + ) + } else if engine.storage_bound() { + ( + crate::i18n::tr("status.storage.written"), + colors.disabled, + ) + } else { + ( + crate::i18n::tr("status.storage.unbound"), + colors.disabled, + ) + }; + div() .h_6() .flex() @@ -77,7 +98,13 @@ impl Render for StatusBar { .child(segment(&colors, crate::i18n::tr("status.ready").into())) .child(segment(&colors, crate::i18n::tr("status.cache").into())) .child(segment(&colors, crate::i18n::tr("status.proxy").into())) - .child(segment(&colors, crate::i18n::tr("status.autosave").into())) + .child( + div() + .px_2() + .py_1() + .text_color(storage_color) + .child(storage_text), + ) .child(div().flex_1()) .child(segment( &colors,