diff --git a/app.go b/app.go index 39feb82..44f738a 100644 --- a/app.go +++ b/app.go @@ -523,8 +523,22 @@ func (a *App) startup(ctx context.Context) { // Windows reinstall. It lives OUTSIDE the DB since we must know the path // before opening it. if custom := readDBPointer(dataDir); custom != "" { - a.dbPath = custom - usingDefault = false + // Portability guard: a pointer that is merely ANOTHER folder's default DB + // location ("…//data/opslog.db") means the portable folder was + // renamed or copied — its config.json still points at the original. Ignore + // it and use THIS folder's own data (and clear the stale pointer so it + // stops happening). A genuine custom location — another drive, a different + // filename — is NOT default-style, so it's still honoured. + stale := strings.EqualFold(filepath.Base(custom), "opslog.db") && + strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") && + !strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir)) + if stale { + fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath) + _ = writeDBPointer(dataDir, "") + } else { + a.dbPath = custom + usingDefault = false + } } if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil { a.startupErr = "cannot create db folder: " + err.Error() @@ -3174,6 +3188,15 @@ func (a *App) DeleteQSO(id int64) error { return a.qso.Delete(a.ctx, id) } +// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the +// number actually deleted. +func (a *App) DeleteQSOs(ids []int64) (int64, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + return a.qso.DeleteMany(a.ctx, ids) +} + // QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty // string leaves that field unchanged (so you can set only "received = Y + date" // without touching the sent side). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ee7119..99e3975 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import { import { AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, - GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, + GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, CheckForUpdate, @@ -701,8 +701,10 @@ export default function App() { // === Modals === const [editingQSO, setEditingQSO] = useState(null); - const [deletingQSO, setDeletingQSO] = useState(null); + // QSOs queued for the delete confirm (1 or many — multi-row selection). + const [deletingIds, setDeletingIds] = useState([]); const [selectedId, setSelectedId] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); 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]); @@ -1584,8 +1586,8 @@ export default function App() { } catch (err: any) { setError(String(err?.message ?? err)); } } function onModalDelete(id: number) { - const q = editingQSO; setEditingQSO(null); - if (q) setDeletingQSO(q); else askDelete(id); + setEditingQSO(null); + setDeletingIds([id]); } // Bulk grid actions (right-click menu). Recompute country/zones from @@ -1654,20 +1656,25 @@ export default function App() { showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } - function askDelete(id: number) { - const q = qsos.find((x) => x.id === id); - if (q) setDeletingQSO(q); + function askDelete(id: number) { setDeletingIds([id]); } + // Delete the whole multi-row selection (Edit menu / Delete key). + function askDeleteSelected() { + if (selectedIds.length > 0) setDeletingIds(selectedIds); + else if (selectedId != null) setDeletingIds([selectedId]); } async function confirmDelete() { - if (!deletingQSO) return; + if (deletingIds.length === 0) return; + const ids = deletingIds; try { - await DeleteQSO(deletingQSO.id); - if (selectedId === deletingQSO.id) setSelectedId(null); - setDeletingQSO(null); + if (ids.length === 1) await DeleteQSO(ids[0]); + else await DeleteQSOs(ids as any); + setDeletingIds([]); + setSelectedId(null); + setSelectedIds([]); await refresh(); } catch (err: any) { setError(String(err?.message ?? err)); - setDeletingQSO(null); + setDeletingIds([]); } } async function confirmDeleteAll() { @@ -1878,7 +1885,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: '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: 'separator' }, { type: 'item', label: 'Preferences…', action: 'edit.prefs' }, ]}, @@ -1901,7 +1908,7 @@ export default function App() { { name: 'help', label: 'Help', items: [ { type: 'item', label: 'About OpsLog', action: 'help.about' }, ]}, - ], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]); + ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]); function handleMenu(action: string) { switch (action) { @@ -1911,7 +1918,7 @@ export default function App() { case 'view.refresh': refresh(); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; - case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; + case 'edit.delete': askDeleteSelected(); break; case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; @@ -2008,14 +2015,14 @@ export default function App() { } if (typing) return; if (selectedId !== null) { - if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; } + if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; } if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; } } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedId, refresh]); + }, [selectedId, selectedIds, refresh]); // ── Entry-field blocks ───────────────────────────────────────────────── // Each field is defined once here, then composed into either the compact @@ -3213,7 +3220,7 @@ export default function App() { onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} - onRowSelected={(id) => setSelectedId(id)} + onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} />
@@ -3592,16 +3599,21 @@ export default function App() { onOpenDesigner={() => setQslDesignerOpen(true)} /> - {deletingQSO && ( - setDeletingQSO(null)} - /> - )} + {deletingIds.length > 0 && (() => { + const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null; + return ( + 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'} + message={single + ? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.` + : `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`} + confirmLabel="Delete" + danger + onConfirm={confirmDelete} + onCancel={() => setDeletingIds([])} + /> + ); + })()} {showDeleteAll && ( void; - onRowSelected?: (id: number | null) => void; + onRowSelected?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void; @@ -277,8 +277,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); } function onSelectionChanged() { - const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined; - onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null); + const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; + onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); } // ── Column picker (visibility) ── diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 932972e..8abb893 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -87,6 +87,8 @@ export function DeleteProfile(arg1:number):Promise; export function DeleteQSO(arg1:number):Promise; +export function DeleteQSOs(arg1:Array):Promise; + export function DeleteUDPIntegration(arg1:number):Promise; export function DisconnectAllClusters():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 31b6dc9..31718f1 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -146,6 +146,10 @@ export function DeleteQSO(arg1) { return window['go']['main']['App']['DeleteQSO'](arg1); } +export function DeleteQSOs(arg1) { + return window['go']['main']['App']['DeleteQSOs'](arg1); +} + export function DeleteUDPIntegration(arg1) { return window['go']['main']['App']['DeleteUDPIntegration'](arg1); } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 06cc95b..b6343cc 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -651,6 +651,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) { return n, nil } +// DeleteMany removes several QSOs in one statement. Returns the number deleted. +func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + ph := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + ph[i] = "?" + args[i] = id + } + res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...) + if err != nil { + return 0, fmt.Errorf("delete qsos: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} + // Delete removes a QSO by id. func (r *Repo) Delete(ctx context.Context, id int64) error { res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)