mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Save for Web: each browser preview is written to its own folder
Preview in Browser wrote every page to one folder, VectorCraft Save for Web in the system's temporary folder. A second preview of a document with the same title, from the same app or from another one running, overwrote the first preview's page, and any image file of the same name (PNG-8 and PNG-24 both write .png), before the browser read them. The same overwrite made dialogs::tests_save_for_web::preview_in_browser_opens_the_page fail now and then, when another Save for Web test in the same binary, or a test run in another checkout on the machine, previewed Untitled-1 at the same time. Each preview now goes to a new folder inside that one, named after the process id and a count of the process's previews. A name that an earlier process with the same id left there is skipped for the next count. The page keeps its name and its images folder. A preview first removes the entries of VectorCraft Save for Web that were last changed an hour ago or earlier, which also clears the files the single folder held before. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -173,7 +173,7 @@ fn has_warnings(r: &Value) -> bool {
|
||||
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the
|
||||
/// default browser (desktop). When the export has warnings, the status bar shows the first in
|
||||
/// place of "Opened …".
|
||||
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
@@ -196,12 +196,39 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
|
||||
Ok(json!({ "path": path, "warnings": r["warnings"] }))
|
||||
}
|
||||
|
||||
/// The folder browser previews go to.
|
||||
/// The age at which a later browser preview removes an earlier preview's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PREVIEW_KEEP: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
/// The folder a browser preview goes to: a new one for each preview, in the temporary folder's
|
||||
/// `VectorCraft Save for Web`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn temp_folder() -> Result<String, String> {
|
||||
let dir = std::env::temp_dir().join("VectorCraft Save for Web");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
Ok(dir.to_string_lossy().replace('\\', "/"))
|
||||
preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP)
|
||||
}
|
||||
|
||||
/// A new folder in `root`, named after the process id and a count of the process's previews. A
|
||||
/// name that is already taken, by an earlier process with the same id, is skipped for the next
|
||||
/// count. Entries of `root` last changed `keep` or longer ago are removed first.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result<String, String> {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
for e in std::fs::read_dir(root).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep);
|
||||
if old {
|
||||
// Best effort: one that can't be removed now is tried again by the next preview.
|
||||
let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) };
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||||
loop {
|
||||
let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -141,6 +141,68 @@ fn preview_in_browser_opens_the_page() {
|
||||
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
|
||||
}
|
||||
|
||||
/// Each preview has its own folder: a second one, from another app or the same one, leaves the
|
||||
/// first page and its image as they were.
|
||||
#[test]
|
||||
fn a_later_browser_preview_leaves_an_earlier_one_alone() {
|
||||
let dir = vectorcraft_testkit::temp_dir("sfw-browser-two");
|
||||
let (mut first, _) = app(&dir, false);
|
||||
let (mut second, _) = app(&dir, false);
|
||||
let page = |r: serde_json::Value| r["path"].as_str().unwrap().to_string();
|
||||
let jpg = page(first.run("file.saveForWeb.browser", json!({"format": "jpg"})).unwrap());
|
||||
let gif = page(second.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap());
|
||||
let png = page(first.run("file.saveForWeb.browser", json!({"format": "png24"})).unwrap());
|
||||
assert!(jpg != gif && jpg != png && gif != png, "{jpg} {gif} {png}");
|
||||
let root = std::env::temp_dir().join("VectorCraft Save for Web").to_string_lossy().replace('\\', "/");
|
||||
for (page, image) in [(&jpg, "images/Untitled-1.jpg"), (&gif, "images/Untitled-1.gif"), (&png, "images/Untitled-1.png")] {
|
||||
assert!(page.starts_with(&root) && page.ends_with("/Untitled-1.html"), "{page}");
|
||||
assert!(std::fs::read_to_string(page).unwrap().contains(image), "{page}");
|
||||
let folder = std::path::Path::new(page).parent().unwrap();
|
||||
assert!(folder.join(image).exists(), "{page}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Making a preview's folder removes the folders and files in its root that are `keep` old or
|
||||
/// older, and keeps younger ones.
|
||||
#[test]
|
||||
fn a_browser_preview_removes_old_previews() {
|
||||
let root = vectorcraft_testkit::temp_dir("sfw-browser-old").join("previews");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
|
||||
std::fs::create_dir_all(format!("{earlier}/images")).unwrap();
|
||||
std::fs::write(format!("{earlier}/images/Untitled-1.gif"), b"GIF89a").unwrap();
|
||||
std::fs::write(root.join("Untitled-1.html"), b"<html>").unwrap();
|
||||
// The folder and the page are younger than an hour, so both stay.
|
||||
let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
|
||||
assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists());
|
||||
// With a `keep` of zero every entry is old enough: all three go, and only the new folder is left.
|
||||
let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap();
|
||||
assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists());
|
||||
assert!(std::path::Path::new(&next).is_dir());
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
/// A folder that an earlier process with the same id left, younger than `keep`, is not reused:
|
||||
/// the preview makes a new, empty one.
|
||||
#[test]
|
||||
fn a_browser_preview_skips_a_folder_an_earlier_process_left() {
|
||||
let root = vectorcraft_testkit::temp_dir("sfw-browser-reused").join("previews");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let keep = std::time::Duration::from_secs(3600);
|
||||
let earlier = save_for_web::preview_folder(&root, keep).unwrap();
|
||||
let count: u64 = earlier.rsplit('-').next().unwrap().parse().unwrap();
|
||||
// The next 200 names, as an earlier process with the same id would have left them.
|
||||
for n in count + 1..=count + 200 {
|
||||
let left = root.join(format!("{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(left.join("images")).unwrap();
|
||||
std::fs::write(left.join("Untitled-1.html"), b"<html>").unwrap();
|
||||
}
|
||||
let next = save_for_web::preview_folder(&root, keep).unwrap();
|
||||
assert_ne!(next, earlier);
|
||||
assert_eq!(std::fs::read_dir(&next).unwrap().count(), 0, "{next}");
|
||||
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 202);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
@@ -435,6 +435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.
|
||||
- Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`).
|
||||
- Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the files and folders there that are an hour old or older (`file.saveForWeb.browser`).
|
||||
- Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`).
|
||||
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.
|
||||
|
||||
Reference in New Issue
Block a user