Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee9fb51066 | ||
|
|
5bbcaab71a | ||
|
|
18438d7737 |
@@ -25,6 +25,7 @@ import (
|
|||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
"hamlog/internal/awardref"
|
"hamlog/internal/awardref"
|
||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
|
"hamlog/internal/cabrillo"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
@@ -3704,6 +3705,83 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
|
|||||||
return ex.ExportFileByIDs(a.ctx, path, ids)
|
return ex.ExportFileByIDs(a.ctx, path, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
||||||
|
type CabrilloResult struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabrilloHeader fills the Cabrillo header from the active profile (callsign,
|
||||||
|
// operator, grid) plus the contest name the user supplied.
|
||||||
|
func (a *App) cabrilloHeader(contest string) cabrillo.Header {
|
||||||
|
h := cabrillo.Header{Contest: contest}
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
h.Callsign = p.Callsign
|
||||||
|
h.Operators = p.Callsign
|
||||||
|
h.GridLocator = p.MyGrid
|
||||||
|
h.Name = p.OpName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCabrillo collects the QSOs via the given iterator and writes a Cabrillo
|
||||||
|
// document to path.
|
||||||
|
func (a *App) writeCabrillo(path, contest string, iterate func(func(qso.QSO) error) error) (CabrilloResult, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
var qsos []qso.QSO
|
||||||
|
if err := iterate(func(q qso.QSO) error { qsos = append(qsos, q); return nil }); err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
n, err := cabrillo.Generate(f, a.cabrilloHeader(contest), qsos)
|
||||||
|
if err != nil {
|
||||||
|
return CabrilloResult{}, err
|
||||||
|
}
|
||||||
|
return CabrilloResult{Count: n, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrillo writes every QSO to a Cabrillo file.
|
||||||
|
func (a *App) ExportCabrillo(path, contest string) (CabrilloResult, error) {
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrilloFiltered writes the QSOs matching the current filter.
|
||||||
|
func (a *App) ExportCabrilloFiltered(path, contest string, f qso.QueryFilter) (CabrilloResult, error) {
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateFiltered(a.ctx, f, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportCabrilloSelected writes only the highlighted QSOs.
|
||||||
|
func (a *App) ExportCabrilloSelected(path, contest string, ids []int64) (CabrilloResult, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return CabrilloResult{}, fmt.Errorf("no QSOs selected")
|
||||||
|
}
|
||||||
|
return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateByIDs(a.ctx, ids, fn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCabrilloFile opens a save dialog for a Cabrillo (.cbr / .log) file.
|
||||||
|
func (a *App) SaveCabrilloFile() (string, error) {
|
||||||
|
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".cbr"
|
||||||
|
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
|
Title: "Export Cabrillo",
|
||||||
|
DefaultFilename: suggested,
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Cabrillo files (*.cbr, *.log)", Pattern: "*.cbr;*.log"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Lookup bindings ---
|
// --- Lookup bindings ---
|
||||||
|
|
||||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||||
|
|||||||
@@ -7,6 +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,
|
||||||
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
@@ -1980,6 +1981,40 @@ 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)); }
|
||||||
}
|
}
|
||||||
|
// Cabrillo (contest log) export — prompts for the contest name, then writes a
|
||||||
|
// .cbr file. All / filtered / selected variants mirror the ADIF ones.
|
||||||
|
async function exportCabrillo() {
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const r = await ExportCabrillo(path, contest);
|
||||||
|
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function exportFilteredCabrillo() {
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const f = buildActiveFilter();
|
||||||
|
const r = await ExportCabrilloFiltered(path, contest, { ...f, limit: 0, offset: 0 } as any);
|
||||||
|
showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function exportSelectedCabrillo(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', '');
|
||||||
|
if (contest === null) return;
|
||||||
|
try {
|
||||||
|
const path = await SaveCabrilloFile();
|
||||||
|
if (!path) return;
|
||||||
|
const r = await ExportCabrilloSelected(path, contest, ids as any);
|
||||||
|
showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
function askDelete(id: number) { setDeletingIds([id]); }
|
function askDelete(id: number) { setDeletingIds([id]); }
|
||||||
// Delete the whole multi-row selection (Edit menu / Delete key).
|
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||||
function askDeleteSelected() {
|
function askDeleteSelected() {
|
||||||
@@ -2216,6 +2251,7 @@ export default function App() {
|
|||||||
{ name: 'file', label: 'File', items: [
|
{ name: 'file', label: 'File', items: [
|
||||||
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
||||||
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
||||||
|
{ type: 'item', label: 'Export Cabrillo…', action: 'file.exportCabrillo', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -2257,6 +2293,7 @@ export default function App() {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'file.import': importAdif(); break;
|
case 'file.import': importAdif(); break;
|
||||||
case 'file.export': exportAdif(); break;
|
case 'file.export': exportAdif(); break;
|
||||||
|
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||||
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;
|
||||||
@@ -3746,6 +3783,8 @@ export default function App() {
|
|||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
|
onExportCabrilloSelected={exportSelectedCabrillo}
|
||||||
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||||
|
|
||||||
|
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||||
|
// also blank the picker's own state — the search box and the highlighted /
|
||||||
|
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setSelectedRef(null);
|
||||||
|
setQ('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||||
.then(([d, m]) => {
|
.then(([d, m]) => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -142,6 +144,24 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>Export filtered view to ADIF (no limit)</span>
|
<span>Export filtered view to ADIF (no limit)</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportCabrilloSelected && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-amber-600" />
|
||||||
|
<span>Export selected to Cabrillo ({n})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExportCabrilloFiltered && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-amber-700" />
|
||||||
|
<span>Export filtered view to Cabrillo (no limit)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type Props = {
|
|||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
@@ -228,7 +230,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: 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);
|
||||||
@@ -416,6 +418,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.16.2';
|
export const APP_VERSION = '0.16.4';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+8
@@ -131,6 +131,12 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
|
export function ExportCabrillo(arg1:string,arg2:string):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
|
export function ExportCabrilloFiltered(arg1:string,arg2:string,arg3:qso.QueryFilter):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
|
export function ExportCabrilloSelected(arg1:string,arg2:string,arg3:Array<number>):Promise<main.CabrilloResult>;
|
||||||
|
|
||||||
export function FilterFields():Promise<Array<string>>;
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
|
||||||
@@ -535,6 +541,8 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||||
|
|||||||
@@ -226,6 +226,18 @@ export function ExportAwards() {
|
|||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportCabrillo(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExportCabrillo'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportCabrilloFiltered(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportCabrilloSelected(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterFields() {
|
export function FilterFields() {
|
||||||
return window['go']['main']['App']['FilterFields']();
|
return window['go']['main']['App']['FilterFields']();
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1046,10 @@ export function SaveCATSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveCabrilloFile() {
|
||||||
|
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveClusterServer(arg1) {
|
export function SaveClusterServer(arg1) {
|
||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1253,6 +1253,20 @@ export namespace main {
|
|||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class CabrilloResult {
|
||||||
|
count: number;
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CabrilloResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.path = source["path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Package cabrillo writes a Cabrillo v3 contest log from OpsLog QSOs — the
|
||||||
|
// format most contest sponsors require for log submission (a header of
|
||||||
|
// CATEGORY-* / CONTEST / CALLSIGN tags followed by one "QSO:" line each).
|
||||||
|
package cabrillo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Header holds the Cabrillo header fields. Only Callsign and Contest really
|
||||||
|
// matter; the rest carry sensible defaults the operator can edit in the file
|
||||||
|
// before submitting (categories vary per contest).
|
||||||
|
type Header struct {
|
||||||
|
Callsign string
|
||||||
|
Contest string
|
||||||
|
Operators string
|
||||||
|
CatOperator string // SINGLE-OP | MULTI-OP | CHECKLOG
|
||||||
|
CatBand string // ALL | 20M | …
|
||||||
|
CatMode string // CW | SSB | MIXED | …
|
||||||
|
CatPower string // HIGH | LOW | QRP
|
||||||
|
GridLocator string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Club string
|
||||||
|
}
|
||||||
|
|
||||||
|
func def(v, d string) string {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate writes the Cabrillo document for qsos and returns the number of QSO
|
||||||
|
// lines written (rows without a usable frequency/callsign are skipped).
|
||||||
|
func Generate(w io.Writer, h Header, qsos []qso.QSO) (int, error) {
|
||||||
|
myCall := strings.ToUpper(strings.TrimSpace(h.Callsign))
|
||||||
|
fmt.Fprintln(w, "START-OF-LOG: 3.0")
|
||||||
|
fmt.Fprintln(w, "CREATED-BY: OpsLog")
|
||||||
|
fmt.Fprintf(w, "CONTEST: %s\n", strings.ToUpper(def(h.Contest, "UNKNOWN")))
|
||||||
|
fmt.Fprintf(w, "CALLSIGN: %s\n", myCall)
|
||||||
|
fmt.Fprintf(w, "CATEGORY-OPERATOR: %s\n", strings.ToUpper(def(h.CatOperator, "SINGLE-OP")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-BAND: %s\n", strings.ToUpper(def(h.CatBand, "ALL")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-MODE: %s\n", strings.ToUpper(def(h.CatMode, "MIXED")))
|
||||||
|
fmt.Fprintf(w, "CATEGORY-POWER: %s\n", strings.ToUpper(def(h.CatPower, "HIGH")))
|
||||||
|
fmt.Fprintln(w, "CLAIMED-SCORE: 0")
|
||||||
|
fmt.Fprintf(w, "OPERATORS: %s\n", strings.ToUpper(def(h.Operators, myCall)))
|
||||||
|
if h.GridLocator != "" {
|
||||||
|
fmt.Fprintf(w, "GRID-LOCATOR: %s\n", strings.ToUpper(h.GridLocator))
|
||||||
|
}
|
||||||
|
if h.Name != "" {
|
||||||
|
fmt.Fprintf(w, "NAME: %s\n", h.Name)
|
||||||
|
}
|
||||||
|
if h.Club != "" {
|
||||||
|
fmt.Fprintf(w, "CLUB: %s\n", h.Club)
|
||||||
|
}
|
||||||
|
if h.Email != "" {
|
||||||
|
fmt.Fprintf(w, "EMAIL: %s\n", h.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, q := range qsos {
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||||
|
if call == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
khz := freqKHz(q)
|
||||||
|
mode := cabrilloMode(q.Mode)
|
||||||
|
myRST := def(q.RSTSent, defaultRST(mode))
|
||||||
|
hisRST := def(q.RSTRcvd, defaultRST(mode))
|
||||||
|
myExch := exchange(q.STX, q.STXString)
|
||||||
|
hisExch := exchange(q.SRX, q.SRXString)
|
||||||
|
// QSO: <freq> <mo> <date> <time> <mycall> <myrst> <myexch> <call> <rst> <exch>
|
||||||
|
fmt.Fprintf(w, "QSO: %5d %-2s %s %s %-13s %-3s %-6s %-13s %-3s %-6s\n",
|
||||||
|
khz, mode,
|
||||||
|
q.QSODate.UTC().Format("2006-01-02"), q.QSODate.UTC().Format("1504"),
|
||||||
|
myCall, myRST, myExch, call, hisRST, hisExch)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "END-OF-LOG:")
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freqKHz returns the QSO frequency in kHz, falling back to a nominal frequency
|
||||||
|
// for the band when the exact frequency wasn't logged.
|
||||||
|
func freqKHz(q qso.QSO) int {
|
||||||
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
|
return int((*q.FreqHz + 500) / 1000)
|
||||||
|
}
|
||||||
|
return bandNominalKHz(q.Band)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bandNominalKHz(band string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(band)) {
|
||||||
|
case "160m":
|
||||||
|
return 1800
|
||||||
|
case "80m":
|
||||||
|
return 3500
|
||||||
|
case "60m":
|
||||||
|
return 5350
|
||||||
|
case "40m":
|
||||||
|
return 7000
|
||||||
|
case "30m":
|
||||||
|
return 10100
|
||||||
|
case "20m":
|
||||||
|
return 14000
|
||||||
|
case "17m":
|
||||||
|
return 18068
|
||||||
|
case "15m":
|
||||||
|
return 21000
|
||||||
|
case "12m":
|
||||||
|
return 24890
|
||||||
|
case "10m":
|
||||||
|
return 28000
|
||||||
|
case "6m":
|
||||||
|
return 50000
|
||||||
|
case "2m":
|
||||||
|
return 144000
|
||||||
|
case "70cm":
|
||||||
|
return 432000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabrilloMode maps an ADIF mode to the two-letter Cabrillo mode code.
|
||||||
|
func cabrilloMode(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
case "RTTY":
|
||||||
|
return "RY"
|
||||||
|
case "FM":
|
||||||
|
return "FM"
|
||||||
|
case "SSB", "USB", "LSB", "AM":
|
||||||
|
return "PH"
|
||||||
|
case "":
|
||||||
|
return "PH"
|
||||||
|
default:
|
||||||
|
return "DG" // FT8/FT4/PSK/JT/DATA and other digital modes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRST(cabMode string) string {
|
||||||
|
if cabMode == "CW" || cabMode == "RY" || cabMode == "DG" {
|
||||||
|
return "599"
|
||||||
|
}
|
||||||
|
return "59"
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange renders the contest exchange: the string field if set, else the
|
||||||
|
// numeric serial, else empty.
|
||||||
|
func exchange(num *int, str string) string {
|
||||||
|
if s := strings.TrimSpace(str); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if num != nil && *num > 0 {
|
||||||
|
return fmt.Sprintf("%d", *num)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
+14
-7
@@ -177,14 +177,21 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
BandFromHz(freqA) == BandFromHz(freqB)
|
BandFromHz(freqA) == BandFromHz(freqB)
|
||||||
|
|
||||||
if genuineSplit {
|
if genuineSplit {
|
||||||
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
|
||||||
// ADIF: FreqHz = TX, RxFreqHz = RX.
|
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
|
||||||
|
// RX = the active VFO, TX = the other one. This is far more reliable than
|
||||||
|
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
|
||||||
|
// report inverted — the split then showed TX/RX swapped.
|
||||||
s.Split = true
|
s.Split = true
|
||||||
switch s.Vfo {
|
switch {
|
||||||
case "BA":
|
case freqMain != 0 && freqMain == freqA:
|
||||||
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
|
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
|
||||||
default: // "AB" and the usual "TX on the other VFO" case
|
case freqMain != 0 && freqMain == freqB:
|
||||||
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
|
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
|
||||||
|
case s.Vfo == "BA":
|
||||||
|
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
|
||||||
|
default:
|
||||||
|
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.16.2"
|
appVersion = "0.16.4"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user