style(backend): apply rustfmt across the backend
Mechanical cargo fmt pass so the CI rustfmt gate starts green; no semantic changes (verified token-level + cargo check).
This commit is contained in:
+6
-2
@@ -15,13 +15,17 @@ fn main() {
|
|||||||
if let Ok(path) = &cuda_path {
|
if let Ok(path) = &cuda_path {
|
||||||
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
|
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
|
||||||
} else {
|
} else {
|
||||||
println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled.");
|
println!(
|
||||||
|
"cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
|
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
|
||||||
println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads");
|
println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads");
|
||||||
println!("cargo:warning= Or build without GPU support: cargo build --no-default-features");
|
println!(
|
||||||
|
"cargo:warning= Or build without GPU support: cargo build --no-default-features"
|
||||||
|
);
|
||||||
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-35
@@ -499,7 +499,8 @@ pub async fn find_similar_by_region(
|
|||||||
None => {
|
None => {
|
||||||
// Fetch one extra candidate to compensate for the source image that
|
// Fetch one extra candidate to compensate for the source image that
|
||||||
// will be removed, so has_more is accurate and results span multiple pages.
|
// will be removed, so has_more is accurate and results span multiple pages.
|
||||||
let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
let mut ids =
|
||||||
|
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
ids.retain(|&id| id != params.image_id);
|
ids.retain(|&id| id != params.image_id);
|
||||||
ids
|
ids
|
||||||
@@ -639,7 +640,12 @@ pub async fn search_images_by_tag(
|
|||||||
offset,
|
offset,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(TagSearchPage { images, total, offset, limit })
|
Ok(TagSearchPage {
|
||||||
|
images,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -835,7 +841,6 @@ pub struct TagCloudEntry {
|
|||||||
pub image_ids: Vec<i64>,
|
pub image_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Clusters the library's image embeddings with k-means and returns one representative
|
/// Clusters the library's image embeddings with k-means and returns one representative
|
||||||
/// image per cluster — the member whose embedding is closest to its cluster centroid.
|
/// image per cluster — the member whose embedding is closest to its cluster centroid.
|
||||||
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
|
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
|
||||||
@@ -957,7 +962,8 @@ pub async fn get_explore_tags(
|
|||||||
params: GetExploreTagsParams,
|
params: GetExploreTagsParams,
|
||||||
) -> Result<Vec<ExploreTagEntry>, String> {
|
) -> Result<Vec<ExploreTagEntry>, String> {
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)).map_err(|e| e.to_string())
|
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -969,7 +975,12 @@ pub async fn search_tags_autocomplete(
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
db::search_tags_autocomplete(&conn, ¶ms.query, params.folder_id, params.limit.unwrap_or(10))
|
db::search_tags_autocomplete(
|
||||||
|
&conn,
|
||||||
|
¶ms.query,
|
||||||
|
params.folder_id,
|
||||||
|
params.limit.unwrap_or(10),
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,12 +996,15 @@ pub async fn find_duplicates(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let total = records.len();
|
let total = records.len();
|
||||||
let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "checking".to_string(),
|
phase: "checking".to_string(),
|
||||||
processed: 0,
|
processed: 0,
|
||||||
total,
|
total,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Three-phase detection.
|
// Three-phase detection.
|
||||||
//
|
//
|
||||||
@@ -1007,8 +1021,11 @@ pub async fn find_duplicates(
|
|||||||
// For sample-hash groups that contain files > 64 KB, compute a full-file
|
// For sample-hash groups that contain files > 64 KB, compute a full-file
|
||||||
// hash to confirm the match before presenting them as deletable duplicates.
|
// hash to confirm the match before presenting them as deletable duplicates.
|
||||||
let app_hash = app.clone();
|
let app_hash = app.clone();
|
||||||
let (pairs, skipped_before_confirm, candidate_total):
|
let (pairs, skipped_before_confirm, candidate_total): (
|
||||||
(Vec<(u64, u64, i64, String)>, usize, usize) = tokio::task::spawn_blocking(move || {
|
Vec<(u64, u64, i64, String)>,
|
||||||
|
usize,
|
||||||
|
usize,
|
||||||
|
) = tokio::task::spawn_blocking(move || {
|
||||||
use memmap2::Mmap;
|
use memmap2::Mmap;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -1048,12 +1065,15 @@ pub async fn find_duplicates(
|
|||||||
};
|
};
|
||||||
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == total {
|
if done % 100 == 0 || done == total {
|
||||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app_hash.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "checking".to_string(),
|
phase: "checking".to_string(),
|
||||||
processed: done,
|
processed: done,
|
||||||
total,
|
total,
|
||||||
skipped: stat_skipped.load(Ordering::Relaxed),
|
skipped: stat_skipped.load(Ordering::Relaxed),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
@@ -1076,12 +1096,15 @@ pub async fn find_duplicates(
|
|||||||
|
|
||||||
// ── Phase 2: sample hash ──────────────────────────────────────────
|
// ── Phase 2: sample hash ──────────────────────────────────────────
|
||||||
let c_total = candidates.len();
|
let c_total = candidates.len();
|
||||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app_hash.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "hashing".to_string(),
|
phase: "hashing".to_string(),
|
||||||
processed: 0,
|
processed: 0,
|
||||||
total: c_total,
|
total: c_total,
|
||||||
skipped: stat_skipped,
|
skipped: stat_skipped,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
let counter = AtomicUsize::new(0);
|
let counter = AtomicUsize::new(0);
|
||||||
let hash_skipped = AtomicUsize::new(0);
|
let hash_skipped = AtomicUsize::new(0);
|
||||||
|
|
||||||
@@ -1103,12 +1126,15 @@ pub async fn find_duplicates(
|
|||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == c_total {
|
if done % 100 == 0 || done == c_total {
|
||||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app_hash.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "hashing".to_string(),
|
phase: "hashing".to_string(),
|
||||||
processed: done,
|
processed: done,
|
||||||
total: c_total,
|
total: c_total,
|
||||||
skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed),
|
skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
@@ -1126,7 +1152,10 @@ pub async fn find_duplicates(
|
|||||||
let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<(i64, String)>> =
|
let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<(i64, String)>> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
for (hash, file_size, id, path) in pairs {
|
for (hash, file_size, id, path) in pairs {
|
||||||
size_hash_map.entry((hash, file_size)).or_default().push((id, path));
|
size_hash_map
|
||||||
|
.entry((hash, file_size))
|
||||||
|
.or_default()
|
||||||
|
.push((id, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: for large-file groups (> 64 KB) the sample hash is not exact;
|
// Phase 3: for large-file groups (> 64 KB) the sample hash is not exact;
|
||||||
@@ -1140,12 +1169,15 @@ pub async fn find_duplicates(
|
|||||||
.map(|(_, entries)| entries.len())
|
.map(|(_, entries)| entries.len())
|
||||||
.sum();
|
.sum();
|
||||||
if confirming_total > 0 {
|
if confirming_total > 0 {
|
||||||
let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "confirming".to_string(),
|
phase: "confirming".to_string(),
|
||||||
processed: 0,
|
processed: 0,
|
||||||
total: confirming_total,
|
total: confirming_total,
|
||||||
skipped: skipped_before_confirm,
|
skipped: skipped_before_confirm,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let app_confirm = app.clone();
|
let app_confirm = app.clone();
|
||||||
let (confirmed, skipped_files): (Vec<(u64, u64, Vec<i64>)>, usize) =
|
let (confirmed, skipped_files): (Vec<(u64, u64, Vec<i64>)>, usize) =
|
||||||
@@ -1163,7 +1195,11 @@ pub async fn find_duplicates(
|
|||||||
.flat_map(|((sample_hash, file_size), entries)| {
|
.flat_map(|((sample_hash, file_size), entries)| {
|
||||||
if file_size <= COVERED {
|
if file_size <= COVERED {
|
||||||
// Sample was already a full hash — one group, no re-read needed.
|
// Sample was already a full hash — one group, no re-read needed.
|
||||||
return vec![(sample_hash, file_size, entries.into_iter().map(|(id, _)| id).collect())];
|
return vec![(
|
||||||
|
sample_hash,
|
||||||
|
file_size,
|
||||||
|
entries.into_iter().map(|(id, _)| id).collect(),
|
||||||
|
)];
|
||||||
}
|
}
|
||||||
// Full-file hash pass. Group by full hash so that two sets of
|
// Full-file hash pass. Group by full hash so that two sets of
|
||||||
// files that collide only in the sample produce separate groups.
|
// files that collide only in the sample produce separate groups.
|
||||||
@@ -1179,13 +1215,16 @@ pub async fn find_duplicates(
|
|||||||
}
|
}
|
||||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if done % 100 == 0 || done == confirming_total {
|
if done % 100 == 0 || done == confirming_total {
|
||||||
let _ = app_confirm.emit("duplicate_scan_progress", DuplicateScanProgress {
|
let _ = app_confirm.emit(
|
||||||
|
"duplicate_scan_progress",
|
||||||
|
DuplicateScanProgress {
|
||||||
phase: "confirming".to_string(),
|
phase: "confirming".to_string(),
|
||||||
processed: done,
|
processed: done,
|
||||||
total: confirming_total,
|
total: confirming_total,
|
||||||
skipped: skipped_before_confirm
|
skipped: skipped_before_confirm
|
||||||
+ confirm_skipped.load(Ordering::Relaxed),
|
+ confirm_skipped.load(Ordering::Relaxed),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
(*id, hash)
|
(*id, hash)
|
||||||
})
|
})
|
||||||
@@ -1470,7 +1509,8 @@ pub async fn get_worker_states(folder_ids: Vec<i64>) -> Result<Vec<FolderWorkerS
|
|||||||
Ok(folder_ids
|
Ok(folder_ids
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|folder_id| {
|
.map(|folder_id| {
|
||||||
let state = states
|
let state =
|
||||||
|
states
|
||||||
.get(&folder_id)
|
.get(&folder_id)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(indexer::FolderWorkerPausedState {
|
.unwrap_or(indexer::FolderWorkerPausedState {
|
||||||
@@ -1631,7 +1671,11 @@ pub async fn queue_tagging_jobs(
|
|||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
||||||
let (total, folder_ids) = match (params.folder_id, params.image_id, requested_folder_ids.is_empty()) {
|
let (total, folder_ids) = match (
|
||||||
|
params.folder_id,
|
||||||
|
params.image_id,
|
||||||
|
requested_folder_ids.is_empty(),
|
||||||
|
) {
|
||||||
(_, Some(image_id), _) => {
|
(_, Some(image_id), _) => {
|
||||||
db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?;
|
db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?;
|
||||||
// Look up just this image's folder_id rather than fetching all folders
|
// Look up just this image's folder_id rather than fetching all folders
|
||||||
@@ -1675,7 +1719,8 @@ pub async fn clear_tagging_jobs(
|
|||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
||||||
let (n, folder_ids): (usize, Vec<i64>) = match (params.folder_id, requested_folder_ids.is_empty()) {
|
let (n, folder_ids): (usize, Vec<i64>) =
|
||||||
|
match (params.folder_id, requested_folder_ids.is_empty()) {
|
||||||
(Some(id), _) => (
|
(Some(id), _) => (
|
||||||
db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?,
|
db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?,
|
||||||
vec![id],
|
vec![id],
|
||||||
@@ -1683,7 +1728,8 @@ pub async fn clear_tagging_jobs(
|
|||||||
(None, false) => {
|
(None, false) => {
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
for &folder_id in &requested_folder_ids {
|
for &folder_id in &requested_folder_ids {
|
||||||
total += db::clear_tagging_jobs(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
|
total += db::clear_tagging_jobs(&conn, Some(folder_id))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
(total, requested_folder_ids)
|
(total, requested_folder_ids)
|
||||||
}
|
}
|
||||||
@@ -1747,7 +1793,11 @@ pub async fn get_tagging_queue_scope(app: AppHandle) -> Result<String, String> {
|
|||||||
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE);
|
let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE);
|
||||||
let value = std::fs::read_to_string(path).unwrap_or_default();
|
let value = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() })
|
Ok(if value.trim() == "selected" {
|
||||||
|
"selected".to_string()
|
||||||
|
} else {
|
||||||
|
"all".to_string()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1760,7 +1810,11 @@ pub async fn set_tagging_queue_scope(
|
|||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
let value = if params.scope == "selected" { "selected" } else { "all" };
|
let value = if params.scope == "selected" {
|
||||||
|
"selected"
|
||||||
|
} else {
|
||||||
|
"all"
|
||||||
|
};
|
||||||
std::fs::write(path, value).map_err(|e| e.to_string())?;
|
std::fs::write(path, value).map_err(|e| e.to_string())?;
|
||||||
Ok(value.to_string())
|
Ok(value.to_string())
|
||||||
}
|
}
|
||||||
@@ -1825,14 +1879,21 @@ pub struct VacuumResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result<DatabaseInfo, String> {
|
pub async fn get_database_info(
|
||||||
|
app: AppHandle,
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
) -> Result<DatabaseInfo, String> {
|
||||||
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
let db_path = app_dir.join("gallery.db");
|
let db_path = app_dir.join("gallery.db");
|
||||||
let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?;
|
let page_size: i64 = conn
|
||||||
let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?;
|
.query_row("PRAGMA page_size", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let freelist_count: i64 = conn
|
||||||
|
.query_row("PRAGMA freelist_count", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(DatabaseInfo {
|
Ok(DatabaseInfo {
|
||||||
size_mb: size_bytes as f64 / 1_048_576.0,
|
size_mb: size_bytes as f64 / 1_048_576.0,
|
||||||
@@ -1841,13 +1902,17 @@ pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result<VacuumResult, String> {
|
pub async fn vacuum_database(
|
||||||
|
app: AppHandle,
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
) -> Result<VacuumResult, String> {
|
||||||
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
let db_path = app_dir.join("gallery.db");
|
let db_path = app_dir.join("gallery.db");
|
||||||
let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?;
|
conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||||
@@ -1870,7 +1935,9 @@ pub struct CleanupOrphanedThumbnailsResult {
|
|||||||
pub freed_mb: f64,
|
pub freed_mb: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> Result<std::collections::HashSet<String>, String> {
|
fn collect_db_thumbnail_filenames(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
) -> Result<std::collections::HashSet<String>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL")
|
.prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -1993,8 +2060,7 @@ pub async fn set_muted_folder_ids(
|
|||||||
.map(|id| id.to_string())
|
.map(|id| id.to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
std::fs::write(settings_dir.join("muted_folder_ids.txt"), content)
|
std::fs::write(settings_dir.join("muted_folder_ids.txt"), content).map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
+30
-18
@@ -318,9 +318,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
// Index must be created after ensure_column adds the column; it cannot live
|
// Index must be created after ensure_column adds the column; it cannot live
|
||||||
// in the execute_batch above because that batch runs before the column exists
|
// in the execute_batch above because that batch runs before the column exists
|
||||||
// on databases that predate Phase 1.
|
// on databases that predate Phase 1.
|
||||||
conn.execute_batch(
|
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?;
|
||||||
"CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);",
|
|
||||||
)?;
|
|
||||||
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
||||||
|
|
||||||
vector::migrate(conn)?;
|
vector::migrate(conn)?;
|
||||||
@@ -1410,7 +1408,10 @@ pub fn update_image_details(
|
|||||||
|
|
||||||
/// Look up the lightweight indexed-media entry for a single path.
|
/// Look up the lightweight indexed-media entry for a single path.
|
||||||
/// Used by the filesystem watcher to run change-detection before upserting.
|
/// Used by the filesystem watcher to run change-detection before upserting.
|
||||||
pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result<Option<IndexedMediaEntry>> {
|
pub fn get_indexed_entry_by_path(
|
||||||
|
conn: &Connection,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<IndexedMediaEntry>> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1",
|
"SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1",
|
||||||
[path],
|
[path],
|
||||||
@@ -1434,11 +1435,9 @@ pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result<Option
|
|||||||
/// Look up just the image id for a path. Used by the filesystem watcher
|
/// Look up just the image id for a path. Used by the filesystem watcher
|
||||||
/// to find the DB row to delete when a file is removed from disk.
|
/// to find the DB row to delete when a file is removed from disk.
|
||||||
pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
|
pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row("SELECT id FROM images WHERE path = ?1", [path], |row| {
|
||||||
"SELECT id FROM images WHERE path = ?1",
|
row.get(0)
|
||||||
[path],
|
});
|
||||||
|row| row.get(0),
|
|
||||||
);
|
|
||||||
match result {
|
match result {
|
||||||
Ok(id) => Ok(Some(id)),
|
Ok(id) => Ok(Some(id)),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
@@ -1474,8 +1473,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||||
let mut stmt =
|
let mut stmt = conn.prepare(
|
||||||
conn.prepare("SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name")?;
|
"SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Folder {
|
Ok(Folder {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -1497,7 +1497,13 @@ pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> {
|
pub fn update_folder_path(
|
||||||
|
conn: &Connection,
|
||||||
|
folder_id: i64,
|
||||||
|
old_path: &str,
|
||||||
|
new_path: &str,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
// Both updates must be atomic: if the image path rewrite fails (e.g. a
|
// Both updates must be atomic: if the image path rewrite fails (e.g. a
|
||||||
// uniqueness collision) the folder row must not remain at the new location.
|
// uniqueness collision) the folder row must not remain at the new location.
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
@@ -1659,7 +1665,13 @@ pub fn search_images_by_tag(
|
|||||||
AND (?3 = 0 OR i.favorite = 1)
|
AND (?3 = 0 OR i.favorite = 1)
|
||||||
AND i.rating >= ?4
|
AND i.rating >= ?4
|
||||||
AND LOWER(TRIM(t.tag)) = ?5",
|
AND LOWER(TRIM(t.tag)) = ?5",
|
||||||
params![folder_id, media_kind, favorites_flag, rating_min, normalized_query],
|
params![
|
||||||
|
folder_id,
|
||||||
|
media_kind,
|
||||||
|
favorites_flag,
|
||||||
|
rating_min,
|
||||||
|
normalized_query
|
||||||
|
],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)? as usize;
|
)? as usize;
|
||||||
|
|
||||||
@@ -1769,10 +1781,7 @@ pub fn get_image_id_and_thumbnail_by_path(
|
|||||||
|
|
||||||
/// Returns all non-null thumbnail_path values for images in a folder.
|
/// Returns all non-null thumbnail_path values for images in a folder.
|
||||||
/// Called before folder deletion so callers can remove the files from disk.
|
/// Called before folder deletion so callers can remove the files from disk.
|
||||||
pub fn get_thumbnail_paths_for_folder(
|
pub fn get_thumbnail_paths_for_folder(conn: &Connection, folder_id: i64) -> Result<Vec<String>> {
|
||||||
conn: &Connection,
|
|
||||||
folder_id: i64,
|
|
||||||
) -> Result<Vec<String>> {
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT thumbnail_path FROM images WHERE folder_id = ?1 AND thumbnail_path IS NOT NULL",
|
"SELECT thumbnail_path FROM images WHERE folder_id = ?1 AND thumbnail_path IS NOT NULL",
|
||||||
)?;
|
)?;
|
||||||
@@ -2054,7 +2063,10 @@ pub fn clear_tagging_jobs(conn: &Connection, folder_id: Option<i64>) -> Result<u
|
|||||||
WHERE status = 'processing'",
|
WHERE status = 'processing'",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
let n = conn.execute("DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')", [])?;
|
let n = conn.execute(
|
||||||
|
"DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE images SET ai_tagger_error = NULL WHERE ai_tagged_at IS NULL",
|
"UPDATE images SET ai_tagger_error = NULL WHERE ai_tagged_at IS NULL",
|
||||||
[],
|
[],
|
||||||
|
|||||||
+46
-13
@@ -7,9 +7,9 @@ use crate::tagger::{self, WdTagger};
|
|||||||
use crate::thumbnail;
|
use crate::thumbnail;
|
||||||
use crate::vector;
|
use crate::vector;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
@@ -1401,7 +1401,11 @@ impl WatcherHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.inner.folder_map.lock().unwrap().insert(path, folder_id);
|
self.inner
|
||||||
|
.folder_map
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(path, folder_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_folder(&self, path: &Path) {
|
pub fn remove_folder(&self, path: &Path) {
|
||||||
@@ -1446,7 +1450,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
|||||||
// Seed the folder map from the DB so existing folders are watched on startup.
|
// Seed the folder map from the DB so existing folders are watched on startup.
|
||||||
let folder_map: Arc<Mutex<HashMap<PathBuf, i64>>> = Arc::new(Mutex::new(HashMap::new()));
|
let folder_map: Arc<Mutex<HashMap<PathBuf, i64>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
{
|
{
|
||||||
let conn = pool.get().expect("Watcher: failed to get DB connection for init");
|
let conn = pool
|
||||||
|
.get()
|
||||||
|
.expect("Watcher: failed to get DB connection for init");
|
||||||
let folders = db::get_folders(&conn).unwrap_or_default();
|
let folders = db::get_folders(&conn).unwrap_or_default();
|
||||||
let mut map = folder_map.lock().unwrap();
|
let mut map = folder_map.lock().unwrap();
|
||||||
for f in folders {
|
for f in folders {
|
||||||
@@ -1505,7 +1511,10 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
|||||||
|
|
||||||
// Absorb incoming event — coalesces rapid writes into one deadline.
|
// Absorb incoming event — coalesces rapid writes into one deadline.
|
||||||
if let Some(Ok(event)) = received {
|
if let Some(Ok(event)) = received {
|
||||||
use notify::{EventKind, event::{ModifyKind, RenameMode}};
|
use notify::{
|
||||||
|
event::{ModifyKind, RenameMode},
|
||||||
|
EventKind,
|
||||||
|
};
|
||||||
if !matches!(event.kind, EventKind::Access(_)) {
|
if !matches!(event.kind, EventKind::Access(_)) {
|
||||||
// Intercept atomic rename events (old + new path in one event).
|
// Intercept atomic rename events (old + new path in one event).
|
||||||
// Handle these separately to preserve embeddings and thumbnails.
|
// Handle these separately to preserve embeddings and thumbnails.
|
||||||
@@ -1536,7 +1545,14 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
|||||||
// Process renames immediately — they are atomic filesystem operations
|
// Process renames immediately — they are atomic filesystem operations
|
||||||
// and do not benefit from debouncing.
|
// and do not benefit from debouncing.
|
||||||
for (old_path, new_path) in pending_renames.drain(..) {
|
for (old_path, new_path) in pending_renames.drain(..) {
|
||||||
process_watcher_rename(&app, &pool, &folder_map_thread, &thumb_dir, &old_path, &new_path);
|
process_watcher_rename(
|
||||||
|
&app,
|
||||||
|
&pool,
|
||||||
|
&folder_map_thread,
|
||||||
|
&thumb_dir,
|
||||||
|
&old_path,
|
||||||
|
&new_path,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all paths whose debounce window has expired.
|
// Process all paths whose debounce window has expired.
|
||||||
@@ -1594,7 +1610,13 @@ fn process_watcher_path(
|
|||||||
match commit_batch(pool, &[record]) {
|
match commit_batch(pool, &[record]) {
|
||||||
Ok(committed) if !committed.is_empty() => {
|
Ok(committed) if !committed.is_empty() => {
|
||||||
// Always emit the images — they are committed to the DB.
|
// Always emit the images — they are committed to the DB.
|
||||||
emit_images(app, &IndexedImagesBatch { folder_id, images: committed });
|
emit_images(
|
||||||
|
app,
|
||||||
|
&IndexedImagesBatch {
|
||||||
|
folder_id,
|
||||||
|
images: committed,
|
||||||
|
},
|
||||||
|
);
|
||||||
emit_folder_job_progress(app, pool, &[folder_id], false);
|
emit_folder_job_progress(app, pool, &[folder_id], false);
|
||||||
// Update the sidebar count only if we successfully write the new
|
// Update the sidebar count only if we successfully write the new
|
||||||
// count; skip the frontend notification on pool/DB failure to
|
// count; skip the frontend notification on pool/DB failure to
|
||||||
@@ -1643,7 +1665,9 @@ fn process_watcher_rename(
|
|||||||
let folder_id = {
|
let folder_id = {
|
||||||
let map = folder_map.lock().unwrap();
|
let map = folder_map.lock().unwrap();
|
||||||
map.iter()
|
map.iter()
|
||||||
.find(|(fp, _)| old_path.starts_with(fp.as_path()) || new_path.starts_with(fp.as_path()))
|
.find(|(fp, _)| {
|
||||||
|
old_path.starts_with(fp.as_path()) || new_path.starts_with(fp.as_path())
|
||||||
|
})
|
||||||
.map(|(_, &id)| id)
|
.map(|(_, &id)| id)
|
||||||
};
|
};
|
||||||
let Some(folder_id) = folder_id else { return };
|
let Some(folder_id) = folder_id else { return };
|
||||||
@@ -1684,19 +1708,28 @@ fn process_watcher_rename(
|
|||||||
// the thumbnail worker regenerates it.
|
// the thumbnail worker regenerates it.
|
||||||
let effective_thumb: Option<&str> = if thumb_ok { Some(&new_thumb_str) } else { None };
|
let effective_thumb: Option<&str> = if thumb_ok { Some(&new_thumb_str) } else { None };
|
||||||
|
|
||||||
let new_filename = new_path
|
let new_filename = new_path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||||
.file_name()
|
|
||||||
.and_then(|f| f.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if let Err(e) = db::update_image_path(&conn, image_id, &new_path_str, new_filename, effective_thumb) {
|
if let Err(e) = db::update_image_path(
|
||||||
|
&conn,
|
||||||
|
image_id,
|
||||||
|
&new_path_str,
|
||||||
|
new_filename,
|
||||||
|
effective_thumb,
|
||||||
|
) {
|
||||||
eprintln!("Watcher rename: DB update error: {}", e);
|
eprintln!("Watcher rename: DB update error: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the updated record so the frontend refreshes without a full reload.
|
// Emit the updated record so the frontend refreshes without a full reload.
|
||||||
match db::get_image_by_id(&conn, image_id) {
|
match db::get_image_by_id(&conn, image_id) {
|
||||||
Ok(record) => emit_images(app, &IndexedImagesBatch { folder_id, images: vec![record] }),
|
Ok(record) => emit_images(
|
||||||
|
app,
|
||||||
|
&IndexedImagesBatch {
|
||||||
|
folder_id,
|
||||||
|
images: vec![record],
|
||||||
|
},
|
||||||
|
),
|
||||||
Err(e) => eprintln!("Watcher rename: post-update fetch error: {}", e),
|
Err(e) => eprintln!("Watcher rename: post-update fetch error: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,11 +200,7 @@ pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
|
|||||||
let Ok(value) = std::fs::read_to_string(path) else {
|
let Ok(value) = std::fs::read_to_string(path) else {
|
||||||
return 8;
|
return 8;
|
||||||
};
|
};
|
||||||
value
|
value.trim().parse::<usize>().unwrap_or(8).clamp(1, 100)
|
||||||
.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.unwrap_or(8)
|
|
||||||
.clamp(1, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
||||||
|
|||||||
@@ -289,8 +289,8 @@ mod tests {
|
|||||||
assert_eq!((decoded.width(), decoded.height()), (400, 300));
|
assert_eq!((decoded.width(), decoded.height()), (400, 300));
|
||||||
|
|
||||||
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
|
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
|
||||||
let covered = decode_jpeg_scaled(&src_path, 224, true)
|
let covered =
|
||||||
.expect("fast path should handle plain JPEG");
|
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
|
||||||
assert_eq!((covered.width(), covered.height()), (400, 300));
|
assert_eq!((covered.width(), covered.height()), (400, 300));
|
||||||
|
|
||||||
let cache = dir.join("cache");
|
let cache = dir.join("cache");
|
||||||
|
|||||||
Reference in New Issue
Block a user