Files
OneCAD/crates/engine/src/cmd/metadata.rs
T
Lasse Lian a4a8b48567 Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
2026-10-14 18:25:35 -04:00

262 lines
13 KiB
Rust

//! Design properties (#1015): `document.metadata` reads the design's `metadata` object, and
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
//! agents all go through these commands.
use serde_json::{Map, Value, json};
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: project, part_number, revision, \
author, organization, description and license, each its text or null (absent, or not text)",
),
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys: project, part_number, \
revision, author, organization, description, license (text; \"\" or null removes); another tool's data goes under its \
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
),
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
.doc_only()
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
];
fn read(s: &mut Session, _: &Value) -> Result<Value> {
let m = &s.doc.metadata;
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
Ok(json!({"metadata": m, "properties": properties}))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.metadata.set";
let entries: Vec<(&str, &Value)> = match (p.get("values"), p.get("key")) {
(Some(Value::Object(values)), None) if values.len() <= MAX_KEYS => values.iter().map(|(k, v)| (k.as_str(), v)).collect(),
(Some(Value::Object(_)), None) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
(None, Some(_)) => {
let key = str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?;
let value = p.get("value").ok_or_else(|| bad(cmd, "`value` is required (null removes the key)"))?;
vec![(key, value)]
}
_ => return Err(bad(cmd, "give `key` and `value`, or `values`: {key: value, …}")),
};
let mut next = s.doc.metadata.clone();
let mut changed = Vec::new();
for (key, value) in entries {
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
changed.push(key.to_string());
}
}
// Only a real change touches the design (an undo step, unsaved changes).
if next != s.doc.metadata {
s.doc_mut().metadata = next;
}
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.metadata.remove";
let keys: Vec<&str> = match (p.get("keys"), p.get("key")) {
(Some(Value::Array(keys)), None) if keys.len() <= MAX_KEYS => {
keys.iter().map(Value::as_str).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`keys` must be a list of keys"))?
}
(None, Some(_)) => vec![str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?],
_ => return Err(bad(cmd, format!("give `key`, or `keys`: a list of at most {MAX_KEYS} keys"))),
};
let mut removed: Vec<&str> = Vec::new();
for k in keys {
if s.doc.metadata.get(k).is_some() && !removed.contains(&k) {
removed.push(k);
}
}
if !removed.is_empty() {
let m = &mut s.doc_mut().metadata;
for k in &removed {
m.remove(k);
}
}
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
}
#[cfg(test)]
mod tests {
use super::*;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A folder of its own for one test's files.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-metadata-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn properties_set_read_and_remove_in_one_undo_step_each() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
s.mark_saved();
let undo = s.undo.len();
let r = run(
&mut s,
"document.metadata.set",
json!({"values": {"author": "Jane Doe", "part_number": "ACME-1042", "com.acme.shop": {"shop_id": 88123}}}),
);
assert_eq!(r["changed"], json!(["author", "com.acme.shop", "part_number"]));
assert_eq!(s.undo.len(), undo + 1, "several keys, one undo step");
assert!(s.is_dirty());
let r = run(&mut s, "document.metadata", json!({}));
assert_eq!(r["properties"]["author"], "Jane Doe");
assert_eq!(r["properties"]["project"], Value::Null);
assert_eq!(r["metadata"]["com.acme.shop"]["shop_id"], 88123);
// Setting what is already there changes nothing: no undo step.
let r = run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane Doe"}));
assert_eq!(r["changed"], json!([]));
assert_eq!(s.undo.len(), undo + 1);
let r = run(&mut s, "document.metadata.remove", json!({"keys": ["author", "author", "not.there"]}));
assert_eq!(r["removed"], json!(["author"]));
assert_eq!(s.doc.metadata.author(), None);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.metadata.author(), Some("Jane Doe"));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.metadata.is_empty());
assert!(!s.is_dirty(), "undone back to the saved design");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.metadata.part_number(), Some("ACME-1042"));
}
#[test]
fn a_refused_change_leaves_the_design_as_it_was() {
let mut s = Session::default();
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
let undo = s.undo.len();
for p in [
json!({"values": {"project": "P", "author": 42}}),
json!({"values": {"project": "P", "colour": "red"}}),
json!({"values": {"project": "P", "com.acme": "not an object"}}),
json!({"key": "author"}),
json!({"key": 7, "value": "x"}),
json!({"key": "author", "value": "x", "values": {}}),
json!({"values": [1, 2]}),
json!({}),
] {
assert!(s.execute("document.metadata.set", &p).is_err(), "{p}");
}
for p in [json!({}), json!({"keys": "author"}), json!({"keys": [1]}), json!({"key": ["author"]})] {
assert!(s.execute("document.metadata.remove", &p).is_err(), "{p}");
}
assert_eq!(s.undo.len(), undo);
assert_eq!(s.doc.metadata.project(), None, "all or nothing");
assert_eq!(s.doc.metadata.author(), Some("Jane"));
}
#[test]
fn other_tools_keys_and_odd_values_survive_open_edit_and_save() {
let dir = scratch("roundtrip");
// A design another tool added metadata to by hand: a namespaced object with every kind of
// JSON value, a well-known key of the wrong type, a key a newer SolidCraft might read, and
// numbers no feature input could hold.
let foreign = json!({
"author": 42,
"revision": "B",
"com.acme.description": {"shop_id": 88123, "nested": {"list": [1, 2.5, null, true, "x", {"deep": []}]}, "big": 1.5e300},
"org.printfarm": {"slicer_profile": "PETG-0.2"},
"future_key": ["kept"],
});
let mut design: Value = serde_json::from_str(&solidcraft_doc::Document::new("Foreign").to_json()).unwrap();
design["metadata"] = foreign.clone();
let plain = dir.join("foreign.json");
std::fs::write(&plain, serde_json::to_string_pretty(&design).unwrap()).unwrap();
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": plain}));
assert_eq!(serde_json::to_value(&s.doc.metadata).unwrap(), foreign, "opened as it was");
assert_eq!(s.doc.metadata.author(), None, "a number is not an author");
let r = run(&mut s, "document.metadata", json!({}));
assert_eq!(r["properties"]["author"], Value::Null);
assert_eq!(r["properties"]["revision"], "B");
run(&mut s, "document.metadata.set", json!({"key": "project", "value": "ACME enclosures"}));
let mut expected = foreign.clone();
expected["project"] = json!("ACME enclosures");
for (file, format) in [("edited.json", "json"), ("edited.solidcraft", "container")] {
let path = dir.join(file);
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
let mut reopened = Session::default();
run(&mut reopened, "doc.open", json!({"path": path}));
assert_eq!(serde_json::to_value(&reopened.doc.metadata).unwrap(), expected, "{format}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_design_without_properties_saves_as_before() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let before = solidcraft_io::write_design(&s.doc);
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
run(&mut s, "document.metadata.remove", json!({"key": "author"}));
assert_eq!(solidcraft_io::write_design(&s.doc), before, "byte for byte");
assert!(!String::from_utf8_lossy(&before).contains("\"metadata\""));
}
#[test]
fn exports_carry_the_properties_and_never_other_tools_keys() {
// `file.export` hands the design's properties to the writers; the mapping itself is
// tested in solidcraft-io.
let dir = scratch("export");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(
&mut s,
"document.metadata.set",
json!({"values": {
"project": "ACME enclosures", "author": "Jane Doe", "organization": "ACME", "license": "CC-BY-4.0",
"description": "Vented case\nfor a Pi", "part_number": "ACME-1042", "com.acme.secret": {"token": "SECRET-VALUE"},
}}),
);
let text = |s: &mut Session, name: &str| {
let path = dir.join(name);
run(s, "file.export", json!({"path": path}));
std::fs::read(&path).unwrap()
};
let step = String::from_utf8(text(&mut s, "part.step")).unwrap();
assert!(step.contains("FILE_DESCRIPTION(('Vented case for a Pi'),'2;1');"), "{}", &step[..400]);
assert!(step.contains("('Jane Doe'),('ACME')"), "{}", &step[..400]);
let iges = String::from_utf8(text(&mut s, "part.igs")).unwrap();
assert!(iges.contains("8HJane Doe") && iges.contains("4HACME"), "{}", &iges[..600]);
// 3MF model metadata is checked where it is written (solidcraft-io); here: it exports.
assert!(!text(&mut s, "part.3mf").is_empty());
for out in [step.as_bytes(), iges.as_bytes()] {
let found = |needle: &str| out.windows(needle.len()).any(|w| w == needle.as_bytes());
assert!(!found("SECRET-VALUE") && !found("com.acme"), "another tool's data is never exported");
}
// An assembly's STEP gets the same header.
run(&mut s, "component.create", json!({}));
assert!(!s.doc.occurrences.is_empty());
let assembly = String::from_utf8(text(&mut s, "assembly.step")).unwrap();
assert!(assembly.contains("('Jane Doe'),('ACME')"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn inspect_lists_the_metadata() {
let mut s = Session::default();
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({}));
run(&mut s, "document.metadata.set", json!({"key": "revision", "value": "C"}));
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({"revision": "C"}));
}
}