Files
figcraft/crates/engine/src/cmd/file/open.rs
T
BFlat 8ee8ba6ce7 M6: FigJam .jam files open, as boards (#1342) (#1586)
* M6: FigJam .jam files open like .fig files (#1342)

A .jam is the same archive as a .fig (its canvas header reads fig-jam.), which the .fig reader already took; File > Open and its filter, drag and drop, file.import and the command line now take .jam too, and the home screen shows its thumbnail. FigJam layers come in as the .fig reader makes them (stickies and shapes with text as frames with their generated layers, other FigJam layers as frames or vectors with a note, unknown types left out with a note). Tests use a synthetic fig-jam. canvas and a .jam made from our own export.

* M12: a FigJam .jam file opens as a board (#1342)

The .fig reader marks a canvas with FigJam's fig-jam. header as FileKind::Board, so a .jam opens with the board toolbar, the dot grid and the board panels and saves as a board; a .fig stays a design file, and importing a .jam into an open file leaves its kind alone. Its objects stay ordinary layers (frames and vectors with the existing import notes). Builds on the .jam opening branch (#1586).
2026-10-30 08:03:56 +07:00

287 lines
13 KiB
Rust

use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
use super::*;
/// How many files File ▸ Open recent keeps.
pub const MAX_RECENT: usize = 20;
/// Put `path` at the top of the recent files.
pub(super) fn remember(s: &mut Session, path: &str) {
let r = &mut s.prefs.recent_files;
r.retain(|p| p != path);
r.insert(0, path.to_string());
r.truncate(MAX_RECENT);
}
pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
use crate::file_access::{Access, allow, allowed};
const CMD: &str = "file.openRecent";
// Confined to automation roots, the recent files outside them are never listed, opened, removed
// or named: `index` counts the files listed.
let listed = |r: &String| allowed(r, Access::Read);
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.retain(|r| !listed(r));
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => {
allow(&path, Access::Read)?;
Some(path)
}
(None, Some(i)) => {
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
if let Some(path) = path {
if !crate::file_access::exists(&path) {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
return open(s, &json!({"path": path}));
}
let files: Vec<Value> = s
.prefs
.recent_files
.iter()
.filter(|r| listed(r))
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
})
.collect();
Ok(json!({"files": files}))
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let variables = variables_param("file.open", p)?;
if string(p, "path").is_none()
&& let Some(d) = string(p, "data")
{
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
// Already open: switch to it, and say when the file changed on disk since this tab read or
// saved it (`stale`), or read it again (`reload`).
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
if !boolean(p, "reload").unwrap_or(false) {
return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
}
if s.ds()?.is_dirty() {
return Err(EngineError::Disabled(
"file.open".into(),
"the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
));
}
if s.live().doc_uid.is_some() {
return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
}
revert(s)?;
return Ok(json!({"index": i, "reloaded": true}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || matches!(ext(&path).as_str(), "svg" | "penpot");
// A native file that lost something (a damaged image, say): the app says so, not only the result.
if (native || warnings) && !loaded.issues.is_empty() {
s.message = Some(match loaded.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&path)),
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
});
}
let summary = import_summary(&loaded.doc);
let images_left_out = if native { loaded.images_past_budget } else { 0 };
let mut ds = DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out);
ds.on_disk = on_disk.filter(|_| native);
s.docs.push(ds);
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
pub(super) fn revert(s: &mut Session) -> Result<()> {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
Ok(())
}
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
let name = req_string("file.open", p, "name")?;
let data = data.split_once(',').map_or(data, |(_, b)| b);
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot");
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let path = string(p, "path");
let variables = variables_param("file.import", p)?;
let (bytes, name) = if let Some(path) = &path {
(read(path)?, path.clone())
} else if let Some(d) = string(p, "data") {
let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d);
(
base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?,
string(p, "name").unwrap_or_else(|| "import.png".into()),
)
} else {
return Err(bad("file.import", "pass `path` or `data`"));
};
let page = s.page()?;
let (x, y) = (num(p, "x"), num(p, "y"));
let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
match ext(&name).as_str() {
"svg" => {
let doc = s.doc_mut()?;
let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1));
let imported =
figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?;
let id = imported.frame;
// Centred on the point (a paste lands in the middle of the view).
if boolean(p, "center").unwrap_or(false)
&& let Some(n) = doc.node_mut(id)
{
n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round()));
}
s.set_selection(vec![id]);
let mut out = json!({"id": id});
if !imported.notes.is_empty() {
out["warnings"] = json!(imported.notes);
}
Ok(out)
}
"figcraft" | "json" | "fig" | "jam" | "sketch" | "penpot" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
None => &figcraft_format::rest::no_images,
};
let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
let summary = import_summary(&other.doc);
let pages = append_pages(s.doc_mut()?, &other.doc)?;
if !other.issues.is_empty() {
s.message = Some(match other.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&name)),
more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),
});
}
let mut out = json!({"pages": pages, "summary": summary});
if rest || !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
}
_ => {
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
crate::cmd::create::place_image(
s,
&json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}),
)
}
}
}
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
/// their source and interactions and flows their targets wherever those are. Styles, variables and
/// collections whose id the document already has are kept as they are.
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
for (k, v) in &other.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
for st in &other.styles {
if !doc.styles.iter().any(|s| s.id == st.id) {
doc.styles.push(st.clone());
}
}
let have: std::collections::BTreeSet<String> = doc.variables.collections.iter().map(|c| c.id.clone()).collect();
doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned());
let have: std::collections::BTreeSet<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned());
let mut map = std::collections::BTreeMap::new();
for pg in &other.pages {
for id in other.subtree(*pg).into_iter().skip(1) {
map.insert(id, doc.alloc_id());
}
}
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
// the layers of its store come along into the store of `doc`. Recorded copies are left to
// `merge_store`, which shares them with copies `doc` already has.
let cached: Vec<NodeId> = other
.library_store()
.and_then(|s| other.node(s))
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
.unwrap_or_default();
for root in &cached {
for id in other.subtree(*root) {
map.insert(id, doc.alloc_id());
}
}
let mut new_pages = Vec::new();
for pg in &other.pages {
let Some(pn) = other.node(*pg) else { continue };
let np = doc.add_page(&pn.name);
let mut kind = pn.kind.clone();
if let NodeKind::Page { flows, .. } = &mut kind {
flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some());
}
if let Some(n) = doc.node_mut(np) {
n.kind = kind;
}
for c in &pn.children {
let nodes: Vec<Node> = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect();
doc.paste_nodes(&nodes, &map, *c, np, None)?;
}
new_pages.push(np);
}
if !cached.is_empty() {
let store = doc.ensure_library_store();
for root in &cached {
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
doc.paste_nodes(&nodes, &map, *root, store, None)?;
}
}
for new in map.values() {
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
n.source = Some(src);
}
}
crate::library::merge_store(doc, other, &map)?;
Ok(new_pages)
}