feat(frontend): show keep-to-other distance in the duplicates view
deploy / deploy (push) Successful in 1m1s
deploy / deploy (push) Successful in 1m1s
Each action row now shows the perceptual distance (Δn) between the kept file and that file, read from the new per-cluster distances; recomputed live when the survivor pick changes. A transitively-linked pair with no stored distance shows a muted Δ—.
This commit is contained in:
@@ -1,9 +1,19 @@
|
|||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import type { File } from '$lib/api/types';
|
import type { File } from '$lib/api/types';
|
||||||
|
|
||||||
/** A group of mutually similar files. */
|
/** A stored perceptual-hash (Hamming) distance between two files of a cluster. */
|
||||||
|
export interface DuplicatePairDistance {
|
||||||
|
a: string;
|
||||||
|
b: string;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of mutually similar files, with the pairwise distances known between
|
||||||
|
* them. A file linked into the cluster only transitively may lack a direct
|
||||||
|
* distance to some others, so that pair is absent. */
|
||||||
export interface DuplicateCluster {
|
export interface DuplicateCluster {
|
||||||
files: File[];
|
files: File[];
|
||||||
|
distances?: DuplicatePairDistance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuplicateClusterPage {
|
export interface DuplicateClusterPage {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { getDuplicates, dismissDuplicate } from '$lib/api/duplicates';
|
import {
|
||||||
|
getDuplicates,
|
||||||
|
dismissDuplicate,
|
||||||
|
type DuplicatePairDistance
|
||||||
|
} from '$lib/api/duplicates';
|
||||||
import Thumb from '$lib/components/file/Thumb.svelte';
|
import Thumb from '$lib/components/file/Thumb.svelte';
|
||||||
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
|
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
|
||||||
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
||||||
@@ -14,6 +18,7 @@
|
|||||||
interface Cluster {
|
interface Cluster {
|
||||||
key: number;
|
key: number;
|
||||||
files: File[];
|
files: File[];
|
||||||
|
distances: DuplicatePairDistance[];
|
||||||
}
|
}
|
||||||
let nextKey = 0;
|
let nextKey = 0;
|
||||||
|
|
||||||
@@ -48,13 +53,26 @@
|
|||||||
return keepers[c.key] ?? c.files[0]?.id ?? '';
|
return keepers[c.key] ?? c.files[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored perceptual distance between the kept file and another, or null when
|
||||||
|
// the two are linked only transitively (no direct stored pair).
|
||||||
|
function distanceFromKeep(c: Cluster, keep: string, other: string): number | null {
|
||||||
|
for (const d of c.distances) {
|
||||||
|
if ((d.a === keep && d.b === other) || (d.a === other && d.b === keep)) return d.distance;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await getDuplicates(LIMIT, offset);
|
const res = await getDuplicates(LIMIT, offset);
|
||||||
const incoming = (res.items ?? []).map((c) => ({ key: nextKey++, files: c.files }));
|
const incoming = (res.items ?? []).map((c) => ({
|
||||||
|
key: nextKey++,
|
||||||
|
files: c.files,
|
||||||
|
distances: c.distances ?? []
|
||||||
|
}));
|
||||||
total = res.total ?? total;
|
total = res.total ?? total;
|
||||||
// The server paginates by group index and may drop groups that fell below
|
// The server paginates by group index and may drop groups that fell below
|
||||||
// two live files, so advance by the page size (clamped), not items returned.
|
// two live files, so advance by the page size (clamped), not items returned.
|
||||||
@@ -276,8 +294,18 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
|
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
|
||||||
|
{@const dist = distanceFromKeep(c, keep, other.id)}
|
||||||
<div class="actrow">
|
<div class="actrow">
|
||||||
<span class="aname" title={other.original_name ?? ''}>{other.original_name ?? '—'}</span>
|
<span class="aname" title={other.original_name ?? ''}>{other.original_name ?? '—'}</span>
|
||||||
|
<span
|
||||||
|
class="dist"
|
||||||
|
class:unknown={dist === null}
|
||||||
|
title={dist === null
|
||||||
|
? 'No direct match — linked through another file'
|
||||||
|
: 'Perceptual distance from the kept file (lower = more similar)'}
|
||||||
|
>
|
||||||
|
Δ{dist ?? '—'}
|
||||||
|
</span>
|
||||||
<button class="abtn" onclick={() => openMerge(c, other)}>Merge</button>
|
<button class="abtn" onclick={() => openMerge(c, other)}>Merge</button>
|
||||||
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
|
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
|
||||||
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
|
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
|
||||||
@@ -493,6 +521,20 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.dist {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.dist.unknown {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
.abtn {
|
.abtn {
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
|
|||||||
Reference in New Issue
Block a user