mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
#1244: streaming open and save, per-section limits, recovery that keeps images apart, background open
Save serializes JSON straight into the compressor and writes images from their Arc into the file; open reads each image into its own buffer, and NodeKind reads its tag first instead of buffering every node. Already-compressed images are stored rather than deflated again in the same gzip stream. Limits apply per section, so any file VectorCraft writes opens again. Data Recovery writes each image once, content-addressed, instead of every interval. Native files open on a background thread. Files stay readable by main both ways; JSON-only saves are identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
5beac4f122
commit
023c8e3edd
@@ -283,8 +283,26 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
|
||||
|
||||
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
|
||||
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
||||
open_read(s, read_for_open(name, bytes, p)?, path)
|
||||
}
|
||||
|
||||
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
|
||||
pub struct ReadFile {
|
||||
loaded: Loaded,
|
||||
opts: LoadOptions,
|
||||
}
|
||||
|
||||
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
|
||||
/// app may do it on another thread) → what [`open_read`] opens.
|
||||
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
|
||||
let opts = LoadOptions::from_params("document.open", p)?;
|
||||
open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false)
|
||||
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
|
||||
}
|
||||
|
||||
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
|
||||
/// document (`path`: where it was read from) → `document.open`'s result.
|
||||
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
|
||||
open_loaded(s, file.loaded, path, &file.opts, false)
|
||||
}
|
||||
|
||||
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
|
||||
|
||||
@@ -53,15 +53,18 @@ use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
read_for_open,
|
||||
};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
templates_dialog_folder, templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
|
||||
stamp_save_dates, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
|
||||
pub(crate) use screens::{
|
||||
@@ -769,6 +772,13 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
may_write(path)?;
|
||||
@@ -938,6 +948,11 @@ pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
|
||||
@@ -135,6 +135,14 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
|
||||
|
||||
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
encode_to(cmd, f, doc, p, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`encode`], written to `w` as it is made ([`vectorcraft_format::save_to`]: give it a buffered
|
||||
/// writer).
|
||||
pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: impl std::io::Write) -> Result<()> {
|
||||
let (mut so, extras) = save_options(cmd, f, doc, p)?;
|
||||
// Include Linked Files keeps placed documents' files; older versions get their art.
|
||||
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
|
||||
@@ -158,7 +166,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
vectorcraft_format::save_with(&native, &so).map_err(|e| bad(cmd, e.to_string()))
|
||||
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
|
||||
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -14,7 +15,6 @@ use vectorcraft_doc::Document;
|
||||
use super::super::*;
|
||||
use super::{
|
||||
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
|
||||
write_file,
|
||||
};
|
||||
use crate::{DocState, Prefs};
|
||||
|
||||
@@ -300,6 +300,31 @@ pub struct SaveJob {
|
||||
boards: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Writes the file at a path with what the filler it is given writes into it
|
||||
/// ([`SaveJob::write_streamed`]).
|
||||
pub type StreamWrite<'a> = &'a mut dyn FnMut(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()>;
|
||||
|
||||
/// Bytes gathered before a streamed save writes them to the file.
|
||||
const STREAM_BUFFER: usize = 256 << 10;
|
||||
|
||||
/// A writer that counts the bytes written through it.
|
||||
struct Counted<W> {
|
||||
inner: W,
|
||||
n: u64,
|
||||
}
|
||||
|
||||
impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let n = self.inner.write(buf)?;
|
||||
self.n += n as u64;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// The artboards a native or `.ai` save also writes to files of their own: with
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
@@ -443,6 +468,16 @@ impl SaveJob {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
/// The document the job writes (the snapshot, as the file keeps it).
|
||||
pub(crate) fn doc(&self) -> &Arc<Document> {
|
||||
&self.doc
|
||||
}
|
||||
|
||||
/// Encode `doc` (the job's document, changed) as the job encodes its own: one file.
|
||||
pub(crate) fn encode_doc(&self, doc: &Document) -> Result<Encoded> {
|
||||
self.encode_one(doc)
|
||||
}
|
||||
|
||||
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
|
||||
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…).
|
||||
@@ -500,6 +535,53 @@ impl SaveJob {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Is the file written as it is encoded by [`SaveJob::write_streamed`]? A native file (or
|
||||
/// template) to a path, without a file per artboard.
|
||||
pub fn streams(&self) -> bool {
|
||||
self.plan.path.is_some() && matches!(self.plan.format.id, "vectorcraft" | "template") && self.boards.is_empty()
|
||||
}
|
||||
|
||||
/// [`SaveJob::write`] through `stream`, which writes the file at a path with what the filler
|
||||
/// it is given writes (atomically, as [`vectorcraft_format::write_atomic_with`]). A native
|
||||
/// file goes into it as it is encoded ([`SaveJob::streams`]), never held whole in memory;
|
||||
/// other files are encoded first, then written into it.
|
||||
pub fn write_streamed(&self, stream: StreamWrite) -> Result<Value> {
|
||||
let (Some(path), true) = (&self.plan.path, self.streams()) else {
|
||||
return self.write(|p, b| stream(p, &mut |w| w.write_all(b)));
|
||||
};
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let doc = self.doc.without_edit_modes();
|
||||
let doc = match f.id {
|
||||
// A native file flagged so opening it starts a new untitled document (as
|
||||
// `encode_all` writes it).
|
||||
"template" => {
|
||||
let mut d = doc.into_owned();
|
||||
d.template = true;
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
_ => doc,
|
||||
};
|
||||
// The encoder's failure, kept apart from the writer's (which `stream` reports).
|
||||
let mut failed = None;
|
||||
let mut written = 0u64;
|
||||
let mut fill = |w: &mut dyn std::io::Write| {
|
||||
let mut out = Counted { inner: std::io::BufWriter::with_capacity(STREAM_BUFFER, w), n: 0 };
|
||||
let r = super::native::encode_to(cmd, f, &doc, &self.params, &mut out);
|
||||
written = out.n;
|
||||
match r {
|
||||
Ok(()) => out.inner.flush(),
|
||||
Err(e) => {
|
||||
failed = Some(e);
|
||||
Err(std::io::Error::other("the file couldn't be encoded"))
|
||||
}
|
||||
}
|
||||
};
|
||||
let r = stream(path, &mut fill);
|
||||
r.map_err(|e| failed.take().unwrap_or(e))?;
|
||||
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
|
||||
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
|
||||
/// title, format, options, and the snapshot as the saved state (edits made since the job was
|
||||
/// made keep the document modified).
|
||||
@@ -543,10 +625,14 @@ pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8])
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A save command: plan, then write with the file system.
|
||||
/// A save command: plan, then write with the file system (a native file as it is encoded).
|
||||
fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
save_with(s, plan, write_file)
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let out = job.write_streamed(&mut super::write_file_with)?;
|
||||
job.complete(s, uid);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -412,3 +412,33 @@ fn new_files_never_replace_a_file() {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
let d = tmp_dir("stream");
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
|
||||
let path = d.join(name).to_string_lossy().to_string();
|
||||
let p = json!({"path": path, "compress": compress});
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
let written = std::fs::read(&path).unwrap();
|
||||
assert_eq!(r["bytes"], written.len(), "{r}");
|
||||
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
|
||||
let job = save_job(&mut s, plan).unwrap();
|
||||
assert!(job.streams());
|
||||
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
|
||||
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
|
||||
}
|
||||
// A writer that fails: the save fails with its error.
|
||||
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
|
||||
let job = save_job(&mut s, plan).unwrap();
|
||||
let e = job
|
||||
.write_streamed(&mut |_, fill| {
|
||||
fill(&mut std::io::sink()).unwrap();
|
||||
Err(EngineError::Other("disk full".into()))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(e.to_string(), "disk full");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -26,17 +26,21 @@
|
||||
//! again.
|
||||
//!
|
||||
//! Each copy is two entries: `<area>/<file>.vectorcraft` (the native document, written as a save
|
||||
//! writes it) and `<area>/<file>.json` (its title, original path and format, and when it was
|
||||
//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
|
||||
//! writes it, without its images) and `<area>/<file>.json` (its title, original path and format,
|
||||
//! when it was written, and its images). The images are entries of their own, `<area>/img-…blob`,
|
||||
//! named by their content and shared by the area's copies: a copy written again writes only the
|
||||
//! images that changed, and the images no copy names any more go. Copies written before images
|
||||
//! were kept apart (the images in the document entry) still restore. The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
|
||||
//! preference, else the folder the app sets), browser storage on the web, or any
|
||||
//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan};
|
||||
use super::*;
|
||||
@@ -50,6 +54,8 @@ pub const COMPLEX_OBJECTS: usize = 20_000;
|
||||
const DOC_EXT: &str = ".vectorcraft";
|
||||
/// The extension of a copy's details entry.
|
||||
const META_EXT: &str = ".json";
|
||||
/// The extension of an image entry (see [`blob_name`]).
|
||||
const BLOB_EXT: &str = ".blob";
|
||||
/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
|
||||
/// seconds, as text).
|
||||
const BEAT: &str = "heartbeat";
|
||||
@@ -527,6 +533,73 @@ struct Meta {
|
||||
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
|
||||
imported_from: Option<String>,
|
||||
import_losses: Vec<String>,
|
||||
/// The document's images by key, in entries of the area (none in copies written before
|
||||
/// images were kept apart: the document entry holds them).
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
images: BTreeMap<String, ImageRef>,
|
||||
}
|
||||
|
||||
/// One of a copy's images: its entry in the copy's area.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct ImageRef {
|
||||
mime: String,
|
||||
/// The entry's name in the area ([`blob_name`]).
|
||||
blob: String,
|
||||
/// A linked image's preview, as a save writes it ([`ImageBlob::proxy`]).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
proxy: bool,
|
||||
}
|
||||
|
||||
/// Writes of image entries and their sweeping ([`sweep`]) take turns, in this app (the background
|
||||
/// worker and the UI thread), so an image is never swept between being written and named.
|
||||
static WRITES: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Image bytes and the name of their entry ([`blob_name`]).
|
||||
type Named = (Weak<Vec<u8>>, String);
|
||||
|
||||
/// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once,
|
||||
/// not at every interval. Bytes behind a live `Weak` never change (`Arc::make_mut` copies them).
|
||||
static NAMES: Mutex<Vec<Named>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so
|
||||
/// identical images share one entry and one written before isn't written again.
|
||||
fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
|
||||
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
names.retain(|(w, _)| w.strong_count() > 0);
|
||||
if let Some((_, name)) = names.iter().find(|(w, _)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))) {
|
||||
return name.clone();
|
||||
}
|
||||
let hash = |seed: u8| {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
seed.hash(&mut h);
|
||||
bytes.as_slice().hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len());
|
||||
names.push((Arc::downgrade(bytes), name.clone()));
|
||||
name
|
||||
}
|
||||
|
||||
/// The MIME type of a linked image's preview (JPEG or PNG), as a save names it.
|
||||
fn proxy_mime(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
|
||||
}
|
||||
|
||||
/// Remove the image entries of `area` no copy's details name any more. Best effort: an entry left
|
||||
/// behind goes at the next sweep, or with the area.
|
||||
fn sweep(store: &dyn RecoveryStore, area: &str) {
|
||||
let Ok(names) = store.list() else { return };
|
||||
let in_area = |n: &&String| n.split_once('/').is_some_and(|(a, _)| a == area);
|
||||
let mut named = BTreeSet::new();
|
||||
for meta in names.iter().filter(in_area).filter_map(|n| n.strip_suffix(META_EXT)) {
|
||||
named.extend(read_meta(store, meta).images.into_values().map(|r| r.blob));
|
||||
}
|
||||
for n in names.iter().filter(in_area).filter(|n| n.ends_with(BLOB_EXT)) {
|
||||
if n.split_once('/').is_some_and(|(_, b)| !named.contains(b)) {
|
||||
let _ = store.remove(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
|
||||
@@ -548,14 +621,44 @@ pub struct RecoveryJob {
|
||||
}
|
||||
|
||||
impl RecoveryJob {
|
||||
/// Encode the snapshot and store it with its details → `{file, title, bytes}`.
|
||||
/// Encode the snapshot and store it with its details and the images the area doesn't have
|
||||
/// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`.
|
||||
pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result<Value, String> {
|
||||
let enc = self.save.encode().map_err(|e| e.to_string())?;
|
||||
let doc = self.save.doc();
|
||||
// A linked image keeps its preview, as a save writes it.
|
||||
let linked = doc.linked_only_images();
|
||||
let mut lean = Document::clone(doc);
|
||||
let blobs = std::mem::take(&mut lean.images);
|
||||
let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?;
|
||||
let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?;
|
||||
let meta = serde_json::to_vec_pretty(&self.meta).map_err(|e| e.to_string())?;
|
||||
let mut meta = self.meta.clone();
|
||||
let mut images = vec![];
|
||||
for (key, b) in &blobs {
|
||||
let (mime, data, proxy) = match b.proxy.as_ref().filter(|_| linked.contains(key)) {
|
||||
Some(p) => (proxy_mime(p), p, true),
|
||||
None => (b.mime.as_str(), &b.bytes, false),
|
||||
};
|
||||
let blob = blob_name(data);
|
||||
meta.images.insert(key.clone(), ImageRef { mime: mime.into(), blob: blob.clone(), proxy });
|
||||
images.push((blob, data));
|
||||
}
|
||||
let meta_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?;
|
||||
let area = self.file.split_once('/').map_or("", |(a, _)| a);
|
||||
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let have: BTreeSet<String> = if images.is_empty() { BTreeSet::new() } else { store.list()?.into_iter().collect() };
|
||||
let mut image_bytes = 0;
|
||||
for (blob, data) in images {
|
||||
let name = format!("{area}/{blob}");
|
||||
if !have.contains(&name) {
|
||||
store.write(&name, data)?;
|
||||
image_bytes += data.len();
|
||||
}
|
||||
}
|
||||
store.write(&format!("{}{DOC_EXT}", self.file), bytes)?;
|
||||
store.write(&format!("{}{META_EXT}", self.file), &meta)?;
|
||||
Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() }))
|
||||
store.write(&format!("{}{META_EXT}", self.file), &meta_bytes)?;
|
||||
// Images this copy no longer shows, nor any other.
|
||||
sweep(store, area);
|
||||
Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len(), "imageBytes": image_bytes }))
|
||||
}
|
||||
|
||||
/// Once stored: the document (if still open) remembers the copy, so it isn't written again
|
||||
@@ -662,6 +765,8 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
saved,
|
||||
imported_from: st.imported_from.clone(),
|
||||
import_losses: st.import_losses.clone(),
|
||||
// Filled in as the copy is written.
|
||||
images: BTreeMap::new(),
|
||||
};
|
||||
out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
|
||||
}
|
||||
@@ -829,7 +934,24 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
|
||||
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
|
||||
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
|
||||
let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
|
||||
let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
|
||||
// The images, from the entries of the copy's area.
|
||||
let from = file.split_once('/').map_or("", |(a, _)| a);
|
||||
let mut blobs = vec![];
|
||||
for (key, r) in &meta.images {
|
||||
match store.read(&format!("{from}/{}", r.blob)) {
|
||||
Ok(data) => {
|
||||
let mut blob = ImageBlob::new(r.mime.clone(), data);
|
||||
// Until the linked file is read, the preview stands in for it.
|
||||
if r.proxy {
|
||||
blob.proxy = Some(blob.bytes.clone());
|
||||
}
|
||||
blobs.push((r.blob.clone(), blob.bytes.clone()));
|
||||
doc.images.insert(key.clone(), blob);
|
||||
}
|
||||
Err(e) => warnings.push(format!("the image “{key}” of the recovered document couldn't be read: {e}")),
|
||||
}
|
||||
}
|
||||
let area = claim(s, store)?;
|
||||
let mut taken: Vec<String> = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect();
|
||||
taken.extend(open_copies(s));
|
||||
@@ -838,8 +960,15 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
f if taken.contains(&f) => fresh_name(&area, stem, &taken),
|
||||
f => f,
|
||||
};
|
||||
store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
|
||||
store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
|
||||
{
|
||||
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (blob, data) in &blobs {
|
||||
store.write(&format!("{area}/{blob}"), data).map_err(EngineError::Other)?;
|
||||
}
|
||||
store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
|
||||
store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
|
||||
}
|
||||
// The copy's images in its old area go with the area, once no copy is left there.
|
||||
store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
|
||||
store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?;
|
||||
if doc.title.is_empty() {
|
||||
@@ -901,7 +1030,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
|
||||
has_store,
|
||||
save
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user