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:
@@ -6,6 +6,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"opener:allow-open-url",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user