feat: ADIF export field picker — choose exactly which fields to write
Global "Export to ADIF" gains a third option "Choose fields…" next to the standard-only and full-OpsLog choices, and right-clicking selected QSOs adds "Export selected — choose fields…". The picker separates official ADIF 3.1.7 fields (grouped by category) from OpsLog/non-standard tags actually present in the log (discovered via DistinctExtraKeys over extras_json), with All/None per group and reset-to-defaults; the selection is persisted per user. Backend: adif.Exporter gains a Fields allow-set that gates every promoted and extras field in writeRecord; app.go ExportADIF/ExportADIFFiltered/ ExportADIFSelected take a fields []string param, plus new ADIFExtraFields.
This commit is contained in:
@@ -5465,37 +5465,55 @@ func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }
|
|||||||
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
|
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
|
||||||
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
|
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
|
||||||
|
|
||||||
|
// adifFieldSet turns a list of ADIF tag names into an allow-set (uppercased),
|
||||||
|
// or nil when the list is empty — nil means "all fields" (standard/full mode),
|
||||||
|
// so the two-mode export path is unchanged when no explicit field list is given.
|
||||||
|
func adifFieldSet(fields []string) map[string]bool {
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if t := strings.ToUpper(strings.TrimSpace(f)); t != "" {
|
||||||
|
set[t] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||||||
// Streams from DB so memory stays flat even with 100k+ records.
|
// Streams from DB so memory stays flat even with 100k+ records.
|
||||||
// includeAppFields=false → portable standard ADIF (for other loggers);
|
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||||||
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
||||||
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
|
// fields (optional): when non-empty, export EXACTLY those ADIF tags — the
|
||||||
|
// "choose which fields to export" mode (overrides includeAppFields).
|
||||||
|
func (a *App) ExportADIF(path string, includeAppFields bool, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFile(a.ctx, path)
|
return ex.ExportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
|
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
|
||||||
// NO row limit (the on-screen list is capped by the threshold; this is not).
|
// NO row limit (the on-screen list is capped by the threshold; this is not).
|
||||||
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
|
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFileFiltered(a.ctx, path, f)
|
return ex.ExportFileFiltered(a.ctx, path, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
|
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
|
||||||
// operator highlighted in the grid).
|
// operator highlighted in the grid).
|
||||||
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
|
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -5505,10 +5523,32 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
|
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFileByIDs(a.ctx, path, ids)
|
return ex.ExportFileByIDs(a.ctx, path, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADIFExtraFields returns the distinct NON-standard ADIF tags actually present
|
||||||
|
// in the logbook's stored extras (APP_* and vendor tags) — the "OpsLog / extra
|
||||||
|
// fields" group in the export field picker, alongside the static official-ADIF
|
||||||
|
// list from ADIFFields(). Sampled + capped so it stays fast on a big remote log.
|
||||||
|
func (a *App) ADIFExtraFields() []string {
|
||||||
|
if a.qso == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys, err := a.qso.DistinctExtraKeys(a.ctx, 5000)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
if t := strings.ToUpper(strings.TrimSpace(k)); t != "" && !adif.IsStandardField(t) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
||||||
type CabrilloResult struct {
|
type CabrilloResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.20.12",
|
"version": "0.20.12",
|
||||||
"date": "2026-07-23",
|
"date": "2026-07-23",
|
||||||
"en": [
|
"en": [
|
||||||
|
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
||||||
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
||||||
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
||||||
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
||||||
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
||||||
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
||||||
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||||
|
|||||||
+53
-22
@@ -74,6 +74,7 @@ import { AwardsPanel } from '@/components/AwardsPanel';
|
|||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
import { StationControlPanel } from '@/components/StationControlPanel';
|
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
|
import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
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';
|
||||||
@@ -1328,6 +1329,9 @@ export default function App() {
|
|||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
|
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
|
||||||
const [showExportChoice, setShowExportChoice] = useState(false);
|
const [showExportChoice, setShowExportChoice] = useState(false);
|
||||||
|
// Field-picker export: null when closed; ids present = export those selected
|
||||||
|
// rows, absent = export the whole logbook (global "choose fields" flow).
|
||||||
|
const [fieldPicker, setFieldPicker] = useState<{ ids?: number[]; count: number } | null>(null);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||||
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||||
@@ -2614,25 +2618,32 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
||||||
// matches the current filter, bypassing the on-screen row threshold.
|
// matches the current filter, bypassing the on-screen row threshold.
|
||||||
async function exportFilteredADIF() {
|
// fields (optional): a chosen ADIF-tag list — the "choose which fields" export.
|
||||||
|
// Empty = the standard/full behaviour (all fields, includeAppFields=false here).
|
||||||
|
async function exportFilteredADIF(fields: string[] = []) {
|
||||||
try {
|
try {
|
||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const f = buildActiveFilter();
|
const f = buildActiveFilter();
|
||||||
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
|
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any, fields);
|
||||||
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Right-click "Export selected to ADIF": only the highlighted rows.
|
// Right-click "Export selected to ADIF": only the highlighted rows.
|
||||||
async function exportSelectedADIF(ids: number[]) {
|
async function exportSelectedADIF(ids: number[], fields: string[] = []) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const r = await ExportADIFSelected(path, false, ids as any);
|
const r = await ExportADIFSelected(path, false, ids as any, fields);
|
||||||
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)); }
|
||||||
}
|
}
|
||||||
|
// Open the field-picker to export the highlighted rows with chosen fields.
|
||||||
|
function exportSelectedFields(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setFieldPicker({ ids, count: ids.length });
|
||||||
|
}
|
||||||
// Cabrillo (contest log) export → a .log file. The contest name comes from the
|
// Cabrillo (contest log) export → a .log file. The contest name comes from the
|
||||||
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
|
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
|
||||||
async function exportCabrillo() {
|
async function exportCabrillo() {
|
||||||
@@ -2872,7 +2883,10 @@ export default function App() {
|
|||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setShowExportChoice(true); // pick standard vs full first
|
setShowExportChoice(true); // pick standard vs full first
|
||||||
}
|
}
|
||||||
async function runExport(includeAppFields: boolean) {
|
// includeAppFields is ignored when fields is a non-empty chosen set (the set
|
||||||
|
// decides everything). Used by both the standard/full buttons and the
|
||||||
|
// "choose fields" flow (global export of the whole logbook).
|
||||||
|
async function runExport(includeAppFields: boolean, fields: string[] = []) {
|
||||||
setShowExportChoice(false);
|
setShowExportChoice(false);
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setError('');
|
setError('');
|
||||||
@@ -2880,9 +2894,10 @@ export default function App() {
|
|||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const res = await ExportADIF(path, includeAppFields);
|
const res = await ExportADIF(path, includeAppFields, fields);
|
||||||
|
const kind = fields.length ? ` (${fields.length} fields)` : includeAppFields ? ' (full)' : ' (standard)';
|
||||||
// Green success toast (auto-dismiss) — not the red error banner.
|
// Green success toast (auto-dismiss) — not the red error banner.
|
||||||
showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
|
showToast(`ADIF exported${kind}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3855,6 +3870,7 @@ export default function App() {
|
|||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
|
onExportSelectedFields={exportSelectedFields}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
@@ -4933,6 +4949,7 @@ export default function App() {
|
|||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
|
onExportSelectedFields={exportSelectedFields}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onExportCabrilloSelected={exportSelectedCabrillo}
|
onExportCabrilloSelected={exportSelectedCabrillo}
|
||||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
@@ -5566,10 +5583,8 @@ export default function App() {
|
|||||||
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
|
||||||
<DialogContent className="max-w-lg px-6">
|
<DialogContent className="max-w-lg px-6">
|
||||||
<DialogHeader className="px-2">
|
<DialogHeader className="px-2">
|
||||||
<DialogTitle>Export ADIF</DialogTitle>
|
<DialogTitle>{t('exp.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('exp.desc')}</DialogDescription>
|
||||||
Choose which fields to include. OpsLog writes ADIF 3.1.7.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="px-2 py-1 space-y-2.5">
|
<div className="px-2 py-1 space-y-2.5">
|
||||||
<button
|
<button
|
||||||
@@ -5577,30 +5592,46 @@ export default function App() {
|
|||||||
onClick={() => runExport(false)}
|
onClick={() => runExport(false)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">Standard ADIF only</div>
|
<div className="font-semibold text-sm">{t('exp.stdTitle')}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.stdDesc')}</div>
|
||||||
Only fields defined in the ADIF 3.1.7 spec — portable to other loggers (Log4OM, N1MM, LoTW…).
|
|
||||||
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => runExport(true)}
|
onClick={() => runExport(true)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
|
<div className="font-semibold text-sm">{t('exp.allTitle')}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.allDesc')}</div>
|
||||||
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags —
|
</button>
|
||||||
a lossless backup you'll re-import into OpsLog.
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => { setShowExportChoice(false); setFieldPicker({ count: total }); }}
|
||||||
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-sm">{t('exp.chooseTitle')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.chooseDesc')}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||||
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setShowExportChoice(false)}>{t('exf.cancel')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
{fieldPicker && (
|
||||||
|
<ExportFieldsDialog
|
||||||
|
open
|
||||||
|
count={fieldPicker.count}
|
||||||
|
onClose={() => setFieldPicker(null)}
|
||||||
|
onExport={(fields) => {
|
||||||
|
const fp = fieldPicker;
|
||||||
|
setFieldPicker(null);
|
||||||
|
if (!fp) return;
|
||||||
|
if (fp.ids) exportSelectedADIF(fp.ids, fields);
|
||||||
|
else runExport(false, fields);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pendingImportPath && (
|
{pendingImportPath && (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||||
<DialogContent className="max-w-lg px-6">
|
<DialogContent className="max-w-lg px-6">
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||||
|
import { adif } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type FieldDef = adif.FieldDef;
|
||||||
|
const PREF_KEY = 'opslog.exportFields';
|
||||||
|
|
||||||
|
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||||
|
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||||
|
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||||
|
// chosen set is returned to the parent (which runs the selected/global export)
|
||||||
|
// and persisted so it's the default next time.
|
||||||
|
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||||
|
open: boolean;
|
||||||
|
count: number; // how many QSOs the pending export covers
|
||||||
|
onExport: (fields: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||||
|
const [extras, setExtras] = useState<string[]>([]);
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const defaultSet = (flds: FieldDef[]) =>
|
||||||
|
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const flds = (f ?? []) as FieldDef[];
|
||||||
|
setOfficial(flds);
|
||||||
|
setExtras((e ?? []) as string[]);
|
||||||
|
let saved: string[] | null = null;
|
||||||
|
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||||
|
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||||
|
})().catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const m = new Map<string, FieldDef[]>();
|
||||||
|
for (const d of official) {
|
||||||
|
if (d.deprecated) continue;
|
||||||
|
const g = d.category || 'Misc';
|
||||||
|
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||||
|
}
|
||||||
|
return [...m.entries()];
|
||||||
|
}, [official]);
|
||||||
|
|
||||||
|
const toggle = (name: string, on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
|
const setMany = (names: string[], on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
const fields = [...sel];
|
||||||
|
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||||
|
onExport(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => (
|
||||||
|
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||||
|
<span className="flex gap-1.5 shrink-0">
|
||||||
|
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button>
|
||||||
|
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tags.map((name) => (
|
||||||
|
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||||
|
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
|
||||||
|
<span className="font-mono break-all">{name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||||
|
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />}
|
||||||
|
{groups.map(([g, list]) => (
|
||||||
|
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||||
|
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -35,7 +36,7 @@ const UPLOAD_TARGETS: { service: string; name: 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, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportSelectedFields && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -262,7 +263,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -600,6 +601,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+5
-3
@@ -28,6 +28,8 @@ export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ADIFExtraFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
export function ADIFVersion():Promise<string>;
|
export function ADIFVersion():Promise<string>;
|
||||||
@@ -176,11 +178,11 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profil
|
|||||||
|
|
||||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function ACOMSetPower(arg1) {
|
|||||||
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ADIFExtraFields() {
|
||||||
|
return window['go']['main']['App']['ADIFExtraFields']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ADIFFields() {
|
export function ADIFFields() {
|
||||||
return window['go']['main']['App']['ADIFFields']();
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
}
|
}
|
||||||
@@ -306,16 +310,16 @@ export function ExplainAward(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1, arg2, arg3) {
|
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1, arg2, arg3) {
|
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportAward(arg1) {
|
export function ExportAward(arg1) {
|
||||||
|
|||||||
+162
-131
@@ -34,6 +34,12 @@ type Exporter struct {
|
|||||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||||
// round-trip that preserves everything.
|
// round-trip that preserves everything.
|
||||||
IncludeAppFields bool
|
IncludeAppFields bool
|
||||||
|
|
||||||
|
// Fields, when non-nil, restricts the export to exactly these ADIF tags
|
||||||
|
// (uppercase) — the "choose which fields to export" mode. nil = write every
|
||||||
|
// field (the standard/full behaviour above). When set it overrides
|
||||||
|
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
|
||||||
|
Fields map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||||
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
err := iter(ctx, func(q qso.QSO) error {
|
err := iter(ctx, func(q qso.QSO) error {
|
||||||
writeRecord(bw, q, e.IncludeAppFields)
|
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
// Uploads target other services — keep it standard (no app-specific tags).
|
// Uploads target other services — keep it standard (no app-specific tags).
|
||||||
writeRecord(bw, q, false)
|
writeRecord(bw, q, false, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
func FullRecordADIF(q qso.QSO) string {
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
writeRecord(bw, q, true)
|
writeRecord(bw, q, true, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
||||||
|
// allow == nil → write every promoted field (standard/full behaviour).
|
||||||
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||||
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||||
|
// stay one-liners.
|
||||||
|
ok := func(tag string) bool { return allow == nil || allow[tag] }
|
||||||
|
w := func(tag, v string) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeField(bw, tag, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wi := func(tag string, p *int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeIntPtr(bw, tag, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wf := func(tag string, p *float64, decimals int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeFloatPtr(bw, tag, p, decimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
// --- Core ---
|
// --- Core ---
|
||||||
writeField(bw, "CALL", q.Callsign)
|
w("CALL", q.Callsign)
|
||||||
|
|
||||||
if !q.QSODate.IsZero() {
|
if !q.QSODate.IsZero() {
|
||||||
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
w("TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
if !q.QSODateOff.IsZero() {
|
if !q.QSODateOff.IsZero() {
|
||||||
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
writeField(bw, "BAND", q.Band)
|
w("BAND", q.Band)
|
||||||
writeField(bw, "BAND_RX", q.BandRX)
|
w("BAND_RX", q.BandRX)
|
||||||
|
|
||||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
writeField(bw, "MODE", mode)
|
w("MODE", mode)
|
||||||
writeField(bw, "SUBMODE", submode)
|
w("SUBMODE", submode)
|
||||||
|
|
||||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||||
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeField(bw, "RST_SENT", q.RSTSent)
|
w("RST_SENT", q.RSTSent)
|
||||||
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
w("RST_RCVD", q.RSTRcvd)
|
||||||
|
|
||||||
// --- Contacted ---
|
// --- Contacted ---
|
||||||
writeField(bw, "NAME", q.Name)
|
w("NAME", q.Name)
|
||||||
writeField(bw, "QTH", q.QTH)
|
w("QTH", q.QTH)
|
||||||
writeField(bw, "ADDRESS", q.Address)
|
w("ADDRESS", q.Address)
|
||||||
writeField(bw, "EMAIL", q.Email)
|
w("EMAIL", q.Email)
|
||||||
writeField(bw, "WEB", q.Web)
|
w("WEB", q.Web)
|
||||||
writeField(bw, "GRIDSQUARE", q.Grid)
|
w("GRIDSQUARE", q.Grid)
|
||||||
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
w("GRIDSQUARE_EXT", q.GridExt)
|
||||||
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
w("VUCC_GRIDS", q.VUCCGrids)
|
||||||
writeField(bw, "COUNTRY", q.Country)
|
w("COUNTRY", q.Country)
|
||||||
writeField(bw, "STATE", q.State)
|
w("STATE", q.State)
|
||||||
writeField(bw, "CNTY", q.County)
|
w("CNTY", q.County)
|
||||||
writeIntPtr(bw, "DXCC", q.DXCC)
|
wi("DXCC", q.DXCC)
|
||||||
writeField(bw, "CONT", q.Continent)
|
w("CONT", q.Continent)
|
||||||
writeIntPtr(bw, "CQZ", q.CQZ)
|
wi("CQZ", q.CQZ)
|
||||||
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
wi("ITUZ", q.ITUZ)
|
||||||
writeField(bw, "IOTA", q.IOTA)
|
w("IOTA", q.IOTA)
|
||||||
writeField(bw, "SOTA_REF", q.SOTARef)
|
w("SOTA_REF", q.SOTARef)
|
||||||
writeField(bw, "POTA_REF", q.POTARef)
|
w("POTA_REF", q.POTARef)
|
||||||
writeIntPtr(bw, "AGE", q.Age)
|
wi("AGE", q.Age)
|
||||||
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
wf("LAT", q.Lat, 6)
|
||||||
writeFloatPtr(bw, "LON", q.Lon, 6)
|
wf("LON", q.Lon, 6)
|
||||||
writeField(bw, "RIG", q.Rig)
|
w("RIG", q.Rig)
|
||||||
writeField(bw, "ANT", q.Ant)
|
w("ANT", q.Ant)
|
||||||
|
|
||||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||||
writeField(bw, "QSL_SENT", q.QSLSent)
|
w("QSL_SENT", q.QSLSent)
|
||||||
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
w("QSL_RCVD", q.QSLRcvd)
|
||||||
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
w("QSLSDATE", q.QSLSentDate)
|
||||||
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
w("QSLRDATE", q.QSLRcvdDate)
|
||||||
writeField(bw, "QSL_VIA", q.QSLVia)
|
w("QSL_VIA", q.QSLVia)
|
||||||
writeField(bw, "QSLMSG", q.QSLMsg)
|
w("QSLMSG", q.QSLMsg)
|
||||||
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
w("QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||||
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
w("LOTW_QSL_SENT", q.LOTWSent)
|
||||||
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
w("LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||||
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
w("LOTW_QSLSDATE", q.LOTWSentDate)
|
||||||
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||||
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
w("EQSL_QSL_SENT", q.EQSLSent)
|
||||||
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
w("EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||||
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
w("EQSL_QSLSDATE", q.EQSLSentDate)
|
||||||
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
w("CONTEST_ID", q.ContestID)
|
||||||
writeIntPtr(bw, "SRX", q.SRX)
|
wi("SRX", q.SRX)
|
||||||
writeIntPtr(bw, "STX", q.STX)
|
wi("STX", q.STX)
|
||||||
writeField(bw, "SRX_STRING", q.SRXString)
|
w("SRX_STRING", q.SRXString)
|
||||||
writeField(bw, "STX_STRING", q.STXString)
|
w("STX_STRING", q.STXString)
|
||||||
writeField(bw, "CHECK", q.Check)
|
w("CHECK", q.Check)
|
||||||
writeField(bw, "PRECEDENCE", q.Precedence)
|
w("PRECEDENCE", q.Precedence)
|
||||||
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
w("ARRL_SECT", q.ARRLSect)
|
||||||
|
|
||||||
// --- Satellite / propagation ---
|
// --- Satellite / propagation ---
|
||||||
writeField(bw, "PROP_MODE", q.PropMode)
|
w("PROP_MODE", q.PropMode)
|
||||||
writeField(bw, "SAT_NAME", q.SatName)
|
w("SAT_NAME", q.SatName)
|
||||||
writeField(bw, "SAT_MODE", q.SatMode)
|
w("SAT_MODE", q.SatMode)
|
||||||
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
wf("ANT_AZ", q.AntAz, 1)
|
||||||
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
wf("ANT_EL", q.AntEl, 1)
|
||||||
writeField(bw, "ANT_PATH", q.AntPath)
|
w("ANT_PATH", q.AntPath)
|
||||||
|
|
||||||
// --- My station / operator ---
|
// --- My station / operator ---
|
||||||
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
w("STATION_CALLSIGN", q.StationCallsign)
|
||||||
writeField(bw, "OPERATOR", q.Operator)
|
w("OPERATOR", q.Operator)
|
||||||
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
w("MY_GRIDSQUARE", q.MyGrid)
|
||||||
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||||
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
w("MY_COUNTRY", q.MyCountry)
|
||||||
writeField(bw, "MY_STATE", q.MyState)
|
w("MY_STATE", q.MyState)
|
||||||
writeField(bw, "MY_CNTY", q.MyCounty)
|
w("MY_CNTY", q.MyCounty)
|
||||||
writeField(bw, "MY_IOTA", q.MyIOTA)
|
w("MY_IOTA", q.MyIOTA)
|
||||||
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
w("MY_SOTA_REF", q.MySOTARef)
|
||||||
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
w("MY_POTA_REF", q.MyPOTARef)
|
||||||
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
wi("MY_DXCC", q.MyDXCC)
|
||||||
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
wi("MY_CQ_ZONE", q.MyCQZone)
|
||||||
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
wi("MY_ITU_ZONE", q.MyITUZone)
|
||||||
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
wf("MY_LAT", q.MyLat, 6)
|
||||||
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
wf("MY_LON", q.MyLon, 6)
|
||||||
writeField(bw, "MY_STREET", q.MyStreet)
|
w("MY_STREET", q.MyStreet)
|
||||||
writeField(bw, "MY_CITY", q.MyCity)
|
w("MY_CITY", q.MyCity)
|
||||||
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
w("MY_POSTAL_CODE", q.MyPostalCode)
|
||||||
writeField(bw, "MY_RIG", q.MyRig)
|
w("MY_RIG", q.MyRig)
|
||||||
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
w("MY_ANTENNA", q.MyAntenna)
|
||||||
|
|
||||||
// --- Misc ---
|
// --- Misc ---
|
||||||
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
wf("TX_PWR", q.TXPower, 1)
|
||||||
writeField(bw, "COMMENT", q.Comment)
|
w("COMMENT", q.Comment)
|
||||||
writeField(bw, "NOTES", q.Notes)
|
w("NOTES", q.Notes)
|
||||||
|
|
||||||
// --- ADIF 3.1.7 additional promoted fields ---
|
// --- ADIF 3.1.7 additional promoted fields ---
|
||||||
writeField(bw, "SIG", q.SIG)
|
w("SIG", q.SIG)
|
||||||
writeField(bw, "SIG_INFO", q.SIGInfo)
|
w("SIG_INFO", q.SIGInfo)
|
||||||
writeField(bw, "MY_SIG", q.MySIG)
|
w("MY_SIG", q.MySIG)
|
||||||
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
|
w("MY_SIG_INFO", q.MySIGInfo)
|
||||||
writeField(bw, "WWFF_REF", q.WWFFRef)
|
w("WWFF_REF", q.WWFFRef)
|
||||||
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
|
w("MY_WWFF_REF", q.MyWWFFRef)
|
||||||
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
|
wf("DISTANCE", q.Distance, 1)
|
||||||
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
|
wf("RX_PWR", q.RXPower, 1)
|
||||||
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
|
wf("A_INDEX", q.AIndex, 0)
|
||||||
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
|
wf("K_INDEX", q.KIndex, 0)
|
||||||
writeFloatPtr(bw, "SFI", q.SFI, 0)
|
wf("SFI", q.SFI, 0)
|
||||||
writeField(bw, "SKCC", q.SKCC)
|
w("SKCC", q.SKCC)
|
||||||
writeField(bw, "FISTS", q.FISTS)
|
w("FISTS", q.FISTS)
|
||||||
writeField(bw, "TEN_TEN", q.TenTen)
|
w("TEN_TEN", q.TenTen)
|
||||||
writeField(bw, "CONTACTED_OP", q.ContactedOp)
|
w("CONTACTED_OP", q.ContactedOp)
|
||||||
writeField(bw, "EQ_CALL", q.EqCall)
|
w("EQ_CALL", q.EqCall)
|
||||||
writeField(bw, "PFX", q.PFX)
|
w("PFX", q.PFX)
|
||||||
writeField(bw, "MY_NAME", q.MyName)
|
w("MY_NAME", q.MyName)
|
||||||
writeField(bw, "CLASS", q.Class)
|
w("CLASS", q.Class)
|
||||||
writeField(bw, "DARC_DOK", q.DarcDOK)
|
w("DARC_DOK", q.DarcDOK)
|
||||||
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
|
w("MY_DARC_DOK", q.MyDarcDOK)
|
||||||
writeField(bw, "REGION", q.Region)
|
w("REGION", q.Region)
|
||||||
writeField(bw, "SILENT_KEY", q.SilentKey)
|
w("SILENT_KEY", q.SilentKey)
|
||||||
writeField(bw, "SWL", q.SWL)
|
w("SWL", q.SWL)
|
||||||
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
|
w("QSO_COMPLETE", q.QSOComplete)
|
||||||
writeField(bw, "QSO_RANDOM", q.QSORandom)
|
w("QSO_RANDOM", q.QSORandom)
|
||||||
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
|
w("CREDIT_GRANTED", q.CreditGranted)
|
||||||
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
|
w("CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||||
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
|
w("MY_ARRL_SECT", q.MyARRLSect)
|
||||||
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
|
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||||
|
|
||||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||||
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
|||||||
// extra for a lossless OpsLog round-trip.
|
// extra for a lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
tag := strings.ToUpper(k)
|
tag := strings.ToUpper(k)
|
||||||
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
if allow != nil {
|
||||||
|
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
|
||||||
|
if !allow[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeField(bw, tag, v)
|
writeField(bw, tag, v)
|
||||||
|
|||||||
@@ -366,6 +366,42 @@ func decodeExtras(s string) map[string]string {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DistinctExtraKeys returns the set of keys present across stored QSO extras
|
||||||
|
// (the non-promoted ADIF tags kept verbatim), scanning the most recent `sample`
|
||||||
|
// rows that have any extras. Capped so it stays fast on a large remote logbook —
|
||||||
|
// the extras vocabulary is small and stable, so a recent sample finds it all.
|
||||||
|
func (r *Repo) DistinctExtraKeys(ctx context.Context, sample int) ([]string, error) {
|
||||||
|
if sample <= 0 {
|
||||||
|
sample = 5000
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT extras_json FROM qso
|
||||||
|
WHERE extras_json IS NOT NULL AND extras_json <> '' AND extras_json <> '{}'
|
||||||
|
ORDER BY id DESC LIMIT ?`, sample)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s sql.NullString
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k := range decodeExtras(s.String) {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for k := range seen {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Add inserts a QSO and returns its ID.
|
// Add inserts a QSO and returns its ID.
|
||||||
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
||||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||||
|
|||||||
Reference in New Issue
Block a user