From 61aa2f974872eefd9d718cea4264ee8487dc1324 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:37:37 -0400 Subject: [PATCH] Edit > Presets > Migrate Presets Merge a presets/preferences file (another install, or exported library) into the current preset library, appending gradient/style/shape/pattern/ tool-preset groups the library lacks (by name); idempotent. Headless, 2 tests. Parity 606 -> 607; FLOOR 602. Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/migrate_cmds.rs | 116 ++++++++++++++++++++++++++++++ crates/ui-egui/src/parity.rs | 2 +- docs/parity.md | 8 +-- 5 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/migrate_cmds.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e067b821..ed254ea6 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -837,6 +837,7 @@ fn build() -> Vec { v.extend(crate::print_cmds::specs()); v.extend(crate::pick_cmds::specs()); v.extend(crate::frame_cmds::specs()); + v.extend(crate::migrate_cmds::specs()); v.extend(crate::variables_cmds::specs()); v } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 81eba461..bf68e28a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod artboard_cmds; pub mod comps_cmds; pub mod automate_cmds; mod frame_cmds; +mod migrate_cmds; mod variables_cmds; pub mod pick_cmds; pub mod print_cmds; diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs new file mode 100644 index 00000000..89fc3604 --- /dev/null +++ b/crates/engine/src/migrate_cmds.rs @@ -0,0 +1,116 @@ +//! Edit › Presets › Migrate Presets. Photoshop imports presets from a previous version; we merge a +//! presets file (another install's preferences, or an exported preset library) into the current +//! library, appending groups the library doesn't already have (by name). Headless and scriptable. + +use serde_json::{Value, json}; + +use crate::presets::{Group, PresetState}; +use crate::{EngineError, Result, Session}; + +/// Append incoming groups whose name the library doesn't already have; returns how many were added. +fn merge_groups(cur: &mut Vec>, incoming: Vec>) -> usize { + let mut added = 0; + for g in incoming { + if !cur.iter().any(|e| e.name == g.name) { + cur.push(g); + added += 1; + } + } + added +} + +/// Deserialize `v[key]` into `Vec>`, defaulting to empty. +fn groups(v: &Value, key: &str) -> Vec> { + v.get(key).cloned().and_then(|g| serde_json::from_value(g).ok()).unwrap_or_default() +} + +fn migrate(s: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { + cmd: "edit.presets.migratePresets".into(), + msg: "need `path` to a presets or preferences file".into(), + })?; + let text = std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + let root: Value = serde_json::from_str(&text).map_err(|e| EngineError::Other(format!("`{path}` is not JSON: {e}")))?; + // Accept a full preferences file (has a `presets` section) or a bare presets object. + let v = root.get("presets").cloned().unwrap_or(root); + + let ps: &mut PresetState = &mut s.presets; + let mut counts = serde_json::Map::new(); + let g = merge_groups(&mut ps.gradients, groups(&v, "gradients")); + let st = merge_groups(&mut ps.styles, groups(&v, "styles")); + let sh = merge_groups(&mut ps.shapes, groups(&v, "shapes")); + let pt = merge_groups(&mut ps.pattern_groups, groups(&v, "pattern_groups")); + // Tool presets are a flat list, matched by name. + let mut tp = 0usize; + if let Some(arr) = v.get("tool_presets").cloned() + && let Ok(incoming) = serde_json::from_value::>(arr) + { + for t in incoming { + if !ps.tool_presets.iter().any(|e| e.name == t.name) { + ps.tool_presets.push(t); + tp += 1; + } + } + } + let total = g + st + sh + pt + tp; + if total > 0 { + ps.rev += 1; + } + counts.insert("gradients".into(), json!(g)); + counts.insert("styles".into(), json!(st)); + counts.insert("shapes".into(), json!(sh)); + counts.insert("patternGroups".into(), json!(pt)); + counts.insert("toolPresets".into(), json!(tp)); + Ok(json!({"migrated": total, "added": counts})) +} + +pub fn specs() -> Vec { + vec![crate::commands::CommandSpec { + id: "edit.presets.migratePresets", + label: "Migrate Presets", + menu: &["Edit", "Presets"], + shortcut: None, + params: r#"{path} → {migrated, added:{gradients,styles,shapes,patternGroups,toolPresets}}: merge a presets/preferences file into the library (appends groups by name)"#, + enabled: |_s| Ok(()), + journal: false, + run: |s, p| migrate(s, &p), + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_new_groups_only() { + let mut s = Session::new(); + let before = s.presets.gradients.len(); + let existing = s.presets.gradients[0].name.clone(); + // A presets file with one new gradient group and one that already exists. + let file = std::env::temp_dir().join(format!("pc-migrate-{}.json", std::process::id())); + let data = json!({ + "presets": { + "gradients": [ + {"name": "My Imported Set", "items": []}, + {"name": existing, "items": []}, + ] + } + }); + std::fs::write(&file, serde_json::to_string(&data).unwrap()).unwrap(); + let r = s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap(); + assert_eq!(r["added"]["gradients"], 1, "only the new group is added"); + assert_eq!(s.presets.gradients.len(), before + 1); + assert!(s.presets.gradients.iter().any(|g| g.name == "My Imported Set")); + // Idempotent: migrating again adds nothing. + let r2 = s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap(); + assert_eq!(r2["migrated"], 0); + let _ = std::fs::remove_file(file); + } + + #[test] + fn bad_path_is_an_error() { + let mut s = Session::new(); + assert!(s.execute("edit.presets.migratePresets", json!({"path": "/no/such/file.json"})).is_err()); + assert!(s.execute("edit.presets.migratePresets", json!({})).is_err()); + } +} diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index 2177c999..69bb198e 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 601; +pub const FLOOR: usize = 602; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/docs/parity.md b/docs/parity.md index 03a6db54..a94550e8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,12 +2,12 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**606 of 625 menu items live (97.0%).** +**607 of 625 menu items live (97.1%).** | Menu | Live | Total | % | |---|---:|---:|---:| | File | 47 | 50 | 94% | -| Edit | 74 | 75 | 99% | +| Edit | 75 | 75 | 100% | | Image | 60 | 61 | 98% | | Layer | 147 | 160 | 92% | | Type | 44 | 44 | 100% | @@ -24,10 +24,6 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho - Import › Video Frames to Layers… (`file.import.videoFramesToLayers`) - Import › WIA Support… (`file.import.wiaSupport`) -### Edit (1) - -- Presets › Migrate Presets (`edit.presets.migratePresets`) - ### Image (1) - Trap… (`image.trap`)