Plug-ins: File › Open lists only the importer extensions it opens with an importer

An importer may declare an extension that File › Open handles another
way: a plug-in, shortcut set, script, preset, library or swatch library
such as .ase, or one a built-in format reads. "All readable files" and
the importer's own filter no longer list those extensions, and an
importer with none left gets no filter. Open With still opens them.
This commit is contained in:
dotCooCoo
2026-11-08 07:31:40 -08:00
parent d65207dc62
commit edd6acd9c0
6 changed files with 135 additions and 25 deletions
+2 -1
View File
@@ -132,7 +132,8 @@ use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters,
export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions,
open_filters, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format,
open_filters, opens_as_other, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export,
writable_format,
};
#[cfg(test)]
+64 -20
View File
@@ -669,39 +669,83 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, read_extensions(f)))
}
/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the
/// installed importer plug-ins list that no built-in format reads, in plug-in id order. Made once
/// for each set of installed plug-ins, so it lives as long as the dialogs that hold it.
pub fn open_extensions() -> &'static [&'static str] {
/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a
/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print,
/// perspective grid or export presets (imported), a Libraries panel library, or a swatch or graphic
/// style library.
pub fn opens_as_other(ext: &str) -> bool {
[
crate::cmd::plugin::EXTS,
crate::cmd::shortcutset::EXTS,
crate::cmd::scripts::EXTS,
crate::cmd::flatten::PRESET_EXTS,
crate::cmd::pdfcmds::PRESET_EXTS,
crate::cmd::printpresets::PRESET_EXTS,
crate::cmd::perspgrid::PRESET_EXTS,
screens::PRESET_FILE_EXTS,
crate::cmd::library::LIBRARY_EXTS,
crate::cmd::swatchlib::LIBRARY_EXTS,
std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT),
]
.iter()
.any(|exts| exts.contains(&ext))
}
/// File › Open's extensions and filters for the installed importer plug-ins.
type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>);
/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files"
/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer
/// that has extensions left, under its name. An importer's extensions leave out those a built-in
/// format reads, which File › Open reads with that format, and those File › Open opens as something
/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as
/// the dialogs that hold it.
fn importer_open_part() -> ImporterPart {
use vectorcraft_plugins::registry;
static MADE: std::sync::Mutex<Option<(u64, &'static [&'static str])>> = std::sync::Mutex::new(None);
static MADE: std::sync::Mutex<Option<(u64, ImporterPart)>> = std::sync::Mutex::new(None);
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
let revision = registry::revision();
if let Some((r, exts)) = *made
&& r == revision
if let Some((r, part)) = made.as_ref()
&& *r == revision
{
return exts;
return part.clone();
}
let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec();
for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) {
if !exts.contains(&e) && readable_format(e).is_none() {
exts.push(e);
let mut filters = Vec::new();
for f in importer_formats() {
let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect();
if own.is_empty() {
continue;
}
for &e in &own {
if !exts.contains(&e) {
exts.push(e);
}
}
let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) };
filters.push((f.label, own));
}
let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) };
*made = Some((revision, exts));
exts
let part = (exts, filters);
*made = Some((revision, part.clone()));
part
}
/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the
/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open
/// as something else ([`opens_as_other`]), in plug-in id order.
pub fn open_extensions() -> &'static [&'static str] {
importer_open_part().0
}
/// File › Open's filters: "All readable files" first, then one per readable format, then one per
/// importer plug-in (under its name), then swatch libraries (which open in the library panel),
/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked
/// without running them) and plug-ins (installed).
/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then
/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF,
/// print and export presets (imported), scripts (checked without running them) and plug-ins
/// (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", open_extensions()))
.chain(format_filters())
.chain(importer_formats().into_iter().map(|f| (f.label, f.extensions)))
.chain(other_open_filters())
let (all, importers) = importer_open_part();
std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters())
}
/// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For
+21
View File
@@ -157,6 +157,27 @@ fn built_in_formats_keep_their_files() {
assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1);
}
/// File › Open lists an importer's extensions only where it would open the file with the importer:
/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a
/// script, presets or a library. An importer with none of its own gets no filter.
#[test]
fn file_open_lists_only_the_extensions_it_opens_with_an_importer() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT));
install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT));
for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] {
assert!(fileio::opens_as_other(ext), "{ext}");
assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}");
}
assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg"));
assert!(fileio::open_extensions().contains(&"tcl"));
let filters: Vec<_> = fileio::open_filters().collect();
assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}");
assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}");
s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap();
s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap();
}
/// Several importers list one extension: the one whose id sorts first opens the file, and
/// `importer` picks another (by its format id or its id).
#[test]
@@ -164,6 +164,47 @@ fn only_file_open_s_dialog_lists_an_importer_s_files() {
app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap();
}
/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the
/// ones File › Open installs, imports, checks or loads as something other than a document.
#[test]
fn importer_filters_leave_out_what_file_open_opens_as_something_else() {
use vectorcraft_engine::cmd::{self, fileio};
let lists: [&[&str]; 10] = [
cmd::plugin::EXTS,
cmd::shortcutset::EXTS,
cmd::scripts::EXTS,
cmd::flatten::PRESET_EXTS,
cmd::pdfcmds::PRESET_EXTS,
cmd::printpresets::PRESET_EXTS,
cmd::perspgrid::PRESET_EXTS,
fileio::SCREEN_EXPORT_PRESET_EXTS,
cmd::library::LIBRARY_EXTS,
cmd::swatchlib::LIBRARY_EXTS,
];
for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) {
assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}");
assert!(fileio::opens_as_other(ext), "{ext}");
}
for ext in ["svg", "tcl", "pdf"] {
assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}");
}
// File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there).
let mut app = app();
install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), tpl::ECHO, "", 1));
let asked: std::rc::Rc<std::cell::RefCell<Option<crate::FilePick>>> = Default::default();
let seen = asked.clone();
app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| {
*seen.borrow_mut() = Some(pick.clone());
None
}));
let _ = app.run("file.open", json!({}));
let pick = asked.borrow_mut().take().expect("File › Open showed its dialog");
let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone());
assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters);
assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]);
app.run("plugin.remove", json!({"id": "test.ui.claimed"})).unwrap();
}
/// File › Open reads a file of an extension only an importer lists, and refuses one past the
/// importer's input limit before reading it, as Open With does.
#[test]
+1 -1
View File
@@ -6091,7 +6091,7 @@ id `plugin.<id>`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc
`effect.list` lists it. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats`
with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?:
with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?:
"plugin.<id>", params?}` (and `open_file {path, importer?, params?}`) opens a file whose extension no built-in format reads, by its content or its
extension, with the installed importer that lists the extension (the one whose id sorts first, when several do).
`importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such
+6 -3
View File
@@ -41,8 +41,8 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li
`document.export {"format": "plugin.<id>", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of
its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats.
An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id, its extensions
in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format
An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id. `openExtensions`
holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format
reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the
one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer,
which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg`
@@ -60,7 +60,10 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo
Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the
same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File ›
Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New
from Template and Load Styles, list only the files the built-in formats read); **File › Open With** lists the
from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's
extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`,
`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give
those files to the importer, and Open With does. **File › Open With** lists the
installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its
extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer
with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open