forked from 1b-agent/opencadstudio-upstream
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:
1 parent
5dfa41c032
commit
2b50b6c858
16 files changed
+2701
-262
No files matched your search
+42
-23
@@ -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>>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user