feat: :export! writes the whole diary out as a zip

There was no way to get everything out — only one entry at a time, by
hand. A diary you cannot leave is not a diary you can trust.

The archive is entries/ as one markdown file per entry named by its day,
media/ with every embedded file under its real name, and a README. Media
links are rewritten to relative paths, so the entries render in any
markdown reader with no help from this application. Names are restricted
to characters that need no escaping inside a markdown link.

It is built on the blocking pool into a temporary file and streamed, so a
diary with photographs in it never has to fit in memory.
This commit is contained in:
nvrl committed 2026-09-05 09:45:44 +02:00
1 parent 54743346fe
commit f0135d34ac
5 files changed
+314

No files matched your search

Generated
+89
View File
@@ -29,6 +29,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.44" version = "0.4.44"
@@ -316,6 +325,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -432,6 +452,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -907,6 +933,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -1031,6 +1063,7 @@ dependencies = [
"sha2 0.10.9", "sha2 0.10.9",
"sqlx", "sqlx",
"subtle", "subtle",
"tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower", "tower",
@@ -1038,6 +1071,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -1375,6 +1409,19 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -1842,6 +1889,19 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
@@ -2420,6 +2480,23 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror",
"zopfli",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.7" version = "0.6.7"
@@ -2431,3 +2508,15 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+2
View File
@@ -26,6 +26,8 @@ mime_guess = "2"
rust-embed = { version = "8", features = ["interpolate-folder-path"] } rust-embed = { version = "8", features = ["interpolate-folder-path"] }
regex = "1" regex = "1"
anyhow = "1" anyhow = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
tempfile = "3.27.0"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
+211
View File
@@ -0,0 +1,211 @@
use std::io::{Seek, SeekFrom, Write};
use axum::{
body::Body,
extract::State,
http::header,
response::{IntoResponse, Response},
};
use sqlx::Row;
use tokio_util::io::ReaderStream;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::{auth::Session, error::AppResult, state::AppState};
/// One entry, flattened out of the database ahead of the blocking zip work.
struct ExportEntry {
id: i64,
title: String,
body: String,
created_at: i64,
}
/// `2026-09-05-first-light-12.md`. The id is always on the end, so two entries
/// written on the same day under the same title cannot collide.
fn entry_filename(entry: &ExportEntry) -> String {
let slug: String = entry
.title
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug = slug.trim_matches('-').split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");
let slug: String = slug.chars().take(60).collect();
let day = day_string(entry.created_at);
if slug.is_empty() {
format!("{day}-entry-{}.md", entry.id)
} else {
format!("{day}-{slug}-{}.md", entry.id)
}
}
/// `YYYY-MM-DD` in UTC, from a Unix timestamp, without pulling in a date crate.
fn day_string(seconds: i64) -> String {
let days = seconds.div_euclid(86_400);
// Civil-from-days, Howard Hinnant's algorithm.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
/// Uploads are stored under a uuid; the archive gives them their name back.
/// Only characters that are safe in a path *and* need no escaping inside a
/// markdown link survive, because the entries link to these names directly.
fn media_filename(id: &str, original: &str) -> String {
let safe: String = original
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
.collect();
let safe = safe.trim_matches(['.', '_']).to_string();
if safe.is_empty() {
id.to_string()
} else {
format!("{id}-{safe}")
}
}
const README: &str = "\
This is a complete copy of a ~/diary.
entries/ one markdown file per entry, named by the day it is about
media/ every file the entries embed
Embedded media is linked relatively, so the entries render correctly in any
markdown reader as long as the two folders stay next to each other. Nothing
here needs the diary application to read it.
";
/// Everything, as one zip. A diary you cannot get out of is not a diary you
/// can trust, so this is deliberately a plain archive of plain files.
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
let entries: Vec<ExportEntry> = sqlx::query(
"SELECT id, title, body, created_at FROM entries ORDER BY created_at, id",
)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|row| ExportEntry {
id: row.get("id"),
title: row.get("title"),
body: row.get("body"),
created_at: row.get("created_at"),
})
.collect();
let media: Vec<(String, String)> = sqlx::query("SELECT id, filename FROM media")
.fetch_all(&state.db)
.await?
.into_iter()
.map(|row| (row.get("id"), row.get("filename")))
.collect();
let uploads = state.config.uploads_dir();
// Zip writing is synchronous and reads every upload off disk, so it happens
// on the blocking pool, into a temporary file rather than into memory: a
// diary with photographs in it does not fit comfortably in a Vec.
let file = tokio::task::spawn_blocking(move || -> anyhow::Result<std::fs::File> {
let mut zip = ZipWriter::new(tempfile::tempfile()?);
let text = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
// Media is overwhelmingly already-compressed; deflating it again costs
// a lot of CPU to save nothing.
let blob = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("README.txt", text)?;
zip.write_all(README.as_bytes())?;
let names: Vec<(String, String)> = media
.iter()
.map(|(id, filename)| (id.clone(), media_filename(id, filename)))
.collect();
for entry in &entries {
let mut body = entry.body.clone();
for (id, name) in &names {
body = body.replace(&format!("/api/media/{id}"), &format!("../media/{name}"));
}
let document = if entry.title.trim().is_empty() {
body
} else {
format!("# {}\n\n{body}", entry.title.trim())
};
zip.start_file(format!("entries/{}", entry_filename(entry)), text)?;
zip.write_all(document.as_bytes())?;
}
for (id, name) in &names {
// A row whose file has gone missing must not fail the whole backup.
let Ok(bytes) = std::fs::read(uploads.join(id)) else {
continue;
};
zip.start_file(format!("media/{name}"), blob)?;
zip.write_all(&bytes)?;
}
let mut file = zip.finish()?;
file.seek(SeekFrom::Start(0))?;
Ok(file)
})
.await??;
let size = file.metadata()?.len();
let name = format!("diary-{}.zip", day_string(crate::now()));
Ok((
[
(header::CONTENT_TYPE, "application/zip".to_string()),
(header::CONTENT_LENGTH, size.to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{name}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
Body::from_stream(ReaderStream::new(tokio::fs::File::from_std(file))),
)
.into_response())
}
#[cfg(test)]
mod tests {
use super::{day_string, entry_filename, media_filename, ExportEntry};
#[test]
fn renders_days_without_a_date_crate() {
assert_eq!(day_string(0), "1970-01-01");
assert_eq!(day_string(1_757_030_400), "2025-09-05");
assert_eq!(day_string(951_782_400), "2000-02-29"); // a leap day the 100-year rule skips
assert_eq!(day_string(-1), "1969-12-31");
}
fn entry(title: &str, id: i64) -> ExportEntry {
ExportEntry { id, title: title.into(), body: String::new(), created_at: 0 }
}
#[test]
fn entry_names_are_readable_and_unique() {
assert_eq!(entry_filename(&entry("First Light", 12)), "1970-01-01-first-light-12.md");
assert_eq!(entry_filename(&entry("", 3)), "1970-01-01-entry-3.md");
assert_eq!(entry_filename(&entry("!!! ???", 4)), "1970-01-01-entry-4.md");
// Same day, same title, different entries.
assert_ne!(entry_filename(&entry("a", 1)), entry_filename(&entry("a", 2)));
}
#[test]
fn media_names_cannot_escape_their_folder() {
assert_eq!(media_filename("abc", "../../etc/passwd"), "abc-etc_passwd");
// Nothing that would need escaping in the markdown link that points at it.
assert_eq!(media_filename("abc", "my photo (1).png"), "abc-my_photo__1_.png");
assert_eq!(media_filename("abc", "photo.jpg"), "abc-photo.jpg");
assert_eq!(media_filename("abc", "..."), "abc");
}
}
+2
View File
@@ -1,4 +1,5 @@
pub mod entries; pub mod entries;
pub mod export;
pub mod media; pub mod media;
pub mod session; pub mod session;
pub mod share; pub mod share;
@@ -16,6 +17,7 @@ pub fn api_router() -> Router<AppState> {
.route("/login", post(session::login)) .route("/login", post(session::login))
.route("/logout", post(session::logout)) .route("/logout", post(session::logout))
.route("/entries", get(entries::list).post(entries::create)) .route("/entries", get(entries::list).post(entries::create))
.route("/export", get(export::export))
.route("/entries/{id}", get(entries::get)) .route("/entries/{id}", get(entries::get))
.route("/entries/{id}", put(entries::update)) .route("/entries/{id}", put(entries::update))
.route("/entries/{id}", delete(entries::remove)) .route("/entries/{id}", delete(entries::remove))
+10
View File
@@ -312,6 +312,16 @@ export const commands: CommandSpec[] = [
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
diary.say(`wrote ${anchor.download}`) diary.say(`wrote ${anchor.download}`)
}, },
bang: {
help: 'download the whole diary — every entry and every file — as a zip',
run: async () => {
// A plain navigation, so the browser streams the archive straight to
// disk instead of the tab holding all of it in memory first.
await diary.flush()
diary.say('building the archive — the download starts on its own')
location.href = '/api/export'
},
},
}, },
{ {
name: 'upload', name: 'upload',