소스 제외(인덱스에서 제거) 기능 추가
- 사이드바 소스 행 hover 시 ✕ 버튼 → 확인 후 인덱스/폴더/태그/썸네일 캐시 제거. 디스크의 실제 파일은 삭제하지 않음. - 로컬: 파일 워처 해제. 원격: 연결 해제 + 자격증명(키체인/볼트) 삭제. - maintenance::remove_source (defer_foreign_keys로 자기참조 FK 처리), 스캔 중 제외 방지, 제외한 소스를 보던 중이면 전체 뷰로 전환. Rust 테스트: 실제 파일 보존 + 인덱스/썸네일 제거 검증 통과. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,40 @@ pub fn purge_deleted(db: &Db, older_than_ms: i64) -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 소스를 인덱스에서 완전히 제거한다. **디스크의 실제 파일은 건드리지 않는다.**
|
||||
/// 관련 썸네일 캐시 파일도 정리한다. 반환: 제거된 파일 행 수.
|
||||
pub fn remove_source(db: &Db, thumb_root: &Path, source_id: i64) -> Result<u64, String> {
|
||||
// 1) 썸네일 캐시 파일 먼저 삭제 (DB cascade 후에는 키를 알 수 없음)
|
||||
let keys: Vec<(String, i64)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.cache_key, t.size_class FROM thumbs t
|
||||
JOIN files f ON f.id = t.file_id WHERE f.source_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for (key, size_class) in &keys {
|
||||
let _ = std::fs::remove_file(crate::thumbq::thumb_path(thumb_root, *size_class as u8, key));
|
||||
}
|
||||
|
||||
// 2) DB 삭제 — defer_foreign_keys로 자기참조 FK(folders.parent_id) 순서 문제 회피
|
||||
let removed = db
|
||||
.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch("PRAGMA defer_foreign_keys = ON")?;
|
||||
let n = tx.execute("DELETE FROM files WHERE source_id = ?1", [source_id])?;
|
||||
tx.execute("DELETE FROM folders WHERE source_id = ?1", [source_id])?;
|
||||
tx.execute("DELETE FROM scans WHERE source_id = ?1", [source_id])?;
|
||||
tx.execute("DELETE FROM sources WHERE id = ?1", [source_id])?;
|
||||
tx.commit()?;
|
||||
Ok(n as u64)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
|
||||
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(backup_path);
|
||||
@@ -154,6 +188,68 @@ mod tests {
|
||||
assert_eq!(count, 1); // recent만 남음
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_source_purges_index_and_thumbs_but_not_files() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let thumbs_dir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
// 실제 파일 + 스캔 (중첩 폴더 포함 — folders.parent_id 자기참조 FK 검증)
|
||||
std::fs::create_dir_all(media.path().join("a/b")).unwrap();
|
||||
std::fs::write(media.path().join("a/사진.jpg"), b"one").unwrap();
|
||||
std::fs::write(media.path().join("a/b/깊은.jpg"), b"two").unwrap();
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write(move |c| {
|
||||
c.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(c.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
// 썸네일 캐시 흉내 (파일 + thumbs 행)
|
||||
let fid: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT id FROM files LIMIT 1", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
let key = format!("{:032x}", 7);
|
||||
let tp = crate::thumbq::thumb_path(thumbs_dir.path(), 0, &key);
|
||||
std::fs::create_dir_all(tp.parent().unwrap()).unwrap();
|
||||
std::fs::write(&tp, b"jpg").unwrap();
|
||||
db.with_write({
|
||||
let key = key.clone();
|
||||
move |c| {
|
||||
c.execute(
|
||||
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes) VALUES(?1,0,?2,3)",
|
||||
rusqlite::params![fid, key],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let removed = remove_source(&db, thumbs_dir.path(), sid).unwrap();
|
||||
assert_eq!(removed, 2);
|
||||
|
||||
// 인덱스 완전 제거
|
||||
let counts: (i64, i64, i64) = db
|
||||
.with_read(|c| {
|
||||
Ok((
|
||||
c.query_row("SELECT count(*) FROM sources", [], |r| r.get(0))?,
|
||||
c.query_row("SELECT count(*) FROM folders", [], |r| r.get(0))?,
|
||||
c.query_row("SELECT count(*) FROM files", [], |r| r.get(0))?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(counts, (0, 0, 0));
|
||||
// 썸네일 캐시 파일 삭제됨
|
||||
assert!(!tp.exists());
|
||||
// 실제 미디어 파일은 그대로
|
||||
assert!(media.path().join("a/사진.jpg").exists());
|
||||
assert!(media.path().join("a/b/깊은.jpg").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_creates_valid_db() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user