From 679e8f8d39fa26d20cd7e45c93e58fa1254e9791 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 18 Jun 2026 19:08:38 +0200 Subject: [PATCH] fix: added additional selection in recent qso filters --- app.go | 64 ++++++++ frontend/src/App.tsx | 18 +++ frontend/src/components/BulkEditModal.tsx | 165 +++++++++++++++++++++ frontend/src/components/FilterBuilder.tsx | 17 ++- frontend/src/components/QSOContextMenu.tsx | 20 ++- frontend/src/components/RecentQSOsGrid.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/qso/qso.go | 93 +++++++++++- 9 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/BulkEditModal.tsx diff --git a/app.go b/app.go index 5e80ae4..666c5e0 100644 --- a/app.go +++ b/app.go @@ -3263,6 +3263,70 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) { return n, nil } +// bulkFieldColumns maps the UI field ids to their QSO column. Kept in the app +// layer so the frontend works with stable ids, not raw column names. +var bulkFieldColumns = map[string]string{ + // QSL / upload status + "lotw_sent": "lotw_sent", + "lotw_rcvd": "lotw_rcvd", + "eqsl_sent": "eqsl_sent", + "eqsl_rcvd": "eqsl_rcvd", + "qsl_sent": "qsl_sent", + "qsl_rcvd": "qsl_rcvd", + "qsl_via": "qsl_via", + "qrz_upload": "qrzcom_qso_upload_status", + "clublog_upload": "clublog_qso_upload_status", + "hrdlog_upload": "hrdlog_qso_upload_status", + // My station / operator + "station_callsign": "station_callsign", + "operator": "operator", + "my_grid": "my_grid", + "my_country": "my_country", + "my_state": "my_state", + "my_cnty": "my_cnty", + "my_iota": "my_iota", + "my_sota_ref": "my_sota_ref", + "my_pota_ref": "my_pota_ref", + "my_wwff_ref": "my_wwff_ref", + "my_street": "my_street", + "my_city": "my_city", + "my_postal_code": "my_postal_code", + "my_rig": "my_rig", + "my_antenna": "my_antenna", + "my_sig": "my_sig", + "my_sig_info": "my_sig_info", + // Misc text + "comment": "comment", + "notes": "notes", + "rig": "rig", + "ant": "ant", +} + +// BulkUpdateField sets one QSL/upload status field to value on the given QSOs +// (e.g. flip a filtered set from N to R so they upload). field is one of the +// ids in bulkFieldColumns; value is a status code (Y/N/R/I) or "" to clear. +// Returns how many rows were updated. +func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + col, ok := bulkFieldColumns[field] + if !ok { + return 0, fmt.Errorf("unknown field %q", field) + } + // Trim only — do NOT force case here: status codes arrive already upper from + // the UI, while free-text fields (address, antenna, comment…) must keep + // their case. Callsign/grid uppercasing is handled in the UI. + n, err := a.qso.BulkSetField(a.ctx, ids, col, strings.TrimSpace(value)) + if err != nil { + return 0, err + } + if n > 0 { + a.invalidateAwardStats() + } + return n, nil +} + // WorkedBefore returns prior contacts with the given callsign at both // call and DXCC granularity. Pass dxccHint=0 when unknown — the function // will infer it from past QSOs with the same call when possible. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de64fa1..16adc42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; +import { BulkEditModal } from '@/components/BulkEditModal'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; @@ -705,6 +706,8 @@ export default function App() { const [deletingIds, setDeletingIds] = useState([]); const [selectedId, setSelectedId] = useState(null); const [selectedIds, setSelectedIds] = useState([]); + const [bulkEditIds, setBulkEditIds] = useState([]); + const [bulkEditOpen, setBulkEditOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); // Re-read the "beam on map" toggle when Preferences closes (it's edited there). useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); @@ -1598,6 +1601,11 @@ export default function App() { if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`); } + function openBulkEdit(ids: number[]) { + if (ids.length === 0) return; + setBulkEditIds(ids); + setBulkEditOpen(true); + } async function bulkUpdateFromCty(ids: number[]) { if (ids.length === 0) return; try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); } @@ -1886,6 +1894,7 @@ export default function App() { { name: 'edit', label: 'Edit', items: [ { type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null }, + { type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 }, { type: 'separator' }, { type: 'item', label: 'Preferences…', action: 'edit.prefs' }, ]}, @@ -1919,6 +1928,7 @@ export default function App() { case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.delete': askDeleteSelected(); break; + case 'edit.bulkedit': openBulkEdit(selectedIds); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; @@ -3224,6 +3234,7 @@ export default function App() { onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} + onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} @@ -3551,6 +3562,13 @@ export default function App() { setEditingQSO(null)} countries={countries} bands={bands} modes={modes} /> )} + setBulkEditOpen(false)} + onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }} + /> + setShowSpotModal(false)} diff --git a/frontend/src/components/BulkEditModal.tsx b/frontend/src/components/BulkEditModal.tsx new file mode 100644 index 0000000..2086710 --- /dev/null +++ b/frontend/src/components/BulkEditModal.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { BulkUpdateField } from '../../wailsjs/go/main/App'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@/components/ui/select'; + +type FieldKind = 'status' | 'text'; +type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean }; + +// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input. +// upper:true uppercases code-like values (callsign, grid, refs). The id matches +// the backend BulkUpdateField whitelist. +const FIELDS: FieldDef[] = [ + // QSL / upload status + { id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' }, + { id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' }, + { id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' }, + { id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' }, + { id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' }, + { id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' }, + { id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' }, + { id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' }, + { id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' }, + { id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' }, + // My station / operator + { id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true }, + { id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true }, + { id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true }, + { id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' }, + { id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' }, + { id: 'my_street', label: 'My street', group: 'My station', kind: 'text' }, + { id: 'my_city', label: 'My city', group: 'My station', kind: 'text' }, + { id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' }, + { id: 'my_country', label: 'My country', group: 'My station', kind: 'text' }, + { id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true }, + { id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' }, + { id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true }, + { id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true }, + { id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true }, + { id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true }, + { id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' }, + { id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' }, + // Misc + { id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' }, + { id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' }, + { id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' }, + { id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' }, +]; + +const STATUS_VALUES: { v: string; label: string }[] = [ + { v: 'Y', label: 'Y — Yes / uploaded' }, + { v: 'N', label: 'N — No' }, + { v: 'R', label: 'R — Requested' }, + { v: 'I', label: 'I — Ignore' }, + { v: '_', label: '(blank — clear)' }, +]; + +const GROUPS = ['QSL / upload', 'My station', 'Misc']; + +type Props = { + open: boolean; + ids: number[]; + onClose: () => void; + onApplied: (n: number) => void; +}; + +// BulkEditModal sets one QSL/upload status field to a chosen value across the +// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R +// so they become eligible for upload. +export function BulkEditModal({ open, ids, onClose, onApplied }: Props) { + const [field, setField] = useState('hrdlog_upload'); + const [statusValue, setStatusValue] = useState('R'); + const [textValue, setTextValue] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]); + const isStatus = def.kind === 'status'; + const effectiveValue = isStatus + ? (statusValue === '_' ? '' : statusValue) + : textValue.trim(); + + async function apply() { + setBusy(true); + setError(''); + try { + const n = await BulkUpdateField(ids as any, field, effectiveValue); + onApplied(Number(n) || 0); + onClose(); + } catch (e: any) { + setError(String(e?.message ?? e)); + } finally { + setBusy(false); + } + } + + return ( + { if (!o) onClose(); }}> + + + Bulk edit field + + Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}. + This overwrites the current value — there is no undo. + + + +
+ + + + + {isStatus ? ( + + ) : ( + setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} + /> + )} +
+ +
+ Will set {def.label} ={' '} + {effectiveValue === '' ? '(blank)' : effectiveValue} on {ids.length} QSO{ids.length > 1 ? 's' : ''}. +
+ {error &&
{error}
} + + + + + +
+
+ ); +} diff --git a/frontend/src/components/FilterBuilder.tsx b/frontend/src/components/FilterBuilder.tsx index acf9a23..bbf0f87 100644 --- a/frontend/src/components/FilterBuilder.tsx +++ b/frontend/src/components/FilterBuilder.tsx @@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [ { value: 'iota', label: 'IOTA', type: 'text' }, { value: 'sota_ref', label: 'SOTA ref', type: 'text' }, { value: 'pota_ref', label: 'POTA ref', type: 'text' }, + { value: 'wwff_ref', label: 'WWFF ref', type: 'text' }, { value: 'rig', label: 'Rig', type: 'text' }, { value: 'ant', label: 'Antenna', type: 'text' }, { value: 'qsl_sent', label: 'QSL sent', type: 'text' }, @@ -64,6 +65,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [ { value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' }, { value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' }, { value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' }, + { value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' }, { value: 'contest_id', label: 'Contest ID', type: 'text' }, { value: 'srx', label: 'Serial rcvd', type: 'number' }, { value: 'stx', label: 'Serial sent', type: 'number' }, @@ -74,6 +76,17 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [ { value: 'owner_callsign', label: 'Owner callsign', type: 'text' }, { value: 'my_grid', label: 'My grid', type: 'text' }, { value: 'my_country', label: 'My country', type: 'text' }, + { value: 'my_state', label: 'My state', type: 'text' }, + { value: 'my_cnty', label: 'My county', type: 'text' }, + { value: 'my_iota', label: 'My IOTA', type: 'text' }, + { value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' }, + { value: 'my_pota_ref', label: 'My POTA ref', type: 'text' }, + { value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' }, + { value: 'my_street', label: 'My street', type: 'text' }, + { value: 'my_city', label: 'My city', type: 'text' }, + { value: 'my_postal_code', label: 'My postal code', type: 'text' }, + { value: 'my_rig', label: 'My rig', type: 'text' }, + { value: 'my_antenna', label: 'My antenna', type: 'text' }, { value: 'tx_pwr', label: 'TX power (W)', type: 'number' }, { value: 'comment', label: 'Comment', type: 'text' }, { value: 'notes', label: 'Notes', type: 'text' }, @@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) { )} {conditions.map((c, i) => { const needsValue = c.op !== 'empty' && c.op !== 'notempty'; + const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text'; return (
{i === 0 ? 'WHERE' : match} @@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) { setCond(i, { value: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index e46bbe5..d4cd82f 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react'; +import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react'; export type QSOMenuState = { x: number; y: number; ids: number[] } | null; @@ -12,6 +12,7 @@ type Props = { onSendTo?: (service: string, ids: number[]) => void; onSendRecording?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void; + onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; }; @@ -19,13 +20,15 @@ type Props = { const UPLOAD_TARGETS: { service: string; label: string }[] = [ { service: 'qrz', label: 'Send to QRZ.com' }, { service: 'clublog', label: 'Send to Club Log' }, + { service: 'hrdlog', label: 'Send to HRDLog.net' }, + { service: 'eqsl', label: 'Send to eQSL.cc' }, { service: 'lotw', label: 'Send to LoTW' }, ]; // Lightweight right-click menu for the QSO grids. AG Grid's native context // menu is an Enterprise feature, so this is a plain floating menu driven by // onCellContextMenu. Closes on any outside click, scroll or Escape. -export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) { useEffect(() => { if (!menu) return; const close = () => onClose(); @@ -105,6 +108,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ )} + {onBulkEdit && ( + <> +
+ + + )} + {(onExportSelected || onExportFiltered) && ( <>
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 17a8a5e..8a99f12 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -53,6 +53,7 @@ type Props = { onSendTo?: (service: string, ids: number[]) => void; onSendRecording?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void; + onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; }; @@ -218,7 +219,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { +export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -365,6 +366,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda onSendTo={onSendTo} onSendRecording={onSendRecording} onSendEQSL={onSendEQSL} + onBulkEdit={onBulkEdit} onExportSelected={onExportSelected} onExportFiltered={onExportFiltered} /> diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ae4fb54..b7547ca 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -35,6 +35,8 @@ export function AwardMissingQSOs(arg1:string):Promise>; export function BrowseExecutable():Promise; +export function BulkUpdateField(arg1:Array,arg2:string,arg3:string):Promise; + export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promise; export function CheckForUpdate():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ddb4a1a..629f521 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -42,6 +42,10 @@ export function BrowseExecutable() { return window['go']['main']['App']['BrowseExecutable'](); } +export function BulkUpdateField(arg1, arg2, arg3) { + return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3); +} + export function BulkUpdateQSL(arg1, arg2) { return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index bf8b427..982b1a9 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "reflect" + "regexp" "sort" "strings" "time" @@ -637,6 +638,76 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error { return nil } +// bulkEditableCols whitelists the columns BulkSetField may write. Limited to +// TEXT fields where setting one value across many QSOs is meaningful: the +// per-service QSL/upload status fields, plus "my station"/operator fields that +// are naturally constant across a run (grid, antenna, rig, address, …). It +// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the +// contacted station's details) and numeric columns (power, zones, lat/lon), +// which would be corrupted or meaningless if bulk-set to a single value. +var bulkEditableCols = map[string]bool{ + // QSL / upload status + "lotw_sent": true, + "lotw_rcvd": true, + "eqsl_sent": true, + "eqsl_rcvd": true, + "qsl_sent": true, + "qsl_rcvd": true, + "qsl_via": true, + "qrzcom_qso_upload_status": true, + "clublog_qso_upload_status": true, + "hrdlog_qso_upload_status": true, + // My station / operator + "station_callsign": true, + "operator": true, + "my_grid": true, + "my_country": true, + "my_state": true, + "my_cnty": true, + "my_iota": true, + "my_sota_ref": true, + "my_pota_ref": true, + "my_wwff_ref": true, + "my_street": true, + "my_city": true, + "my_postal_code": true, + "my_rig": true, + "my_antenna": true, + "my_sig": true, + "my_sig_info": true, + // Misc text + "comment": true, + "notes": true, + "rig": true, + "ant": true, +} + +// BulkSetField sets one whitelisted column to value on every listed QSO in a +// single statement. value "" clears the field. Returns rows affected. +func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) { + if !bulkEditableCols[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) + args = append(args, value, 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 +} + // Update overwrites all editable fields of an existing QSO. updated_at is bumped. func (r *Repo) Update(ctx context.Context, q QSO) error { if q.ID == 0 { @@ -829,16 +900,26 @@ var filterableColumns = map[string]bool{ "name": true, "qth": true, "address": true, "email": true, "grid": true, "country": true, "state": true, "cnty": true, "dxcc": true, "cont": true, "cqz": true, "ituz": true, - "iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true, + "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, - "qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, + "qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "contest_id": true, "srx": true, "stx": true, "prop_mode": true, "sat_name": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true, + "my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true, + "my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true, + "my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true, "tx_pwr": true, "comment": true, "notes": true, } +// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD +// value compares on the date part (see conditionSQL) so day filters are exact. +var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true} + +// bareDateRe matches a plain calendar date with no time component. +var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + // filterableExtras whitelists virtual filter fields stored inside extras_json // (valid ADIF fields we don't promote to columns). The value is the uppercase // ADIF/Extras key; the SQL expression uses json_extract. @@ -885,6 +966,14 @@ func conditionSQL(c Condition) (string, []any, error) { return "", nil, fmt.Errorf("unknown filter field %q", c.Field) } v := c.Value + // Date columns hold full ISO timestamps ("2020-01-01T12:34:56.000Z"). When + // the user filters on a bare calendar date, compare only the date part so + // "= 2020-01-01" matches that whole day and "<= 2020-12-31" includes it + // (a raw string compare would otherwise drop times on the boundary day). + if dateColumns[strings.ToLower(strings.TrimSpace(c.Field))] && bareDateRe.MatchString(strings.TrimSpace(v)) { + col = "substr(" + col + ",1,10)" + v = strings.TrimSpace(v) + } switch c.Op { case "eq": return col + " = ?", []any{v}, nil