DES-10: link placed files at once; saves make missing proxies, never hashes

A file placed from a path becomes a link immediately, with its size and
time as the fingerprint and its bytes kept resident. The background job
adds the content hash and proxy to the live document, undo history and
saved state without changing storage. Saves (file.save, saveACopy,
serialize, recovery snapshots) make the proxies still missing and write
links without a hash. An open hashes a no-hash link whose file matches by
size and time. embed:true placements hash while placing. The embed-until-
hashed path (settle_pending, settle_copy, keep_embedded) is gone.
This commit is contained in:
aiongg committed 2026-11-14 13:10:36 +00:00
1 parent 7d4e757435
commit d380fca0d0
7 files changed
+243 -204

No files matched your search

+2
View File
@@ -83,6 +83,8 @@ pub fn specs() -> Vec<CommandSpec> {
let st = s.doc()?;
// Preview fill is session state. Serialize the unfilled template.
let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref());
let proxied = crate::cmd::native_links::with_proxies(doc, || false);
let doc = proxied.as_ref().unwrap_or(doc);
let bytes = to_bytes(doc)?;
let warnings = crate::cmd::native_links::web_save_warnings(doc);
Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings}))
+10 -5
View File
@@ -18,8 +18,6 @@ pub(super) fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
// Before anything about the document changes: a refused save leaves it as it was.
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
// Graphics just placed whose hash the background job hasn't made yet are saved embedded.
crate::cmd::native_links::settle_pending(st);
// Prepare paths for the destination without changing the live document until persistence succeeds.
let destination = std::path::Path::new(&path);
let from = st.path.as_deref().map(std::path::Path::new);
@@ -27,8 +25,13 @@ pub(super) fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
let links_changed = linked.is_some();
let mut saved = linked.unwrap_or_else(|| (*st.doc).clone());
crate::cmd::datamerge::refresh_relative_paths(&mut saved, destination);
let paths_changed = links_changed || saved.data_merge != st.doc.data_merge;
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
// Proxies the background job hasn't made yet are made here, on this thread: the save is
// synchronous. The document keeps them.
let proxied = crate::cmd::native_links::with_proxies(&saved, || false);
let proxies_made = proxied.is_some();
let saved = proxied.unwrap_or(saved);
let changed = links_changed || proxies_made || saved.data_merge != st.doc.data_merge;
let saved = if changed { Arc::new(saved) } else { st.doc.clone() };
let warnings = crate::cmd::native_links::web_save_warnings(&saved);
let bytes = to_bytes_with_preview(&saved, &s.cache)?;
#[cfg(not(target_arch = "wasm32"))]
@@ -68,10 +71,12 @@ pub(super) fn file_save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
let from = st.path.as_deref().map(std::path::Path::new);
let mut copy = crate::cmd::native_links::for_destination(&st.doc, from, destination).unwrap_or_else(|| (*st.doc).clone());
crate::cmd::datamerge::refresh_relative_paths(&mut copy, destination);
crate::cmd::native_links::settle_copy(st, &mut copy);
if embed {
crate::cmd::native_links::embed_linked(&mut copy, "file.saveACopy")?;
}
if let Some(proxied) = crate::cmd::native_links::with_proxies(&copy, || false) {
copy = proxied;
}
let warnings = crate::cmd::native_links::web_save_warnings(&copy);
let bytes = to_bytes_with_preview(&copy, &s.cache)?;
#[cfg(not(target_arch = "wasm32"))]
+37 -28
View File
@@ -202,7 +202,7 @@ impl FileLink {
/// The proxy a linked graphic is drawn with while its file can't be read: none for video and
/// sound, or a file that doesn't decode.
fn proxy_of(a: &Asset, bytes: &[u8]) -> Option<designcraft_doc::Proxy> {
pub(super) fn proxy_of(a: &Asset, bytes: &[u8]) -> Option<designcraft_doc::Proxy> {
if a.mime.starts_with("video/") || a.mime.starts_with("audio/") {
return None;
}
@@ -235,13 +235,14 @@ pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into<Arc<Vec<u8>>>, file: Op
link_to_file(a, bytes, file, document, None);
}
/// [`give_bytes`] for a graphic being placed, leaving out the work that grows with the file: its
/// content hash and its proxy are made in the background ([`super::native_links::prepare`] the
/// returned work once the asset is in the document). Until then the graphic is embedded with the
/// file's link record and no hash, and drawn from its bytes; [`link_prepared`] links it when they
/// are in. A save before that writes it embedded ([`super::native_links::settle_pending`]).
/// Placing many press-size images would otherwise decode and hash each one before the app
/// responds again. Without a file, or on the web (no threads), this is [`give_bytes`].
/// [`give_bytes`] for a graphic being placed, leaving out the work that grows with the file: the
/// graphic links to its file at once, with the file's size and time as its fingerprint and the
/// bytes just read kept in memory for drawing, and its content hash and proxy are made in the
/// background ([`super::native_links::prepare`] the returned work once the asset is in the
/// document; [`add_prepared`] adds them). A link without a hash is complete: its status goes by
/// size and time until the hash is in. Placing many press-size images would otherwise decode and
/// hash each one before the app responds again. Embedded placements, bytes without a file and
/// the web (no threads) are [`give_bytes`].
#[must_use = "the hash and proxy are made only once the returned work is queued"]
pub(crate) fn give_placed(
a: &mut Asset,
@@ -252,18 +253,16 @@ pub(crate) fn give_placed(
) -> Option<super::native_links::Pending> {
let bytes: Arc<Vec<u8>> = bytes.into();
let file = match file {
Some(file) if cfg!(not(target_arch = "wasm32")) => file,
Some(file) if !embed && cfg!(not(target_arch = "wasm32")) => file,
file => {
give_bytes(a, bytes, file, embed, document);
return None;
}
};
a.proxy = None;
record_file(a, &bytes, file, document);
a.link_hash = None;
a.storage = Storage::Embedded;
a.data = AssetData::embedded(Arc::clone(&bytes));
Some(super::native_links::Pending::of(a, bytes, !embed))
link_recorded(a, Arc::clone(&bytes), designcraft_doc::SourceKey::fresh(), document, |_, _| None);
Some(super::native_links::Pending::of(a, bytes))
}
/// Link `a` to `file`, whose bytes (`bytes`) were just read: the file's record (paths, size and
@@ -275,18 +274,29 @@ pub(crate) fn link_to_file(a: &mut Asset, bytes: Arc<Vec<u8>>, file: FileLink, d
link_recorded(a, bytes, designcraft_doc::SourceKey::fresh(), document, |a, bytes| preview.or_else(|| proxy_of(a, bytes)));
}
/// A graphic placed with [`give_placed`] (embedded, its link record complete but for the hash)
/// once its content `hash` and its `proxy` are made: linked to its file under the key its bytes
/// already have, so nothing drawn from them is decoded again, or with only the hash added when it
/// was placed embedded (`link` false).
pub(super) fn link_prepared(a: &mut Asset, hash: String, link: bool, document: Option<&Path>, proxy: Option<designcraft_doc::Proxy>) {
a.link_hash = Some(hash);
if !link {
return;
/// Add to a graphic placed with [`give_placed`] (or opened with a link that has no hash) its
/// content `hash` and `proxy`, made from the bytes it has under its current key: the hash when it
/// has none, the proxy when it is linked and has none. Its storage stays as it is (a graphic
/// embedded since with Links › Embed stays embedded). → whether `a` changed.
pub(super) fn add_prepared(a: &mut Asset, hash: &str, proxy: Option<&designcraft_doc::Proxy>) -> bool {
let mut changed = false;
if a.link_hash.is_none() {
a.link_hash = Some(hash.to_owned());
// The same bytes: the key stays, so nothing decoded from them is decoded again.
if let Some(mut source) = a.data.linked_source().cloned() {
source.hash = Some(hash.to_owned());
a.data = AssetData::linked_under(a.data.key(), source);
}
changed = true;
}
let Some(bytes) = a.data.embedded_bytes().cloned() else { return };
let key = a.data.key();
link_recorded(a, bytes, key, document, |_, _| proxy);
if a.storage == Storage::Linked
&& a.proxy.is_none()
&& let Some(proxy) = proxy
{
a.proxy = Some(proxy.clone());
changed = true;
}
changed
}
/// `a`, whose link record is complete, as a link to its file under `key`: drawn with `proxy`'s
@@ -693,11 +703,12 @@ pub fn remember_link_files(s: &mut Session, doc: &Arc<Document>, files: &[(Asset
if let Some(a) = d.assets.get_mut(id) {
let a = Arc::make_mut(a);
a.link_fingerprint = Some(*f);
// Linked bytes are read by that time too (the same bytes: the key stays).
// Linked bytes are read by that time too (the same bytes, found in the file: the key
// stays, also for a link with no hash yet).
if a.data.is_linked()
&& let Some(source) = LinkedSource::of(a, document.as_deref().map(Path::new))
{
a.data = a.data.with_source(source);
a.data = AssetData::linked_under(a.data.key(), source);
}
}
}
@@ -1048,8 +1059,6 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
a.link_hash = hash;
Ok(Value::Null)
})?;
// Just placed and not linked yet: the background work leaves it embedded.
s.doc_mut()?.links.keep_embedded(key);
Ok(Value::Null)
}
+43 -9
View File
@@ -825,9 +825,10 @@ fn a_file_placed_from_a_path_links_to_it_and_embed_true_embeds_it() {
assert_eq!((r["storage"].as_str(), r["status"].as_str()), (Some("embedded"), Some("embedded")));
}
/// Placing a large image returns before its proxy and hash are made: until the background job
/// has them it is embedded with its link record and drawn from its bytes, then it links under the
/// same key. A save in between writes it embedded, with the hash, never a link nobody checked.
/// Placing a large image links it at once and returns before its proxy and hash are made. A save
/// in between writes the link without a hash, with a proxy made for it then; the copy reopens as
/// that link, and its open hashes the file. The background job adds the hash and proxy to the
/// live document under the key its bytes were drawn with.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn placing_leaves_the_proxy_and_hash_to_the_background() {
@@ -842,26 +843,59 @@ fn placing_leaves_the_proxy_and_hash_to_the_background() {
let id = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap());
let a = asset_of(&s, id);
assert!(a.proxy.is_none() && a.link_hash.is_none(), "no proxy or hash is made while placing");
assert_eq!((a.storage, a.link.as_deref()), (Storage::Embedded, path.to_str()));
assert_eq!((a.storage, a.link.as_deref()), (Storage::Linked, path.to_str()));
assert_eq!(a.data.resident().as_deref(), Some(&bytes), "the bytes read for placing are kept for drawing");
assert!(s.doc().unwrap().links.checking(), "the background job is running");
let key = a.data.key();
let early = dir.0.join("early.designcraft");
s.execute("file.saveACopy", &json!({"path": early.to_string_lossy()})).unwrap();
let copy = super::super::file::from_bytes(&std::fs::read(&early).unwrap()).unwrap();
let c = copy.assets.values().next().unwrap();
let mut t = Session::new();
t.execute("file.open", &json!({"path": early.to_string_lossy()})).unwrap();
crate::cmd::native_links::wait(&mut t);
let c = t.doc().unwrap().doc.assets.values().next().cloned().unwrap();
assert_eq!(c.storage, Storage::Linked);
assert!(c.proxy.is_some(), "the save made the proxy");
assert_eq!(c.data.load().unwrap().as_slice(), bytes.as_slice());
let hash = crate::asset_io::content_hash(&bytes);
assert_eq!((c.storage, c.link_hash.as_deref()), (Storage::Embedded, Some(hash.as_str())));
assert_eq!(c.link_hash.as_deref(), Some(hash.as_str()), "the open hashed the file that matched by size and time");
crate::cmd::native_links::wait(&mut s);
let a = asset_of(&s, id);
assert_eq!((a.storage, a.link_hash.as_deref()), (Storage::Linked, Some(hash.as_str())));
assert_eq!(a.data.key(), key, "linked under the key its decodes are cached by");
assert_eq!(a.data.key(), key, "still under the key its decodes are cached by");
assert!(a.proxy.as_ref().is_some_and(|p| p.pixels == (1024, 768)), "the proxy is made in the background");
assert_eq!(a.data.resident().as_deref(), Some(&bytes));
assert!(!s.doc().unwrap().links.checking());
}
/// Cmd-S right after placing a large photo writes a link and a proxy, not the photo.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_save_right_after_placing_writes_no_copy_of_the_file() {
let dir = TempDir::new("save-at-once");
let path = dir.0.join("photo.png");
// Opaque noise doesn't compress: the PNG is about as large as its pixels.
let mut seed = 0x2545_f491_u32;
let pixels = (0..800 * 600 * 4)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
})
.collect();
let bytes = designcraft_render::Rendered { width: 800, height: 600, pixels }.to_png();
std::fs::write(&path, &bytes).unwrap();
set_age(&path, 60);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
let doc = dir.0.join("doc.designcraft");
s.execute("file.save", &json!({"path": doc.to_string_lossy()})).unwrap();
let saved = std::fs::metadata(&doc).unwrap().len();
assert!(saved < bytes.len() as u64, "saved {saved} bytes for a {} byte photo", bytes.len());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_modified_link_is_updated_undoably() {
+143 -156
View File
@@ -12,10 +12,11 @@
//! first whose hash is the recorded one is the file, and its time is kept with the asset; when
//! none matches the link is Modified and the file's bytes are never used.
//!
//! The same thread makes the content hash and proxy of graphics just placed from files
//! ([`prepare`]), which link to their files once those are in.
//! Graphics placed from files link to them at once. The same thread makes their content hash
//! and proxy ([`prepare`]), and hashes linked files that an open found by size and time and that
//! have no hash yet. A save makes the proxies still missing ([`with_proxies`]), never a hash.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
@@ -76,8 +77,6 @@ pub struct OpenLinks {
/// Embedded images the check found identical to their linked files, so far.
migrated: usize,
notice: Option<String>,
/// The keys of graphics placed from files whose content hash is still being made ([`prepare`]).
pending: BTreeSet<SourceKey>,
}
impl std::fmt::Debug for OpenLinks {
@@ -102,33 +101,20 @@ impl OpenLinks {
pub fn take_notice(&mut self) -> Option<String> {
self.notice.take()
}
/// Whether `asset` was just placed from a file and its content hash and proxy are still
/// being made ([`prepare`]): embedded with its link record until then.
pub fn preparing(&self, asset: &Asset) -> bool {
unsettled(asset, &self.pending)
}
/// A graphic placed with the bytes `key` was embedded on purpose (Links › Embed) before the
/// background work linked it: it stays embedded.
pub(crate) fn keep_embedded(&mut self, key: SourceKey) {
self.pending.remove(&key);
}
}
/// One file check for the background thread.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum Work {
/// Find the file that holds `source`'s bytes: one at its absolute or relative path with the
/// recorded size and time, else one of the recorded size whose content hash is the recorded
/// one.
/// recorded size and time (hashed when the record has no hash), else one of the recorded size
/// whose content hash is the recorded one.
Find { asset: AssetId, key: SourceKey, source: LinkedSource },
/// Compare the file at `source`'s path (the first of its candidates that has the size of the
/// embedded `bytes`) with the embedded bytes of a document saved before linked storage.
Migrate { asset: AssetId, key: SourceKey, bytes: Arc<Vec<u8>>, source: LinkedSource, page: u32 },
/// Hash the `bytes` of graphics just placed from a file (and make their proxy with `proxy`),
/// to link them with `link`.
Prepare { key: SourceKey, bytes: Arc<Vec<u8>>, page: u32, proxy: bool, link: bool },
/// Hash the `bytes` of graphics just placed from a file, and make their proxy with `proxy`.
Prepare { key: SourceKey, bytes: Arc<Vec<u8>>, page: u32, proxy: bool },
}
/// What a [`Work`] found. `key` is the asset's bytes' key when the check started: a result for
@@ -136,8 +122,9 @@ enum Work {
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
enum Outcome {
/// The file at `path` holds the bytes; `fingerprint` is its size and time, when they can
/// vouch for the bytes later ([`super::links::settled`]).
Holds { asset: AssetId, key: SourceKey, path: PathBuf, fingerprint: Option<Fingerprint> },
/// vouch for the bytes later ([`super::links::settled`]), and `hash` the content hash of a
/// record that had none.
Holds { asset: AssetId, key: SourceKey, path: PathBuf, fingerprint: Option<Fingerprint>, hash: Option<String> },
/// Files are there; none holds the bytes.
Differs { asset: AssetId, key: SourceKey },
/// No file at either path, or one that may not be followed.
@@ -154,7 +141,7 @@ enum Outcome {
/// The file isn't (or can't be shown to be) the embedded bytes: they stay embedded.
Unconfirmed,
/// The content hash and proxy of placed graphics' bytes ([`Work::Prepare`]).
Prepared { key: SourceKey, hash: String, proxy: Option<designcraft_render::proxy::ProxyImage>, link: bool },
Prepared { key: SourceKey, hash: String, proxy: Option<designcraft_render::proxy::ProxyImage> },
}
/// A graphic placed from a file whose content hash and proxy are still to be made
@@ -165,18 +152,20 @@ pub(crate) struct Pending {
bytes: Arc<Vec<u8>>,
page: u32,
proxy: bool,
link: bool,
}
impl Pending {
/// The work for `a`, embedded with `bytes`, to be linked to its file when `link`. Video and
/// sound get no proxy, nor does a graphic that stays embedded.
pub(super) fn of(a: &Asset, bytes: Arc<Vec<u8>>, link: bool) -> Self {
let media = a.mime.starts_with("video/") || a.mime.starts_with("audio/");
Pending { key: a.data.key(), bytes, page: a.page, proxy: link && !media, link }
/// The work for `a`, linked with `bytes` just read from its file. Video and sound get no proxy.
pub(super) fn of(a: &Asset, bytes: Arc<Vec<u8>>) -> Self {
Pending { key: a.data.key(), bytes, page: a.page, proxy: !media(a) }
}
}
/// Video and sound, which have no image proxy.
fn media(a: &Asset) -> bool {
a.mime.starts_with("video/") || a.mime.starts_with("audio/")
}
#[derive(Default)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
struct Job {
@@ -255,7 +244,7 @@ pub(crate) fn resolve(d: &mut Document, document: Option<&Path>, migrate: bool)
}
/// `path` (a file found to hold `a`'s bytes) as `a`'s link, with its path relative to the
/// document and its bytes read from there.
/// document and its bytes (under their key) read from there.
fn adopt_path(a: &mut Asset, path: &Path, document: Option<&Path>) {
let found = path.to_string_lossy().into_owned();
if a.link.as_deref() == Some(found.as_str()) {
@@ -266,7 +255,7 @@ fn adopt_path(a: &mut Asset, path: &Path, document: Option<&Path>) {
a.link_relative = Some(relative);
}
if let Some(source) = LinkedSource::of(a, document) {
a.data = a.data.with_source(source);
a.data = AssetData::linked_under(a.data.key(), source);
}
}
@@ -355,17 +344,16 @@ fn spawn(work: Vec<Work>, results: Vec<Outcome>) -> Arc<JobHandle> {
}
/// Have the content hash and proxy of graphics just placed into `st`'s document made in the
/// background, in the order given; [`Session::poll_link_checks`] links them when they are in.
/// background, in the order given; [`Session::poll_link_checks`] adds them when they are in.
pub(crate) fn prepare(st: &mut DocState, pending: Vec<Pending>) {
if pending.is_empty() {
return;
}
st.links.pending.extend(pending.iter().map(|p| p.key));
#[cfg(not(target_arch = "wasm32"))]
{
// Taken from the end: the first placed is made first, before checks still waiting.
let work: Vec<Work> =
pending.into_iter().rev().map(|Pending { key, bytes, page, proxy, link }| Work::Prepare { key, bytes, page, proxy, link }).collect();
pending.into_iter().rev().map(|Pending { key, bytes, page, proxy }| Work::Prepare { key, bytes, page, proxy }).collect();
let mut results = Vec::new();
if let Some(handle) = &st.links.job {
let mut state = lock(&handle.0);
@@ -378,50 +366,8 @@ pub(crate) fn prepare(st: &mut DocState, pending: Vec<Pending>) {
}
st.links.job = Some(spawn(work, results));
}
}
/// Before a save of `d` (`st`'s document or a copy of it): the content hash of each graphic
/// placed from a file whose hash isn't made yet ([`prepare`]), from its bytes, which are embedded
/// until then. It is saved as an embedded copy with a complete link record, never as a link to a
/// file whose bytes nobody has checked; without the hash a later open would take it for a copy
/// saved before linked storage. → whether `d` changed.
fn settle(d: &mut Document, pending: &BTreeSet<SourceKey>) -> bool {
let unsettled: Vec<AssetId> = d.assets.values().filter(|a| unsettled(a, pending)).map(|a| a.id).collect();
for id in &unsettled {
let Some(a) = d.assets.get_mut(id).map(Arc::make_mut) else { continue };
if let Some(bytes) = a.data.embedded_bytes() {
a.link_hash = Some(crate::asset_io::content_hash(bytes));
}
}
!unsettled.is_empty()
}
/// Whether `a` was placed from a file ([`prepare`]) and has no content hash yet.
fn unsettled(a: &Asset, pending: &BTreeSet<SourceKey>) -> bool {
a.storage == Storage::Embedded && a.link.is_some() && a.link_hash.is_none() && pending.contains(&a.data.key())
}
/// [`settle`] for the live document of `st`, before it is saved. A document without unsaved
/// changes stays so.
pub(crate) fn settle_pending(st: &mut DocState) {
if !st.doc.assets.values().any(|a| unsettled(a, &st.links.pending)) {
return;
}
let clean = !st.is_dirty();
let mut doc = (*st.doc).clone();
if settle(&mut doc, &st.links.pending) {
st.doc = Arc::new(doc);
if clean {
st.saved_doc = Arc::clone(&st.doc);
}
}
}
/// [`settle`] for a copy of `st`'s document, before the copy is written.
pub(crate) fn settle_copy(st: &DocState, copy: &mut Document) {
if !st.links.pending.is_empty() {
settle(copy, &st.links.pending);
}
#[cfg(target_arch = "wasm32")]
let _ = st;
}
fn lock(job: &Job) -> std::sync::MutexGuard<'_, JobState> {
@@ -465,8 +411,13 @@ fn check(work: Work, cancel: &AtomicBool) -> Option<Outcome> {
Work::Find { asset, key, source } => {
let found: Vec<(PathBuf, Fingerprint)> =
crate::asset_io::candidates(&source).into_iter().filter_map(|p| fingerprint(&p).map(|f| (p, f))).collect();
if let Some((path, _)) = found.iter().find(|(_, f)| super::links::settled(f) && Some(*f) == source.fingerprint) {
return Some(Outcome::Holds { asset, key, path: path.clone(), fingerprint: None });
if let Some((path, at)) = found.iter().find(|(_, f)| super::links::settled(f) && Some(*f) == source.fingerprint) {
// A record without a hash gets the file's, read while it keeps that size and time.
let hash = match source.hash {
Some(_) => None,
None => read_file(path, source.len, cancel, |_, _| true)?.filter(|_| fingerprint(path) == Some(*at)),
};
return Some(Outcome::Holds { asset, key, path: path.clone(), fingerprint: None, hash });
}
if found.is_empty() {
return Some(Outcome::Absent { asset, key });
@@ -476,7 +427,7 @@ fn check(work: Work, cancel: &AtomicBool) -> Option<Outcome> {
match read_file(path, source.len, cancel, |_, _| true)? {
Some(found) if found == *hash && fingerprint(path) == Some(*before) => {
let fingerprint = super::links::settled(before).then_some(*before);
return Some(Outcome::Holds { asset, key, path: path.clone(), fingerprint });
return Some(Outcome::Holds { asset, key, path: path.clone(), fingerprint, hash: None });
}
_ => {}
}
@@ -503,13 +454,13 @@ fn check(work: Work, cancel: &AtomicBool) -> Option<Outcome> {
let fingerprint = if super::links::settled(&before) { before } else { Fingerprint { size: len, mtime: super::links::UNVERIFIED_MTIME } };
Some(Outcome::Confirmed { asset, key, path, fingerprint, hash, proxy })
}
Work::Prepare { key, bytes, page, proxy, link } => {
Work::Prepare { key, bytes, page, proxy } => {
let hash = hash_bytes(&bytes, cancel)?;
if cancel.load(Ordering::Relaxed) {
return None;
}
let proxy = if proxy { designcraft_render::proxy::make_proxy(&bytes, page) } else { None };
Some(Outcome::Prepared { key, hash, proxy, link })
Some(Outcome::Prepared { key, hash, proxy })
}
}
}
@@ -638,25 +589,10 @@ fn current(st: &mut DocState, id: AssetId, key: SourceKey) -> Option<&mut Asset>
fn apply_one(st: &mut DocState, outcome: Outcome, document: Option<&Path>) -> bool {
match outcome {
Outcome::Holds { asset, key, path, fingerprint } => {
let unchanged = fingerprint.is_none()
&& st.doc.assets.get(&asset).is_some_and(|a| a.data.key() == key && a.link.as_deref() == Some(path.to_string_lossy().as_ref()));
if unchanged {
// The file at the recorded path, with the recorded size and time: nothing to change.
st.links.status.insert(asset, (key, LinkStatus::Ok));
return false;
}
let Some(a) = current(st, asset, key) else { return false };
if let Some(f) = fingerprint {
a.link_fingerprint = Some(f);
}
adopt_path(a, &path, document);
if let Some(source) = LinkedSource::of(a, document) {
a.data = a.data.with_source(source);
}
let key = a.data.key();
st.links.status.insert(asset, (key, LinkStatus::Ok));
true
Outcome::Holds { asset, key, path, fingerprint, hash } => {
let held = held(st, asset, key, &path, fingerprint, document);
let hashed = hash.is_some_and(|hash| add_everywhere(st, key, &hash, None));
held || hashed
}
Outcome::Differs { asset, key } => {
found(st, asset, key, LinkStatus::Modified);
@@ -677,44 +613,14 @@ fn apply_one(st: &mut DocState, outcome: Outcome, document: Option<&Path>) -> bo
}
a.link_fingerprint = Some(fingerprint);
a.link_hash = Some(hash);
a.proxy = proxy.map(|p| designcraft_doc::Proxy { mime: p.mime.into(), pixels: p.pixels, data: AssetData::embedded(p.data) });
a.proxy = proxy.map(doc_proxy);
a.storage = Storage::Linked;
st.links.status.insert(asset, (key, LinkStatus::Ok));
st.links.migrated = st.links.migrated.saturating_add(1);
true
}
Outcome::Unconfirmed => false,
Outcome::Prepared { key, hash, proxy, link } => {
// Embedded on purpose since (Links › Embed): it stays so.
if !st.links.pending.remove(&key) {
return false;
}
let proxy = proxy.map(|p| designcraft_doc::Proxy { mime: p.mime.into(), pixels: p.pixels, data: AssetData::embedded(p.data) });
// Each document changed once, its holders sharing the result: a history entry that
// was the saved state still is.
let mut done: Vec<(Arc<Document>, Arc<Document>)> = Vec::new();
let mut prepared = |doc: &mut Arc<Document>| {
if let Some((_, after)) = done.iter().find(|(before, _)| Arc::ptr_eq(before, doc)) {
*doc = Arc::clone(after);
return true;
}
let before = Arc::clone(doc);
let changed = link_placed(doc, key, &hash, link, document, proxy.as_ref());
if changed {
done.push((before, Arc::clone(doc)));
}
changed
};
let changed = prepared(&mut st.doc);
// The undo history and the last saved state hold the same bytes: linked there too, so
// they don't keep every placed file in memory, and a redo of an undone placement
// brings it back linked.
for entry in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
prepared(&mut entry.doc);
}
prepared(&mut st.saved_doc);
changed
}
Outcome::Prepared { key, hash, proxy } => add_everywhere(st, key, &hash, proxy.map(doc_proxy).as_ref()),
}
}
@@ -725,26 +631,74 @@ fn found(st: &mut DocState, id: AssetId, key: SourceKey, status: LinkStatus) {
}
}
/// The graphics of `doc` placed with the bytes `key` ([`prepare`]) with their content `hash` and
/// `proxy`, linked to their files when `link` ([`super::links::link_prepared`]) → whether `doc`
/// has any.
fn link_placed(
doc: &mut Arc<Document>,
key: SourceKey,
hash: &str,
link: bool,
document: Option<&Path>,
proxy: Option<&designcraft_doc::Proxy>,
) -> bool {
let placed: Vec<AssetId> =
doc.assets.values().filter(|a| a.data.key() == key && a.storage == Storage::Embedded && a.link.is_some()).map(|a| a.id).collect();
if placed.is_empty() {
/// A proxy the background job made, as a document keeps it.
fn doc_proxy(p: designcraft_render::proxy::ProxyImage) -> designcraft_doc::Proxy {
designcraft_doc::Proxy { mime: p.mime.into(), pixels: p.pixels, data: AssetData::embedded(p.data) }
}
/// The file at `path` holds asset `id`'s bytes (still `key`'s): its link, with `fingerprint`
/// when the file's time can vouch for them. → whether the document changed.
fn held(st: &mut DocState, id: AssetId, key: SourceKey, path: &Path, fingerprint: Option<Fingerprint>, document: Option<&Path>) -> bool {
let unchanged = fingerprint.is_none()
&& st.doc.assets.get(&id).is_some_and(|a| a.data.key() == key && a.link.as_deref() == Some(path.to_string_lossy().as_ref()));
if unchanged {
// The file at the recorded path, with the recorded size and time: nothing to change.
st.links.status.insert(id, (key, LinkStatus::Ok));
return false;
}
let Some(a) = current(st, id, key) else { return false };
if let Some(f) = fingerprint {
a.link_fingerprint = Some(f);
}
adopt_path(a, path, document);
// The file holds the same bytes: the key stays, also for a record with no hash.
if let Some(source) = LinkedSource::of(a, document) {
a.data = AssetData::linked_under(key, source);
}
st.links.status.insert(id, (key, LinkStatus::Ok));
true
}
/// Add the content `hash` and `proxy` of the bytes `key` to the graphics that have those bytes
/// and lack them ([`super::links::add_prepared`]), in the live document, the undo history and the
/// last saved state alike: a redo of an undone placement, or an undo to before an open-time
/// check, then has them too. → whether the live document changed.
fn add_everywhere(st: &mut DocState, key: SourceKey, hash: &str, proxy: Option<&designcraft_doc::Proxy>) -> bool {
// Each document changed once, its holders sharing the result: a history entry that was the
// saved state still is.
let mut done: Vec<(Arc<Document>, Arc<Document>)> = Vec::new();
let mut add = |doc: &mut Arc<Document>| {
if let Some((_, after)) = done.iter().find(|(before, _)| Arc::ptr_eq(before, doc)) {
*doc = Arc::clone(after);
return true;
}
let before = Arc::clone(doc);
let changed = add_to(doc, key, hash, proxy);
if changed {
done.push((before, Arc::clone(doc)));
}
changed
};
let changed = add(&mut st.doc);
for entry in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
add(&mut entry.doc);
}
add(&mut st.saved_doc);
changed
}
/// [`add_everywhere`] for one version of the document → whether `doc` changed.
fn add_to(doc: &mut Arc<Document>, key: SourceKey, hash: &str, proxy: Option<&designcraft_doc::Proxy>) -> bool {
let wants = |a: &Asset| a.data.key() == key && (a.link_hash.is_none() || (proxy.is_some() && a.storage == Storage::Linked && a.proxy.is_none()));
let ids: Vec<AssetId> = doc.assets.values().filter(|a| wants(a)).map(|a| a.id).collect();
if ids.is_empty() {
return false;
}
let d = Arc::make_mut(doc);
for id in placed {
let Some(a) = d.assets.get_mut(&id).map(Arc::make_mut) else { continue };
super::links::link_prepared(a, hash.to_owned(), link, document, proxy.cloned());
for id in ids {
if let Some(a) = d.assets.get_mut(&id).map(Arc::make_mut) {
super::links::add_prepared(a, hash, proxy);
}
}
true
}
@@ -841,9 +795,10 @@ pub(crate) fn after_save(d: &Document, destination: &Path) -> Option<Document> {
if a.data.linked_source() == Some(&source) {
return None;
}
// Embedded bytes are linked only once their hash was found in the file (`Confirmed`).
let data = if a.data.is_linked() { a.data.with_source(source) } else { AssetData::linked_under(a.data.key(), source) };
Some((a.id, data))
// The record names the same bytes, now resolved against the destination: the key
// stays, also for a link with no hash yet. Embedded bytes are linked only once their
// hash was found in the file (`Confirmed`).
Some((a.id, AssetData::linked_under(a.data.key(), source)))
})
.collect();
if relinked.is_empty() {
@@ -858,6 +813,38 @@ pub(crate) fn after_save(d: &Document, destination: &Path) -> Option<Document> {
Some(out)
}
/// `d` as a save writes it, when that changes it: each linked graphic without a proxy (placed
/// moments ago, its proxy still being made in the background) gets one from its bytes, so a saved
/// link always has a preview to draw while its file can't be read. Nothing is hashed: a link
/// without a hash is saved without one. A graphic whose bytes can't be read or don't decode stays
/// without a proxy. `None` when no graphic needs one, or once `cancelled` says so.
pub(crate) fn with_proxies(d: &Document, cancelled: impl Fn() -> bool) -> Option<Document> {
let wanting: Vec<AssetId> = d.assets.values().filter(|a| a.storage == Storage::Linked && a.proxy.is_none() && !media(a)).map(|a| a.id).collect();
if wanting.is_empty() {
return None;
}
let mut out = d.clone();
let mut changed = false;
for id in wanting {
if cancelled() {
return None;
}
let Some(asset) = out.assets.get_mut(&id) else { continue };
let Ok(bytes) = asset.data.load() else { continue };
// A file that trips a decoder leaves its graphic without a proxy, not the save undone.
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::links::proxy_of(asset, &bytes)));
match made {
Ok(Some(proxy)) => {
Arc::make_mut(asset).proxy = Some(proxy);
changed = true;
}
Ok(None) => {}
Err(payload) => log::warn!("making the proxy of {}: {}", asset.name, crate::guard::panic_message(payload.as_ref())),
}
}
changed.then_some(out)
}
/// `d` with every linked file it uses copied in (Save a Copy `{embedLinks: true}`): a document
/// that opens anywhere. Fails, naming the image, when a file is missing or has changed.
pub(crate) fn embed_linked(d: &mut Document, command: &str) -> Result<()> {
+6 -4
View File
@@ -751,7 +751,8 @@ impl Snapshot {
let doc = d.preview_stash.clone().unwrap_or_else(|| d.doc.clone());
// Linked files are recorded as a save to the document's own file records them, never
// copied in; relative paths are relative to that file, which recovery resolves them against.
// Embedded images not yet saved as links keep their bytes.
// Graphics placed since stay links to their files. Images of a document saved before
// linked storage that are not yet saved as links keep their bytes.
let doc = match d.path.as_deref().map(Path::new) {
Some(path) => crate::cmd::native_links::for_recovery(&doc, path).map_or(doc, std::sync::Arc::new),
None => doc,
@@ -817,15 +818,16 @@ fn write_snapshot(entry: &Snapshot, cancelled: impl Fn() -> bool) -> Written {
#[cfg(any(test, feature = "testing"))]
testing::gate(&entry.dir);
let (doc, meta) = files(&entry.dir, entry.uid);
// Linked graphics placed moments ago get their proxies here, off the interface thread.
let proxied = crate::cmd::native_links::with_proxies(&entry.doc, &cancelled);
let document = proxied.as_ref().unwrap_or(&*entry.doc);
// Set at the rename: an error after it (syncing the folder) leaves the entry on disk.
let landed = std::cell::Cell::new(false);
let published = publish_recovery_written(
&doc,
entry.own,
// A cancel stops the write at its next chunk instead of after the whole package.
|out| {
designcraft_format::write(&mut StopWhen { out, cancelled: &cancelled }, &entry.doc, None).map_err(|e| EngineError::Other(e.to_string()))
},
|out| designcraft_format::write(&mut StopWhen { out, cancelled: &cancelled }, document, None).map_err(|e| EngineError::Other(e.to_string())),
&cancelled,
// Written: in a folder that is not this run's own, the entry is now this session's to
// replace and remove (Save and Close must clean up the snapshot they wrote). Recorded with