feat: New tool to remove duplicates in the log

This commit is contained in:
2026-07-05 10:58:49 +02:00
parent 2d742be7df
commit 45b9bcdea7
7 changed files with 300 additions and 2 deletions
+78
View File
@@ -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).