편의 기능(정렬·종류필터·전체선택/단축키·정보 오버레이) + 스냅샷 범위 안전화
Snapshot 접근자 범위 안전화(핵심 버그): - id/width/height/kind/hasThumb/thumbFailed가 count 밖 i에서 DataView RangeError를 던져 Solid 반응성 그래프 전체가 무너지던 문제 수정. 스냅샷이 줄어들 때(필터/검색/ 폴더 전환) For가 초과 셀을 제거하기 전에 셀 접근자가 옛 인덱스로 재실행되며 발생. 범위 밖이면 0/false 반환. (증상: 필터/검색이 한 번은 되고 이후 먹통) 기능: - 정렬: 촬영일/이름/크기/수정일 × 오름·내림 (툴바 드롭다운+방향 토글). backend snap_tail로 ORDER BY 구성, 4개 스냅샷 커맨드에 sort/kind 파라미터 추가. - 종류 필터: 전체/사진/영상 툴바 토글 (backend kind 필터). - 전체 선택(Ctrl+A)/선택 반전(Ctrl+Shift+A), ? 키 단축키 도움말 오버레이. - 썸네일 정보 오버레이(ⓘ 토글): 가시 셀에 파일명·크기 지연 로드 표시. 검증(사진120+영상25): 종류필터 120/25/145/120 반복 정상, 정렬 순서 변경, Ctrl+A 145개 선택, ? 오버레이, ⓘ로 파일명 표시 확인. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+62
-12
@@ -373,7 +373,32 @@ fn pack_snapshot(rows: Vec<(i64, Option<u32>, Option<u32>, Option<u32>, i64, i64
|
||||
|
||||
type SnapRow = (i64, Option<u32>, Option<u32>, Option<u32>, i64, i64);
|
||||
const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state";
|
||||
const SNAP_ORDER: &str = "ORDER BY COALESCE(f.taken_at, f.mtime_ms) DESC, f.id DESC";
|
||||
|
||||
/// 스냅샷 공통 옵션 — 종류 필터 + 정렬. 프론트 툴바에서 전달.
|
||||
#[derive(Default)]
|
||||
pub struct SnapOpts {
|
||||
pub sort_key: Option<String>, // taken(기본)|name|size|mtime
|
||||
pub sort_desc: Option<bool>, // 기본 내림차순
|
||||
pub kind: Option<i64>, // 0=사진, 1=영상, 그 외/None=전체
|
||||
}
|
||||
|
||||
/// WHERE 꼬리(종류 필터) + ORDER BY 절을 만든다.
|
||||
/// sort_key/kind는 고정 집합에서만 해석하므로 SQL 주입 안전(kind는 i64 리터럴).
|
||||
fn snap_tail(o: &SnapOpts) -> String {
|
||||
let kind_clause = match o.kind {
|
||||
Some(0) => " AND f.kind = 0",
|
||||
Some(1) => " AND f.kind = 1",
|
||||
_ => "",
|
||||
};
|
||||
let dir = if o.sort_desc.unwrap_or(true) { "DESC" } else { "ASC" };
|
||||
let order = match o.sort_key.as_deref() {
|
||||
Some("name") => format!("ORDER BY f.name COLLATE NOCASE {dir}, f.id {dir}"),
|
||||
Some("size") => format!("ORDER BY f.size {dir}, f.id {dir}"),
|
||||
Some("mtime") => format!("ORDER BY f.mtime_ms {dir}, f.id {dir}"),
|
||||
_ => format!("ORDER BY COALESCE(f.taken_at, f.mtime_ms) {dir}, f.id {dir}"),
|
||||
};
|
||||
format!("{kind_clause} {order}")
|
||||
}
|
||||
|
||||
fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SnapRow> {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
|
||||
@@ -385,7 +410,11 @@ pub fn folder_snapshot(
|
||||
source_id: Option<i64>,
|
||||
folder_id: Option<i64>,
|
||||
recursive: bool,
|
||||
sort_key: Option<String>,
|
||||
sort_desc: Option<bool>,
|
||||
kind: Option<i64>,
|
||||
) -> CmdResult<Response> {
|
||||
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
|
||||
let rows: Vec<SnapRow> = state
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
@@ -393,7 +422,7 @@ pub fn folder_snapshot(
|
||||
(Some(fid), false) => {
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map([fid], |r| map_snap_row(r))?;
|
||||
@@ -411,7 +440,7 @@ pub fn folder_snapshot(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3)
|
||||
AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows =
|
||||
@@ -422,7 +451,7 @@ pub fn folder_snapshot(
|
||||
Some(sid) => {
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
WHERE f.source_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE f.source_id = ?1 AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map([sid], |r| map_snap_row(r))?;
|
||||
@@ -431,7 +460,7 @@ pub fn folder_snapshot(
|
||||
None => {
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
WHERE f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map([], |r| map_snap_row(r))?;
|
||||
@@ -445,11 +474,18 @@ pub fn folder_snapshot(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdResult<Response> {
|
||||
pub fn search_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
query: String,
|
||||
sort_key: Option<String>,
|
||||
sort_desc: Option<bool>,
|
||||
kind: Option<i64>,
|
||||
) -> CmdResult<Response> {
|
||||
let q = query.trim().to_string();
|
||||
if q.is_empty() {
|
||||
return Ok(Response::new(Vec::new()));
|
||||
}
|
||||
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
|
||||
let use_fts = state.db.fts_enabled && q.chars().count() >= 3;
|
||||
let rows: Vec<SnapRow> = state
|
||||
.db
|
||||
@@ -458,7 +494,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files_fts ft
|
||||
JOIN files f ON f.id = ft.rowid
|
||||
WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
// MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다
|
||||
@@ -468,7 +504,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
|
||||
} else {
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
@@ -619,14 +655,21 @@ pub fn export_tags(state: tauri::State<'_, AppState>, path: Option<String>) -> C
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn tag_snapshot(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult<Response> {
|
||||
pub fn tag_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
tag_id: i64,
|
||||
sort_key: Option<String>,
|
||||
sort_desc: Option<bool>,
|
||||
kind: Option<i64>,
|
||||
) -> CmdResult<Response> {
|
||||
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
|
||||
let rows: Vec<SnapRow> = state
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT {SNAP_COLS} FROM files f
|
||||
JOIN file_tags ft ON ft.file_id = f.id
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {tail}"
|
||||
);
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows = stmt.query_map([tag_id], |r| map_snap_row(r))?;
|
||||
@@ -797,7 +840,14 @@ pub fn delete_smart_folder(state: tauri::State<'_, AppState>, id: i64) -> CmdRes
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<Response> {
|
||||
pub fn smart_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: i64,
|
||||
sort_key: Option<String>,
|
||||
sort_desc: Option<bool>,
|
||||
kind: Option<i64>,
|
||||
) -> CmdResult<Response> {
|
||||
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
|
||||
let query: SmartQuery = state
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
@@ -834,7 +884,7 @@ pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<R
|
||||
let rows: Vec<SnapRow> = state
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {SNAP_ORDER}");
|
||||
let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {tail}");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], |r| map_snap_row(r))?;
|
||||
rows.collect()
|
||||
|
||||
Reference in New Issue
Block a user