fix: added additional selection in recent qso filters
This commit is contained in:
@@ -3263,6 +3263,70 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
|
|||||||
return n, nil
|
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
|
// WorkedBefore returns prior contacts with the given callsign at both
|
||||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||||
// will infer it from past QSOs with the same call when possible.
|
// will infer it from past QSOs with the same call when possible.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
@@ -705,6 +706,8 @@ export default function App() {
|
|||||||
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||||
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
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);
|
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
|
||||||
showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
|
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[]) {
|
async function bulkUpdateFromCty(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
|
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
|
||||||
@@ -1886,6 +1894,7 @@ export default function App() {
|
|||||||
{ name: 'edit', label: 'Edit', items: [
|
{ name: 'edit', label: 'Edit', items: [
|
||||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ 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 ? `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: 'separator' },
|
||||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
{ 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 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||||
case 'edit.delete': askDeleteSelected(); break;
|
case 'edit.delete': askDeleteSelected(); break;
|
||||||
|
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
@@ -3224,6 +3234,7 @@ export default function App() {
|
|||||||
onSendTo={bulkSendTo}
|
onSendTo={bulkSendTo}
|
||||||
onSendRecording={bulkSendRecording}
|
onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
@@ -3551,6 +3562,13 @@ export default function App() {
|
|||||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
|
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<BulkEditModal
|
||||||
|
open={bulkEditOpen}
|
||||||
|
ids={bulkEditIds}
|
||||||
|
onClose={() => setBulkEditOpen(false)}
|
||||||
|
onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
|
||||||
|
/>
|
||||||
|
|
||||||
<SendSpotModal
|
<SendSpotModal
|
||||||
open={showSpotModal}
|
open={showSpotModal}
|
||||||
onClose={() => setShowSpotModal(false)}
|
onClose={() => setShowSpotModal(false)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk edit field</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
This overwrites the current value — there is no undo.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center py-2">
|
||||||
|
<Label className="text-sm">Field</Label>
|
||||||
|
<Select value={field} onValueChange={setField}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
||||||
|
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">Value</Label>
|
||||||
|
{isStatus ? (
|
||||||
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={textValue}
|
||||||
|
placeholder="leave empty to clear the field"
|
||||||
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||||
|
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Apply to {ids.length}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||||
{ value: 'pota_ref', label: 'POTA 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: 'rig', label: 'Rig', type: 'text' },
|
||||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||||
{ value: 'qsl_sent', label: 'QSL sent', 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: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', 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: '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: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'Serial sent', 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: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||||
{ value: 'my_country', label: 'My country', 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: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||||
@@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
{conditions.map((c, i) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
||||||
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
|
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? 'value' : '—'}
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||||
value={c.value}
|
value={c.value}
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
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;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,13 +20,15 @@ type Props = {
|
|||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||||
{ service: 'clublog', label: 'Send to Club Log' },
|
{ 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' },
|
{ service: 'lotw', label: 'Send to LoTW' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// 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
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -105,6 +108,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBulkEdit && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4 text-indigo-600" />
|
||||||
|
<span>Bulk edit field… ({n})</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onExportSelected || onExportFiltered) && (
|
{(onExportSelected || onExportFiltered) && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -218,7 +219,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'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<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -365,6 +366,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Vendored
+2
@@ -35,6 +35,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export function BrowseExecutable() {
|
|||||||
return window['go']['main']['App']['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) {
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -637,6 +638,76 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
|||||||
return nil
|
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.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -829,16 +900,26 @@ var filterableColumns = map[string]bool{
|
|||||||
"name": true, "qth": true, "address": true, "email": true,
|
"name": true, "qth": true, "address": true, "email": true,
|
||||||
"grid": true, "country": true, "state": true, "cnty": true,
|
"grid": true, "country": true, "state": true, "cnty": true,
|
||||||
"dxcc": true, "cont": true, "cqz": true, "ituz": 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,
|
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": 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,
|
"contest_id": true, "srx": true, "stx": true,
|
||||||
"prop_mode": true, "sat_name": true,
|
"prop_mode": true, "sat_name": true,
|
||||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": 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,
|
"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
|
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
||||||
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
||||||
// ADIF/Extras key; the SQL expression uses json_extract.
|
// 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)
|
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
||||||
}
|
}
|
||||||
v := c.Value
|
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 {
|
switch c.Op {
|
||||||
case "eq":
|
case "eq":
|
||||||
return col + " = ?", []any{v}, nil
|
return col + " = ?", []any{v}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user