중복 탐지 범위 선택 추가 — 현재 폴더/소스 또는 전체

기존엔 항상 라이브러리 전체를 대상으로 했다. 이제 중복 검토 패널에서
현재 보고 있는 뷰(폴더=하위 포함 / 소스)로 범위를 좁힐 수 있고,
폴더/소스 뷰에서는 기본값이 '현재 위치'다. 전체/검색/태그/스마트 뷰는
전체 라이브러리로 고정.

- dedupe::Scope { All | Source | Folder{recursive} } 도입, load_files/run에 적용
  (folder_snapshot과 동일한 경로 프리픽스 스코핑)
- start_dedupe에 folder_id/recursive 파라미터 추가
- DedupePanel: view() 기반 범위 세그먼트 컨트롤
- 테스트: folder_scope_restricts_detection (folderA=1그룹, 전체=2그룹, 누출 없음)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-19 19:57:40 +09:00
co-authored by Claude Fable 5
parent a734ba21b9
commit 8ba0c24ce8
5 changed files with 165 additions and 14 deletions
+51 -1
View File
@@ -1,5 +1,7 @@
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
@@ -26,6 +28,7 @@ import {
refreshSidebar,
refreshSnapshot,
toast,
view,
} from "../../state/store";
function fmtSize(bytes: number): string {
@@ -48,13 +51,29 @@ export const DedupePanel: Component = () => {
() => listDupeGroups(),
);
// 현재 뷰가 폴더/소스면 그 범위로 좁힐 수 있다 (그 외 전체/검색/태그/스마트는 전체만).
const currentScope = createMemo<
{ label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null
>(() => {
const v = view();
if (v.type === "folder") return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } };
return null;
});
// 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
const [useCurrentScope, setUseCurrentScope] = createSignal(true);
createEffect(() => {
if (dedupeOpen()) setUseCurrentScope(currentScope() != null);
});
const runScan = async () => {
setRunning(true);
setProgress(undefined);
const scoped = useCurrentScope() ? currentScope() : null;
try {
await new Promise<void>((resolve, reject) => {
startDedupe(
{ imageThreshold: threshold(), includeVideo: includeVideo() },
{ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() },
(p) => {
setProgress(p);
if (p.phase === "done") resolve();
@@ -129,6 +148,37 @@ export const DedupePanel: Component = () => {
/>
{ko.dedupe.includeVideo}
</label>
{/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
<Show
when={currentScope()}
fallback={<span class="text-[var(--text-muted)]">{ko.dedupe.scopeAll}</span>}
>
<div class="flex items-center gap-1.5">
<span>{ko.dedupe.scopeLabel}</span>
<div class="flex overflow-hidden rounded border border-[var(--border)]">
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !useCurrentScope(),
}}
onClick={() => setUseCurrentScope(true)}
>
{currentScope()!.label}
</button>
<button
class="border-l border-[var(--border)] px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": !useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": useCurrentScope(),
}}
onClick={() => setUseCurrentScope(false)}
>
{ko.dedupe.scopeAll}
</button>
</div>
</div>
</Show>
</div>
<div class="ml-auto flex items-center gap-2">
<Show when={running()}>
+4
View File
@@ -122,6 +122,10 @@ export const ko = {
},
threshold: "유사도 임계값",
includeVideo: "영상 포함",
scopeLabel: "범위:",
scopeFolder: "현재 폴더 (하위 포함)",
scopeSource: "현재 소스",
scopeAll: "전체 라이브러리",
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n}개 그룹`,
memberCount: (n: number) => `${n}개 항목`,
+9 -1
View File
@@ -278,13 +278,21 @@ export interface DupeGroup {
}
export function startDedupe(
args: { sourceId?: number; imageThreshold?: number; includeVideo?: boolean },
args: {
sourceId?: number;
folderId?: number;
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
},
onProgress: (p: DedupeProgress) => void,
): Promise<void> {
const ch = new Channel<DedupeProgress>();
ch.onmessage = onProgress;
return invoke<void>("start_dedupe", {
sourceId: args.sourceId ?? null,
folderId: args.folderId ?? null,
recursive: args.recursive ?? null,
imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false,
onProgress: ch,