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:
+192
-1
@@ -2,7 +2,9 @@ use crate::captioner::{
|
||||
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
|
||||
CaptionVisionProbe,
|
||||
};
|
||||
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag};
|
||||
use crate::db::{
|
||||
self, Album, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag,
|
||||
};
|
||||
use crate::embedder;
|
||||
use crate::hnsw_index;
|
||||
use crate::indexer::{self, WatcherHandle};
|
||||
@@ -1647,6 +1649,17 @@ pub async fn get_images_by_ids(
|
||||
db::get_images_by_ids(&conn, ¶ms.image_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Which acceleration variant this binary was compiled with. Used to badge the
|
||||
/// version in Settings so it's clear whether the CPU or CUDA build is running.
|
||||
#[tauri::command]
|
||||
pub fn get_build_variant() -> String {
|
||||
if cfg!(feature = "candle-cuda") {
|
||||
"cuda".to_string()
|
||||
} else {
|
||||
"cpu".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
|
||||
|
||||
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
@@ -2061,6 +2074,184 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
|
||||
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Albums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAlbumParams {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenameAlbumParams {
|
||||
pub album_id: i64,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAlbumParams {
|
||||
pub album_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReorderAlbumsParams {
|
||||
pub album_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAlbumsParams {
|
||||
pub album_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AlbumImagesParams {
|
||||
pub album_id: i64,
|
||||
pub image_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAlbumImagesParams {
|
||||
pub album_id: i64,
|
||||
pub sort: Option<String>,
|
||||
pub offset: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::list_albums(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_album(db: State<'_, DbState>, params: CreateAlbumParams) -> Result<Album, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let name = params.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Album name cannot be empty".to_string());
|
||||
}
|
||||
db::create_album(&conn, name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rename_album(db: State<'_, DbState>, params: RenameAlbumParams) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let name = params.new_name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Album name cannot be empty".to_string());
|
||||
}
|
||||
db::rename_album(&conn, params.album_id, name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::delete_album(&conn, params.album_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_albums(db: State<'_, DbState>, params: ReorderAlbumsParams) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::reorder_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_albums(db: State<'_, DbState>, params: DeleteAlbumsParams) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::delete_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_images_to_album(
|
||||
db: State<'_, DbState>,
|
||||
params: AlbumImagesParams,
|
||||
) -> Result<i64, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::add_images_to_album(&conn, params.album_id, ¶ms.image_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_images_from_album(
|
||||
db: State<'_, DbState>,
|
||||
params: AlbumImagesParams,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::remove_images_from_album(&conn, params.album_id, ¶ms.image_ids)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_album_images(
|
||||
db: State<'_, DbState>,
|
||||
params: GetAlbumImagesParams,
|
||||
) -> Result<ImagesPage, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let sort = params.sort.as_deref().unwrap_or("position");
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(100);
|
||||
let total = db::count_album_images(&conn, params.album_id).map_err(|e| e.to_string())?;
|
||||
let images = db::get_album_images(&conn, params.album_id, sort, offset, limit)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ImagesPage {
|
||||
images,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk image operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkUpdateDetailsParams {
|
||||
pub image_ids: Vec<i64>,
|
||||
pub favorite: Option<bool>,
|
||||
pub rating: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkAddTagsParams {
|
||||
pub image_ids: Vec<i64>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkRemoveTagParams {
|
||||
pub image_ids: Vec<i64>,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bulk_update_details(
|
||||
db: State<'_, DbState>,
|
||||
params: BulkUpdateDetailsParams,
|
||||
) -> Result<Vec<ImageRecord>, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::bulk_update_details(&conn, ¶ms.image_ids, params.favorite, params.rating)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bulk_add_tags(
|
||||
db: State<'_, DbState>,
|
||||
params: BulkAddTagsParams,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::bulk_add_tags(&conn, ¶ms.image_ids, ¶ms.tags).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bulk_remove_tag(
|
||||
db: State<'_, DbState>,
|
||||
params: BulkRemoveTagParams,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::bulk_remove_tag_by_name(&conn, ¶ms.image_ids, ¶ms.tag).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue scope / folder-id persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -185,6 +185,19 @@ pub fn run() {
|
||||
commands::get_image_tags,
|
||||
commands::add_user_tag,
|
||||
commands::remove_tag,
|
||||
commands::list_albums,
|
||||
commands::create_album,
|
||||
commands::rename_album,
|
||||
commands::delete_album,
|
||||
commands::delete_albums,
|
||||
commands::reorder_albums,
|
||||
commands::add_images_to_album,
|
||||
commands::remove_images_from_album,
|
||||
commands::get_album_images,
|
||||
commands::bulk_update_details,
|
||||
commands::bulk_add_tags,
|
||||
commands::bulk_remove_tag,
|
||||
commands::get_build_variant,
|
||||
commands::search_tags_autocomplete,
|
||||
commands::find_duplicates,
|
||||
commands::load_duplicate_scan_cache,
|
||||
|
||||
Reference in New Issue
Block a user