feat: lightbox EXIF panel, tag management, reorderable albums

EXIF info panel:
- New on-demand get_image_exif command (kamadak-exif) returning camera/lens/
  aperture/shutter/ISO/focal-length and decimal GPS; read from the file when the
  lightbox opens (no DB schema change, works on already-indexed images).
- Lightbox shows a Camera panel; GPS opens the location in the browser via
  OpenStreetMap (adds opener:allow-open-url capability).

Tag management:
- Backend rename_tag (rename, or merge when the target exists) and delete_tag
  (library-wide), both clearing the tag-cloud cache; store actions invalidate
  tag caches, refresh Explore, and re-point/refresh an active tag-search.
- Explore -> Tag Cloud gains a Manage mode: a flat list with per-tag rename/
  merge/delete.

Albums:
- Drag-to-reorder in the sidebar via framer-motion Reorder with a hover handle;
  order persists through the existing reorder_albums command.

Reads live store order on drag end and robustly derives GPS hemisphere from raw
EXIF ref bytes (review follow-ups). CHANGELOG updated.
This commit is contained in:
2026-06-27 23:50:44 +01:00
parent 6bef90b7fb
commit a12e81d8bd
9 changed files with 574 additions and 5 deletions
+1
View File
@@ -6,6 +6,7 @@
"permissions": [
"core:default",
"opener:default",
"opener:allow-open-url",
"dialog:default",
"dialog:allow-open",
"fs:default",
+125
View File
@@ -1660,6 +1660,96 @@ pub fn get_build_variant() -> String {
}
}
/// Camera/EXIF metadata for the lightbox info panel. Read on demand from the
/// file (not stored in the DB) so it works on every already-indexed image
/// without a reindex. All fields are optional — absent tags are simply omitted.
#[derive(Serialize, Default)]
pub struct ImageExif {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
pub iso: Option<String>,
pub f_number: Option<String>,
pub exposure_time: Option<String>,
pub focal_length: Option<String>,
pub datetime_original: Option<String>,
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
}
fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option<f64> {
let field = exif.get_field(coord, exif::In::PRIMARY)?;
if let exif::Value::Rational(ref parts) = field.value {
if parts.len() >= 3 {
let degrees = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0;
// Read the hemisphere straight from the ref tag's ASCII bytes
// ("N"/"S"/"E"/"W") rather than its formatted display string.
let negative = exif
.get_field(reference, exif::In::PRIMARY)
.map(|f| match &f.value {
exif::Value::Ascii(values) => values
.iter()
.flatten()
.next()
.map(|&byte| byte == b'S' || byte == b'W')
.unwrap_or(false),
_ => false,
})
.unwrap_or(false);
return Some(if negative { -degrees } else { degrees });
}
}
None
}
fn extract_image_exif(path: &Path) -> ImageExif {
let mut out = ImageExif::default();
let Ok(file) = std::fs::File::open(path) else {
return out;
};
let mut reader = std::io::BufReader::new(file);
let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else {
return out;
};
let text = |tag: exif::Tag| -> Option<String> {
let value = exif
.get_field(tag, exif::In::PRIMARY)?
.display_value()
.with_unit(&exif)
.to_string();
let trimmed = value.trim().trim_matches('"').trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
};
out.make = text(exif::Tag::Make);
out.model = text(exif::Tag::Model);
out.lens = text(exif::Tag::LensModel);
out.iso = text(exif::Tag::PhotographicSensitivity);
out.f_number = text(exif::Tag::FNumber);
out.exposure_time = text(exif::Tag::ExposureTime);
out.focal_length = text(exif::Tag::FocalLength);
out.datetime_original = text(exif::Tag::DateTimeOriginal);
out.gps_lat = gps_coord(&exif, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef);
out.gps_lon = gps_coord(&exif, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef);
out
}
#[derive(Deserialize)]
pub struct GetImageExifParams {
pub image_id: i64,
}
#[tauri::command]
pub async fn get_image_exif(
db: State<'_, DbState>,
params: GetImageExifParams,
) -> Result<ImageExif, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let record = db::get_image_by_id(&conn, params.image_id).map_err(|e| e.to_string())?;
Ok(extract_image_exif(Path::new(&record.path)))
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -2074,6 +2164,41 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct RenameTagParams {
pub from: String,
pub to: String,
}
#[derive(Deserialize)]
pub struct DeleteTagParams {
pub tag: String,
}
#[tauri::command]
pub async fn rename_tag(db: State<'_, DbState>, params: RenameTagParams) -> Result<(), String> {
let from = params.from.trim();
let to = params.to.trim();
if from.is_empty() || to.is_empty() {
return Err("Tag names cannot be empty".to_string());
}
if from == to {
return Ok(());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::rename_tag(&conn, from, to).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_tag(db: State<'_, DbState>, params: DeleteTagParams) -> Result<i64, String> {
let tag = params.tag.trim();
if tag.is_empty() {
return Err("Tag name cannot be empty".to_string());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_tag(&conn, tag).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Albums
// ---------------------------------------------------------------------------
+29
View File
@@ -2624,6 +2624,35 @@ pub fn remove_tag(conn: &Connection, tag_id: i64) -> Result<()> {
Ok(())
}
/// Rename a tag across the whole library, or merge it into an existing tag when
/// `to` already exists. Images that already carry `to` keep a single instance
/// (the colliding source row is dropped).
pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
// Move rows where the target tag isn't already on that image; UNIQUE
// collisions are skipped (OR IGNORE)…
tx.execute(
"UPDATE OR IGNORE image_tags SET tag = ?2 WHERE tag = ?1",
params![from, to],
)?;
// …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(())
}
/// Delete a tag from every image in the library. Returns the number of rows removed.
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(removed)
}
pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> {
let inserted = conn.execute(
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
+3
View File
@@ -185,6 +185,9 @@ pub fn run() {
commands::get_image_tags,
commands::add_user_tag,
commands::remove_tag,
commands::rename_tag,
commands::delete_tag,
commands::get_image_exif,
commands::list_albums,
commands::create_album,
commands::rename_album,