Files
EventSnap/backend/src/services/export.rs
MechaCat02 ffc926bf4d feat: replace HTML export with SvelteKit viewer
Replace the minijinja-based HTML export with a full SvelteKit static
viewer app. The new export produces a ZIP with:
- Pre-built viewer assets (index.html + JS/CSS bundle)
- data.json with all posts, comments, tags, and like counts
- Processed media: 400px thumbnails for grid, full images (2000px
  cap if >5MB), video thumbnails via ffmpeg

Remove minijinja dependency, add include_dir to embed viewer assets
at compile time. Update Dockerfile to copy static/ for builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:03 +02:00

618 lines
21 KiB
Rust

use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo};
use include_dir::{include_dir, Dir};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid;
use crate::state::SseEvent;
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
// ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ExportUploadRow {
id: Uuid,
original_path: String,
mime_type: String,
caption: Option<String>,
uploader_name: String,
like_count: i64,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ExportCommentRow {
upload_id: Uuid,
uploader_name: String,
body: String,
created_at: DateTime<Utc>,
}
// ── Viewer JSON structs (serialised to data.json) ───────────────────────────
#[derive(Serialize)]
struct ViewerData {
event: ViewerEvent,
posts: Vec<ViewerPost>,
}
#[derive(Serialize)]
struct ViewerEvent {
name: String,
exported_at: String,
}
#[derive(Serialize)]
struct ViewerPost {
id: String,
uploader: String,
caption: String,
tags: Vec<String>,
timestamp: String,
likes: i64,
comments: Vec<ViewerComment>,
media: ViewerMedia,
}
#[derive(Serialize)]
struct ViewerComment {
author: String,
text: String,
timestamp: String,
}
#[derive(Serialize)]
struct ViewerMedia {
#[serde(rename = "type")]
media_type: String,
thumb: String,
full: String,
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
// ── ZIP export ───────────────────────────────────────────────────────────────
async fn run_zip_export(
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
let out_path = exports_dir.join("Gallery.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
});
tracing::info!("ZIP export complete for event {event_id}");
Ok(())
}
// ── HTML viewer export ──────────────────────────────────────────────────────
async fn run_html_export(
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
// 1. Query data
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
update_progress(pool, event_id, "html", 5).await;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
let media_tmp = tmp_dir.join("media");
tokio::fs::create_dir_all(&media_tmp).await?;
// 3. Process media and build post data
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let is_video = row.mime_type.starts_with("video/");
let id_str = row.id.to_string();
// Generate thumbnail and full variant
let (thumb_name, full_name) = if is_video {
let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}");
// Video thumbnail via ffmpeg
let thumb_path = media_tmp.join(&thumb);
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
.args([
"-i",
src.to_str().unwrap_or_default(),
"-vframes",
"1",
"-ss",
"00:00:01",
"-vf",
"scale=400:-1",
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.output()
.await;
match ffmpeg_result {
Ok(output) if output.status.success() => {}
_ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
// Create empty thumb entry — viewer handles missing thumbs gracefully
}
}
// Copy video as-is
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
(thumb, full)
} else {
let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path);
let full = format!("{id_str}_full.{ext}");
// Image thumbnail: resize to 400px wide
let src_clone = src.clone();
let thumb_path = media_tmp.join(&thumb);
let thumb_path_clone = thumb_path.clone();
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
.context("failed to save thumbnail")?;
Ok(())
})
.await?;
if let Err(e) = thumb_result {
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
}
// Full variant: compress if >5MB, otherwise copy original
let src_meta = tokio::fs::metadata(&src).await?;
let full_path = media_tmp.join(&full);
if src_meta.len() > 5_000_000 {
// Resize to max 2000px
let src_clone = src.clone();
let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img =
image::open(&src_clone).context("failed to open image for compression")?;
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
.context("failed to save compressed full image")?;
Ok(())
})
.await?;
if let Err(e) = compress_result {
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
tokio::fs::copy(&src, &full_path).await?;
}
} else {
tokio::fs::copy(&src, &full_path).await?;
}
(thumb, full)
};
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
.iter()
.filter(|c| c.upload_id == row.id)
.map(|c| ViewerComment {
author: c.uploader_name.clone(),
text: c.body.clone(),
timestamp: c.created_at.to_rfc3339(),
})
.collect();
// Build tags for this upload
let tags: Vec<String> = hashtags_per_upload
.iter()
.filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone())
.collect();
viewer_posts.push(ViewerPost {
id: id_str,
uploader: row.uploader_name.clone(),
caption: row.caption.clone().unwrap_or_default(),
tags,
timestamp: row.created_at.to_rfc3339(),
likes: row.like_count,
comments: post_comments,
media: ViewerMedia {
media_type: if is_video {
"video".to_string()
} else {
"image".to_string()
},
thumb: format!("media/{thumb_name}"),
full: format!("media/{full_name}"),
},
});
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
update_progress(pool, event_id, "html", pct.min(69)).await;
}
// 4. Build data.json
let viewer_data = ViewerData {
event: ViewerEvent {
name: event_name.to_string(),
exported_at: Utc::now().to_rfc3339(),
},
posts: viewer_posts,
};
let data_json =
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
update_progress(pool, event_id, "html", 75).await;
// Write data.json
{
let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
// Write README.txt
{
let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
update_progress(pool, event_id, "html", 78).await;
// Write media files from temp directory
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
let mut file_count = 0u32;
let mut files_written = 0u32;
// Count files first
{
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
while counter.next_entry().await?.is_some() {
file_count += 1;
}
}
let file_total = file_count.max(1) as f32;
while let Some(dir_entry) = media_entries.next_entry().await? {
let filename = dir_entry.file_name();
let entry_name = format!("media/{}", filename.to_string_lossy());
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut zip_entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
fcopy(&mut f, &mut zip_entry).await?;
zip_entry.close().await?;
files_written += 1;
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
update_progress(pool, event_id, "html", pct.min(98)).await;
}
zip.close().await?;
}
// 6. Finalise
tokio::fs::rename(&tmp_path, &out_path).await?;
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
)
.bind(event_id)
.bind("exports/Memories.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
});
tracing::info!("HTML viewer export complete for event {event_id}");
Ok(())
}
// ── DB helpers ───────────────────────────────────────────────────────────────
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
Ok(sqlx::query_as::<_, ExportUploadRow>(
"SELECT u.id, u.original_path, u.mime_type, u.caption,
usr.display_name AS uploader_name,
COUNT(DISTINCT l.user_id) AS like_count,
u.created_at
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
Ok(sqlx::query_as::<_, ExportCommentRow>(
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" usr ON usr.id = c.user_id
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT uh.upload_id, h.tag
FROM upload_hashtag uh
JOIN hashtag h ON h.id = uh.hashtag_id
JOIN upload u ON u.id = uh.upload_id
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
)
.bind(event_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.execute(pool)
.await;
}
async fn maybe_broadcast_complete(
pool: &PgPool,
event_id: Uuid,
sse_tx: &broadcast::Sender<SseEvent>,
) {
let row: Option<(bool, bool)> = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some((zip_ready, html_ready)) = row {
if zip_ready && html_ready {
let _ = sse_tx.send(SseEvent {
event_type: "export-available".to_string(),
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
});
}
}
}
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
async fn write_dir_to_zip(
dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>,
) -> Result<()> {
for file in dir.files() {
let path = file.path().to_string_lossy().to_string();
let contents = file.contents();
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
}
Ok(())
}
fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin")
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
// ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "EventSnap Offline-Galerie\n\
\n\
So geht's:\n\
1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\
2. Öffne \"index.html\" im Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\
Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
nach Hashtags filtern und nach Nutzern suchen.\n\
4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\
Viel Freude mit den Erinnerungen!\n";