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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user