Files
Graphite/document/document-format/src/export.rs
Dennis Kobert de11d29d8e Add document-format crate (#4234)
* Add document-format crate

* Adapt document-format to merged graph-storage and consolidate errors

* Fix resource write ordering, codec error masking, and export docs

* Remove document-format RFC; tracked in a dedicated PR

* Restructure document-format into gdd/persist/export/resource modules

Split the 963-line lib.rs by responsibility: persist path into persist.rs, resource I/O into resource.rs, export engine into export.rs. Rename the GddV1 layout struct to GddV1Layout and add a GddV1 = Gdd<GddV1Layout> alias.

* Feature-split document-format and graph-storage for minimal builds

* Address PR review: O(1) history-delta lookup and no-default-features test build

* Address PR review: propagate apply_hot_op persist error and assert resource hash

* Make decompression from archive streaming

* Share archive export body between export and export_to_bytes

* Review cleanup

* Export documents with un-retired hot ops losslessly

* Persist last_broadcast_rev in session.json, drop unused last_retired_at

* Move peer_id from manifest to session.json

* Rename gesture to interaction in document-format storage layer
2026-06-21 12:35:47 +00:00

311 lines
13 KiB
Rust

//! Export: walking the working copy through an archive codec, keeping payloads as-is.
//!
//! [`ExportFormat`] / [`ExportOptions`] are the public settings; the [`Gdd`] export methods drive a
//! [`ExportSink`] (folder / zip / xz) through the manifest → registry → history → resources sequence.
#[cfg(not(target_family = "wasm"))]
use std::path::Path;
use graphene_resource::{LoadResource, Resource, ResourceHash};
use crate::error::Error;
use crate::layout::Layout;
use crate::session_state::SessionState;
use crate::{Gdd, MANIFEST_CODEC, io};
/// Export wrapping. Payloads keep the working copy's recorded per-payload codecs (see
/// [`crate::manifest::PayloadCodecs`]); export does not re-encode.
#[derive(Copy, Clone, Debug)]
pub enum ExportFormat {
/// Copy the working copy to a destination folder.
Folder,
/// Wrap as a `.gdd.zip` archive.
#[cfg(feature = "zip")]
Zip,
/// Wrap as a `.gdd.tar.xz` archive (whole-archive xz via `lzma-rust2`).
#[cfg(feature = "xz")]
Xz,
}
#[derive(Copy, Clone, Debug)]
pub struct ExportOptions {
/// Whether to include the registry snapshot. `false` produces a history-only export, useful
/// for VCS workflows where the diffable `history.jsonl` is the interesting payload and the
/// registry would rewrite whole-file on every retirement. Consumers replay history from an
/// empty registry.
pub include_registry: bool,
/// Whether to include history + hot-log. `false` produces a flat snapshot (registry only),
/// useful for sharing without revealing edit history and for cutting file size.
pub include_history: bool,
/// Materialize every non-`DataSource::Embedded` resource into `resources/<hash>` for portability.
/// Does not mutate the in-memory `Gdd`.
pub embed_all_resources: bool,
}
impl ExportOptions {
/// Returns an error description if the combination is incoherent.
pub fn validate(&self) -> Result<(), &'static str> {
if !self.include_registry && !self.include_history {
return Err("export must include at least one of: registry, history");
}
Ok(())
}
}
impl Default for ExportOptions {
fn default() -> Self {
Self {
include_registry: true,
include_history: true,
embed_all_resources: false,
}
}
}
impl<L: Layout> Gdd<L> {
/// Stream the working copy to `dest` as a folder/zip/xz archive, keeping payload codecs as-is.
/// Does not mutate `self` and does not buffer the export. Native-only (writes a filesystem path).
///
/// # Errors
/// [`Error::InvalidExportOptions`] for incoherent options, [`Error::MissingResource`] if an
/// embedded resource's bytes are absent from `byte_store`.
#[cfg(not(target_family = "wasm"))]
pub async fn export(&self, dest: &Path, format: ExportFormat, options: ExportOptions, byte_store: &dyn LoadResource, legacy_document: Option<&[u8]>) -> Result<(), Error> {
options.validate().map_err(Error::InvalidExportOptions)?;
match format {
ExportFormat::Folder => {
let mut folder = document_container::backends::folder::FolderBackend::create(dest)?;
let mut sink = FolderSink { folder: &mut folder };
self.stream_entries(options, byte_store, &mut sink).await?;
if let Some(legacy) = legacy_document {
sink.write_entry(self.layout.legacy_path(), legacy)?;
}
}
#[cfg(feature = "zip")]
ExportFormat::Zip => {
let file = std::fs::File::create(dest).map_err(document_container::ContainerError::Io)?;
self.export_archive::<document_container::archive::Zip, _>(file, options, byte_store, legacy_document).await?;
}
#[cfg(feature = "xz")]
ExportFormat::Xz => {
let file = std::fs::File::create(dest).map_err(document_container::ContainerError::Io)?;
self.export_archive::<document_container::archive::Xz, _>(file, options, byte_store, legacy_document).await?;
}
}
Ok(())
}
/// In-memory variant of [`export`](Self::export) returning the archive bytes. Available on every
/// target (no `std::fs`) but buffers the whole archive. `legacy_document` is embedded verbatim at
/// [`Layout::legacy_path`]. `ExportFormat::Folder` has no single-file form and is rejected.
#[cfg(any(feature = "zip", feature = "xz"))]
pub async fn export_to_bytes(&self, format: ExportFormat, options: ExportOptions, byte_store: &dyn LoadResource, legacy_document: Option<&[u8]>) -> Result<Vec<u8>, Error> {
options.validate().map_err(Error::InvalidExportOptions)?;
let cursor = std::io::Cursor::new(Vec::new());
let buffer = match format {
ExportFormat::Folder => return Err(Error::InvalidExportOptions("folder export has no single-file byte form")),
#[cfg(feature = "zip")]
ExportFormat::Zip => self.export_archive::<document_container::archive::Zip, _>(cursor, options, byte_store, legacy_document).await?,
#[cfg(feature = "xz")]
ExportFormat::Xz => self.export_archive::<document_container::archive::Xz, _>(cursor, options, byte_store, legacy_document).await?,
};
Ok(buffer.into_inner())
}
/// Stream entries into a fresh `A` archive over `output`, append the optional legacy blob, then
/// finalize and hand back the inner sink. The shared body of both archive export paths.
#[cfg(any(feature = "zip", feature = "xz"))]
async fn export_archive<A, W>(&self, output: W, options: ExportOptions, byte_store: &dyn LoadResource, legacy_document: Option<&[u8]>) -> Result<W, Error>
where
A: document_container::archive::Archive,
W: std::io::Write + std::io::Seek + Send,
A::Writer<W>: ExportSink + document_container::archive::ArchiveWriter<Sink = W>,
{
use document_container::archive::ArchiveWriter;
let mut writer = A::writer(output)?;
self.stream_entries(options, byte_store, &mut writer).await?;
if let Some(legacy) = legacy_document {
ExportSink::write_entry(&mut writer, self.layout.legacy_path(), legacy)?;
}
Ok(writer.finish_into()?)
}
/// Drive a sink through manifest → session → registry → history → resources, one entry at a time,
/// keeping each payload's recorded codec.
async fn stream_entries(&self, options: ExportOptions, byte_store: &dyn LoadResource, sink: &mut dyn ExportSink) -> Result<(), Error> {
use document_container::AsyncContainer;
let codecs = self.manifest.codecs;
sink.write_entry(&io::path_for(self.layout.manifest_basename(), MANIFEST_CODEC), &MANIFEST_CODEC.write_single(&self.manifest)?)?;
// Carry the per-peer cursor + view settings so a `.gdd` reopened elsewhere restores the viewport.
let session_state = SessionState {
peer_id: self.session.peer(),
head_rev: self.session.head_rev(),
last_broadcast_rev: self.session.last_broadcast_rev(),
redo_stack: self.session.redo_stack().to_vec(),
next_node_counter: self.session.next_node_counter(),
view_settings: self.view_settings.clone(),
network_view_settings: self.network_view_settings.clone(),
};
sink.write_entry(&io::path_for(self.layout.session_basename(), codecs.session), &codecs.session.write_single(&session_state)?)?;
let working_copy_hashes: std::collections::HashSet<ResourceHash> = self.resource_hashes().await?.into_iter().collect();
// Resources to embed as bytes: every `Embedded` entry, plus link-only ones when
// `embed_all_resources`. Bytes already in the working copy are written by the copy-through pass
// below, so only the gap is loaded from the byte store here.
let mut export_session = self.session.clone();
let mut hashes_from_store: Vec<ResourceHash> = Vec::new();
let mut links_to_promote: Vec<graph_storage::ResourceId> = Vec::new();
for (id, entry) in &export_session.registry().resources {
let Some(hash) = entry.hash else { continue };
let embed = entry.has_embedded_source() || options.embed_all_resources;
if !embed {
continue;
}
if !entry.has_embedded_source() {
links_to_promote.push(*id);
}
if !working_copy_hashes.contains(&hash) {
hashes_from_store.push(hash);
}
}
hashes_from_store.sort_unstable();
hashes_from_store.dedup();
// Fail fast if an embedded resource is missing, then promote link-only sources on the clone so
// the exported registry and history stay consistent. The live `Gdd` is untouched.
let mut embedded_bytes: Vec<(ResourceHash, Resource)> = Vec::new();
for hash in hashes_from_store {
let Some(resource) = byte_store.load(hash).await else {
return Err(Error::MissingResource(hash));
};
embedded_bytes.push((hash, resource));
}
export_session.embed_resource_sources(links_to_promote)?;
if options.include_registry {
// With history, the persisted snapshot is the retired registry and the hot log layers on top
// (mirrors `Session::load`); without history it must be the full working registry, since
// `bootstrap_from_registry` reconstructs the whole document from it alone.
let snapshot = if options.include_history { export_session.retired_registry() } else { export_session.registry() };
sink.write_entry(&io::path_for(self.layout.registry_basename(), codecs.registry), &codecs.registry.write_single(snapshot)?)?;
}
if options.include_history {
let mut buffer = Vec::new();
for delta in export_session.history() {
codecs.history.append(&mut buffer, delta)?;
}
if !buffer.is_empty() {
sink.write_entry(&io::path_for(self.layout.history_basename(), codecs.history), &buffer)?;
}
// Carry the un-retired hot ops alongside history so a document exported mid-interaction (e.g. a save
// during a tool drag) isn't shipped with its pending edits dropped. `open` replays them on top of
// the retired snapshot, same as the working copy does.
let mut hot_buffer = Vec::new();
for hot_op in export_session.hot_log() {
codecs.hot_log.append(&mut hot_buffer, hot_op)?;
}
if !hot_buffer.is_empty() {
sink.write_entry(&io::path_for(self.layout.hot_log_basename(), codecs.hot_log), &hot_buffer)?;
}
}
// Copy bytes the working copy already holds, tracking covered hashes so the embed pass below
// doesn't re-emit them.
let mut emitted = std::collections::HashSet::new();
let resources_dir = self.layout.resources_dir();
if self.working.list_dirs("").await?.iter().any(|d| d == resources_dir) {
let prefix = format!("{resources_dir}/");
for path in self.working.list(resources_dir).await? {
if let Some(hash) = path.strip_prefix(&prefix).and_then(|name| name.parse::<ResourceHash>().ok()) {
emitted.insert(hash);
}
let holder = self.working.read(&path).await?;
// Native `External` (mmap'd) holders copy CoW via a source path; others write bytes.
#[cfg(not(target_family = "wasm"))]
match holder.source_path() {
Some(src_path) => sink.write_entry_from_path(&path, src_path)?,
None => sink.write_entry(&path, holder.as_slice())?,
}
#[cfg(target_family = "wasm")]
sink.write_entry(&path, holder.as_slice())?;
}
}
for (hash, resource) in &embedded_bytes {
if emitted.insert(*hash) {
sink.write_entry(&self.layout.resource_path(hash), resource.as_ref())?;
}
}
Ok(())
}
}
/// Sink an export streams entries into, so one async loop drives folder/zip/xz writes. Archive sinks
/// work on every target; the folder sink and `write_entry_from_path` are native-only. `Send` because
/// `stream_entries` holds `&mut dyn ExportSink` across `.await`s.
pub(crate) trait ExportSink: Send {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error>;
/// Copy a file from disk into the sink. Default reads it into memory; the folder sink overrides to
/// `fs::copy` (CoW). Native-only: only reachable for an `External` (mmap'd) holder.
#[cfg(not(target_family = "wasm"))]
fn write_entry_from_path(&mut self, path: &str, src: &std::path::Path) -> Result<(), Error> {
let bytes = std::fs::read(src).map_err(document_container::ContainerError::Io)?;
self.write_entry(path, &bytes)
}
}
#[cfg(not(target_family = "wasm"))]
struct FolderSink<'a> {
folder: &'a mut document_container::backends::folder::FolderBackend,
}
#[cfg(not(target_family = "wasm"))]
impl ExportSink for FolderSink<'_> {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error> {
document_container::Container::write(self.folder, path, bytes)?;
Ok(())
}
fn write_entry_from_path(&mut self, path: &str, src: &std::path::Path) -> Result<(), Error> {
document_container::validate_path(path)?;
let dest = self.folder.root().join(path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(document_container::ContainerError::Io)?;
}
std::fs::copy(src, &dest).map_err(document_container::ContainerError::Io)?;
Ok(())
}
}
#[cfg(feature = "zip")]
impl<W: std::io::Write + std::io::Seek + Send> ExportSink for document_container::archive::ZipWriter<W> {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error> {
use document_container::archive::ArchiveWriter;
ArchiveWriter::write_entry(self, path, bytes)?;
Ok(())
}
}
#[cfg(feature = "xz")]
impl<W: std::io::Write + std::io::Seek + Send> ExportSink for document_container::archive::XzWriter<W> {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error> {
use document_container::archive::ArchiveWriter;
ArchiveWriter::write_entry(self, path, bytes)?;
Ok(())
}
}