mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-18 13:16:57 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904f0fe522 | ||
|
|
5e6810655f | ||
|
|
299a4b36ad | ||
|
|
6a83f6fea6 | ||
|
|
e6aec0664c | ||
|
|
d44dc983e6 | ||
|
|
39f9204d57 | ||
|
|
82e363c73b | ||
|
|
b05043c759 | ||
|
|
ef5084f48b | ||
|
|
1ddfb56f66 | ||
|
|
b46ffd0059 |
No files matched your search
Generated
+35
-28
@@ -806,6 +806,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -4507,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4542,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4553,7 +4558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4565,7 +4570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4581,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4603,7 +4608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4615,7 +4620,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
@@ -4633,7 +4638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4649,8 +4654,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4686,7 +4692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4709,7 +4715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4724,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4736,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4755,7 +4761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4769,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4781,7 +4787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4811,7 +4817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4825,7 +4831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4852,15 +4858,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4883,7 +4890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -4901,7 +4908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4921,7 +4928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4929,7 +4936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4945,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4957,7 +4964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4991,7 +4998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6101,7 +6108,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -389,6 +389,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -559,8 +560,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
|
||||
/// no Windows file name has) is refused rather than split into other arguments.
|
||||
fn explorer_safe(path: &str) -> Result<(), String> {
|
||||
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
|
||||
fn open_file(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -297,8 +297,11 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
// Paths are checked as stored in a document inside `folder` (only the folder is used).
|
||||
let document = folder.map(|f| std::path::Path::new(f).join("-").to_string_lossy().into_owned());
|
||||
let read = |path: &str| {
|
||||
scope.clone().run(|| {
|
||||
let path = &super::document_path(path, document.as_deref()).ok()?;
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
|
||||
@@ -850,9 +850,36 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// `path` checked for reading against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_read(path: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own).
|
||||
#[cfg(test)]
|
||||
static TOUCHED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
#[cfg(test)]
|
||||
static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
fn touch(path: &str) {
|
||||
if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 {
|
||||
TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` and list the paths read through this module meanwhile (by any test running then).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len();
|
||||
RECORDING.fetch_add(1, SeqCst);
|
||||
let out = f();
|
||||
RECORDING.fetch_sub(1, SeqCst);
|
||||
let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec();
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_write(path: &str) -> Result<()> {
|
||||
@@ -1010,8 +1037,7 @@ fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
|
||||
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
may_read(path).ok()?;
|
||||
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
|
||||
let m = file_metadata(path).filter(std::fs::Metadata::is_file)?;
|
||||
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
@@ -1020,11 +1046,17 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
/// (and automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
may_read(path).ok()?;
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
let t = file_metadata(path)?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// The file system's metadata of `path`, following links (when automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_metadata(path: &str) -> Option<std::fs::Metadata> {
|
||||
may_read(path).ok()?;
|
||||
std::fs::metadata(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_created(_: &str) -> Option<u64> {
|
||||
None
|
||||
@@ -1040,6 +1072,21 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an
|
||||
/// SVG image's href), or why it is not followed. `document` is the path of the document it is
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
|
||||
None
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -245,21 +245,24 @@ fn found_at(path: String) -> Option<Found> {
|
||||
Some(Found { path, size, modified, bytes: None })
|
||||
}
|
||||
|
||||
/// Where the file of `link` is: at its path, else at its relative path or by name in the
|
||||
/// document's folder `dir`.
|
||||
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
|
||||
let mut paths = vec![link.path.clone()];
|
||||
if let Some(dir) = dir {
|
||||
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
|
||||
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
|
||||
}
|
||||
paths.into_iter().find_map(found_at)
|
||||
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
|
||||
/// its relative path or by name in the document's folder. Only paths
|
||||
/// [`fileio::document_path`] follows are looked at.
|
||||
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
|
||||
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
|
||||
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
|
||||
/// only when its size or modification time changed.
|
||||
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
|
||||
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
|
||||
/// names another computer, a device or a URL.
|
||||
fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Refused> {
|
||||
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
|
||||
/// the content shown. Its bytes are read only when its size or modification time changed.
|
||||
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
|
||||
let l = &g.link;
|
||||
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
|
||||
return (Status::Ok, Some(f));
|
||||
@@ -274,11 +277,6 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
(if same { Status::Ok } else { Status::Modified }, Some(f))
|
||||
}
|
||||
|
||||
/// The folder of the document saved at `doc_path`.
|
||||
fn folder(doc_path: Option<&str>) -> Option<&Path> {
|
||||
doc_path.and_then(|p| Path::new(p).parent())
|
||||
}
|
||||
|
||||
/// Is image `key` of `d` showing its preview (its file not read)?
|
||||
fn previewing(d: &Document, key: &str) -> bool {
|
||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||
@@ -314,7 +312,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
.filter(|g| seen.insert(g.link.path.clone()))
|
||||
.map(|g| {
|
||||
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
|
||||
let found = locate(&g.link, folder(doc_path));
|
||||
let found = locate(&g.link, doc_path);
|
||||
let found_path = found.as_ref().map(|f| f.path.clone());
|
||||
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
|
||||
@@ -328,7 +326,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
|
||||
groups(&st.doc, ids)
|
||||
.into_iter()
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, folder(st.path.as_deref())).0 == Status::Missing))
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
|
||||
.map(|g| (g.link, g.ids))
|
||||
.collect()
|
||||
}
|
||||
@@ -510,9 +508,23 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
|
||||
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
|
||||
}
|
||||
|
||||
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
|
||||
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
|
||||
/// the layers), with `refused`: why the path isn't followed, when it isn't.
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty())
|
||||
.map(|g| {
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, doc_path);
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
|
||||
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
|
||||
if let Some(r) = refused(link, doc_path) {
|
||||
row["refused"] = json!(r.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
|
||||
@@ -545,9 +557,9 @@ impl Resolved {
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g));
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
match probe(&g, folder(doc_path)) {
|
||||
match probe(&g, doc_path) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
|
||||
@@ -714,10 +726,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut missing, mut modified) = (0, 0);
|
||||
let mut links = vec![];
|
||||
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
missing += usize::from(status == Status::Missing);
|
||||
modified += usize::from(status == Status::Modified);
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, st.path.as_deref());
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
}
|
||||
@@ -734,7 +747,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids");
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for by id.
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
|
||||
@@ -783,11 +796,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => groups(&st.doc, None)
|
||||
.into_iter()
|
||||
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
|
||||
.flat_map(|g| g.ids)
|
||||
.collect(),
|
||||
Some(_) if selected.is_empty() => {
|
||||
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
|
||||
}
|
||||
_ => selected,
|
||||
}
|
||||
}
|
||||
@@ -969,7 +980,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
|
||||
let mut out = BTreeMap::new();
|
||||
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
|
||||
let preview = stale(&st.doc, g);
|
||||
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
|
||||
@@ -1110,7 +1121,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
done.extend(g.ids);
|
||||
continue;
|
||||
}
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
@@ -1190,10 +1201,13 @@ fn placement_json(o: PlacementOptions) -> Value {
|
||||
|
||||
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
|
||||
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
|
||||
let stamp = file_stamp(&l.path);
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
|
||||
let doc_path = st.path.as_deref();
|
||||
let path = fileio::document_path(&l.path, doc_path).ok();
|
||||
let stamp = path.as_deref().and_then(file_stamp);
|
||||
let folder_len = l.path.len().saturating_sub(l.name().len());
|
||||
out["status"] = json!(status.as_str());
|
||||
note_refused(out, l, doc_path);
|
||||
out["path"] = json!(l.path);
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
|
||||
@@ -1201,7 +1215,7 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("created", file_created(&l.path)),
|
||||
("created", path.as_deref().and_then(file_created)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
out[k] = json!(v);
|
||||
|
||||
@@ -2102,6 +2102,8 @@ mod tests_legacy_ai;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_link_paths;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_linkfiles;
|
||||
|
||||
@@ -30,15 +30,19 @@ pub type Stamp = (u64, u128);
|
||||
/// object ids, stamp)]).
|
||||
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
|
||||
|
||||
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
|
||||
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
|
||||
/// automation may not read it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
|
||||
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
|
||||
let meta = crate::cmd::fileio::file_metadata(&path)?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
|
||||
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
|
||||
s.apply_link_stamps(uid, now, true)
|
||||
}
|
||||
|
||||
@@ -64,14 +68,15 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let stamps = scope.run(|| files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect());
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Paths stored with linked files and SVG images are followed only on this computer or on the
|
||||
//! document's own share (`fileio::document_path`): opening a document, Link Info, watching linked
|
||||
//! files, and the images an SVG file links to.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::fileio::touched;
|
||||
use crate::tests_links::{Folder, RED, place, png, session};
|
||||
|
||||
/// Paths to other computers (and, on Unix, a local file written as one: `//tmp/…`, which Linux
|
||||
/// reads as `/tmp/…`).
|
||||
fn far(dir: &Folder) -> Vec<String> {
|
||||
let mut out = vec![r"\\192.0.2.1\share\x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
|
||||
if cfg!(unix) {
|
||||
out.push(format!("/{}", dir.file("far/far.png")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Did any path read reach another computer (or the local file written as one)?
|
||||
fn far_reads(seen: &[String]) -> Vec<&String> {
|
||||
seen.iter().filter(|p| p.contains("192.0.2.1") || p.starts_with("//") || p.starts_with(r"\\")).collect()
|
||||
}
|
||||
|
||||
/// A document saved at `dir/doc.vectorcraft` with one linked image per path in `paths`, each
|
||||
/// showing `dir/pic.png` until its link is set → the document's path.
|
||||
fn save_linked(dir: &Folder, paths: &[String]) -> String {
|
||||
let pic = dir.file("pic.png");
|
||||
std::fs::write(&pic, png(8, 8, RED)).unwrap();
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = paths.iter().map(|_| place(&mut s, &pic)).collect();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
for (id, path) in ids.iter().zip(paths) {
|
||||
let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new(path.clone()));
|
||||
}
|
||||
let path = dir.file("doc.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::save(&doc, false)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(rows: &Value) -> Vec<&str> {
|
||||
rows.as_array().unwrap().iter().map(|r| r["path"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-open");
|
||||
std::fs::create_dir_all(dir.0.join("far")).unwrap();
|
||||
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
|
||||
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
|
||||
let far = far(&dir);
|
||||
let mut paths = far.clone();
|
||||
paths.push("near.png".into());
|
||||
let doc = save_linked(&dir, &paths);
|
||||
|
||||
let mut s = Session::new();
|
||||
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": doc})).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// The far paths are missing, saying why; the file beside the document is found.
|
||||
assert_eq!(names(&r["missingLinks"]), far.iter().map(String::as_str).collect::<Vec<_>>(), "{r}");
|
||||
for row in r["missingLinks"].as_array().unwrap() {
|
||||
assert_eq!(row["refused"], "the path is on another computer", "{row}");
|
||||
}
|
||||
let check = s.execute("links.check", &json!({})).unwrap();
|
||||
let near = check["links"].as_array().unwrap().iter().find(|l| l["name"] == "near.png").unwrap();
|
||||
assert_ne!(near["status"], "missing", "{check}");
|
||||
|
||||
// Link Info and the linked-file watch don't look at them either.
|
||||
let ids: Vec<u64> = r["missingLinks"].as_array().unwrap().iter().map(|row| row["ids"][0].as_u64().unwrap()).collect();
|
||||
let (_, seen) = touched(|| {
|
||||
for id in &ids {
|
||||
let info = s.execute("links.info", &json!({"id": id})).unwrap();
|
||||
assert_eq!(info["refused"], "the path is on another computer", "{info}");
|
||||
}
|
||||
s.execute("links.updateChanged", &json!({})).unwrap();
|
||||
s.start_link_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.poll_link_scan().is_none() {
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
});
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// The watch kept no stamp of them (`//tmp/…` would have one had it been looked at).
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_images_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-svg");
|
||||
std::fs::create_dir_all(dir.0.join("far")).unwrap();
|
||||
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
|
||||
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
|
||||
let mut hrefs = vec![r"\\192.0.2.1\share\x.png".to_string(), "file:////192.0.2.1/s/x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
|
||||
if cfg!(unix) {
|
||||
hrefs.push(format!("/{}", dir.file("far/far.png")));
|
||||
}
|
||||
hrefs.push("near.png".into());
|
||||
let images: String = hrefs.iter().map(|h| format!("<image width=\"8\" height=\"8\" href=\"{h}\"/>")).collect();
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">{images}</svg>");
|
||||
let path = dir.file("art.svg");
|
||||
std::fs::write(&path, &svg).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": path})).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// Every far image is a placeholder; only the one beside the SVG is read.
|
||||
let warnings = r["warnings"].as_array().unwrap();
|
||||
assert_eq!(warnings.iter().filter(|w| w.as_str().unwrap().contains("a placeholder keeps its place")).count(), hrefs.len() - 1, "{r}");
|
||||
let near = |p: &&String| p.contains("docpath-svg") && p.ends_with("near.png");
|
||||
assert!(seen.iter().any(|p| near(&p)), "{seen:?}");
|
||||
|
||||
// Pasted SVG text has no folder: only absolute local paths are read.
|
||||
let (_, seen) = touched(|| crate::cmd::fileio::import_svg(&svg, None).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
assert!(!seen.iter().any(|p| near(&p)), "{seen:?}");
|
||||
}
|
||||
@@ -9,6 +9,8 @@ description = "VectorCraft SVG import and export"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -79,33 +79,25 @@ fn percent_decoded(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Is `p` an absolute path here or on another system (`/…`, `C:/…`, `C:\…`)?
|
||||
fn is_absolute(p: &str) -> bool {
|
||||
Path::new(p).is_absolute() || p.starts_with('/') || matches!(p.as_bytes(), [d, b':', b'/' | b'\\', ..] if d.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Where href `h` points (a path, made absolute from `folder`, or a URL) and whether it is a
|
||||
/// file that can be read.
|
||||
/// Where href `h` points (a path, or a URL) and whether it is a file that may be read: a local
|
||||
/// file, or one on the network share the SVG is on. A relative path is taken from `folder`, the
|
||||
/// SVG's own; without it, only absolute paths are read (see `craft_docpath`).
|
||||
fn target(h: &str, folder: Option<&str>) -> (String, bool) {
|
||||
let h = percent_decoded(h.trim());
|
||||
let path = match h.strip_prefix("file://") {
|
||||
Some(rest) => {
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
// `file:///C:/art.png` → `C:/art.png`.
|
||||
match rest.as_bytes() {
|
||||
[b'/', d, b':', ..] if d.is_ascii_alphabetic() => rest.get(1..).unwrap_or(rest).to_string(),
|
||||
_ => rest.to_string(),
|
||||
}
|
||||
}
|
||||
None if h.contains("://") => return (h, false),
|
||||
None => h,
|
||||
};
|
||||
if is_absolute(&path) {
|
||||
return (path, true);
|
||||
let h = h.trim();
|
||||
// `craft_docpath` decodes a `file:` URL itself; any other href is decoded here, once.
|
||||
let file_url = h.get(..5).is_some_and(|s| s.eq_ignore_ascii_case("file:"));
|
||||
let stored = if file_url { h.to_string() } else { percent_decoded(h) };
|
||||
if !file_url && stored.contains("://") {
|
||||
return (stored, false);
|
||||
}
|
||||
match folder {
|
||||
Some(f) => (Path::new(f).join(&path).to_string_lossy().into_owned(), true),
|
||||
None => (path, false),
|
||||
// Resolved as a document inside `folder`: only the folder is used.
|
||||
let document = folder.map(|f| Path::new(f).join("-"));
|
||||
match craft_docpath::resolve(&stored, document.as_deref()) {
|
||||
Ok(path) => {
|
||||
let local = path.is_absolute();
|
||||
(path.to_string_lossy().into_owned(), local)
|
||||
}
|
||||
Err(_) => (stored, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +227,32 @@ mod tests {
|
||||
#[test]
|
||||
fn hrefs_resolve_against_the_folder() {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
assert_eq!(target("photo%20one.png", Some("/art")), (format!("/art{sep}photo one.png"), true));
|
||||
assert_eq!(target("photo.png", None), ("photo.png".into(), false));
|
||||
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
|
||||
assert_eq!(target("file:///C:/art/a.png", None), ("C:/art/a.png".into(), true));
|
||||
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
|
||||
assert_eq!(target("https://example.com/a.png", Some("/art")), ("https://example.com/a.png".into(), false));
|
||||
let art = std::env::temp_dir().join("art");
|
||||
let art = art.to_str().unwrap();
|
||||
assert_eq!(target("photo%20one.png", Some(art)), (format!("{art}{sep}photo one.png"), true));
|
||||
assert!(!target("photo.png", None).1);
|
||||
assert_eq!(target("file:///C:/art/a.png", None), (r"C:\art\a.png".into(), cfg!(windows)));
|
||||
assert_eq!(target("https://example.com/a.png", Some(art)), ("https://example.com/a.png".into(), false));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
|
||||
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hrefs_to_other_computers_are_not_read() {
|
||||
let art = std::env::temp_dir().join("art");
|
||||
for h in [
|
||||
r"\\192.0.2.1\share\x.png",
|
||||
"//192.0.2.1/share/x.png",
|
||||
"file:////192.0.2.1/share/x.png",
|
||||
"file://192.0.2.1/share/x.png",
|
||||
"%5C%5C192.0.2.1%5Cshare%5Cx.png",
|
||||
] {
|
||||
assert!(!target(h, art.to_str()).1, "{h}");
|
||||
assert!(!target(h, None).1, "{h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5138,6 +5138,11 @@ theme Appearance Външен вид
|
||||
Movie Clip Филмов клип
|
||||
@msg name is empty името е празно
|
||||
@msg no symbol named `{n}` няма символ с име `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one „Редактиране на оригинал“ отваря само файлове с изображения и документи; „{name}“ не е такъв файл
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” в браузъра се отварят само URL адреси с http, https и mailto, не „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} отваря друга програма: само човекът пред клавиатурата може да изпълни командата
|
||||
@msg “{name}” is not a file „{name}“ не е файл
|
||||
Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране
|
||||
Persistent Постоянна
|
||||
Library Panel Options Опции на панела с библиотека
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5156,6 +5156,11 @@ mark-style Japanese Japonské
|
||||
Movie Clip Filmový klip
|
||||
@msg name is empty název je prázdný
|
||||
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Upravit originál otevírá pouze soubory obrázků a dokumentů; „{name}“ mezi ně nepatří
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” v prohlížeči se otevírají pouze adresy URL http, https a mailto, ne „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} otevírá jinou aplikaci: spustit ho může pouze člověk u klávesnice
|
||||
@msg “{name}” is not a file „{name}“ není soubor
|
||||
Enable Guides for 9-Slice Scaling Povolit vodítka pro 9-slice škálování
|
||||
@msg a mask has more than one art block maska má více než jeden blok kresby
|
||||
@msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4731,6 +4731,11 @@ effect Regular Regelmäßig
|
||||
Movie Clip Movieclip
|
||||
@msg name is empty Name ist leer
|
||||
@msg no symbol named `{n}` kein Symbol namens `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Original bearbeiten öffnet nur Bild- und Dokumentdateien; „{name}“ gehört nicht dazu
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” nur http-, https- und mailto-URLs werden im Browser geöffnet, nicht „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} öffnet eine andere Anwendung: Nur die Person an der Tastatur kann den Befehl ausführen
|
||||
@msg “{name}” is not a file „{name}“ ist keine Datei
|
||||
Enable Guides for 9-Slice Scaling Hilfslinien für 9-Slice-Skalierung aktivieren
|
||||
@msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock
|
||||
@msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5108,6 +5108,11 @@ effect Points Σημεία
|
||||
Movie Clip Κλιπ ταινίας
|
||||
@msg name is empty το όνομα είναι κενό
|
||||
@msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one η Επεξεργασία πρωτοτύπου ανοίγει μόνο αρχεία εικόνων και εγγράφων· το «{name}» δεν είναι τέτοιο αρχείο
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” στο πρόγραμμα περιήγησης ανοίγουν μόνο URL http, https και mailto, όχι το «{url}»
|
||||
@msg {id} opens another app: only the person at the keyboard can run it η εντολή {id} ανοίγει άλλη εφαρμογή: μόνο όποιος βρίσκεται στο πληκτρολόγιο μπορεί να την εκτελέσει
|
||||
@msg “{name}” is not a file το «{name}» δεν είναι αρχείο
|
||||
Enable Guides for 9-Slice Scaling Ενεργοποίηση οδηγών για κλιμάκωση 9-slice
|
||||
@msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών
|
||||
@msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2000 and column 19.
|
@@ -5098,6 +5098,11 @@ mark-style Japanese Japonesas
|
||||
Movie Clip Clip de película
|
||||
@msg name is empty el nombre está vacío
|
||||
@msg no symbol named `{n}` no hay ningún símbolo llamado `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Editar original solo abre archivos de imagen y de documento; “{name}” no lo es
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” solo se abren en el navegador las URL http, https y mailto, no “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} abre otra aplicación: solo puede ejecutarlo la persona que está frente al teclado
|
||||
@msg “{name}” is not a file “{name}” no es un archivo
|
||||
Enable Guides for 9-Slice Scaling Activar guías para escalado de 9 sectores
|
||||
@msg a mask has more than one art block una máscara tiene más de un bloque de ilustración
|
||||
@msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5120,6 +5120,11 @@ mark-style Japanese Japanilainen
|
||||
Movie Clip Elokuvaleike
|
||||
@msg name is empty nimi on tyhjä
|
||||
@msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Muokkaa alkuperäistä avaa vain kuva- ja asiakirjatiedostoja; “{name}” ei ole sellainen
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” selaimessa avataan vain http-, https- ja mailto-URL-osoitteet, ei “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} avaa toisen sovelluksen: vain näppäimistön ääressä oleva henkilö voi suorittaa sen
|
||||
@msg “{name}” is not a file “{name}” ei ole tiedosto
|
||||
Enable Guides for 9-Slice Scaling Ota käyttöön 9-slice-skaalauksen apuviivat
|
||||
@msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko
|
||||
@msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5155,6 +5155,11 @@ theme Appearance Apparence
|
||||
Movie Clip Clip
|
||||
@msg name is empty le nom est vide
|
||||
@msg no symbol named `{n}` aucun symbole nommé `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Modifier l’original n’ouvre que les fichiers d’image et de document ; « {name} » n’en est pas un
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” seules les URL http, https et mailto s’ouvrent dans le navigateur, pas « {url} »
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} ouvre une autre application : seule la personne devant le clavier peut l’exécuter
|
||||
@msg “{name}” is not a file « {name} » n’est pas un fichier
|
||||
Enable Guides for 9-Slice Scaling Activer les repères pour la mise à l’échelle 9-slice
|
||||
@msg a mask has more than one art block un masque contient plusieurs blocs graphiques
|
||||
@msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5142,6 +5142,11 @@ imageTrace Outline Körvonal
|
||||
Movie Clip Mozgóklip
|
||||
@msg name is empty a név üres
|
||||
@msg no symbol named `{n}` nincs `{n}` nevű szimbólum
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one az Eredeti szerkesztése csak kép- és dokumentumfájlokat nyit meg; ez nem ilyen: „{name}”
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” a böngészőben csak a http-, https- és mailto-URL-ek nyílnak meg; ez nem ilyen: „{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id}: másik alkalmazást nyit meg, ezért csak a billentyűzetnél ülő felhasználó futtathatja
|
||||
@msg “{name}” is not a file nem fájl: „{name}”
|
||||
Enable Guides for 9-Slice Scaling Segédvonalak engedélyezése a 9-slice méretezéshez
|
||||
@msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz
|
||||
@msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4769,6 +4769,11 @@ lighting Light Luce
|
||||
Movie Clip Clip filmato
|
||||
@msg name is empty il nome è vuoto
|
||||
@msg no symbol named `{n}` nessun simbolo denominato `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Modifica originale apre solo file di immagini e documenti; “{name}” non lo è
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” nel browser si aprono solo URL http, https e mailto, non “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} apre un'altra applicazione: solo chi è alla tastiera può eseguirlo
|
||||
@msg “{name}” is not a file “{name}” non è un file
|
||||
Enable Guides for 9-Slice Scaling Abilita guide per il ridimensionamento 9-slice
|
||||
@msg a mask has more than one art block una maschera contiene più di un blocco grafico
|
||||
@msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4810,6 +4810,11 @@ lighting Light ライト
|
||||
Movie Clip ムービークリップ
|
||||
@msg name is empty 名前が空です
|
||||
@msg no symbol named `{n}` `{n}` という名前のシンボルはありません
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 「オリジナルを編集」で開けるのは画像ファイルとドキュメントファイルだけです: 「{name}」はどちらでもありません
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” ブラウザーで開けるのは http、https、mailto の URL だけです: 「{url}」は開けません
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} は別のアプリを開きます: 実行できるのはキーボードの前にいるユーザーだけです
|
||||
@msg “{name}” is not a file 「{name}」はファイルではありません
|
||||
Enable Guides for 9-Slice Scaling 9スライス拡大・縮小用ガイドを有効にする
|
||||
@msg a mask has more than one art block マスクに複数のアートブロックがあります
|
||||
@msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4733,6 +4733,11 @@ lighting Light 광원
|
||||
Movie Clip 무비 클립
|
||||
@msg name is empty 이름이 비어 있습니다
|
||||
@msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 원본 편집은 이미지 및 문서 파일만 엽니다. “{name}”은(는) 해당하지 않습니다
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” 브라우저에서는 http, https, mailto URL만 열립니다. “{url}”은(는) 열 수 없습니다
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id}은(는) 다른 앱을 엽니다. 키보드 앞에 있는 사용자만 실행할 수 있습니다
|
||||
@msg “{name}” is not a file “{name}”은(는) 파일이 아닙니다
|
||||
Enable Guides for 9-Slice Scaling 9-슬라이스 크기 조절용 안내선 사용
|
||||
@msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다
|
||||
@msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5008,6 +5008,11 @@ mark-style Japanese Japońskie
|
||||
Movie Clip Klip filmowy
|
||||
@msg name is empty nazwa jest pusta
|
||||
@msg no symbol named `{n}` brak symbolu o nazwie `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Edytuj oryginał otwiera tylko pliki obrazów i dokumentów; „{name}” do nich nie należy
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” w przeglądarce otwierają się tylko adresy URL http, https i mailto, a nie „{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} otwiera inną aplikację: uruchomić to polecenie może tylko osoba przy klawiaturze
|
||||
@msg “{name}” is not a file „{name}” nie jest plikiem
|
||||
Enable Guides for 9-Slice Scaling Włącz prowadnice skalowania 9-slice
|
||||
@msg a mask has more than one art block maska ma więcej niż jeden blok grafiki
|
||||
@msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5073,6 +5073,11 @@ mark-style Japanese Japonesas
|
||||
# Symbol Options › Export Type
|
||||
@msg name is empty o nome está vazio
|
||||
@msg no symbol named `{n}` nenhum símbolo chamado `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Editar Original abre apenas arquivos de imagem e de documento; “{name}” não é um deles
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” apenas URLs http, https e mailto abrem no navegador, não “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} abre outro aplicativo: apenas a pessoa ao teclado pode executá-lo
|
||||
@msg “{name}” is not a file “{name}” não é um arquivo
|
||||
Enable Guides for 9-Slice Scaling Ativar guias para dimensionamento 9-slice
|
||||
@msg a mask has more than one art block uma máscara contém mais de um bloco de arte
|
||||
@msg mask art ends outside a mask dictionary a arte da máscara termina fora de um dicionário de máscara
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4765,6 +4765,11 @@ lighting Light Свет
|
||||
Movie Clip Видеоклип
|
||||
@msg name is empty имя пустое
|
||||
@msg no symbol named `{n}` нет символа с именем `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one «Редактировать оригинал» открывает только файлы изображений и документов; «{name}» к ним не относится
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” в браузере открываются только URL с http, https и mailto, а не «{url}»
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} открывает другое приложение: выполнить эту команду может только человек за клавиатурой
|
||||
@msg “{name}” is not a file «{name}» не является файлом
|
||||
Enable Guides for 9-Slice Scaling Включить направляющие для 9-slice масштабирования
|
||||
@msg a mask has more than one art block маска содержит более одного блока графики
|
||||
@msg mask art ends outside a mask dictionary графика маски заканчивается за пределами словаря маски
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5112,6 +5112,11 @@ theme Appearance Utseende
|
||||
Movie Clip Filmklipp
|
||||
@msg name is empty namnet är tomt
|
||||
@msg no symbol named `{n}` ingen symbol med namnet `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Redigera original öppnar bara bild- och dokumentfiler; ”{name}” är ingen sådan
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” bara http-, https- och mailto-URL:er öppnas i webbläsaren, inte ”{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} öppnar en annan app: bara personen vid tangentbordet kan köra kommandot
|
||||
@msg “{name}” is not a file ”{name}” är inte en fil
|
||||
Enable Guides for 9-Slice Scaling Aktivera hjälplinjer för 9-slice-skalning
|
||||
@msg a mask has more than one art block en mask innehåller mer än ett grafikblock
|
||||
@msg mask art ends outside a mask dictionary maskens grafik slutar utanför en maskordlista
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4749,6 +4749,11 @@ lighting Light Світло
|
||||
Movie Clip Кліп
|
||||
@msg name is empty ім’я порожнє
|
||||
@msg no symbol named `{n}` немає символу з назвою `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one «Редагувати оригінал» відкриває лише файли зображень і документів; «{name}» до них не належить
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” у браузері відкриваються лише URL з http, https і mailto, а не «{url}»
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} відкриває іншу програму: виконати цю команду може лише людина за клавіатурою
|
||||
@msg “{name}” is not a file «{name}» не є файлом
|
||||
Enable Guides for 9-Slice Scaling Увімкнути напрямні для 9-slice масштабування
|
||||
@msg a mask has more than one art block маска містить більше одного блоку графіки
|
||||
@msg mask art ends outside a mask dictionary графіка маски закінчується за межами словника маски
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 725 and column 40.
|
@@ -4749,6 +4749,11 @@ theme Appearance Giao diện
|
||||
Movie Clip Clip phim
|
||||
@msg name is empty tên trống
|
||||
@msg no symbol named `{n}` không có biểu tượng nào tên `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Chỉnh sửa bản gốc chỉ mở tệp hình ảnh và tài liệu; “{name}” không thuộc loại đó
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” chỉ URL http, https và mailto mới được mở trong trình duyệt, không mở “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} mở một ứng dụng khác: chỉ người đang ở bàn phím mới chạy được lệnh này
|
||||
@msg “{name}” is not a file “{name}” không phải là tệp
|
||||
Enable Guides for 9-Slice Scaling Bật đường dẫn cho co giãn 9-slice
|
||||
@msg a mask has more than one art block mặt nạ có nhiều hơn một khối đồ họa
|
||||
@msg mask art ends outside a mask dictionary đồ họa mặt nạ kết thúc bên ngoài từ điển mặt nạ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3945 and column 10.
|
@@ -5007,3 +5007,8 @@ lighting Light 光源
|
||||
@msg too many library panels were opened in this session 本次会话中打开的库面板过多
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} 会打开其他应用程序:只有使用键盘的人才能运行它
|
||||
@msg “{name}” is not a file “{name}” 不是文件
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -203,6 +203,18 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
/// Opens a URL in the system browser.
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
|
||||
|
||||
/// Whether a URL from a document may open in the browser: an `http:`, `https:` or `mailto:` URL
|
||||
/// without control characters. The system hands other schemes (`file:`, application schemes) to
|
||||
/// other programs.
|
||||
pub(crate) fn browsable_url(url: &str) -> Result<(), String> {
|
||||
let scheme = url.split_once(':').map_or("", |(s, _)| s);
|
||||
if ["http", "https", "mailto"].iter().any(|k| scheme.eq_ignore_ascii_case(k)) && !url.chars().any(char::is_control) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("only http, https and mailto URLs open in the browser, not “{url}”"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a file in the system file manager, or opens it in its app.
|
||||
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
|
||||
@@ -795,6 +807,24 @@ impl VectorcraftApp {
|
||||
self.ui.status = format!("Opened {url}");
|
||||
}
|
||||
|
||||
/// Open `url`, which came from a document (an object's URL attribute), in the browser:
|
||||
/// [`browsable_url`]s only.
|
||||
pub(crate) fn open_document_url(&mut self, url: &str) -> Result<(), String> {
|
||||
browsable_url(url)?;
|
||||
self.open_url(url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse UI command `id`, which hands something to another app, when automation sent it (a
|
||||
/// control or MCP request, or a frame of synthetic input): the automation roots don't confine
|
||||
/// what that app does.
|
||||
pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> {
|
||||
if self.ui.answering_control || self.synthetic_frame {
|
||||
return Err(format!("{id} opens another app: only the person at the keyboard can run it"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a Help link command and open the URL it returns.
|
||||
pub fn open_link(&mut self, id: &str) {
|
||||
if let Ok(v) = self.run(id, serde_json::json!({}))
|
||||
|
||||
@@ -634,7 +634,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"attributes.openUrl",
|
||||
"Browser",
|
||||
"",
|
||||
"{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}",
|
||||
"{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button; http, https and mailto URLs only; not over the control channel or MCP) → {url}",
|
||||
),
|
||||
(
|
||||
"ui.javascriptFilesDialog",
|
||||
@@ -751,7 +751,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"links.editOriginal",
|
||||
"Edit Original",
|
||||
"",
|
||||
"{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update",
|
||||
"{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads, and not over the control channel or MCP); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update",
|
||||
),
|
||||
(
|
||||
"links.reveal",
|
||||
|
||||
@@ -161,11 +161,12 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
/// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser.
|
||||
pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
app.refuse_automation("attributes.openUrl")?;
|
||||
let url = match p.get("url").and_then(Value::as_str) {
|
||||
Some(u) => u.to_string(),
|
||||
None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?,
|
||||
};
|
||||
app.open_url(&url);
|
||||
app.open_document_url(&url)?;
|
||||
Ok(json!({ "url": url }))
|
||||
}
|
||||
|
||||
@@ -223,4 +224,37 @@ mod tests {
|
||||
assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}");
|
||||
assert!(open_url(&mut app, &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Only web and mail URLs reach the browser.
|
||||
#[test]
|
||||
fn the_browser_opens_web_and_mail_urls_only() {
|
||||
for ok in ["https://example.com/a", "HTTP://example.com", "mailto:[email protected]"] {
|
||||
assert!(crate::browsable_url(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for refused in [
|
||||
"file:///tmp/a.html",
|
||||
"javascript:void(0)",
|
||||
"ms-settings:",
|
||||
"smb://host/share",
|
||||
"x-app://run",
|
||||
"example.com",
|
||||
" https://example.com",
|
||||
"https://a\nb",
|
||||
"",
|
||||
] {
|
||||
assert!(crate::browsable_url(refused).is_err(), "{refused}");
|
||||
}
|
||||
let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
|
||||
let o = opened.clone();
|
||||
let services = crate::Services { open_url: Some(Box::new(move |u: &str| o.borrow_mut().push(u.to_string()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
run(&mut app, "attributes.set", json!({"url": "file:///tmp/run.desktop"}));
|
||||
assert!(open_url(&mut app, &json!({})).is_err());
|
||||
assert!(open_url(&mut app, &json!({"url": "vscode://file/x"})).is_err());
|
||||
assert!(opened.borrow().is_empty());
|
||||
open_url(&mut app, &json!({"url": "https://example.com/b"})).unwrap();
|
||||
assert_eq!(*opened.borrow(), ["https://example.com/b"]);
|
||||
}
|
||||
}
|
||||
@@ -207,10 +207,35 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re
|
||||
crate::dialogs::settle(app);
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?;
|
||||
if reveal {
|
||||
crate::io::reveal_path(app, &path)?;
|
||||
} else {
|
||||
app.refuse_automation("links.editOriginal")?;
|
||||
openable_original(&path)?;
|
||||
crate::io::open_in_app(app, &path)?;
|
||||
}
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Whether Edit Original may hand `path` to the system's default app: a regular file (after
|
||||
/// following links) whose name, and the name it links to, have the extension of a format
|
||||
/// VectorCraft reads. Anything else (a folder or app bundle, a script, a shortcut) is refused.
|
||||
pub(crate) fn openable_original(path: &str) -> Result<(), String> {
|
||||
let readable = |p: &std::path::Path| {
|
||||
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
!ext.is_empty() && vectorcraft_engine::cmd::fileio::FORMATS.iter().any(|f| f.read && f.extensions.contains(&ext.as_str()))
|
||||
};
|
||||
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
|
||||
let real = std::fs::canonicalize(path).map_err(|e| format!("can't open “{name}”: {e}"))?;
|
||||
if !real.is_file() {
|
||||
return Err(format!("“{name}” is not a file"));
|
||||
}
|
||||
if !readable(std::path::Path::new(path)) || !readable(&real) {
|
||||
return Err(format!("Edit Original opens image and document files only; “{name}” is not one"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A status badge: missing (red, ?), modified (amber, !), embedded (the image icon).
|
||||
fn badge(ui: &Ui, r: egui::Rect, status: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -168,6 +168,42 @@ fn edit_original_and_show_in_folder_hand_the_file_to_the_system() {
|
||||
assert!(app.run("links.editOriginal", json!({"id": linked})).is_err());
|
||||
}
|
||||
|
||||
/// Edit Original hands only regular image and document files to the system's default app.
|
||||
#[test]
|
||||
fn edit_original_opens_image_and_document_files_only() {
|
||||
let dir = Folder::new("kinds");
|
||||
for f in ["photo.png", "Art.SVG", "run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop"] {
|
||||
dir.file(f, b"");
|
||||
}
|
||||
for f in ["Tool.app", "folder.png"] {
|
||||
std::fs::create_dir_all(dir.0.join(f)).unwrap();
|
||||
}
|
||||
let ok = |f: &str| super::links::openable_original(&dir.0.join(f).to_string_lossy()).is_ok();
|
||||
assert!(ok("photo.png") && ok("Art.SVG"));
|
||||
for f in ["run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop", "Tool.app", "folder.png", "missing.png", ""] {
|
||||
assert!(!ok(f), "{f}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(dir.0.join("app.desktop"), dir.0.join("link.png")).unwrap();
|
||||
assert!(!ok("link.png"), "an image name linking to another kind of file");
|
||||
// The command refuses a linked image that became such a link, without opening anything.
|
||||
let (mut app, linked, _) = app(&dir);
|
||||
let opened: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let o = opened.clone();
|
||||
app.services.open_file = Some(Box::new(move |p: &str| {
|
||||
o.borrow_mut().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
let png = dir.0.join("linked.png");
|
||||
std::fs::remove_file(&png).unwrap();
|
||||
std::os::unix::fs::symlink(dir.0.join("app.desktop"), &png).unwrap();
|
||||
let e = app.run("links.editOriginal", json!({"id": linked})).unwrap_err();
|
||||
assert!(e.contains("image and document files only"), "{e}");
|
||||
assert!(opened.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_options_dialog_sets_the_options() {
|
||||
let dir = Folder::new("dialog");
|
||||
|
||||
@@ -118,3 +118,39 @@ fn frames_with_injected_input_are_confined() {
|
||||
assert!(!app.synthetic_frame, "the frames after it are the person's");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a
|
||||
/// control request (or MCP, which comes through it) can't run them, roots or not.
|
||||
#[test]
|
||||
fn automation_cannot_hand_files_or_urls_to_other_apps() {
|
||||
let base = std::env::temp_dir().join(format!("vc-ui-handoff-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let png = base.join("linked.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])).save(&png).unwrap();
|
||||
let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
|
||||
let (u, f) = (opened.clone(), opened.clone());
|
||||
let services = Services {
|
||||
open_url: Some(Box::new(move |p: &str| u.borrow_mut().push(p.to_string()))),
|
||||
open_file: Some(Box::new(move |p: &str| {
|
||||
f.borrow_mut().push(p.to_string());
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let image = app.run("file.place", json!({"path": s(png.clone())})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
app.run("attributes.set", json!({"url": "https://example.com/a"})).unwrap();
|
||||
for (command, params) in [("attributes.openUrl", json!({})), ("links.editOriginal", json!({"id": image}))] {
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": command, "params": params}));
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("only the person at the keyboard")), "{command}: {r}");
|
||||
}
|
||||
assert!(opened.borrow().is_empty());
|
||||
// The person at the keyboard can.
|
||||
app.run("attributes.openUrl", json!({})).unwrap();
|
||||
app.run("links.editOriginal", json!({"id": image})).unwrap();
|
||||
assert_eq!(opened.borrow().len(), 2);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -410,6 +410,8 @@ one undo step.
|
||||
Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`,
|
||||
`path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command
|
||||
`attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`.
|
||||
It opens `http:`, `https:` and `mailto:` URLs only, and answers a control request with an error: it hands the URL to
|
||||
another app.
|
||||
|
||||
SVG Interactivity panel: `window.panel {panel: "svgInteractivity"}` shows it; its controls run
|
||||
`svgInteractivity.setEvent`, `svgInteractivity.removeEvent` and `svgInteractivity.clearEvents`, and its Link JavaScript
|
||||
@@ -610,7 +612,8 @@ Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?
|
||||
`fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as
|
||||
one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's
|
||||
`links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to
|
||||
the system and return `{path}`.
|
||||
the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open`
|
||||
reads, and answers a control request with an error unless the file is a placed document (which opens in a tab).
|
||||
|
||||
Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved
|
||||
gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder),
|
||||
|
||||
+9
-5
@@ -499,7 +499,8 @@ symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual s
|
||||
colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other
|
||||
filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect,
|
||||
and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `<image>` files (relative links are
|
||||
found in the SVG's folder) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that
|
||||
found in the SVG's folder; only files on this computer or the SVG's network share are read, and pasted SVG reads only
|
||||
absolute paths) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that
|
||||
can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`.
|
||||
|
||||
Native files: `run_command document.save {compress: true}` writes a gzip-compressed `.vectorcraft` (the
|
||||
@@ -2643,9 +2644,11 @@ A placed image linked to its file (`file.place {path}`, Link on) records the fil
|
||||
modification time and hash, and keeps a low-resolution preview (at most 256 px a side): a `.vectorcraft` save writes
|
||||
the preview instead of the pixels, plus the path relative to the saved file. `document.open` reads
|
||||
the linked files again, looking for each at its path, then at its relative path and by name in the document's folder
|
||||
(so a folder moved with its links still opens): the result lists `missingLinks` (their images show the preview),
|
||||
`modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are
|
||||
in `updatedLinks`), each as `{name, path, ids}`. The desktop app also checks the active document's links when its window
|
||||
(so a folder moved with its links still opens). Only files on this computer, or on the network share the document is
|
||||
on, are looked at: a path to another computer, a device or a URL counts as missing. The result lists `missingLinks`
|
||||
(their images show the preview), `modifiedLinks` (left as they were; read again only with the preference
|
||||
`updateLinks: "automatically"`, then they are in `updatedLinks`), each as `{name, path, ids}`, with `refused` (why the
|
||||
path isn't followed) when it isn't. The desktop app also checks the active document's links when its window
|
||||
comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`,
|
||||
`missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at
|
||||
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a
|
||||
@@ -2837,7 +2840,8 @@ file is missing stays linked: relink it first); `links.unembed {id, path}` write
|
||||
`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight,
|
||||
clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old
|
||||
bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and
|
||||
`links.reveal` open the linked file in its app or show it in its folder (desktop).
|
||||
`links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window,
|
||||
except for a placed document, which opens in a tab.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}}
|
||||
|
||||
@@ -97,6 +97,21 @@ notarize() {
|
||||
fi
|
||||
}
|
||||
|
||||
# `stapler validate` looks the ticket up in Apple's CloudKit, and a dropped connection there
|
||||
# (NSURLErrorDomain -1005, "Error 68") fails it although the staple worked. Retry with a pause; a
|
||||
# missing or bad ticket still fails every try.
|
||||
validate_stapled() {
|
||||
local try
|
||||
for try in 1 2 3 4; do
|
||||
xcrun stapler validate "$1" && return 0
|
||||
[ "$try" = 4 ] && break
|
||||
echo "stapler validate failed (try $try of 4); retrying in $((try * 20)) s" >&2
|
||||
sleep $((try * 20))
|
||||
done
|
||||
echo "error: stapler validate $(basename "$1") failed 4 times" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- VectorCraft.app ----------------------------------------------------------------------------
|
||||
echo "==> assembling $APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
@@ -121,7 +136,7 @@ if [ "$NOTARIZE" = 1 ]; then
|
||||
ditto -c -k --keepParent "$APP" "$WORK/VectorCraft-notarize.zip"
|
||||
notarize "$WORK/VectorCraft-notarize.zip"
|
||||
xcrun stapler staple "$APP"
|
||||
xcrun stapler validate "$APP"
|
||||
validate_stapled "$APP"
|
||||
spctl --assess --type execute -vvv "$APP"
|
||||
fi
|
||||
|
||||
@@ -171,7 +186,7 @@ hdiutil_retry detach detach "$MOUNT" >/dev/null
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
notarize "$DMG"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
validate_stapled "$DMG"
|
||||
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user