feat(bulk): My DXCC / My CQ zone / My ITU zone

The My-station group covered nineteen fields and not the three numeric ones.
They take their own integer path rather than the generic text setter: the
columns are nullable integers, and while SQLite would coerce '14' quietly, a
shared MySQL logbook would not — and empty must become NULL, never ''. Bounds
checked (DXCC <1000, CQ 1-40, ITU 1-90) so a slip cannot stamp zone 400 across
a thousand rows.
This commit is contained in:
2026-08-29 00:13:47 +02:00
parent 10ef984962
commit 284ee4ba7c
5 changed files with 69 additions and 4 deletions
+22
View File
@@ -6938,6 +6938,28 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
if field == "freq" { if field == "freq" {
return a.bulkSetFrequency(ids, value) return a.bulkSetFrequency(ids, value)
} }
// The station-side numbers. Bounded so a slip cannot stamp CQ zone 400 on a
// thousand rows: DXCC entities stop short of 1000, CQ zones at 40, ITU at 90.
// Empty clears (NULL).
if field == "my_dxcc" || field == "my_cq_zone" || field == "my_itu_zone" {
var vp *int
if t := strings.TrimSpace(value); t != "" {
v, err := strconv.Atoi(t)
max := map[string]int{"my_dxcc": 999, "my_cq_zone": 40, "my_itu_zone": 90}[field]
if err != nil || v < 1 || v > max {
return 0, fmt.Errorf("%s must be a number between 1 and %d (empty to clear)", field, max)
}
vp = &v
}
n, err := a.qso.BulkSetIntField(a.ctx, ids, field, vp)
if err != nil {
return 0, err
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
// Some ADIF fields have no promoted column and live in extras_json // Some ADIF fields have no promoted column and live in extras_json
// (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on // (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on
// each QSO survive the edit. // each QSO survive the edit.
+4 -2
View File
@@ -8,7 +8,8 @@
"Cluster: NEW SLOT gets its own colour — the sky cyan the panadapter palette already uses for it, clearly apart from POTA green and the yellows. The NEW SLOT and NEW COUNTY filter chips now wear the same colours as the badges they select, and the NEW CALL filter works even when the slot-highlight display option is off.", "Cluster: NEW SLOT gets its own colour — the sky cyan the panadapter palette already uses for it, clearly apart from POTA green and the yellows. The NEW SLOT and NEW COUNTY filter chips now wear the same colours as the badges they select, and the NEW CALL filter works even when the slot-highlight display option is off.",
"Elecraft console: a mode row — CW, USB, LSB, DATA and DATA RTTY. The two DATA buttons set the K3s submode as well (DATA A for FT8/FT4, FSK D for RTTY): switching to DATA by mode alone kept whatever submode the last session left, which is how a K3 “in DATA” keys FT8 with no audio.", "Elecraft console: a mode row — CW, USB, LSB, DATA and DATA RTTY. The two DATA buttons set the K3s submode as well (DATA A for FT8/FT4, FSK D for RTTY): switching to DATA by mode alone kept whatever submode the last session left, which is how a K3 “in DATA” keys FT8 with no audio.",
"DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty.", "DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty.",
"Elecraft console: RIT and XIT use the same control as the Icom and TCI consoles — ± buttons, mouse wheel, typed value, Ctrl+←/→ — and the power slider moves in 1 W steps instead of 5." "Elecraft console: RIT and XIT use the same control as the Icom and TCI consoles — ± buttons, mouse wheel, typed value, Ctrl+←/→ — and the power slider moves in 1 W steps instead of 5.",
"Bulk edit: My DXCC, My CQ zone and My ITU zone join the My-station fields — numbers checked against their real ranges, empty clears."
], ],
"fr": [ "fr": [
"TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».", "TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».",
@@ -16,7 +17,8 @@
"Cluster : NOUVEAU SLOT reçoit sa propre couleur — le cyan ciel que la palette du panadapter lui donne déjà, bien distinct du vert POTA et des jaunes. Les puces de filtre NOUVEAU SLOT et NOUVEAU COMTÉ portent désormais les couleurs des badges qu'elles sélectionnent, et le filtre NOUVEAU CALL fonctionne même quand l'option de surlignage par slot est désactivée.", "Cluster : NOUVEAU SLOT reçoit sa propre couleur — le cyan ciel que la palette du panadapter lui donne déjà, bien distinct du vert POTA et des jaunes. Les puces de filtre NOUVEAU SLOT et NOUVEAU COMTÉ portent désormais les couleurs des badges qu'elles sélectionnent, et le filtre NOUVEAU CALL fonctionne même quand l'option de surlignage par slot est désactivée.",
"Console Elecraft : une rangée de modes — CW, USB, LSB, DATA et DATA RTTY. Les deux boutons DATA règlent aussi le sous-mode du K3 (DATA A pour FT8/FT4, FSK D pour le RTTY) : passer en DATA par le seul mode gardait le sous-mode de la session précédente, et un K3 « en DATA » manipulait le FT8 sans audio.", "Console Elecraft : une rangée de modes — CW, USB, LSB, DATA et DATA RTTY. Les deux boutons DATA règlent aussi le sous-mode du K3 (DATA A pour FT8/FT4, FSK D pour le RTTY) : passer en DATA par le seul mode gardait le sous-mode de la session précédente, et un K3 « en DATA » manipulait le FT8 sans audio.",
"Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides.", "Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides.",
"Console Elecraft : le RIT et le XIT utilisent la même commande que les consoles Icom et TCI — boutons ±, molette, valeur tapée, Ctrl+←/→ — et le curseur de puissance avance par pas de 1 W au lieu de 5." "Console Elecraft : le RIT et le XIT utilisent la même commande que les consoles Icom et TCI — boutons ±, molette, valeur tapée, Ctrl+←/→ — et le curseur de puissance avance par pas de 1 W au lieu de 5.",
"Édition groupée : My DXCC, My CQ zone et My ITU zone rejoignent les champs Ma station — valeurs vérifiées contre leurs bornes réelles, vide efface."
] ]
}, },
{ {
@@ -60,6 +60,9 @@ const FIELDS: FieldDef[] = [
// No promoted column: written into extras_json (see qso.bulkEditableExtras). // No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true }, { id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true }, { id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_dxcc', label: 'bulk.fMyDxcc', group: 'My station', kind: 'text' },
{ id: 'my_cq_zone', label: 'bulk.fMyCqZone', group: 'My station', kind: 'text' },
{ id: 'my_itu_zone', label: 'bulk.fMyItuZone', group: 'My station', kind: 'text' },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' }, { id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' }, { id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' }, { id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
File diff suppressed because one or more lines are too long
+38
View File
@@ -868,6 +868,44 @@ var bulkEditableCols = map[string]bool{
// BulkSetField sets one whitelisted column to value on every listed QSO in a // BulkSetField sets one whitelisted column to value on every listed QSO in a
// single statement. value "" clears the field. Returns rows affected. // single statement. value "" clears the field. Returns rows affected.
// bulkEditableIntCols are the NUMERIC columns the bulk editor may touch. Their
// own path, not the text one: the columns are nullable integers, and while
// SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an
// empty string is NULL here, never "".
var bulkEditableIntCols = map[string]bool{
"my_dxcc": true,
"my_cq_zone": true,
"my_itu_zone": true,
}
// BulkSetIntField sets one integer column across the ids; v nil clears it.
func (r *Repo) BulkSetIntField(ctx context.Context, ids []int64, column string, v *int) (int64, error) {
if !bulkEditableIntCols[column] {
return 0, fmt.Errorf("field %q is not bulk-editable", column)
}
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
var val any
if v != nil {
val = *v
}
args = append(args, val, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...)
if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err)
}
n, _ := res.RowsAffected()
return n, nil
}
func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) { func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) {
if !bulkEditableCols[column] { if !bulkEditableCols[column] {
return 0, fmt.Errorf("field %q is not bulk-editable", column) return 0, fmt.Errorf("field %q is not bulk-editable", column)