feat: New tool to remove duplicates in the log
This commit is contained in:
@@ -3368,6 +3368,84 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
}
|
||||
|
||||
// DuplicateGroup is a set of QSOs the log considers the same contact.
|
||||
type DuplicateGroup struct {
|
||||
Key string `json:"key"`
|
||||
QSOs []qso.QSO `json:"qsos"`
|
||||
}
|
||||
|
||||
// normDupeMode collapses SSB sidebands so USB/LSB count as the same slot; other
|
||||
// modes (FT8/RTTY/CW…) stay distinct so they aren't wrongly grouped.
|
||||
func normDupeMode(m string) string {
|
||||
u := strings.ToUpper(strings.TrimSpace(m))
|
||||
if u == "USB" || u == "LSB" {
|
||||
return "SSB"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// FindDuplicates scans the whole log and returns groups of duplicate QSOs: same
|
||||
// callsign + band + mode (USB/LSB folded to SSB) logged within windowMinutes of
|
||||
// each other — the real signature of an accidental double-log, which lands a few
|
||||
// minutes apart, not necessarily the same minute. QSOs of a call+band+mode are
|
||||
// sorted by time and split into clusters wherever the gap to the next exceeds
|
||||
// the window, so two genuinely separate contacts hours apart are NOT grouped.
|
||||
// windowMinutes <= 0 falls back to same UTC day. Each group's QSOs are
|
||||
// oldest-first and groups are ordered by date, so the UI can pre-select all but
|
||||
// the first.
|
||||
func (a *App) FindDuplicates(windowMinutes int) []DuplicateGroup {
|
||||
if a.qso == nil {
|
||||
return nil
|
||||
}
|
||||
byKey := map[string][]qso.QSO{}
|
||||
_ = a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||
if call == "" {
|
||||
return nil
|
||||
}
|
||||
key := call + "|" + strings.ToLower(strings.TrimSpace(q.Band)) + "|" + normDupeMode(q.Mode)
|
||||
byKey[key] = append(byKey[key], q)
|
||||
return nil
|
||||
})
|
||||
|
||||
out := []DuplicateGroup{}
|
||||
for key, qs := range byKey {
|
||||
if len(qs) < 2 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(qs, func(i, j int) bool { return qs[i].QSODate.Before(qs[j].QSODate) })
|
||||
if windowMinutes <= 0 {
|
||||
// Whole-day fallback: split on a change of UTC calendar day.
|
||||
i := 0
|
||||
for i < len(qs) {
|
||||
j := i + 1
|
||||
for j < len(qs) && qs[j].QSODate.UTC().Format("2006-01-02") == qs[i].QSODate.UTC().Format("2006-01-02") {
|
||||
j++
|
||||
}
|
||||
if j-i > 1 {
|
||||
out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
}
|
||||
win := time.Duration(windowMinutes) * time.Minute
|
||||
i := 0
|
||||
for i < len(qs) {
|
||||
j := i + 1
|
||||
for j < len(qs) && qs[j].QSODate.Sub(qs[j-1].QSODate) <= win {
|
||||
j++
|
||||
}
|
||||
if j-i > 1 {
|
||||
out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].QSOs[0].QSODate.Before(out[j].QSOs[0].QSODate) })
|
||||
return out
|
||||
}
|
||||
|
||||
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
|
||||
// string leaves that field unchanged (so you can set only "received = Y + date"
|
||||
// without touching the sent side).
|
||||
|
||||
@@ -70,6 +70,7 @@ import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||
import { DuplicatesModal } from '@/components/DuplicatesModal';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { AlertsModal } from '@/components/AlertsModal';
|
||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||
@@ -907,6 +908,7 @@ export default function App() {
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
||||
// Check GitHub for a newer release once at startup (unless disabled in
|
||||
// General); surface a toast if one exists. Best effort — silent on failure.
|
||||
@@ -2325,6 +2327,8 @@ export default function App() {
|
||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||
@@ -2354,6 +2358,7 @@ export default function App() {
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.alerts': setAlertsOpen(true); break;
|
||||
case 'tools.duplicates': setShowDuplicates(true); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); break;
|
||||
@@ -4251,6 +4256,10 @@ export default function App() {
|
||||
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
|
||||
)}
|
||||
|
||||
{showDuplicates && (
|
||||
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
|
||||
)}
|
||||
|
||||
<AutoEQSL
|
||||
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
|
||||
onError={(msg) => showToast(msg)}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, Loader2, Trash2, X } from 'lucide-react';
|
||||
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type QSO = {
|
||||
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
|
||||
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
|
||||
};
|
||||
type Group = { key: string; qsos: QSO[] };
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
};
|
||||
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
|
||||
|
||||
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
|
||||
// same day; optionally same minute), groups them, and lets the operator pick
|
||||
// which copies to delete. The oldest in each group is left unchecked by default.
|
||||
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = (w: number) => {
|
||||
setLoading(true);
|
||||
FindDuplicates(w)
|
||||
.then((g) => {
|
||||
const gs = (g ?? []) as Group[];
|
||||
setGroups(gs);
|
||||
// Default: select every QSO except the first (oldest) of each group.
|
||||
const next = new Set<number>();
|
||||
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
|
||||
setSel(next);
|
||||
})
|
||||
.catch(() => { setGroups([]); setSel(new Set()); })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
|
||||
|
||||
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
|
||||
const deselectAll = () => setSel(new Set());
|
||||
|
||||
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (sel.size === 0) return;
|
||||
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await DeleteQSOs(Array.from(sel) as any);
|
||||
onDeleted();
|
||||
load(windowMin); // rescan so the list reflects what's left
|
||||
} catch { /* ignore */ } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
|
||||
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||
<Copy className="size-4 text-primary" />
|
||||
<span className="font-bold">{t('dup.title')}</span>
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-5 py-3 border-b border-border/60 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t('dup.window')}
|
||||
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
|
||||
<option value={0}>{t('dup.sameDay')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
|
||||
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
|
||||
<span className="font-mono">{g.qsos[0].callsign}</span>
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr className="border-b border-border/60">
|
||||
<th className="w-8"></th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
|
||||
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.qsos.map((q, i) => {
|
||||
const checked = sel.has(q.id);
|
||||
return (
|
||||
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-rose-500/10')}>
|
||||
<td className="text-center py-1">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||
{fmtDate(q.qso_date)}
|
||||
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-emerald-600">{t('dup.keep')}</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
||||
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
||||
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
||||
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||
{t('dup.deleteSel', { n: sel.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,13 @@ const en: Dict = {
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
// Duplicates modal
|
||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
||||
'dup.hint': 'Each group is the same contact logged more than once (callsign + band + mode). The first (oldest) is left unchecked; tick the ones to delete.',
|
||||
'dup.window': 'Within', 'dup.minutes': 'min', 'dup.sameDay': 'same day', 'dup.summary': '{groups} groups · {qsos} duplicate QSOs · {sel} to delete',
|
||||
'dup.selectExtras': 'Select all but the first', 'dup.deselectAll': 'Deselect all', 'dup.deleteSel': 'Delete {n} selected', 'dup.deleteConfirm': 'Delete {n} QSO(s)? This cannot be undone.', 'dup.deleted': '{n} duplicate(s) deleted', 'dup.keep': 'oldest', 'dup.close': 'Close',
|
||||
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
|
||||
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
|
||||
// Tabs
|
||||
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
|
||||
@@ -210,7 +216,12 @@ const fr: Dict = {
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
||||
'dup.window': 'En moins de', 'dup.minutes': 'min', 'dup.sameDay': 'même jour', 'dup.summary': '{groups} groupes · {qsos} QSO en double · {sel} à supprimer',
|
||||
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
|
||||
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
|
||||
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
|
||||
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
|
||||
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
|
||||
|
||||
Vendored
+2
@@ -144,6 +144,8 @@ export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<m
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function FlexATUBypass():Promise<void>;
|
||||
|
||||
@@ -250,6 +250,10 @@ export function FilterFields() {
|
||||
return window['go']['main']['App']['FilterFields']();
|
||||
}
|
||||
|
||||
export function FindDuplicates(arg1) {
|
||||
return window['go']['main']['App']['FindDuplicates'](arg1);
|
||||
}
|
||||
|
||||
export function FindQSOsForUpload(arg1, arg2) {
|
||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -1528,6 +1528,38 @@ export namespace main {
|
||||
this.is_custom = source["is_custom"];
|
||||
}
|
||||
}
|
||||
export class DuplicateGroup {
|
||||
key: string;
|
||||
qsos: qso.QSO[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DuplicateGroup(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.key = source["key"];
|
||||
this.qsos = this.convertValues(source["qsos"], qso.QSO);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class EmailSettings {
|
||||
enabled: boolean;
|
||||
smtp_host: string;
|
||||
|
||||
Reference in New Issue
Block a user