feat: implementation of M1

This commit is contained in:
2026-04-04 14:18:09 +02:00
parent fd744573dd
commit 2d07ac7866
44 changed files with 12197 additions and 30 deletions

View File

@@ -0,0 +1,987 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use uuid::Uuid;
use walkdir::WalkDir;
use crate::db::fts;
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation};
use crate::util::sidecar::{MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar};
use crate::util::thumbnail::{
generate_all_thumbnails, image_dimensions, mime_from_extension,
ThumbnailFormat, THUMBNAIL_SIZES,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
media_translation_sidecar_path, now_unix_ms,
};
/// Report returned by `rebuild_media_from_filesystem`.
#[derive(Debug, Default)]
pub struct MediaRebuildReport {
pub media_created: usize,
pub media_updated: usize,
pub translations_created: usize,
pub translations_updated: usize,
pub errors: Vec<String>,
}
/// Import a media file (image, etc.) into the project.
pub fn import_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_path: &Path,
original_name: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
) -> EngineResult<Media> {
let id = Uuid::new_v4().to_string();
let now = now_unix_ms();
// Derive extension from original_name
let ext = Path::new(original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let filename = format!("{id}.{ext}");
// Compute target directory and copy file
let dir_path = media_dir_path(now);
let rel_file_path = format!("{dir_path}{filename}");
let abs_file_path = data_dir.join(&rel_file_path);
if let Some(parent) = abs_file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source_path, &abs_file_path)?;
// Try to get image dimensions (silently ignore errors for non-image files)
let (width, height) = image_dimensions(&abs_file_path)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.unwrap_or((None, None));
// Detect MIME type from extension
let mime_type = mime_from_extension(ext).to_string();
// Get file size
let file_size = fs::metadata(&abs_file_path)?.len() as i64;
// Compute sidecar path
let sidecar_rel = media_sidecar_path(&rel_file_path);
// Compute checksum of the copied file
let file_bytes = fs::read(&abs_file_path)?;
let checksum = content_hash(&file_bytes);
// Generate thumbnails (silently ignore errors for non-image files)
let thumbnails_dir = data_dir.join("thumbnails");
let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);
let media = Media {
id: id.clone(),
project_id: project_id.to_string(),
filename,
original_name: original_name.to_string(),
mime_type,
size: file_size,
width,
height,
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
author: author.map(|s| s.to_string()),
language: language.map(|s| s.to_string()),
file_path: rel_file_path,
sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum),
tags,
created_at: now,
updated_at: now,
};
// Write sidecar
let sidecar = MediaSidecar::from_media(&media, &[]);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Insert into DB
qm::insert_media(conn, &media)?;
// Index in FTS
fts_index_media(conn, &media)?;
Ok(media)
}
/// Update a media item's metadata fields.
pub fn update_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
title: Option<Option<&str>>,
alt: Option<Option<&str>>,
caption: Option<Option<&str>>,
author: Option<Option<&str>>,
language: Option<Option<&str>>,
tags: Option<Vec<String>>,
) -> EngineResult<Media> {
let mut media = qm::get_media_by_id(conn, media_id)?;
if let Some(t) = title {
media.title = t.map(|s| s.to_string());
}
if let Some(a) = alt {
media.alt = a.map(|s| s.to_string());
}
if let Some(c) = caption {
media.caption = c.map(|s| s.to_string());
}
if let Some(a) = author {
media.author = a.map(|s| s.to_string());
}
if let Some(l) = language {
media.language = l.map(|s| s.to_string());
}
if let Some(t) = tags {
media.tags = t;
}
media.updated_at = now_unix_ms();
qm::update_media(conn, &media)?;
// Rewrite sidecar (need linked_post_ids from post_media table)
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|pm| pm.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
let abs_sidecar = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Re-index FTS
fts_index_media(conn, &media)?;
Ok(media)
}
/// Delete a media item and all related artifacts.
pub fn delete_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete binary file
let abs_file = data_dir.join(&media.file_path);
if abs_file.exists() {
fs::remove_file(&abs_file)?;
}
// Delete sidecar file
let abs_sidecar = data_dir.join(&media.sidecar_path);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete all translation sidecar files
let translations = qmt::list_media_translations_by_media(conn, media_id).unwrap_or_default();
for t in &translations {
let trans_sidecar = media_translation_sidecar_path(&media.file_path, &t.language);
let abs_trans = data_dir.join(&trans_sidecar);
if abs_trans.exists() {
fs::remove_file(&abs_trans)?;
}
}
// Delete all thumbnails
let ext_map = |fmt: &ThumbnailFormat| -> &str {
match fmt {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
}
};
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = ext_map(&size.format);
let thumb_rel = format!("thumbnails/{prefix}/{media_id}-{}.{ext}", size.name);
let abs_thumb = data_dir.join(&thumb_rel);
if abs_thumb.exists() {
let _ = fs::remove_file(&abs_thumb);
}
}
// Delete media translations from DB
for t in &translations {
qmt::delete_media_translation(conn, media_id, &t.language)?;
}
// Delete post_media links from DB
let links = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
for link in &links {
qpm::unlink_media(conn, &link.post_id, media_id)?;
}
// Remove from FTS index
fts::remove_media_from_index(conn, media_id)?;
// Delete from media table
qm::delete_media(conn, media_id)?;
Ok(())
}
/// Create or update a translation for a media item.
pub fn upsert_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
) -> EngineResult<MediaTranslation> {
let media = qm::get_media_by_id(conn, media_id)?;
let now = now_unix_ms();
// Check if translation already exists
let existing = qmt::get_media_translation_by_media_and_language(conn, media_id, language);
let translation = match existing {
Ok(mut t) => {
t.title = title.map(|s| s.to_string());
t.alt = alt.map(|s| s.to_string());
t.caption = caption.map(|s| s.to_string());
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
t
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: media.project_id.clone(),
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
t
}
};
// Write translation sidecar file
let trans_sidecar = MediaTranslationSidecar {
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
};
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &trans_sidecar.to_string())?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
Ok(translation)
}
/// Delete a media translation and its sidecar file.
pub fn delete_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete from DB
qmt::delete_media_translation(conn, media_id, language)?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
Ok(())
}
/// Rebuild media entries from filesystem. Walk `media/` directory, parse sidecars, upsert into DB.
pub fn rebuild_media_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaRebuildReport> {
let mut report = MediaRebuildReport::default();
let media_dir = data_dir.join("media");
if !media_dir.exists() {
return Ok(report);
}
let mut canonical_sidecars = Vec::new();
let mut translation_sidecars = Vec::new();
for entry in WalkDir::new(&media_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if !file_name.ends_with(".meta") {
continue;
}
// Determine if this is a translation sidecar: {name}.{lang}.meta
// where lang is exactly 2 lowercase letters.
if is_translation_sidecar(file_name) {
translation_sidecars.push(path.to_path_buf());
} else {
canonical_sidecars.push(path.to_path_buf());
}
}
// Process canonical sidecars first
for path in &canonical_sidecars {
match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.media_created += 1;
} else {
report.media_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Process translation sidecars
for path in &translation_sidecars {
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.translations_created += 1;
} else {
report.translations_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Re-index FTS for all media in this project
let all_media = qm::list_media_by_project(conn, project_id)?;
for m in &all_media {
fts_index_media(conn, m)?;
}
Ok(report)
}
// --- Internal helpers ---
/// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
let translation_texts: Vec<String> = translations
.iter()
.map(|t| {
let mut parts = Vec::new();
if let Some(ref title) = t.title {
parts.push(title.clone());
}
if let Some(ref alt) = t.alt {
parts.push(alt.clone());
}
if let Some(ref caption) = t.caption {
parts.push(caption.clone());
}
parts.join(" ")
})
.collect();
let lang = media.language.as_deref().unwrap_or("en");
fts::index_media(
conn,
&media.id,
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_texts,
lang,
)?;
Ok(())
}
/// Check if a .meta filename is a translation sidecar: `{name}.{lang}.meta`
/// where lang is exactly 2 lowercase ASCII letters.
fn is_translation_sidecar(file_name: &str) -> bool {
// file_name ends with .meta
// Strip .meta suffix, then check if remaining ends with .{2-letter-lang}
let without_meta = match file_name.strip_suffix(".meta") {
Some(s) => s,
None => return false,
};
// Find the last dot in what remains
if let Some(dot_pos) = without_meta.rfind('.') {
let suffix = &without_meta[dot_pos + 1..];
// Must also have another dot before this (the extension of the media file)
// e.g. "foo.jpg.de" -> dot_pos points to the dot before "de"
// "foo.jpg" would be the canonical sidecar (no extra dot+lang)
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) && dot_pos > 0
} else {
false
}
}
/// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated.
fn rebuild_canonical_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
// Derive file_path: the sidecar path minus ".meta" suffix, relative to data_dir
let sidecar_rel = sidecar_path
.strip_prefix(data_dir)
.unwrap_or(sidecar_path)
.to_string_lossy()
.to_string();
let file_path = sidecar_rel
.strip_suffix(".meta")
.unwrap_or(&sidecar_rel)
.to_string();
let abs_file = data_dir.join(&file_path);
// Get file size (if file exists)
let file_size = if abs_file.exists() {
fs::metadata(&abs_file)?.len() as i64
} else {
sc.size
};
// Get checksum (if file exists)
let checksum = if abs_file.exists() {
let bytes = fs::read(&abs_file)?;
Some(content_hash(&bytes))
} else {
None
};
// Get dimensions (if file exists and is an image)
let (width, height) = if abs_file.exists() {
image_dimensions(&abs_file)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.unwrap_or((sc.width, sc.height))
} else {
(sc.width, sc.height)
};
// Derive filename from file_path
let filename = Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let now = now_unix_ms();
let existing = qm::get_media_by_id(conn, &sc.id);
match existing {
Ok(mut media) => {
// Update existing media
media.original_name = sc.original_name;
media.mime_type = sc.mime_type;
media.size = file_size;
media.width = width;
media.height = height;
media.title = sc.title;
media.alt = sc.alt;
media.caption = sc.caption;
media.author = sc.author;
media.language = sc.language;
media.file_path = file_path;
media.sidecar_path = sidecar_rel;
media.checksum = checksum;
media.tags = sc.tags;
media.updated_at = now;
qm::update_media(conn, &media)?;
Ok(false)
}
Err(_) => {
let media = Media {
id: sc.id,
project_id: project_id.to_string(),
filename,
original_name: sc.original_name,
mime_type: sc.mime_type,
size: file_size,
width,
height,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
author: sc.author,
language: sc.language,
file_path,
sidecar_path: sidecar_rel,
checksum,
tags: sc.tags,
created_at: sc.created_at,
updated_at: now,
};
qm::insert_media(conn, &media)?;
Ok(true)
}
}
}
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
fn rebuild_translation_sidecar(
conn: &Connection,
_data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_translation_sidecar(&content).map_err(EngineError::Parse)?;
// Check parent media exists
let _media = qm::get_media_by_id(conn, &sc.translation_for).map_err(|_| {
EngineError::NotFound(format!(
"parent media '{}' not found for translation",
sc.translation_for
))
})?;
let now = now_unix_ms();
let existing =
qmt::get_media_translation_by_media_and_language(conn, &sc.translation_for, &sc.language);
match existing {
Ok(mut t) => {
t.title = sc.title;
t.alt = sc.alt;
t.caption = sc.caption;
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
Ok(false)
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
translation_for: sc.translation_for,
language: sc.language,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::db::Database;
use image::DynamicImage;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
/// Create a simple 100x80 PNG in the given directory.
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test-source.png");
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn import_media_creates_artifacts() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("My Photo"),
Some("A photo"),
None,
Some("Alice"),
Some("en"),
vec!["nature".into()],
)
.unwrap();
// Verify DB entry
let from_db = qm::get_media_by_id(db.conn(), &media.id).unwrap();
assert_eq!(from_db.original_name, "photo.png");
assert_eq!(from_db.title.as_deref(), Some("My Photo"));
assert_eq!(from_db.alt.as_deref(), Some("A photo"));
assert_eq!(from_db.author.as_deref(), Some("Alice"));
assert_eq!(from_db.language.as_deref(), Some("en"));
assert_eq!(from_db.tags, vec!["nature"]);
assert_eq!(from_db.mime_type, "image/png");
assert_eq!(from_db.width, Some(100));
assert_eq!(from_db.height, Some(80));
assert!(from_db.checksum.is_some());
assert!(from_db.size > 0);
// Verify binary file exists
let abs_file = dir.path().join(&from_db.file_path);
assert!(abs_file.exists(), "binary file should exist");
// Verify sidecar exists
let abs_sidecar = dir.path().join(&from_db.sidecar_path);
assert!(abs_sidecar.exists(), "sidecar should exist");
// Verify sidecar content is parseable
let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_sidecar(&sidecar_content).unwrap();
assert_eq!(sc.id, media.id);
assert_eq!(sc.original_name, "photo.png");
// Verify thumbnails exist
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(thumb.exists(), "thumbnail {} should exist", size.name);
}
}
#[test]
fn update_media_rewrites_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("Original Title"),
None,
None,
None,
None,
vec![],
)
.unwrap();
// Read original sidecar
let abs_sidecar = dir.path().join(&media.sidecar_path);
let original_content = fs::read_to_string(&abs_sidecar).unwrap();
// Update
let updated = update_media(
db.conn(),
dir.path(),
&media.id,
Some(Some("New Title")),
Some(Some("New alt")),
None,
None,
None,
Some(vec!["updated-tag".into()]),
)
.unwrap();
assert_eq!(updated.title.as_deref(), Some("New Title"));
assert_eq!(updated.alt.as_deref(), Some("New alt"));
assert_eq!(updated.tags, vec!["updated-tag"]);
// Verify sidecar was rewritten
let new_content = fs::read_to_string(&abs_sidecar).unwrap();
assert_ne!(original_content, new_content);
let sc = read_sidecar(&new_content).unwrap();
assert_eq!(sc.title.as_deref(), Some("New Title"));
assert_eq!(sc.tags, vec!["updated-tag"]);
}
#[test]
fn delete_media_removes_everything() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let abs_file = dir.path().join(&media.file_path);
let abs_sidecar = dir.path().join(&media.sidecar_path);
assert!(abs_file.exists());
assert!(abs_sidecar.exists());
// Delete
delete_media(db.conn(), dir.path(), &media.id).unwrap();
// Verify file gone
assert!(!abs_file.exists(), "binary file should be removed");
// Verify sidecar gone
assert!(!abs_sidecar.exists(), "sidecar should be removed");
// Verify DB entry gone
assert!(qm::get_media_by_id(db.conn(), &media.id).is_err());
// Verify thumbnails gone
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(!thumb.exists(), "thumbnail {} should be removed", size.name);
}
}
#[test]
fn upsert_media_translation_writes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
let t = upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Deutsches Foto"),
Some("Ein Foto"),
None,
)
.unwrap();
assert_eq!(t.language, "de");
assert_eq!(t.title.as_deref(), Some("Deutsches Foto"));
// Verify translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists(), "translation sidecar should exist");
let content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_translation_sidecar(&content).unwrap();
assert_eq!(sc.language, "de");
assert_eq!(sc.title.as_deref(), Some("Deutsches Foto"));
}
#[test]
fn delete_media_translation_removes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Titel"),
None,
None,
)
.unwrap();
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists());
// Delete translation
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
// Sidecar should be gone
assert!(!abs_sidecar.exists(), "translation sidecar should be removed");
// DB entry should be gone
assert!(
qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_err()
);
}
#[test]
fn rebuild_media_from_filesystem_test() {
let (db, dir) = setup();
// Create a fake media file and its sidecar manually
let media_subdir = dir.path().join("media").join("2024").join("01");
fs::create_dir_all(&media_subdir).unwrap();
// Write a dummy binary file
let media_file = media_subdir.join("abcdef12-test-uuid.png");
fs::write(&media_file, b"fake-png-data").unwrap();
// Write a canonical sidecar
let sidecar_content = "\
---
id: abcdef12-test-uuid
originalName: \"photo.png\"
mimeType: image/png
size: 13
title: \"Rebuild Test\"
alt: \"An image\"
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: [\"test\"]
---";
fs::write(media_subdir.join("abcdef12-test-uuid.png.meta"), sidecar_content).unwrap();
// Write a translation sidecar
let trans_sidecar_content = "\
---
translationFor: abcdef12-test-uuid
language: de
title: \"Wiederherstellungstest\"
alt: \"Ein Bild\"
---";
fs::write(
media_subdir.join("abcdef12-test-uuid.png.de.meta"),
trans_sidecar_content,
)
.unwrap();
// Run rebuild
let report = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.media_created, 1);
assert_eq!(report.translations_created, 1);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Verify media in DB
let media = qm::get_media_by_id(db.conn(), "abcdef12-test-uuid").unwrap();
assert_eq!(media.title.as_deref(), Some("Rebuild Test"));
assert_eq!(media.tags, vec!["test"]);
assert_eq!(media.original_name, "photo.png");
// Verify translation in DB
let trans =
qmt::get_media_translation_by_media_and_language(db.conn(), "abcdef12-test-uuid", "de")
.unwrap();
assert_eq!(trans.title.as_deref(), Some("Wiederherstellungstest"));
// Run rebuild again - should update, not create
let report2 = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report2.media_created, 0);
assert_eq!(report2.media_updated, 1);
assert_eq!(report2.translations_created, 0);
assert_eq!(report2.translations_updated, 1);
}
#[test]
fn is_translation_sidecar_detection() {
assert!(is_translation_sidecar("photo.jpg.de.meta"));
assert!(is_translation_sidecar("photo.jpg.fr.meta"));
assert!(is_translation_sidecar("uuid.png.en.meta"));
assert!(!is_translation_sidecar("photo.jpg.meta"));
assert!(!is_translation_sidecar("photo.meta"));
assert!(!is_translation_sidecar("photo.jpg.123.meta"));
assert!(!is_translation_sidecar("photo.jpg.DEU.meta"));
}
}