mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
216 lines
9.5 KiB
Rust
216 lines
9.5 KiB
Rust
//! File → Package…: where the package folder goes, its name, and what it collects (the linked
|
||
//! files, in a folder of their own or not, relinked or not; the fonts; the report). A document
|
||
//! never saved is saved first (Save As); unsaved changes are saved before packaging. The web
|
||
//! downloads the package as a zip.
|
||
//!
|
||
//! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`,
|
||
//! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the
|
||
//! package folder (`file.showPackage`). On the desktop with Background Export on, the package is
|
||
//! collected and written on the background thread ([`crate::background`]) after the dialog closes.
|
||
|
||
use std::cell::Cell;
|
||
use std::rc::Rc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_engine::EngineError;
|
||
|
||
use super::confirm::ask;
|
||
use super::{DialogSpec, form};
|
||
use crate::background::{self, Writer};
|
||
use crate::state::Dialog;
|
||
use crate::theme::Tokens;
|
||
use crate::{VectorcraftApp, io, widgets};
|
||
|
||
pub(super) const KIND: &str = "package";
|
||
const LABEL: f32 = 90.0;
|
||
/// The options: (field, label).
|
||
const OPTIONS: [(&str, &str); 5] = [
|
||
("copyLinks", "Copy Links"),
|
||
("linksFolder", "Collect Links in a Separate Folder"),
|
||
("relink", "Relink Linked Files to the Document"),
|
||
("copyFonts", "Copy Fonts Used in the Document"),
|
||
("report", "Create Report"),
|
||
];
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||
heading: |_| tl!("Package").into(),
|
||
body,
|
||
confirm,
|
||
ok: Some("Package"),
|
||
min_width: 440.0,
|
||
max_width: Some(460.0),
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// Is there no file system to write a folder to (the web downloads a zip)?
|
||
fn downloads(app: &VectorcraftApp) -> bool {
|
||
app.services.download.is_some()
|
||
}
|
||
|
||
/// Open the dialog for the active document; one never saved is offered Save As first.
|
||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let Some(path) = st.path.clone() else {
|
||
ask(
|
||
app,
|
||
tl!("Save the document first?"),
|
||
tl!("Package collects a saved document with its linked files and fonts."),
|
||
"file.saveAs",
|
||
json!({}),
|
||
);
|
||
return Ok(Value::Null);
|
||
};
|
||
let p = std::path::Path::new(&path);
|
||
let stem = p.file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
|
||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||
let mut fields = json!({ "folder": folder, "name": format!("{stem} Folder") });
|
||
for (k, _) in OPTIONS {
|
||
fields[k] = json!(true);
|
||
}
|
||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
if !downloads(app) {
|
||
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
|
||
form::text(ui, d, "folder", 230.0);
|
||
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
|
||
crate::picks::folder_field(app, d, "folder");
|
||
}
|
||
});
|
||
ui.add_space(4.0);
|
||
}
|
||
widgets::label_row(ui, tl!("Folder Name:"), LABEL, |ui| {
|
||
form::text(ui, d, "name", 230.0);
|
||
});
|
||
ui.add_space(10.0);
|
||
widgets::subheader(ui, tl!("Options"));
|
||
for (k, label) in OPTIONS {
|
||
// The link options apply only when the links are copied.
|
||
let enabled = !matches!(k, "linksFolder" | "relink") || d.bool("copyLinks");
|
||
let on = d.bool(k);
|
||
if widgets::check(ui, label, on, enabled) {
|
||
d.fields.insert(k.into(), json!(!on));
|
||
}
|
||
}
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new(tl!("Fonts whose licence doesn't allow embedding are not copied.")).size(11.5).color(t.text_dim));
|
||
false
|
||
}
|
||
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let mut params = Value::Object(d.fields.clone());
|
||
let web = downloads(app);
|
||
if web {
|
||
params["folder"] = Value::Null;
|
||
} else if d.str("folder").trim().is_empty() {
|
||
return Err("choose a location for the package".into());
|
||
}
|
||
// The package holds the document as saved.
|
||
if app.session.active().is_some_and(|st| st.is_dirty()) {
|
||
let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||
// The save asks first (it would replace a file that loses what opening it left out).
|
||
if r.get("pending").is_some() {
|
||
return Ok(r);
|
||
}
|
||
}
|
||
// Background Export (Preferences › File Handling): the linked files and fonts are collected
|
||
// and the package is written from the document as it is now, while editing goes on. The jobs
|
||
// started before it (a save started above, an earlier package to the same folder) are written
|
||
// first: the worker runs its jobs in order, and a package made at once waits for them.
|
||
let enabled = app.session.prefs.background_export;
|
||
if !enabled {
|
||
background::wait_all(app);
|
||
}
|
||
let package = app.session.prepare_package(¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?;
|
||
let label = format!("Packaging {}", d.str("name"));
|
||
let later = Rc::new(Cell::new(false));
|
||
let in_background = later.clone();
|
||
let work = move |write: Writer| {
|
||
// A writer error that already names the file (an automation root's refusal) is kept as it is.
|
||
let mut write =
|
||
|path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(if e.contains(path) { e } else { format!("{path}: {e}") }));
|
||
package.write(&mut write).map_err(|e| e.to_string())
|
||
};
|
||
let r = background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get()))?;
|
||
if r["background"] == true {
|
||
later.set(true);
|
||
app.ui.dialog = None;
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Report the package `r` (`file.package`'s reply): the status bar, the web's download, and the
|
||
/// offer to show the package folder. `later`: it was written in the background, after the dialog
|
||
/// closed, and a dialog open now is another one, which stays (the status bar names the folder).
|
||
fn packaged(app: &mut VectorcraftApp, r: Result<Value, String>, web: bool, later: bool) -> Result<Value, String> {
|
||
let r = r.inspect_err(|e| app.ui.status = e.clone())?;
|
||
if !later {
|
||
app.ui.dialog = None;
|
||
}
|
||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||
let missing = r["missingLinks"].as_array().map_or(0, Vec::len);
|
||
// The fonts left out: their license doesn't allow it, or they aren't on this computer.
|
||
let fonts = r["skippedFonts"].as_array().into_iter().flatten().filter_map(|f| f["font"].as_str()).collect::<Vec<_>>().join(", ");
|
||
let shown_note = package_status_note(missing, &fonts);
|
||
let n_s = n.to_string();
|
||
if web {
|
||
let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default());
|
||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?;
|
||
io::write_named(app, None, name, &bytes)?;
|
||
let status = if shown_note.is_empty() {
|
||
crate::i18n::fmt(tl!("Packaged {n} file(s)"), &[("n", &n_s)])
|
||
} else {
|
||
crate::i18n::fmt(tl!("Packaged {n} file(s) {note}"), &[("n", &n_s), ("note", &shown_note)])
|
||
};
|
||
app.status(status);
|
||
return Ok(r);
|
||
}
|
||
let folder = r["folder"].as_str().unwrap_or_default().to_string();
|
||
let status = if shown_note.is_empty() {
|
||
crate::i18n::fmt(tl!("Packaged {n} file(s) in {folder}"), &[("n", &n_s), ("folder", &folder)])
|
||
} else {
|
||
crate::i18n::fmt(tl!("Packaged {n} file(s) in {folder} {note}"), &[("n", &n_s), ("folder", &folder), ("note", &shown_note)])
|
||
};
|
||
app.status(status);
|
||
let detail = if shown_note.is_empty() {
|
||
crate::i18n::fmt(tl!("{count} file(s) in {folder}. Show the package folder?"), &[("count", &n_s), ("folder", &folder)])
|
||
} else {
|
||
crate::i18n::fmt(
|
||
tl!("{count} file(s) in {folder} {note}. Show the package folder?"),
|
||
&[("count", &n_s), ("folder", &folder), ("note", &shown_note)],
|
||
)
|
||
};
|
||
if app.ui.dialog.is_none() {
|
||
ask(app, tl!("Package created"), &detail, "file.showPackage", json!({ "folder": folder }));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Parenthetical notes after the folder path in a package status (`(2 linked file(s) not found)` …),
|
||
/// joined with spaces and without a leading space (#3494).
|
||
fn package_status_note(missing: usize, fonts: &str) -> String {
|
||
let mut parts = Vec::new();
|
||
if missing > 0 {
|
||
parts.push(format!("({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())])));
|
||
}
|
||
if !fonts.is_empty() {
|
||
parts.push(format!("({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", fonts)])));
|
||
}
|
||
parts.join(" ")
|
||
}
|
||
|
||
/// `file.showPackage {folder}`: show a package folder in the file manager.
|
||
pub fn show_package(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||
app.refuse_automation("file.showPackage")?;
|
||
let folder = p.get("folder").and_then(Value::as_str).ok_or("missing `folder`")?.to_string();
|
||
// A package is a folder: an executable, shortcut or app bundle would run, not be shown.
|
||
if !std::path::Path::new(&folder).is_dir() || folder.to_ascii_lowercase().ends_with(".app") {
|
||
return Err(format!("{folder} is not a package folder"));
|
||
}
|
||
io::open_in_app(app, &folder)?;
|
||
Ok(json!({ "folder": folder }))
|
||
}
|