fix(blocks): one block definition service for delete, rename and purge on every path

new src/scene/block_service.rs (references, delete, rename, purge candidates, name validation, one error type and message). The command-line RENAME, palette Remove, plugin API, control/MCP block_delete and PURGE all use it. Layout, xref, dynamic-block, anonymous and open-in-editor records are protected; PURGE removes an anonymous block only when nothing references it. PROTOCOL CHANGE: control/MCP block_delete refuses a referenced block unless force=true (erases referencing INSERTs in one undo step). New REST route DELETE /api/v1/blocks/{name}?force=true. MCP tool digest re-pinned. Undo labels are Delete block X, Rename block X to Y and Purge.
This commit is contained in:
Karim Jerbi committed 2026-10-12 07:25:11 +01:00
1 parent 5dfa41c032
commit 2b50b6c858
16 files changed
+2701 -262

No files matched your search

+42 -23
View File
@@ -899,33 +899,37 @@ impl OpenCADStudio {
Ok(Task::none())
}
/// `block_delete` — remove a block definition together with its child
/// entities, the Block/BlockEnd markers and every Insert referencing it.
/// `block_delete` — remove a block definition. A definition something
/// still points at is refused unless the request carries `"force": true`,
/// which erases the referencing INSERTs in the same undo step.
pub(super) fn control_block_delete(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.starts_with('*') {
return Err(failure(
"block_protected",
"Layout block records cannot be deleted",
));
}
let force = req["force"].as_bool().unwrap_or(false);
let i = self.active_tab;
if self.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_none()
{
return Err(failure(
"block_missing",
format!("Block '{name}' does not exist"),
));
}
self.push_undo_snapshot(i, "BLOCKDELETE");
let erased = erase_block_definition(&mut self.tabs[i].scene, &name);
let open_edits: Vec<codec::Handle> = self.tabs[i]
.block_edits
.iter()
.map(|session| session.br_handle)
.collect();
self.push_undo_snapshot(i, format!("Delete block {name}"));
let options = crate::scene::block_service::DeleteOptions {
force,
open_edits: &open_edits,
};
let outcome =
match crate::scene::block_service::delete(&mut self.tabs[i].scene, &name, options) {
Ok(outcome) => outcome,
Err(error) => {
self.discard_last_undo_entry(i);
return Err(failure(block_error_code(&error), error.to_string()));
}
};
self.post_ref_op(i);
self.set_control_result(json!({ "block": name, "erased": erased }));
self.set_control_result(json!({
"block": outcome.name,
"erased": outcome.erased,
"references": outcome.references.len(),
}));
Ok(Task::none())
}
@@ -988,6 +992,21 @@ pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str
victims.len()
}
/// The automation error code for each [`BlockServiceError`], so a headless
/// client branches on a stable machine-readable code while the text stays the
/// one the shared formatter produced.
fn block_error_code(error: &crate::scene::block_service::BlockServiceError) -> &'static str {
use crate::scene::block_service::BlockServiceError as E;
match error {
E::NotFound(_) => "block_missing",
E::InvalidName(_) => "block_invalid_name",
E::NameExists(_) => "block_exists",
E::Referenced { .. } => "block_referenced",
E::InEditSession(_) => "block_in_edit",
E::Protected(_) => "block_protected",
}
}
/// `data:[{code,value},…]` → typed XData values; an absent or empty list
/// means "remove this application's record".
fn parse_xdata_values(req: &Value) -> Parsed<Option<Vec<codec::xdata::XDataValue>>> {
-1
View File
@@ -1631,7 +1631,6 @@ mod actions;
pub(crate) mod vision;
pub(crate) use actions::property_json;
mod entities;
pub(crate) use entities::erase_block_definition;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod http_bridge;
mod interactive;
+19 -1
View File
@@ -551,11 +551,29 @@ fn block_delete_and_define_replace_manage_definitions() {
assert_eq!(count_type(&mut app, "Line"), 0, "old children erased");
assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased");
// A definition the insert still points at is refused; "force":true erases
// the referencing inserts in the same undo step.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl1b","document_id":{doc},"name":"MARK"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_referenced");
assert_eq!(
count_type(&mut app, "Insert"),
1,
"the insert survived the refusal"
);
// Delete removes the insert, the markers, the children and the record.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"#
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK","force":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(
r["result"]["references"].as_u64().unwrap(),
1,
"the forced delete reports what it overrode"
);
assert!(
r["result"]["erased"].as_u64().unwrap() >= 3,
"insert + markers + child"