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).
|
||||
|
||||
Reference in New Issue
Block a user