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
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||
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() {
|
||||
<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
|
||||
open={showSpotModal}
|
||||
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: '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 (
|
||||
<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>
|
||||
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? 'value' : '—'}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<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) && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
@@ -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<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -365,6 +366,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportFiltered={onExportFiltered}
|
||||
/>
|
||||
|
||||
Vendored
+2
@@ -35,6 +35,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
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 CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+91
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user