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 serde::Serialize; use sqlx::PgPool; use tokio::sync::broadcast; use tokio_util::compat::TokioAsyncReadCompatExt; use uuid::Uuid; use crate::state::SseEvent; // ── DB query rows ──────────────────────────────────────────────────────────── #[derive(sqlx::FromRow)] struct ExportUploadRow { id: Uuid, original_path: String, mime_type: String, caption: Option, uploader_name: String, like_count: i64, created_at: DateTime, } #[derive(sqlx::FromRow)] struct ExportCommentRow { upload_id: Uuid, uploader_name: String, body: String, created_at: DateTime, } // ── Template context structs ───────────────────────────────────────────────── #[derive(Serialize)] struct TmplComment { uploader_name: String, body: String, created_at: String, } #[derive(Serialize)] struct TmplUpload { id: String, path: String, is_video: bool, caption: String, uploader_name: String, like_count: i64, created_at: String, comments: Vec, hashtags: Vec, } // ── Entry point ────────────────────────────────────────────────────────────── pub fn spawn_export_jobs( event_id: Uuid, event_name: String, pool: PgPool, media_path: PathBuf, sse_tx: broadcast::Sender, ) { 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, ) -> 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 export ────────────────────────────────────────────────────────────── async fn run_html_export( event_id: Uuid, event_name: &str, pool: &PgPool, media_path: &Path, sse_tx: &broadcast::Sender, ) -> Result<()> { mark_running(pool, event_id, "html").await; 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; let exports_dir = media_path.join("exports"); tokio::fs::create_dir_all(&exports_dir).await?; // Build template context let mut tmpl_uploads: Vec = Vec::new(); for (i, row) in uploads.iter().enumerate() { let ext = ext_from_path(&row.original_path); let date_str = 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 filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id); let upload_comments: Vec = comments .iter() .filter(|c| c.upload_id == row.id) .map(|c| TmplComment { uploader_name: c.uploader_name.clone(), body: c.body.clone(), created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(), }) .collect(); let tags: Vec = hashtags_per_upload .iter() .filter(|(uid, _)| *uid == row.id) .map(|(_, tag)| tag.clone()) .collect(); tmpl_uploads.push(TmplUpload { id: row.id.to_string(), path: format!("{folder}/{filename}"), is_video: row.mime_type.starts_with("video/"), caption: row.caption.clone().unwrap_or_default(), uploader_name: row.uploader_name.clone(), like_count: row.like_count, created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(), comments: upload_comments, hashtags: tags, }); let pct = ((i + 1) as f32 / total * 50.0) as i16; update_progress(pool, event_id, "html", pct.min(49)).await; } // Render HTML let mut env = minijinja::Environment::new(); env.add_template("memories", MEMORIES_TEMPLATE) .context("template compile error")?; let tmpl = env.get_template("memories").unwrap(); let html = tmpl .render(minijinja::context!( event_name => event_name, uploads => minijinja::Value::from_serialize(&tmpl_uploads), generated_at => Utc::now().format("%d.%m.%Y").to_string(), )) .context("template render error")?; update_progress(pool, event_id, "html", 55).await; 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); // Memories.html { let builder = ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate); let mut entry = zip.write_entry_stream(builder).await?; let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes())); fcopy(&mut cursor, &mut entry).await?; entry.close().await?; } update_progress(pool, event_id, "html", 60).await; // README.txt { let builder = ZipEntryBuilder::new("Memories/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?; } // Media files 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_str = 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 filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id); let entry_name = format!("Memories/{folder}/{filename}"); 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 = 60 + ((i + 1) as f32 / total * 39.0) as i16; update_progress(pool, event_id, "html", 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 = '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 export complete for event {event_id}"); Ok(()) } // ── DB helpers ─────────────────────────────────────────────────────────────── async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result> { 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> { 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> { 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, ) { 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(), }); } } } 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 = "Willkommen in der Event-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 die Datei \"Memories.html\" in deinem Browser\n\ (z. B. Chrome, Safari oder Firefox).\n\ 3. Stöbere durch alle Fotos und Videos.\n\ Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\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"; const MEMORIES_TEMPLATE: &str = r#" {{ event_name }} – Erinnerungen

{{ event_name }}

Erinnerungen · Erstellt am {{ generated_at }}

{% set ns = namespace(all_tags=[]) %} {% for u in uploads %}{% for t in u.hashtags %}{% if t not in ns.all_tags %}{% set ns.all_tags = ns.all_tags + [t] %}{% endif %}{% endfor %}{% endfor %} {% if ns.all_tags %}
Alle {% for tag in ns.all_tags %}#{{ tag }}{% endfor %}
{% endif %} {% if uploads %}
{% for u in uploads %}
{% if u.is_video %}
{% else %} {% endif %}
{{ u.uploader_name }} · {{ u.created_at }}
{% if u.caption %}
{{ u.caption }}
{% endif %}
♡ {{ u.like_count }} {% if u.comments %}💬 {{ u.comments | length }}{% endif %}
{% endfor %}
{% else %}
Noch keine Fotos vorhanden.
{% endif %}
×
{{ event_name }} · Offline-Galerie · EventSnap
"#;