diff --git a/src-tauri/crates/archive-indexer/src/maintenance.rs b/src-tauri/crates/archive-indexer/src/maintenance.rs index 6d9a2aa..17f13c1 100644 --- a/src-tauri/crates/archive-indexer/src/maintenance.rs +++ b/src-tauri/crates/archive-indexer/src/maintenance.rs @@ -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 { + // 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(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8c985e5..53c5b2d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -112,6 +112,50 @@ pub fn add_local_source(state: tauri::State<'_, AppState>, path: String) -> CmdR }) } +/// 소스를 인덱스에서 제외한다 — **디스크의 실제 파일은 삭제하지 않는다.** +/// 워처 해제, (원격이면) 연결 해제·자격증명 삭제, 썸네일 캐시 정리 포함. +#[tauri::command] +pub async fn remove_source( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + source_id: i64, +) -> CmdResult { + if state.scan_running.load(Ordering::SeqCst) { + return Err("스캔이 진행 중입니다. 완료 후 다시 시도하세요".into()); + } + let kind: String = state + .db + .with_read(|conn| { + conn.query_row("SELECT kind FROM sources WHERE id = ?1", [source_id], |r| r.get(0)) + }) + .map_err(|_| "소스를 찾을 수 없습니다".to_string())?; + + if kind == "local" { + if let Some(w) = state.watchers.get() { + w.remove(source_id); + } + } else { + state.remote.disconnect(source_id).await; + let key = remote::cred_key(&kind, source_id); + if let Err(e) = state.creds.lock().unwrap().delete(&key) { + tracing::warn!("자격증명 삭제 실패: {e}"); + } + } + + let db = state.db.clone(); + let thumb_root = state.data_dir.join("thumbs"); + let removed = tauri::async_runtime::spawn_blocking(move || { + archive_indexer::maintenance::remove_source(&db, &thumb_root, source_id) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + tracing::info!(source_id, removed, "소스 제외 완료"); + let _ = app.emit("library-changed", source_id); + Ok(removed) +} + // ── 스캔 ──────────────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 64c893a..a22f135 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -250,6 +250,7 @@ fn main() { commands::app_info, commands::list_sources, commands::add_local_source, + commands::remove_source, commands::start_scan, commands::cancel_scan, commands::folder_tree, diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index baf964d..8fd6ff1 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -80,6 +80,13 @@ impl WatcherManager { manager } + /// 소스 워치를 해제한다 (소스 제외 시). Debouncer drop으로 감시가 멈춘다. + pub fn remove(&self, source_id: i64) { + if self.watchers.lock().unwrap().remove(&source_id).is_some() { + tracing::info!(source_id, "파일 워처 해제"); + } + } + /// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출). pub fn refresh(&self, db: &archive_db::Db) { let sources: Vec<(i64, String)> = db diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index bd51cef..390eb90 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -12,6 +12,10 @@ export const ko = { dedupe: "중복 항목", addSource: "폴더", noSources: "등록된 소스가 없습니다.\n폴더를 추가해 시작하세요.", + removeConfirm: (name: string) => + `소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`, + removed: (name: string, n: number) => + `"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`, }, onboarding: { title: "Archive에 오신 것을 환영합니다", diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 277fd81..7f6b2d3 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -52,6 +52,9 @@ export const appInfo = () => invoke("app_info"); export const listSources = () => invoke("list_sources"); export const addLocalSource = (path: string) => invoke("add_local_source", { path }); +/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */ +export const removeSource = (sourceId: number) => + invoke("remove_source", { sourceId }); export const cancelScan = () => invoke("cancel_scan"); export const folderTree = (sourceId: number) => invoke("folder_tree", { sourceId }); diff --git a/src/shell/Sidebar.tsx b/src/shell/Sidebar.tsx index e259bda..b428483 100644 --- a/src/shell/Sidebar.tsx +++ b/src/shell/Sidebar.tsx @@ -19,6 +19,7 @@ import { listSmartFolders, listSources, listTags, + removeSource, startScan, type FolderNode, type Source, @@ -133,10 +134,35 @@ const SourceRow: Component<{ source: Source }> = (props) => { const v = view(); return v.type === "source" && v.sourceId === props.source.id; }; + const onRemove = async (e: MouseEvent) => { + e.stopPropagation(); + const yes = await ask(ko.sidebar.removeConfirm(props.source.name), { + title: "Archive", + kind: "warning", + }); + if (!yes) return; + try { + const n = await removeSource(props.source.id); + toast(ko.sidebar.removed(props.source.name, n)); + // 제외한 소스를 보고 있었다면 전체 뷰로 + const v = view(); + if ( + (v.type === "source" && v.sourceId === props.source.id) || + v.type === "folder" + ) { + void setView({ type: "all" }); + } + refreshSidebar(); + await refreshSnapshot(); + } catch (err) { + toast(String(err)); + } + }; + return (
void setView({ type: "source", sourceId: props.source.id })} > @@ -152,9 +178,16 @@ const SourceRow: Component<{ source: Source }> = (props) => { {props.source.name} - + {props.source.fileCount.toLocaleString("ko-KR")} +
{(n) => }