소스 제외(인덱스에서 제거) 기능 추가
- 사이드바 소스 행 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)
|
.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 — 조각모음 겸용).
|
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
|
||||||
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
||||||
let _ = std::fs::remove_file(backup_path);
|
let _ = std::fs::remove_file(backup_path);
|
||||||
@@ -154,6 +188,68 @@ mod tests {
|
|||||||
assert_eq!(count, 1); // recent만 남음
|
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]
|
#[test]
|
||||||
fn backup_creates_valid_db() {
|
fn backup_creates_valid_db() {
|
||||||
let dbdir = tempfile::tempdir().unwrap();
|
let dbdir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -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<u64> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ fn main() {
|
|||||||
commands::app_info,
|
commands::app_info,
|
||||||
commands::list_sources,
|
commands::list_sources,
|
||||||
commands::add_local_source,
|
commands::add_local_source,
|
||||||
|
commands::remove_source,
|
||||||
commands::start_scan,
|
commands::start_scan,
|
||||||
commands::cancel_scan,
|
commands::cancel_scan,
|
||||||
commands::folder_tree,
|
commands::folder_tree,
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ impl WatcherManager {
|
|||||||
manager
|
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의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
|
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
|
||||||
pub fn refresh(&self, db: &archive_db::Db) {
|
pub fn refresh(&self, db: &archive_db::Db) {
|
||||||
let sources: Vec<(i64, String)> = db
|
let sources: Vec<(i64, String)> = db
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export const ko = {
|
|||||||
dedupe: "중복 항목",
|
dedupe: "중복 항목",
|
||||||
addSource: "폴더",
|
addSource: "폴더",
|
||||||
noSources: "등록된 소스가 없습니다.\n폴더를 추가해 시작하세요.",
|
noSources: "등록된 소스가 없습니다.\n폴더를 추가해 시작하세요.",
|
||||||
|
removeConfirm: (name: string) =>
|
||||||
|
`소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`,
|
||||||
|
removed: (name: string, n: number) =>
|
||||||
|
`"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`,
|
||||||
},
|
},
|
||||||
onboarding: {
|
onboarding: {
|
||||||
title: "Archive에 오신 것을 환영합니다",
|
title: "Archive에 오신 것을 환영합니다",
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export const appInfo = () => invoke<AppInfo>("app_info");
|
|||||||
export const listSources = () => invoke<Source[]>("list_sources");
|
export const listSources = () => invoke<Source[]>("list_sources");
|
||||||
export const addLocalSource = (path: string) =>
|
export const addLocalSource = (path: string) =>
|
||||||
invoke<Source>("add_local_source", { path });
|
invoke<Source>("add_local_source", { path });
|
||||||
|
/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */
|
||||||
|
export const removeSource = (sourceId: number) =>
|
||||||
|
invoke<number>("remove_source", { sourceId });
|
||||||
export const cancelScan = () => invoke<void>("cancel_scan");
|
export const cancelScan = () => invoke<void>("cancel_scan");
|
||||||
export const folderTree = (sourceId: number) =>
|
export const folderTree = (sourceId: number) =>
|
||||||
invoke<FolderNode[]>("folder_tree", { sourceId });
|
invoke<FolderNode[]>("folder_tree", { sourceId });
|
||||||
|
|||||||
+35
-2
@@ -19,6 +19,7 @@ import {
|
|||||||
listSmartFolders,
|
listSmartFolders,
|
||||||
listSources,
|
listSources,
|
||||||
listTags,
|
listTags,
|
||||||
|
removeSource,
|
||||||
startScan,
|
startScan,
|
||||||
type FolderNode,
|
type FolderNode,
|
||||||
type Source,
|
type Source,
|
||||||
@@ -133,10 +134,35 @@ const SourceRow: Component<{ source: Source }> = (props) => {
|
|||||||
const v = view();
|
const v = view();
|
||||||
return v.type === "source" && v.sourceId === props.source.id;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
class="group flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||||
classList={{ "bg-[var(--bg-active)]": isActive() }}
|
classList={{ "bg-[var(--bg-active)]": isActive() }}
|
||||||
onClick={() => void setView({ type: "source", sourceId: props.source.id })}
|
onClick={() => void setView({ type: "source", sourceId: props.source.id })}
|
||||||
>
|
>
|
||||||
@@ -152,9 +178,16 @@ const SourceRow: Component<{ source: Source }> = (props) => {
|
|||||||
<span class="truncate font-medium text-[var(--text-primary)]" title={props.source.root}>
|
<span class="truncate font-medium text-[var(--text-primary)]" title={props.source.root}>
|
||||||
{props.source.name}
|
{props.source.name}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
|
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)] group-hover:hidden">
|
||||||
{props.source.fileCount.toLocaleString("ko-KR")}
|
{props.source.fileCount.toLocaleString("ko-KR")}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
class="ml-auto hidden shrink-0 rounded px-1 text-[11px] text-[var(--text-muted)] hover:text-[var(--danger)] group-hover:block"
|
||||||
|
onClick={(e) => void onRemove(e)}
|
||||||
|
title="소스 제외 (파일은 삭제되지 않음)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={expanded() && tree()}>
|
<Show when={expanded() && tree()}>
|
||||||
<For each={tree()}>{(n) => <FolderRow node={n} depth={1} />}</For>
|
<For each={tree()}>{(n) => <FolderRow node={n} depth={1} />}</For>
|
||||||
|
|||||||
Reference in New Issue
Block a user