feat: manual albums + gallery multi-select with bulk actions

Albums (manual collections):
- New albums/album_images tables with FK cascades; DB functions and Tauri
  commands for create/rename/delete/delete-many/reorder/list, add/remove
  images, and paginated get_album_images.
- Distinct sidebar "ALBUMS" section with cover thumbnails, create/rename/
  delete, and a Manage multi-select mode for bulk album deletion.
- Album view reuses the gallery grid (activeView "album" + selectedAlbumId);
  spans folders; add from the bulk bar or the lightbox, remove from within.

Gallery multi-select + bulk actions:
- gallerySelectedIds selection model with a top-left corner checkbox that
  reveals on corner hover; click-to-toggle in selection mode, double-click
  to open.
- Floating BulkActionBar: tag (inline autocomplete popover), rating,
  favorite, add-to-album, and a delete with an explicit "from disk"
  confirmation. Batch commands bulk_update_details/bulk_add_tags/
  bulk_remove_tag.

Also:
- Duplicate Finder delete now requires confirmation with clear "from disk"
  wording (was single-click fire-and-forget).
- CPU/CUDA build-variant badge in Settings (get_build_variant).
- Rating/favorite no longer re-sorts derived collections (similar/region/
  semantic/tag/album results); single and bulk paths replace in place there.
- Album-aware indexed-images/media-updated handlers so thumbnails paint and
  newly-indexed files don't leak into an album view.
- CHANGELOG updated.
This commit is contained in:
2026-06-27 15:23:54 +01:00
parent 55cd3b5aa7
commit 6bef90b7fb
15 changed files with 1901 additions and 44 deletions
+286
View File
@@ -34,6 +34,18 @@ pub struct Folder {
pub sort_order: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: i64,
pub name: String,
pub cover_image_id: Option<i64>,
pub cover_thumbnail_path: Option<String>,
pub image_count: i64,
pub sort_order: i64,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageRecord {
pub id: i64,
@@ -285,6 +297,30 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cover_image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
-- Forward-compat for smart albums (saved searches); unused in v1.
-- 'manual' = a curated set held in album_images.
kind TEXT NOT NULL DEFAULT 'manual',
query_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS album_images (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (album_id, image_id)
);
CREATE INDEX IF NOT EXISTS idx_album_images_album ON album_images(album_id);
CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_id);
",
)?;
@@ -1586,6 +1622,256 @@ pub fn reorder_folders(conn: &Connection, folder_ids: &[i64]) -> Result<()> {
Ok(())
}
// ── Albums ────────────────────────────────────────────────────────────────────
fn map_album_row(row: &Row<'_>) -> rusqlite::Result<Album> {
Ok(Album {
id: row.get(0)?,
name: row.get(1)?,
cover_image_id: row.get(2)?,
cover_thumbnail_path: row.get(3)?,
image_count: row.get(4)?,
sort_order: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
/// SELECT that resolves each album's cover thumbnail (explicit cover, else the
/// first member by position) and live image count. Shared by list/get-one.
const ALBUM_SELECT: &str = "
SELECT a.id, a.name, a.cover_image_id,
(SELECT ci.thumbnail_path FROM images ci
WHERE ci.id = COALESCE(
a.cover_image_id,
(SELECT ai.image_id FROM album_images ai
WHERE ai.album_id = a.id
ORDER BY ai.position, ai.added_at LIMIT 1)
)) AS cover_thumbnail_path,
(SELECT COUNT(*) FROM album_images ai WHERE ai.album_id = a.id) AS image_count,
a.sort_order, a.created_at, a.updated_at
FROM albums a";
pub fn list_albums(conn: &Connection) -> Result<Vec<Album>> {
let sql = format!("{ALBUM_SELECT} ORDER BY a.sort_order, a.id");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], map_album_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_album(conn: &Connection, album_id: i64) -> Result<Album> {
let sql = format!("{ALBUM_SELECT} WHERE a.id = ?1");
conn.query_row(&sql, [album_id], map_album_row)
.map_err(Into::into)
}
pub fn create_album(conn: &Connection, name: &str) -> Result<Album> {
let id: i64 = conn.query_row(
"INSERT INTO albums (name, sort_order)
VALUES (?1, COALESCE((SELECT MAX(sort_order) + 1 FROM albums), 1))
RETURNING id",
params![name],
|row| row.get(0),
)?;
get_album(conn, id)
}
pub fn rename_album(conn: &Connection, album_id: i64, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE albums SET name = ?2, updated_at = datetime('now') WHERE id = ?1",
params![album_id, new_name],
)?;
Ok(())
}
pub fn delete_album(conn: &Connection, album_id: i64) -> Result<()> {
// album_images rows cascade away via the FK.
conn.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
Ok(())
}
/// Delete many albums at once (membership rows cascade away via the FK).
pub fn delete_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for album_id in album_ids {
tx.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
}
tx.commit()?;
Ok(())
}
pub fn reorder_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, album_id) in album_ids.iter().enumerate() {
tx.execute(
"UPDATE albums SET sort_order = ?2 WHERE id = ?1",
params![album_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
/// Append images to an album (idempotent). Returns the number newly added.
pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let mut next_position: i64 = tx.query_row(
"SELECT COALESCE(MAX(position) + 1, 0) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
let mut added = 0i64;
for image_id in image_ids {
let changed = tx.execute(
"INSERT INTO album_images (album_id, image_id, position)
VALUES (?1, ?2, ?3)
ON CONFLICT(album_id, image_id) DO NOTHING",
params![album_id, image_id, next_position],
)?;
if changed > 0 {
next_position += 1;
added += 1;
}
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(added)
}
pub fn remove_images_from_album(
conn: &Connection,
album_id: i64,
image_ids: &[i64],
) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM album_images WHERE album_id = ?1 AND image_id = ?2",
params![album_id, image_id],
)?;
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(())
}
pub fn count_album_images(conn: &Connection, album_id: i64) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
Ok(count)
}
pub fn get_album_images(
conn: &Connection,
album_id: i64,
sort: &str,
offset: i64,
limit: i64,
) -> Result<Vec<ImageRecord>> {
// Default to curated order (album_images.position); otherwise honor the same
// sort vocabulary as get_images. Albums span folders, so no folder filter.
let order = match sort {
"name_asc" => "i.filename ASC",
"name_desc" => "i.filename DESC",
"date_asc" => "i.modified_at ASC NULLS LAST",
"date_desc" => "i.modified_at DESC NULLS LAST",
"size_asc" => "i.file_size ASC",
"size_desc" => "i.file_size DESC",
"rating_asc" => "i.rating ASC, i.modified_at DESC NULLS LAST",
"rating_desc" => "i.rating DESC, i.modified_at DESC NULLS LAST",
"duration_asc" => "i.duration_ms ASC NULLS LAST",
"duration_desc" => "i.duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(i.taken_at, i.modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(i.taken_at, i.modified_at) DESC NULLS LAST",
_ => "ai.position ASC",
};
let sql = format!(
"SELECT i.id, i.folder_id, i.path, i.filename, i.thumbnail_path, i.width, i.height, i.file_size, i.created_at, i.modified_at, i.taken_at, i.mime_type,
i.media_kind, i.duration_ms, i.video_codec, i.audio_codec, i.metadata_updated_at, i.metadata_error,
i.favorite, i.rating, i.embedding_status, i.embedding_model, i.embedding_updated_at, i.embedding_error,
i.generated_caption, i.caption_model, i.caption_updated_at, i.caption_error,
i.ai_rating, i.ai_tagger_model, i.ai_tagged_at, i.ai_tagger_error
FROM images i
JOIN album_images ai ON ai.image_id = i.id
WHERE ai.album_id = ?1
ORDER BY {order}
LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![album_id, limit, offset], map_image_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
// ── Bulk image operations ──────────────────────────────────────────────────────
/// Apply favorite and/or rating to many images at once; returns the updated rows.
pub fn bulk_update_details(
conn: &Connection,
image_ids: &[i64],
favorite: Option<bool>,
rating: Option<i64>,
) -> Result<Vec<ImageRecord>> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"UPDATE images
SET favorite = COALESCE(?2, favorite),
rating = COALESCE(?3, rating)
WHERE id = ?1",
params![image_id, favorite, rating],
)?;
}
tx.commit()?;
get_images_by_ids(conn, image_ids)
}
/// Add one or more user tags to many images at once.
pub fn bulk_add_tags(conn: &Connection, image_ids: &[i64], tags: &[String]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
continue;
}
tx.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
ON CONFLICT(image_id, tag) DO UPDATE SET
source = 'user',
ai_model = NULL,
confidence = NULL
WHERE source = 'ai'",
params![image_id, trimmed],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Remove a tag (by name) from many images at once.
pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM image_tags WHERE image_id = ?1 AND tag = ?2",
params![image_id, tag],
)?;
}
tx.commit()?;
Ok(())
}
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%";
let repaired = conn.execute(