//! 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/` 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 Gdd { /// 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::(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::(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, 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::(cursor, options, byte_store, legacy_document).await?, #[cfg(feature = "xz")] ExportFormat::Xz => self.export_archive::(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(&self, output: W, options: ExportOptions, byte_store: &dyn LoadResource, legacy_document: Option<&[u8]>) -> Result where A: document_container::archive::Archive, W: std::io::Write + std::io::Seek + Send, A::Writer: ExportSink + document_container::archive::ArchiveWriter, { 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 = 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 = Vec::new(); let mut links_to_promote: Vec = 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::().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 ExportSink for document_container::archive::ZipWriter { 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 ExportSink for document_container::archive::XzWriter { fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error> { use document_container::archive::ArchiveWriter; ArchiveWriter::write_entry(self, path, bytes)?; Ok(()) } }