mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 07:45:38 +00:00
MCP: library lists leave out paths outside a confined client's read roots
swatch.library.list, brush.libraries, symbol.libraries and graphicStyle.libraries returned every library's file path and the user library folder to a client confined by --automation-read-root, including paths outside its read roots. They now return null for those paths, and a library loaded from a file outside the roots is listed as loaded/<file name>, numbered (2), (3) when names repeat. That id finds the library in the other library commands, and their replies show it too. - The naming moves from control.rs into swatchlib::concealed_library_ids, which numbers the session's loaded libraries first, so ui.inspect, ui.docking and the library commands show a library under the same id. - swatch.library.copyToUser refuses a library whose file is outside the read roots without naming the file; it and the library saves with `user: true` leave out `path` when the user library folder is outside the read roots. Docs: docs/mcp.md (read roots), docs/target-app-parity.md.
This commit is contained in:
@@ -21,7 +21,7 @@ use super::clipboard::Clipboard;
|
||||
use super::clipboard::resources::SwatchChoices;
|
||||
use super::patterncmds::add_pattern;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
|
||||
/// The extension of saved brush libraries: native documents.
|
||||
@@ -462,6 +462,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<BrushLibrary>
|
||||
if let Some(found) = s.brush_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.brush_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -476,20 +479,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.brush_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.brush_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (info, lib) = library_param(s, p, "brush.library")?;
|
||||
let brushes: Vec<Value> = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "brushes": brushes}))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -545,7 +550,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n));
|
||||
let applied: Vec<u64> = paths.iter().map(|id| id.0).collect();
|
||||
Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "names": doc_names, "added": added, "existing": existing, "applied": applied}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -571,5 +576,5 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ use vectorcraft_doc::style_libs::{self, STYLE_LIBRARIES, STYLES_EXT, builtin_sty
|
||||
use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
|
||||
|
||||
use super::menucmds::squash;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, names_param, stem};
|
||||
use super::swatchlib::{
|
||||
FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, names_param, shown_id, shown_library_id, stem,
|
||||
};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, has_doc, str_param, switch_or, text_param, unique_name};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -94,6 +96,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<StyleLibrary>
|
||||
if let Some(found) = s.style_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.style_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = STYLE_LIBRARIES.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_style_library(key)?));
|
||||
}
|
||||
@@ -108,20 +113,22 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.style_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.style_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.style_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.style_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (info, lib) = library_param(s, p, "graphicStyle.library")?;
|
||||
let styles: Vec<Value> = lib.styles.iter().map(super::style::style_json).collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "styles": styles}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "styles": styles}))
|
||||
}
|
||||
|
||||
/// Pattern `name` of `lib` in `d`: the document's own when it has the same pattern (under any
|
||||
@@ -192,7 +199,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let doc_name = |lib_name: &str| added.iter().find(|(l, _)| l == lib_name).map_or_else(|| lib_name.to_string(), |(_, n)| n.clone());
|
||||
let mut out = json!({
|
||||
"library": info.id,
|
||||
"library": shown_library_id(s, &info.id),
|
||||
"added": added.iter().map(|(_, n)| n).collect::<Vec<_>>(),
|
||||
"existing": existing.iter().map(|g| &g.name).collect::<Vec<_>>(),
|
||||
});
|
||||
@@ -238,5 +245,5 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]).
|
||||
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -211,6 +211,22 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
|
||||
}
|
||||
|
||||
/// The ids [`Libraries::get`] finds: the User Defined and loaded libraries', and the loaded ids of
|
||||
/// libraries copied into the user library folder.
|
||||
fn ids(&self) -> impl Iterator<Item = String> + '_ {
|
||||
self.extra.iter().map(|e| e.info.id.clone()).chain(self.copied.iter().map(|(loaded, _)| loaded.clone()))
|
||||
}
|
||||
|
||||
/// The file library `id` was read from, when the request being served may read it.
|
||||
pub fn shown_path(&self, id: &str) -> Option<&str> {
|
||||
self.path(id).filter(|p| readable(p))
|
||||
}
|
||||
|
||||
/// The user library folder, when the request being served may read it.
|
||||
pub fn shown_user_dir(&self) -> Option<&str> {
|
||||
self.user_dir().filter(|d| readable(d))
|
||||
}
|
||||
|
||||
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
|
||||
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
|
||||
let ext = extension(file);
|
||||
@@ -281,7 +297,10 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.saved_as
|
||||
.insert(format!("user/{file}").to_lowercase(), SavedAs { name: name.to_string(), hash: vectorcraft_doc::links::hash_bytes(bytes) });
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
// The folder is the app's own: a confined request sees the file only inside its read roots.
|
||||
if readable(&path) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
} else if let Some(path) = path {
|
||||
write_file(path, bytes)?;
|
||||
@@ -349,6 +368,10 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// Outside the request's read roots: the error names no path.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
let ext = extension(&p);
|
||||
let base = file_name(&p);
|
||||
// A sandbox copy of an import often lands as tmp….tmp.acb (#2996): store it under the
|
||||
@@ -446,6 +469,72 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force.
|
||||
fn readable(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let session: BTreeSet<String> =
|
||||
s.swatch_libraries.ids().chain(s.brush_libraries.ids()).chain(s.symbol_libraries.ids()).chain(s.style_libraries.ids()).collect();
|
||||
let others: BTreeSet<String> = others.into_iter().filter(|id| !session.contains(id)).collect();
|
||||
let mut hidden = Vec::new();
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
taken.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The id a reply shows for library `id`, given the [`concealed_library_ids`] in force.
|
||||
pub(crate) fn shown_id(concealed: &[(String, String)], id: &str) -> String {
|
||||
concealed.iter().find(|(own, _)| own == id).map_or_else(|| id.to_string(), |(_, shown)| shown.clone())
|
||||
}
|
||||
|
||||
/// The id a reply shows for library `id` ([`concealed_library_ids`]).
|
||||
pub(crate) fn shown_library_id(s: &Session, id: &str) -> String {
|
||||
shown_id(&concealed_library_ids(s, []), id)
|
||||
}
|
||||
|
||||
/// The library id behind `key` when `key` is the id a reply shows for a concealed loaded library.
|
||||
pub(crate) fn concealed_library_id(s: &Session, key: &str) -> Option<String> {
|
||||
if !key.starts_with("loaded/") {
|
||||
return None;
|
||||
}
|
||||
concealed_library_ids(s, []).into_iter().find(|(_, shown)| shown == key).map(|(own, _)| own)
|
||||
}
|
||||
|
||||
/// `name` without its extension.
|
||||
pub(crate) fn stem(name: &str) -> &str {
|
||||
name.rsplit_once('.').map_or(name, |(s, _)| s)
|
||||
@@ -479,6 +568,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary
|
||||
if let Some(found) = s.swatch_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.swatch_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = builtins().find(|(_, b)| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -556,14 +648,16 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.swatch_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.swatch_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.swatch_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.swatch_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
/// The document's swatches `names` (swatches or colour groups; empty: all) as a library named
|
||||
@@ -619,7 +713,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
document_library(&loaded.doc, &[], stem(file).to_string(), C)
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -629,7 +723,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: Vec<Value> =
|
||||
lib.groups.iter().map(|g| json!({"name": g.name, "swatches": g.swatches.iter().map(|w| &w.name).collect::<Vec<_>>()})).collect();
|
||||
Ok(
|
||||
json!({"id": info.id, "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "groups": groups}),
|
||||
json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "swatches": ungrouped.chain(grouped).collect::<Vec<_>>(), "groups": groups}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -652,7 +746,7 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.path(&info.id);
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
}
|
||||
|
||||
@@ -792,7 +886,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?
|
||||
};
|
||||
let names = |list: &[(String, String)]| list.iter().map(|(_, n)| n.clone()).collect::<Vec<_>>();
|
||||
let mut out = json!({"library": info.id, "added": names(&added), "existing": names(&existing)});
|
||||
let mut out = json!({"library": shown_library_id(s, &info.id), "added": names(&added), "existing": names(&existing)});
|
||||
if let (Some(cmd), Some((_, first))) = (apply, picked.first()) {
|
||||
let name = added.iter().chain(&existing).find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
|
||||
s.execute(cmd, &json!({"swatch": name, "focus": focus}))?;
|
||||
|
||||
@@ -19,7 +19,7 @@ use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_nat
|
||||
use super::clipboard::Clipboard;
|
||||
use super::clipboard::resources::SwatchChoices;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, concealed_library_id, concealed_library_ids, shown_id, shown_library_id, stem};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, number_or, str_param};
|
||||
|
||||
/// The extension of saved symbol libraries: native documents.
|
||||
@@ -386,6 +386,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SymbolLibrary
|
||||
if let Some(found) = s.symbol_libraries.get(key) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(found) = concealed_library_id(s, key).and_then(|id| s.symbol_libraries.get(&id)) {
|
||||
return Some(found);
|
||||
}
|
||||
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
|
||||
return Some((builtin_info(b), builtin_library(key)?));
|
||||
}
|
||||
@@ -400,14 +403,16 @@ fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.symbol_libraries.rescan();
|
||||
let concealed = concealed_library_ids(s, []);
|
||||
let libs: Vec<Value> = libraries(s)
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
|
||||
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.symbol_libraries.path(&l.id)})
|
||||
let (id, path) = (shown_id(&concealed, &l.id), s.symbol_libraries.shown_path(&l.id));
|
||||
json!({"id": id, "name": l.name, "category": l.category, "count": count, "path": path})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.user_dir() }))
|
||||
Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.shown_user_dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -421,7 +426,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
json!({"name": sym.name, "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "symbols": symbols}))
|
||||
Ok(json!({"id": shown_library_id(s, &info.id), "name": info.name, "category": info.category, "symbols": symbols}))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -456,7 +461,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None };
|
||||
let parent = if place { st.target_parent()? } else { None };
|
||||
let mut out = json!({ "library": info.id });
|
||||
let mut out = json!({ "library": shown_library_id(s, &info.id) });
|
||||
if imported.added > 0 || place {
|
||||
let placed = s.edit("Add to Symbols", |d, sel| {
|
||||
let imported = clip.import_into(d, &choices, false)?;
|
||||
@@ -512,7 +517,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
SymbolLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
Ok(json!({"library": shown_library_id(s, &info.id), "name": info.name, "count": lib.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -89,6 +89,7 @@ mod layerclip;
|
||||
mod layers;
|
||||
mod legacy_ai;
|
||||
mod library;
|
||||
mod library_roots;
|
||||
mod link_paths;
|
||||
mod linked_stops;
|
||||
mod linkfiles;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The swatch, brush, symbol and graphic style library commands under automation roots: a confined
|
||||
//! request sees a library's file, the user library folder and the path in a loaded library's id only
|
||||
//! inside its read roots, and the id it is shown finds the library.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn text(p: &Path) -> String {
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// A fresh scratch folder for one test, with the folders `in`, `out`, `a`, `b` and `user`.
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vc-library-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for sub in ["in", "out", "a", "b", "user"] {
|
||||
std::fs::create_dir_all(dir.join(sub)).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// One kind of library and its commands.
|
||||
struct Kind {
|
||||
name: &'static str,
|
||||
list: &'static str,
|
||||
get: &'static str,
|
||||
save: &'static str,
|
||||
load: &'static str,
|
||||
ext: &'static str,
|
||||
/// Adds an item named by the second argument to the document.
|
||||
add: fn(&mut Session, &str),
|
||||
set_user_dir: fn(&mut Session, Option<String>),
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session) -> u64 {
|
||||
run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
const SWATCHES: Kind = Kind {
|
||||
name: "swatches",
|
||||
list: "swatch.library.list",
|
||||
get: "swatch.library.get",
|
||||
save: "swatch.library.save",
|
||||
load: "swatch.library.load",
|
||||
ext: "vcswatches",
|
||||
add: |s, name| {
|
||||
run(s, "swatch.new", json!({"name": name, "color": "#3366cc"}));
|
||||
},
|
||||
set_user_dir: |s, dir| s.swatch_libraries.set_user_dir(dir),
|
||||
};
|
||||
|
||||
const BRUSHES: Kind = Kind {
|
||||
name: "brushes",
|
||||
list: "brush.libraries",
|
||||
get: "brush.library",
|
||||
save: "brush.saveLibrary",
|
||||
load: "brush.loadLibrary",
|
||||
ext: crate::cmd::brushlib::BRUSHES_EXT,
|
||||
add: |s, name| {
|
||||
run(s, "brush.new", json!({"type": "calligraphic", "name": name, "params": {"angle": 20, "roundness": 40, "size": 7}}));
|
||||
},
|
||||
set_user_dir: |s, dir| s.brush_libraries.set_user_dir(dir),
|
||||
};
|
||||
|
||||
const SYMBOLS: Kind = Kind {
|
||||
name: "symbols",
|
||||
list: "symbol.libraries",
|
||||
get: "symbol.library",
|
||||
save: "symbol.saveLibrary",
|
||||
load: "symbol.loadLibrary",
|
||||
ext: crate::cmd::symbollib::SYMBOLS_EXT,
|
||||
add: |s, name| {
|
||||
let id = rectangle(s);
|
||||
run(s, "symbol.new", json!({"name": name, "ids": [id]}));
|
||||
},
|
||||
set_user_dir: |s, dir| s.symbol_libraries.set_user_dir(dir),
|
||||
};
|
||||
|
||||
const STYLES: Kind = Kind {
|
||||
name: "styles",
|
||||
list: "graphicStyle.libraries",
|
||||
get: "graphicStyle.library",
|
||||
save: "graphicStyle.saveLibrary",
|
||||
load: "graphicStyle.loadLibrary",
|
||||
ext: vectorcraft_doc::style_libs::STYLES_EXT,
|
||||
add: |s, name| {
|
||||
let id = rectangle(s);
|
||||
run(s, "graphicStyle.new", json!({"name": name, "id": id}));
|
||||
},
|
||||
set_user_dir: |s, dir| s.style_libraries.set_user_dir(dir),
|
||||
};
|
||||
|
||||
/// The loaded libraries of a list reply: id, path and count.
|
||||
fn loaded(list: &Value) -> Vec<(Value, Value, Value)> {
|
||||
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "loaded");
|
||||
libs.map(|l| (l["id"].clone(), l["path"].clone(), l["count"].clone())).collect()
|
||||
}
|
||||
|
||||
/// Does `reply` hold `path` anywhere, as JSON writes it?
|
||||
fn holds(reply: &Value, path: &Path) -> bool {
|
||||
let written = json!(text(path)).to_string();
|
||||
reply.to_string().contains(written.trim_matches('"'))
|
||||
}
|
||||
|
||||
/// Three libraries the person loaded, one inside the client's read root and two outside it in
|
||||
/// different folders under the same file name (the one in `b` holds one more item), and a user
|
||||
/// library folder outside the root. → the session, the folder and the three files.
|
||||
fn three_libraries(kind: &Kind, test: &str) -> (Session, PathBuf, [PathBuf; 3]) {
|
||||
let base = folder(&format!("{}-{test}", kind.name));
|
||||
let file = format!("Kit.{}", kind.ext);
|
||||
let files = [base.join("in").join(&file), base.join("a").join(&file), base.join("b").join(&file)];
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
(kind.add)(&mut s, "First");
|
||||
for path in &files[..2] {
|
||||
run(&mut s, kind.save, json!({"path": text(path), "name": "Kit"}));
|
||||
}
|
||||
(kind.add)(&mut s, "Second");
|
||||
run(&mut s, kind.save, json!({"path": text(&files[2]), "name": "Kit"}));
|
||||
(kind.set_user_dir)(&mut s, Some(text(&base.join("user"))));
|
||||
for path in &files {
|
||||
run(&mut s, kind.load, json!({"path": text(path)}));
|
||||
}
|
||||
(s, base, files)
|
||||
}
|
||||
|
||||
fn lists_leave_out_paths_outside_the_read_root(kind: &Kind) {
|
||||
let (mut s, base, [inside, a, b]) = three_libraries(kind, "list");
|
||||
let file = format!("Kit.{}", kind.ext);
|
||||
let person = run(&mut s, kind.list, json!({}));
|
||||
let rows = loaded(&person);
|
||||
assert_eq!(rows.len(), 3, "{person}");
|
||||
let (small, large) = (rows[1].2.clone(), rows[2].2.clone());
|
||||
assert_ne!(small, large, "{person}");
|
||||
let row = |p: &Path, count: &Value| (json!(format!("loaded/{}", text(p))), json!(text(p)), count.clone());
|
||||
assert_eq!(rows, [row(&inside, &small), row(&a, &small), row(&b, &large)], "the person sees every path: {person}");
|
||||
assert_eq!(person["userFolder"], json!(text(&base.join("user"))));
|
||||
|
||||
// The client sees the paths inside its read root; the others' rows stay, in the same order,
|
||||
// under the file's name, numbered in the order of their full ids.
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let client = confine(roots.as_ref(), || run(&mut s, kind.list, json!({})));
|
||||
let shown = [format!("loaded/{file}"), format!("loaded/{file} (2)")];
|
||||
let hidden = |id: &str, count: &Value| (json!(id), Value::Null, count.clone());
|
||||
assert_eq!(loaded(&client), [row(&inside, &small), hidden(&shown[0], &small), hidden(&shown[1], &large)], "{client}");
|
||||
assert_eq!(client["userFolder"], Value::Null, "{client}");
|
||||
for outside in ["a", "b", "user", "out"] {
|
||||
assert!(!holds(&client, &base.join(outside)), "no path outside the read root: {client}");
|
||||
}
|
||||
let everything = |l: &Value| l["libraries"].as_array().unwrap().iter().map(|l| (l["name"].clone(), l["category"].clone())).collect::<Vec<_>>();
|
||||
assert_eq!(everything(&client), everything(&person), "every library is still listed");
|
||||
|
||||
// The id shown finds its library, and replies about it show that id.
|
||||
let got = confine(roots.as_ref(), || run(&mut s, kind.get, json!({"library": shown[1]})));
|
||||
assert_eq!(got["id"], json!(shown[1]), "{got}");
|
||||
assert!(!holds(&got, &base.join("b")), "{got}");
|
||||
let got = confine(roots.as_ref(), || run(&mut s, kind.get, json!({"library": shown[0]})));
|
||||
assert_eq!(got["id"], json!(shown[0]), "{got}");
|
||||
assert!(s.execute(kind.get, &json!({"library": shown[0]})).is_err(), "the person has no such id");
|
||||
assert_eq!(run(&mut s, kind.list, json!({})), person, "the person's list is not changed");
|
||||
|
||||
// A write root alone lets the client read none of them.
|
||||
let write_only = AutomationRoots::new(&[], &[&base.join("in")]).unwrap();
|
||||
let client = confine(write_only.as_ref(), || run(&mut s, kind.list, json!({})));
|
||||
assert!(loaded(&client).iter().all(|(_, path, _)| path.is_null()), "{client}");
|
||||
assert!(!holds(&client, &base), "{client}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_library_list_leaves_out_paths_outside_the_read_root() {
|
||||
lists_leave_out_paths_outside_the_read_root(&SWATCHES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_libraries_leave_out_paths_outside_the_read_root() {
|
||||
lists_leave_out_paths_outside_the_read_root(&BRUSHES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_libraries_leave_out_paths_outside_the_read_root() {
|
||||
lists_leave_out_paths_outside_the_read_root(&SYMBOLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_style_libraries_leave_out_paths_outside_the_read_root() {
|
||||
lists_leave_out_paths_outside_the_read_root(&STYLES);
|
||||
}
|
||||
|
||||
/// Adding from a concealed library, copying it into the user library folder and saving into that
|
||||
/// folder show neither the library's file nor the folder outside the read root.
|
||||
#[test]
|
||||
fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
|
||||
let (mut s, base, [inside, ..]) = three_libraries(&SWATCHES, "replies");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let shown = "loaded/Kit.vcswatches";
|
||||
let added = confine(roots.as_ref(), || run(&mut s, "swatch.library.add", json!({"library": shown, "names": ["First"]})));
|
||||
assert_eq!(added["library"], shown, "{added}");
|
||||
// A library whose file is outside the root is not copied, and the error does not name the file.
|
||||
let refused = confine(roots.as_ref(), || s.execute("swatch.library.copyToUser", &json!({"library": shown})));
|
||||
let message = refused.map(|r| r.to_string()).unwrap_err().to_string();
|
||||
assert!(message.contains("outside the read roots"), "{message}");
|
||||
assert!(!message.contains(&text(&base)) && !message.contains("Kit.vcswatches"), "{message}");
|
||||
assert!(!base.join("user/Kit.vcswatches").exists());
|
||||
// The file inside the root is copied into the user library folder, which is outside it.
|
||||
let mine = format!("loaded/{}", text(&inside));
|
||||
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
|
||||
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
|
||||
assert_eq!(copied["path"], Value::Null, "{copied}");
|
||||
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
|
||||
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
|
||||
for reply in [&added, &copied, &saved] {
|
||||
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
|
||||
}
|
||||
// A library loaded from data under the file name keeps its id; each concealed one takes the next
|
||||
// free one, so no two rows share an id.
|
||||
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
// Copied into the user library folder, it is still found by that id, which no other library takes.
|
||||
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
|
||||
// The person sees the folder.
|
||||
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
|
||||
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -324,9 +324,10 @@ const PANEL_FIELDS: [&str; 7] =
|
||||
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
|
||||
/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of
|
||||
/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a
|
||||
/// workspace's name) is not read. When another of those ids already shows that name, the next free one
|
||||
/// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that
|
||||
/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id.
|
||||
/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the
|
||||
/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so
|
||||
/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library
|
||||
/// under the same id.
|
||||
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
let ui = serde_json::to_value(&app.ui).unwrap_or_default();
|
||||
@@ -342,30 +343,9 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
|
||||
for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) {
|
||||
add_library_id(id, &mut ids);
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
let mut taken = std::collections::BTreeSet::new();
|
||||
for id in ids {
|
||||
let path = &id["loaded/".len()..];
|
||||
let name = vectorcraft_engine::cmd::fileio::file_name(path);
|
||||
if name != path && !readable(path) {
|
||||
hidden.push((id, name));
|
||||
} else {
|
||||
taken.insert(id);
|
||||
}
|
||||
}
|
||||
let mut hidden = hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
(id, shown)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned());
|
||||
// Only the ids the UI state refers to are replaced in the reply.
|
||||
hidden.retain(|(id, _)| ids.contains(id));
|
||||
// The longest first, so that an id that begins another one does not replace part of it.
|
||||
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
|
||||
hidden
|
||||
|
||||
@@ -425,6 +425,33 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Two libraries loaded from outside the read root under the same file name, only the second of them
|
||||
/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and
|
||||
/// that id finds it.
|
||||
#[test]
|
||||
fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() {
|
||||
let (mut app, tx, base) = confined("one-id");
|
||||
let (x, y) = (base.join("outside/x/Kit.gpl"), base.join("outside/y/Kit.gpl"));
|
||||
for (path, colors) in [(&x, "0 0 0 Black\n"), (&y, "0 0 0 Black\n255 255 255 White\n")] {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, format!("GIMP Palette\nName: Kit\n{colors}")).unwrap();
|
||||
app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap();
|
||||
}
|
||||
let second = format!("loaded/{}", s(y.clone()));
|
||||
app.ui.library_panels = vec![serde_json::from_value(json!({"kind": "swatches", "id": second, "path": s(y.clone())})).unwrap()];
|
||||
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/Kit.gpl (2)", "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.list", "params": {}}));
|
||||
let libs = r["result"]["libraries"].as_array().unwrap_or_else(|| panic!("{r}"));
|
||||
let loaded: Vec<&Value> = libs.iter().filter(|l| l["category"] == "loaded").map(|l| &l["id"]).collect();
|
||||
assert_eq!(loaded, [&json!("loaded/Kit.gpl"), &json!("loaded/Kit.gpl (2)")], "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.get", "params": {"library": "loaded/Kit.gpl (2)"}}));
|
||||
assert_eq!(r["result"]["swatches"].as_array().map(Vec::len), Some(2), "the second library: {r}");
|
||||
assert!(!r.to_string().contains("outside"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A closed panel's former place, as `ui.docking_hidden` keeps it.
|
||||
fn hidden_place() -> craft_ui::docking::Location<String> {
|
||||
serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap()
|
||||
|
||||
+12
-2
@@ -135,8 +135,18 @@ the suite) keep the agent to the folders you give it:
|
||||
roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the
|
||||
Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is
|
||||
closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`,
|
||||
`loaded/<file name> (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both
|
||||
replies. The other entries keep their order, and the app's own state is not changed.
|
||||
`loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session
|
||||
first, then those only the UI state names), and each one keeps its id in both replies and in the library
|
||||
commands below. The other entries keep their order, and the app's own state is not changed.
|
||||
- `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library
|
||||
but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder`
|
||||
are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered
|
||||
`(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the
|
||||
other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`,
|
||||
`symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the
|
||||
others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file
|
||||
is outside the read roots without naming the file, and it and the library saves with `user: true` leave out
|
||||
`path` when the user library folder is outside the read roots.
|
||||
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
|
||||
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
|
||||
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
|
||||
|
||||
@@ -743,6 +743,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
|
||||
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
|
||||
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
|
||||
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
|
||||
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.
|
||||
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.
|
||||
- MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
|
||||
|
||||
Reference in New Issue
Block a user