diff --git a/Cargo.lock b/Cargo.lock index 79498923d..097361d61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1094,6 +1094,16 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +[[package]] +name = "craft-ecproj-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=246e8f837347ac642d6852df89e1425630111d21#246e8f837347ac642d6852df89e1425630111d21" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1783,6 +1793,7 @@ dependencies = [ name = "effectcraft-project" version = "0.19.0" dependencies = [ + "craft-ecproj-format", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", diff --git a/Cargo.toml b/Cargo.toml index eae73bbca..6f8552723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/effectcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "246e8f837347ac642d6852df89e1425630111d21" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 72d40db34..db11fea11 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -14,6 +14,7 @@ effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } +craft-ecproj-format = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index b2b64ed2c..1ef3d1f0a 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -272,26 +272,13 @@ impl Project { /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { - if self.schema > SCHEMA_VERSION { - return Err(ProjectError::Invalid(format!( - "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", - self.schema - ))); - } - #[derive(Serialize)] - struct File<'a> { - #[serde(rename = "savedBy")] - saved_by: &'a str, - #[serde(flatten)] - view: &'a SavedView, - #[serde(flatten)] - project: &'a Project, - } - let file = File { saved_by: APP_VERSION, view, project: self }; + craft_ecproj_format::check_schema(self.schema) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + let file = craft_ecproj_format::File::new(APP_VERSION, view, self); file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } - .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = + if compact { file.to_json_compact() } else { file.to_json() }.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; @@ -312,9 +299,7 @@ impl Project { /// hostile file still opens, within bounds, and the user can be told what changed. pub fn from_json_repaired(s: &str) -> Result<(Project, Vec), ProjectError> { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; - if p.schema > SCHEMA_VERSION { - return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema))); - } + craft_ecproj_format::check_schema(p.schema).map_err(|e| ProjectError::Invalid(e.to_string()))?; p.fix_next_id(); let mut notes = vec![]; for item in p.items.values_mut() { diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs index 5d3d57204..aace78cdc 100644 --- a/crates/project/src/settings.rs +++ b/crates/project/src/settings.rs @@ -4,7 +4,7 @@ use super::{BTreeMap, ColorSpace, Deserialize, Label, Serialize, Tick}; /// The project file schema. 2: effects on layers without a source rectangle (shape, text) /// measure positions from the top-left of their comp-sized bounds (effect space), not from /// the layer's origin; projects from schema 1 are converted when opened (#227). -pub const SCHEMA_VERSION: u32 = 2; +pub use craft_ecproj_format::SCHEMA_VERSION; /// The first schema in which effect positions are in effect space (see [`SCHEMA_VERSION`]). pub const SCHEMA_EFFECT_SPACE: u32 = 2; @@ -13,14 +13,7 @@ pub const SCHEMA_EFFECT_SPACE: u32 = 2; pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// The EffectCraft version that wrote a project file (its `savedBy`), when it says. -pub fn saved_by(text: &str) -> Option { - #[derive(Deserialize)] - struct Meta { - #[serde(rename = "savedBy", default)] - saved_by: Option, - } - serde_json::from_str::(text).ok()?.saved_by -} +pub use craft_ecproj_format::saved_by; /// The compositions that were open when a project file was saved and their current times /// (`activeComp`, `openComps` and `compTimes` next to `savedBy`), so it reopens where it was left diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index afd3d3f03..a3e41adb5 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -193,6 +193,22 @@ fn saved_files_reopen_in_pretty_and_compact_form() { assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON"); } +/// A project file read back and saved again is the same text, indented and compact (Save Compact +/// Project Files), with its saved view. +#[test] +fn project_files_save_again_byte_for_byte() { + let (p, cid) = project_with_layer(); + let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; + for compact in [false, true] { + let text = p.to_file_json_with_view(&view, compact).unwrap(); + let prefix = if compact { "{\"savedBy\":\"" } else { "{\n \"savedBy\": \"" }; + assert!(text.starts_with(&format!("{prefix}{}\"", crate::APP_VERSION)), "compact {compact}: {text}"); + assert_eq!(text.contains('\n'), !compact); + let again = Project::from_json(&text).unwrap().to_file_json_with_view(&crate::saved_view(&text), compact).unwrap(); + assert_eq!(again, text, "compact {compact}"); + } +} + #[test] fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer();