fix: Bug where renaming the main folder did not update db path

settings where the ones of the previous folder.
This commit is contained in:
2026-06-18 11:20:20 +02:00
parent 59f1775fcd
commit b6d991b799
6 changed files with 93 additions and 33 deletions
+25 -2
View File
@@ -523,8 +523,22 @@ func (a *App) startup(ctx context.Context) {
// Windows reinstall. It lives OUTSIDE the DB since we must know the path // Windows reinstall. It lives OUTSIDE the DB since we must know the path
// before opening it. // before opening it.
if custom := readDBPointer(dataDir); custom != "" { if custom := readDBPointer(dataDir); custom != "" {
a.dbPath = custom // Portability guard: a pointer that is merely ANOTHER folder's default DB
usingDefault = false // location ("…/<other>/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 { if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error() 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) 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 // 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" // string leaves that field unchanged (so you can set only "received = Y + date"
// without touching the sent side). // without touching the sent side).
+40 -28
View File
@@ -7,7 +7,7 @@ import {
import { import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, GetStartupStatus, CheckForUpdate,
@@ -701,8 +701,10 @@ export default function App() {
// === Modals === // === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null); const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null); // QSOs queued for the delete confirm (1 or many — multi-row selection).
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 [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]);
@@ -1584,8 +1586,8 @@ export default function App() {
} catch (err: any) { setError(String(err?.message ?? err)); } } catch (err: any) { setError(String(err?.message ?? err)); }
} }
function onModalDelete(id: number) { function onModalDelete(id: number) {
const q = editingQSO; setEditingQSO(null); setEditingQSO(null);
if (q) setDeletingQSO(q); else askDelete(id); setDeletingIds([id]);
} }
// Bulk grid actions (right-click menu). Recompute country/zones from // 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}`); showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); } } catch (e: any) { setError(String(e?.message ?? e)); }
} }
function askDelete(id: number) { function askDelete(id: number) { setDeletingIds([id]); }
const q = qsos.find((x) => x.id === id); // Delete the whole multi-row selection (Edit menu / Delete key).
if (q) setDeletingQSO(q); function askDeleteSelected() {
if (selectedIds.length > 0) setDeletingIds(selectedIds);
else if (selectedId != null) setDeletingIds([selectedId]);
} }
async function confirmDelete() { async function confirmDelete() {
if (!deletingQSO) return; if (deletingIds.length === 0) return;
const ids = deletingIds;
try { try {
await DeleteQSO(deletingQSO.id); if (ids.length === 1) await DeleteQSO(ids[0]);
if (selectedId === deletingQSO.id) setSelectedId(null); else await DeleteQSOs(ids as any);
setDeletingQSO(null); setDeletingIds([]);
setSelectedId(null);
setSelectedIds([]);
await refresh(); await refresh();
} catch (err: any) { } catch (err: any) {
setError(String(err?.message ?? err)); setError(String(err?.message ?? err));
setDeletingQSO(null); setDeletingIds([]);
} }
} }
async function confirmDeleteAll() { async function confirmDeleteAll() {
@@ -1878,7 +1885,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: '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: 'separator' },
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' }, { type: 'item', label: 'Preferences…', action: 'edit.prefs' },
]}, ]},
@@ -1901,7 +1908,7 @@ export default function App() {
{ name: 'help', label: 'Help', items: [ { name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about' }, { 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) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -1911,7 +1918,7 @@ export default function App() {
case 'view.refresh': refresh(); break; case 'view.refresh': refresh(); break;
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': if (selectedId !== null) askDelete(selectedId); break; case 'edit.delete': askDeleteSelected(); 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;
@@ -2008,14 +2015,14 @@ export default function App() {
} }
if (typing) return; if (typing) return;
if (selectedId !== null) { 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; } if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
} }
} }
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, refresh]); }, [selectedId, selectedIds, refresh]);
// ── Entry-field blocks ───────────────────────────────────────────────── // ── Entry-field blocks ─────────────────────────────────────────────────
// Each field is defined once here, then composed into either the compact // 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)} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onExportSelected={exportSelectedADIF} onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onRowSelected={(id) => setSelectedId(id)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/> />
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30"> <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -3592,16 +3599,21 @@ export default function App() {
onOpenDesigner={() => setQslDesignerOpen(true)} onOpenDesigner={() => setQslDesignerOpen(true)}
/> />
{deletingQSO && ( {deletingIds.length > 0 && (() => {
<ConfirmDialog const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
title="Delete QSO?" return (
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`} <ConfirmDialog
confirmLabel="Delete" title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
danger message={single
onConfirm={confirmDelete} ? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
onCancel={() => setDeletingQSO(null)} : `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
/> confirmLabel="Delete"
)} danger
onConfirm={confirmDelete}
onCancel={() => setDeletingIds([])}
/>
);
})()}
{showDeleteAll && ( {showDeleteAll && (
<ConfirmDialog <ConfirmDialog
title="Delete ALL QSOs?" title="Delete ALL QSOs?"
+3 -3
View File
@@ -46,7 +46,7 @@ type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (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); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
} }
function onSelectionChanged() { function onSelectionChanged() {
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined; const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
+2
View File
@@ -87,6 +87,8 @@ export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>; export function DeleteQSO(arg1:number):Promise<void>;
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
export function DeleteUDPIntegration(arg1:number):Promise<void>; export function DeleteUDPIntegration(arg1:number):Promise<void>;
export function DisconnectAllClusters():Promise<void>; export function DisconnectAllClusters():Promise<void>;
+4
View File
@@ -146,6 +146,10 @@ export function DeleteQSO(arg1) {
return window['go']['main']['App']['DeleteQSO'](arg1); return window['go']['main']['App']['DeleteQSO'](arg1);
} }
export function DeleteQSOs(arg1) {
return window['go']['main']['App']['DeleteQSOs'](arg1);
}
export function DeleteUDPIntegration(arg1) { export function DeleteUDPIntegration(arg1) {
return window['go']['main']['App']['DeleteUDPIntegration'](arg1); return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
} }
+19
View File
@@ -651,6 +651,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
return n, nil 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. // Delete removes a QSO by id.
func (r *Repo) Delete(ctx context.Context, id int64) error { func (r *Repo) Delete(ctx context.Context, id int64) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id) res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)