mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
494 lines
22 KiB
Rust
494 lines
22 KiB
Rust
//! File › Package and Links › Copy Links To: gather a document with the files it uses.
|
||
|
||
use std::fs::Metadata;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use std::fs::File;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use std::io::Write;
|
||
|
||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||
use crate::{EngineError, Result, Session};
|
||
use designcraft_compose::ComposedStory;
|
||
use designcraft_doc::{AssetId, Document};
|
||
use designcraft_fonts::{FaceRef, FontFace, FontSource};
|
||
use serde_json::{Value, json};
|
||
|
||
const MAX_OUTPUT_COMPONENT_BYTES: usize = 200;
|
||
|
||
/// The folder of a package that holds the files its document places.
|
||
const LINKS_FOLDER: &str = "Links";
|
||
|
||
fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError {
|
||
EngineError::Other(format!("{}: {message}", path.display()))
|
||
}
|
||
|
||
fn is_redirect(metadata: &Metadata) -> bool {
|
||
// Rust reports Windows name-surrogate reparse points (including directory junctions) as
|
||
// symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone.
|
||
metadata.file_type().is_symlink()
|
||
}
|
||
|
||
fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> {
|
||
match std::fs::symlink_metadata(path) {
|
||
Ok(metadata) => {
|
||
if is_redirect(&metadata) {
|
||
// An existing folder above the package may be reached through a link the user
|
||
// set up (`/tmp` on macOS is one); the package folder itself may not.
|
||
if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
|
||
return Ok(());
|
||
}
|
||
return Err(path_error(path, "refusing a redirected output directory"));
|
||
}
|
||
if !metadata.is_dir() {
|
||
return Err(path_error(path, "output path is not a directory"));
|
||
}
|
||
}
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||
if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty())
|
||
&& parent != path
|
||
{
|
||
ensure_real_directory(parent, true)?;
|
||
}
|
||
if let Err(e) = std::fs::create_dir(path)
|
||
&& e.kind() != std::io::ErrorKind::AlreadyExists
|
||
{
|
||
return Err(path_error(path, e));
|
||
}
|
||
let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?;
|
||
if is_redirect(&metadata) || !metadata.is_dir() {
|
||
return Err(path_error(path, "output directory was redirected while it was being created"));
|
||
}
|
||
}
|
||
Err(e) => return Err(path_error(path, e)),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A selected output directory and its resolved identity. Every package path is checked against
|
||
/// it before use. Outputs are staged in the same directory and atomically persisted over an
|
||
/// existing regular file, preserving overwrite behavior without following file links.
|
||
struct OutputRoot {
|
||
path: PathBuf,
|
||
canonical: PathBuf,
|
||
}
|
||
|
||
impl OutputRoot {
|
||
fn new(path: PathBuf) -> Result<Self> {
|
||
// Before anything is created: the folder must be inside the automation write root.
|
||
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
|
||
ensure_real_directory(&path, false)?;
|
||
let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?;
|
||
Ok(Self { path, canonical })
|
||
}
|
||
|
||
fn validate_directory(&self, path: &Path) -> Result<()> {
|
||
if !path.starts_with(&self.path) {
|
||
return Err(path_error(path, "output path is outside the selected directory"));
|
||
}
|
||
ensure_real_directory(path, false)?;
|
||
let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?;
|
||
if !canonical.starts_with(&self.canonical) {
|
||
return Err(path_error(path, "output directory resolves outside the selected directory"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn child(&self, name: &str) -> Result<PathBuf> {
|
||
let path = self.path.join(name);
|
||
if path.parent() != Some(self.path.as_path()) {
|
||
return Err(path_error(&path, "invalid output directory name"));
|
||
}
|
||
Ok(path)
|
||
}
|
||
|
||
fn validate_file_entry(&self, path: &Path) -> Result<bool> {
|
||
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
|
||
self.validate_directory(parent)?;
|
||
if !path.starts_with(&self.path) {
|
||
return Err(path_error(path, "output file is outside the selected directory"));
|
||
}
|
||
match std::fs::symlink_metadata(path) {
|
||
Ok(metadata) => {
|
||
if is_redirect(&metadata) {
|
||
return Err(path_error(path, "refusing to replace a redirected output file"));
|
||
}
|
||
if !metadata.is_file() {
|
||
return Err(path_error(path, "output path is not a regular file"));
|
||
}
|
||
Ok(true)
|
||
}
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||
Err(e) => Err(path_error(path, e)),
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn temporary_file(&self, path: &Path, permissions: Option<std::fs::Permissions>) -> Result<tempfile::NamedTempFile> {
|
||
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
|
||
self.validate_directory(parent)?;
|
||
let mut builder = tempfile::Builder::new();
|
||
builder.prefix(".designcraft-package-").suffix(".tmp");
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
// Like fs::write for a new output: request 0666 and let the process umask decide the
|
||
// effective mode (0644 with the usual 022, 0664 with 002).
|
||
builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
|
||
}
|
||
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
|
||
#[cfg(unix)]
|
||
if let Some(permissions) = permissions {
|
||
// Overwrites retain their prior mode; copied fonts retain their source mode.
|
||
file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?;
|
||
}
|
||
#[cfg(not(unix))]
|
||
// The replacement inherits its directory's ACL. Rust's portable permission API cannot
|
||
// reproduce a destination file's Windows DACL without platform-specific code.
|
||
let _ = permissions;
|
||
Ok(file)
|
||
}
|
||
|
||
fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = (path, bytes);
|
||
return Err(EngineError::Other("package files are unavailable on the web".into()));
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let existed = self.validate_file_entry(path)?;
|
||
let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None };
|
||
let mut file = self.temporary_file(path, permissions)?;
|
||
file.write_all(bytes).map_err(|e| path_error(path, e))?;
|
||
// Recheck immediately before replacement. If a name is raced after this check,
|
||
// persist replaces that directory entry rather than following it.
|
||
self.validate_file_entry(path)?;
|
||
file.persist(path).map_err(|e| path_error(path, e.error))?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn copy(&self, from: &Path, to: &Path) -> Result<()> {
|
||
if self.validate_file_entry(to)?
|
||
&& std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target))
|
||
{
|
||
return Ok(());
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = from;
|
||
return Err(EngineError::Other("package files are unavailable on the web".into()));
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let mut source = File::open(from).map_err(|e| path_error(from, e))?;
|
||
let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions());
|
||
let mut target = self.temporary_file(to, permissions)?;
|
||
std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?;
|
||
self.validate_file_entry(to)?;
|
||
target.persist(to).map_err(|e| path_error(to, e.error))?;
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
noundo "file.package",
|
||
"Package…",
|
||
["File"],
|
||
None,
|
||
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||
has_doc,
|
||
package
|
||
),
|
||
cmd!(
|
||
"links.copyTo",
|
||
"Copy Link(s) To…",
|
||
[],
|
||
None,
|
||
"{dir, assets?: [asset ids] (default: all)} — write the placed files to `dir` and relink to the copies → {copied}",
|
||
has_doc,
|
||
copy_links
|
||
),
|
||
]
|
||
}
|
||
|
||
fn is_reserved_windows_name(name: &str) -> bool {
|
||
let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']);
|
||
["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved))
|
||
|| (1..=9).any(|n| {
|
||
caseless::compatibility_caseless_match_str(stem, &format!("com{n}"))
|
||
|| caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}"))
|
||
})
|
||
}
|
||
|
||
fn portable_names_equal(a: &str, b: &str) -> bool {
|
||
caseless::canonical_caseless_match_str(a, b)
|
||
}
|
||
|
||
/// A portable single path component made from document metadata.
|
||
fn safe_component(name: &str, fallback: &str) -> String {
|
||
let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("");
|
||
let mut safe = String::new();
|
||
for ch in leaf.trim().chars() {
|
||
let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\');
|
||
let ch = if replacement { '_' } else { ch };
|
||
if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES {
|
||
break;
|
||
}
|
||
safe.push(ch);
|
||
}
|
||
safe.truncate(safe.trim_end_matches([' ', '.']).len());
|
||
if safe.is_empty() || safe == "." || safe == ".." {
|
||
safe = fallback.to_string();
|
||
}
|
||
if is_reserved_windows_name(&safe) {
|
||
safe.insert(0, '_');
|
||
}
|
||
safe
|
||
}
|
||
|
||
fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result<PathBuf> {
|
||
let path = dir.join(format!("{title}.{extension}"));
|
||
if path.parent() != Some(dir) {
|
||
return Err(bad("file.package", "an output filename would be outside the selected directory"));
|
||
}
|
||
Ok(path)
|
||
}
|
||
|
||
/// A file name for asset `a` in a Links folder, unique among `taken`.
|
||
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
|
||
let fallback = format!("asset-{}", id.0);
|
||
unique_name(safe_component(name, &fallback), taken)
|
||
}
|
||
|
||
/// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and
|
||
/// case-insensitive comparison.
|
||
fn unique_name(base: String, taken: &mut Vec<String>) -> String {
|
||
let (stem, ext) = match base.rsplit_once('.') {
|
||
Some((s, e)) => (s.to_string(), format!(".{e}")),
|
||
None => (base.clone(), String::new()),
|
||
};
|
||
let mut n = base;
|
||
let mut k = 2;
|
||
while taken.iter().any(|t| portable_names_equal(t, &n)) {
|
||
n = format!("{stem} {k}{ext}");
|
||
k += 1;
|
||
}
|
||
taken.push(n.clone());
|
||
n
|
||
}
|
||
|
||
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
|
||
/// `relative_to` names the folder `dir` is, beside the saved document: the links then read
|
||
/// `Links/logo.png`, so a package holds no path from the machine that made it (#340). `None` links
|
||
/// the copies by their full paths (Links › Copy Link(s) To, which relinks the open document).
|
||
fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>, relative_to: Option<&str>) -> Result<(Document, Vec<PathBuf>)> {
|
||
root.validate_directory(dir)?;
|
||
let mut out = d.clone();
|
||
let mut taken = Vec::new();
|
||
let mut written = Vec::new();
|
||
// Only files something places; the saved document leaves the others out.
|
||
let ids = d.used_assets();
|
||
for id in ids {
|
||
if only.is_some_and(|o| !o.contains(&id)) {
|
||
continue;
|
||
}
|
||
let Some(a) = d.assets.get(&id) else { continue };
|
||
// A missing link has no bytes: an empty file under its name would read as present, so it
|
||
// keeps its old (missing) path instead.
|
||
if a.data.is_empty() {
|
||
continue;
|
||
}
|
||
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
|
||
let path = dir.join(&name);
|
||
root.write(&path, a.data.as_slice())?;
|
||
let mut na = (**a).clone();
|
||
na.link = Some(match relative_to {
|
||
Some(folder) => format!("{folder}/{name}"),
|
||
None => path.to_string_lossy().to_string(),
|
||
});
|
||
out.assets.insert(id, Arc::new(na));
|
||
written.push(path);
|
||
}
|
||
Ok((out, written))
|
||
}
|
||
|
||
/// Copy the font file of `face` into `dir` (created when first needed) unless its licence
|
||
/// restricts it or it is already there (`copied`: the source files copied) → what the package
|
||
/// report adds after the font's name (nothing when copied).
|
||
fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
|
||
let from = match &face.source {
|
||
FontSource::Bundled => return " — included with DesignCraft".into(),
|
||
FontSource::Memory => return " — not copied: no font file".into(),
|
||
FontSource::Installed(p) | FontSource::Document(p) => p,
|
||
};
|
||
if face.restricted_licence() {
|
||
return " — not copied: its licence doesn't allow it".into();
|
||
}
|
||
if copied.contains(from) {
|
||
return String::new();
|
||
}
|
||
let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font");
|
||
let to = dir.join(unique_name(font_file_name(name, face.data()), taken));
|
||
match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) {
|
||
Ok(()) => {
|
||
copied.push(from.clone());
|
||
files.push(to);
|
||
String::new()
|
||
}
|
||
Err(e) => format!(" — not copied: {e}"),
|
||
}
|
||
}
|
||
|
||
/// `name` with a font extension when it has none: font services name their files as they like,
|
||
/// and a package's `Document Fonts` load only .ttf, .otf, .ttc and .otc files. The extension
|
||
/// comes from the file's first four bytes; a leading dot (a hidden file) is dropped.
|
||
fn font_file_name(name: String, data: &[u8]) -> String {
|
||
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
|
||
if matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||
return name;
|
||
}
|
||
let add = match data.get(..4) {
|
||
Some(b"OTTO") => "otf",
|
||
Some(b"ttcf") => "ttc",
|
||
Some([0, 1, 0, 0] | b"true") => "ttf",
|
||
_ => return name,
|
||
};
|
||
let stem = name.trim_start_matches('.');
|
||
format!("{}.{add}", if stem.is_empty() { "font" } else { stem })
|
||
}
|
||
|
||
/// Add the faces that draw the visible glyphs of `cs` (table cells and footnotes included) to
|
||
/// `out`, each once.
|
||
fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
|
||
// Cells and footnotes hold composed stories of their own; tables nest only so deep.
|
||
if depth > 16 {
|
||
return;
|
||
}
|
||
for f in &cs.frames {
|
||
for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible) {
|
||
if !out.iter().any(|o| o.id() == g.face.id()) {
|
||
out.push(g.face);
|
||
}
|
||
}
|
||
for c in f.tables.iter().flat_map(|t| &t.cells) {
|
||
drawn_faces(&c.text, depth + 1, out);
|
||
}
|
||
for n in &f.notes {
|
||
drawn_faces(&n.text, depth + 1, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
|
||
let st = s.doc()?;
|
||
let root = OutputRoot::new(dir.clone())?;
|
||
let display_title = st.doc.title.clone();
|
||
let title = safe_component(&display_title, "Untitled");
|
||
let fonts = s.execute("font.list", &json!({}))?;
|
||
let links = s.execute("links.list", &json!({}))?;
|
||
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
|
||
let d = s.doc()?.doc.clone();
|
||
let links_dir = root.child(LINKS_FOLDER)?;
|
||
let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?;
|
||
let doc_path = package_output_path(&dir, &title, "designcraft")?;
|
||
root.write(&doc_path, &super::to_bytes(&packed)?)?;
|
||
files.insert(0, doc_path);
|
||
// The font files, and what the report says about each font.
|
||
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
|
||
let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?;
|
||
let (mut copied, mut taken) = (Vec::new(), Vec::new());
|
||
let (mut font_lines, mut named) = (Vec::new(), Vec::new());
|
||
for f in fonts.as_array().into_iter().flatten() {
|
||
let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
|
||
let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false);
|
||
// A missing font's text is drawn in its substitute, which isn't a fallback font.
|
||
let face = db.face(family, style);
|
||
named.push(face.id());
|
||
let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) };
|
||
font_lines.push(format!(" {family} {style}{note}\n"));
|
||
}
|
||
// Fallback fonts: the faces that draw characters the named fonts lack.
|
||
let mut drawn = Vec::new();
|
||
for sid in d.stories.keys() {
|
||
drawn_faces(&s.cache.get(&d, *sid, None), 0, &mut drawn);
|
||
}
|
||
drawn.retain(|f| !named.contains(&f.id()));
|
||
drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style)));
|
||
let fallback_lines: Vec<String> = drawn
|
||
.iter()
|
||
.map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files)))
|
||
.collect();
|
||
// The IDML copy links the placed files, so a per-placement PDF layer choice cannot travel in
|
||
// it (an external-only IDML export rejects one for the same reason). Say so in the report
|
||
// instead of leaving a silent difference between the package's copies.
|
||
let mut idml_layer_note = "";
|
||
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
|
||
let path = package_output_path(&dir, &title, "idml")?;
|
||
// The images are in Links/ beside it: link them there, without embedded copies.
|
||
let opts = designcraft_idml::ExportOptions { embed_images: false };
|
||
root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?;
|
||
files.push(path);
|
||
if super::interchange::has_applicable_pdf_layer_overrides(&packed) {
|
||
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
|
||
}
|
||
}
|
||
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
|
||
let path = package_output_path(&dir, &title, "pdf")?;
|
||
let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default())
|
||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||
root.write(&path, &bytes)?;
|
||
files.push(path);
|
||
}
|
||
// The report: fonts (missing first), links, preflight, the user's instructions.
|
||
let mut r = format!("Package report: {display_title}\n\n");
|
||
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
|
||
r += &format!("Instructions\n{t}\n\n");
|
||
}
|
||
r += "Fonts\n";
|
||
r += &font_lines.concat();
|
||
if !fallback_lines.is_empty() {
|
||
r += "\nFallback fonts (for characters the fonts above lack)\n";
|
||
r += &fallback_lines.concat();
|
||
}
|
||
r += "\nLinks\n";
|
||
for l in links.as_array().into_iter().flatten() {
|
||
r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or(""));
|
||
}
|
||
r += idml_layer_note;
|
||
if let Some(issues) = pre.get("issues").and_then(Value::as_array) {
|
||
r += &format!("\nPreflight: {} issue(s)\n", issues.len());
|
||
for i in issues {
|
||
r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or(""));
|
||
}
|
||
}
|
||
let report = dir.join("Instructions.txt");
|
||
root.write(&report, r.as_bytes())?;
|
||
files.push(report);
|
||
Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r}))
|
||
}
|
||
|
||
fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
|
||
let only: Option<Vec<AssetId>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect());
|
||
let d = s.doc()?.doc.clone();
|
||
let root = OutputRoot::new(dir.clone())?;
|
||
let (relinked, written) = write_links(&d, &root, &dir, only.as_deref(), None)?;
|
||
s.edit(|doc, _| {
|
||
doc.assets = relinked.assets;
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"copied": written.len()}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|