Plug-ins: app.open passes params to the importer File › Open picks

app.open with params and no importer ran file.open with the path alone, so the desktop app ran the importer on its defaults while headless app.open passed the params to document.open. file.open now carries params to the importer that opens the file.

The importer parameters test installs its module with replace from the second pass on, as plugin.install requires. The permission table in docs/plugins.md lists doc.read for filter, effect and exporter.
This commit is contained in:
dotCooCoo
2026-11-08 12:59:17 -08:00
parent d5bb58aad5
commit 36bca85879
9 changed files with 44 additions and 10 deletions
+3 -2
View File
@@ -249,9 +249,10 @@ fn the_importer_is_given_its_parameters_and_the_file_s_name() {
let mut s = Session::new();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#];
for needle in needles {
for (i, needle) in needles.into_iter().enumerate() {
// RECT when the parameters hold the needle; the file, which isn't JSON, otherwise.
install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT));
let wasm = wat::parse_str(tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)).unwrap();
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": i > 0})).unwrap();
let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}}));
assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}");
}
+8 -1
View File
@@ -449,7 +449,14 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({}));
wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params})))
}
None => wrap(app.run("file.open", json!({"path": s("path")}))),
// `params` go to the importer File › Open picks for the file, as in `document.open`.
None => {
let mut open = json!({"path": s("path")});
if let Some(params) = p.get("params").filter(|v| !v.is_null()) {
open["params"] = params.clone();
}
wrap(app.run("file.open", open))
}
},
"app.save" | "app.export" => {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
@@ -142,6 +142,14 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() {
assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}");
assert_ne!(kind(&app), Some(plugin::IMPORT_KIND));
assert_eq!(app.session.documents().len(), 3);
// Without `importer`, `params` go to the importer File › Open picks for the file.
let other = dir.join("b.tup");
std::fs::write(&other, "not json").unwrap();
let other = other.to_string_lossy().into_owned();
let r = control(&mut app, json!({"path": other, "params": {"density": 7}}));
assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}");
assert_eq!(app.session.documents().len(), 4);
app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub(crate) use export::{
pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened};
pub use open::{
MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document,
open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error,
open_path, open_path_with, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error,
};
#[cfg(test)]
pub(crate) use open::{open_file, opened_with_notes};
+17 -2
View File
@@ -13,6 +13,11 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for
/// a document opened now (its `warnings` say what didn't come in as it was), else null.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
open_bytes_with(app, name, bytes, path, &Value::Null)
}
/// [`open_bytes`], a document opening with `document.open`'s params `p`.
fn open_bytes_with(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value, String> {
let opens = opens_as(name);
// A WebAssembly plug-in is installed.
if opens == Some(OpensAs::Plugin) {
@@ -53,7 +58,7 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
{
return Ok(Value::Null);
}
open_document(app, name, bytes, path, &Value::Null)
open_document(app, name, bytes, path, p)
}
/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the
@@ -436,9 +441,19 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<Value, String>
open_file(app, path, false)
}
/// [`open_path`], with `params` for the importer plug-in that opens the file, if one does.
pub fn open_path_with(app: &mut VectorcraftApp, path: &str, params: &Value) -> Result<Value, String> {
open_file_with(app, path, false, &json!({ "params": params }))
}
/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks
/// first) is decoded off the UI thread ([`crate::opening::start`]).
pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result<Value, String> {
open_file_with(app, path, background, &Value::Null)
}
/// [`open_file`], a document opening with `document.open`'s params `p`.
fn open_file_with(app: &mut VectorcraftApp, path: &str, background: bool, p: &Value) -> Result<Value, String> {
// Automation confined to some folders reaches no file outside them, open in a tab or not.
vectorcraft_engine::file_access::check_read(path)?;
// File › Open, Open Recent, the Home screen, a drop and the Finder all focus a document that
@@ -487,7 +502,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool)
Err(back) => bytes = back,
}
}
let r = open_bytes(app, path, &bytes, Some(path.to_string()))?;
let r = open_bytes_with(app, path, &bytes, Some(path.to_string()), p)?;
note_recent(app, path);
Ok(r)
}
+4 -1
View File
@@ -107,7 +107,10 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
Ok(Value::Null)
}
"file.open" => match s("path") {
Some(path) => io::open_path(app, &path),
Some(path) => match p.get("params").filter(|v| !v.is_null()) {
Some(params) => io::open_path_with(app, &path, params),
None => io::open_path(app, &path),
},
None => io::open_dialog(app).map(|_| Value::Null),
},
"file.openWith" => io::open_with(app, p),
+1 -1
View File
@@ -28,7 +28,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.open",
"Open…",
"Cmd+O",
"{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)",
"{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file",
),
(
"file.openWith",
+1 -1
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
+1 -1
View File
@@ -142,7 +142,7 @@ capability model proposed for PhotoCraft plug-ins
| Permission | Kinds | What it allows |
|---|---|---|
| `doc.read` | all | Read the objects it is given. |
| `doc.read` | filter, effect, exporter | Read the objects it is given. |
| `doc.write` | filter | Change, add and delete objects in the document. |
| `state` | filter | Keep data of its own with the document (`documentData`). |
| `effect.render` | effect | Change how the objects its effect is applied to are drawn. |