feat(webpub): offer every field, and make choosing among them possible
The catalogue held 23 hand-picked columns, so publishing a county, a satellite pass or an award reference was simply not possible. It is generated from the qso.QSO struct now — 123 fields — which also means a new ADIF field cannot be forgotten here, as a hand-written list always eventually is. Three keys were renamed to their ADIF names on the way (pota, sota, station). An alias map keeps existing configurations publishing the same columns; without it three would have vanished silently on upgrade, which is the worst way for a setting to change. The picker had to change with it: 123 chips in one wrap is a wall nobody reads to the end of. It is sectioned by group, filtered as you type, and the chosen columns sit on top in publication order — after picking eight out of a hundred, the question stops being "what exists" and becomes "what did I pick". The package doc said columns were curated so the operator's address could not be published. That is no longer true and the comment now says so plainly: the judgement moved to the operator, the default selection is unchanged, and nothing is published that was not chosen. Also collapses the changelog: four separate shared-CAT entries were one thing from where the operator sits, and all of them were far longer than the one or two sentences this project asks for.
This commit is contained in:
+8
-12
@@ -3,20 +3,16 @@
|
||||
"version": "0.24.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Digital decodes: a station's grid square could be logged as its callsign. An unrecognised word after CQ made the parser skip a slot, and a four-character grid passes every shape test a callsign does, so \"CQ FOO JN36\" was read as a contact with JN36 — spotted, coloured and counted like any other station. A grid in the callsign position is now refused.",
|
||||
"Shared CAT no longer drops when the rig is busy. A Kenwood answers \"?;\" for a moment after coming out of transmit, which is exactly when polling resumes — and that single refused poll tore down the whole CAT link. WSJT-X, which keys through it, lost the radio mid-sequence and Hamlib then sent an uninitialised frequency, so the operator got an alarming frequency error whose real cause was three lines earlier. A busy rig is now ridden out for up to three polls; a genuine serial fault still drops immediately.",
|
||||
"CW over CAT now works on a Kenwood. The KY command was written against Elecraft's, which takes a string of any length; a Kenwood requires exactly 24 characters, so every message was refused and OpsLog reported that the radio would not do CW over CAT at all — sending operators to buy a serial keyer they did not need. A semicolon is also stripped from CW text now: it ends a CAT frame, so one in a macro cut the command short.",
|
||||
"Shared CAT now says so when a client is misconfigured. A program set to a Kenwood or Yaesu RIG MODEL instead of Hamlib NET rigctl sends raw rig commands to the CAT-sharing port; the reply it gets back has no terminator it recognises, so it reports a timeout with no data and the operator concludes CAT sharing is broken. The log now names the mistake and the setting that fixes it.",
|
||||
"Shared CAT now warns when another program has taken its port. Binding successfully is not the same as being reachable: OpsLog listens on every interface, and Windows lets a second program bind the SAME port on 127.0.0.1 alone — after which every local connection goes to that one, while OpsLog waits for clients that never arrive and reports nothing wrong. Seen with Nexus, which starts its own rigctld. The log now says so at startup and tells you what to do about it.",
|
||||
"Shared CAT no longer relays a PTT state that has not changed. A client is free to restate PTT as often as it likes, and one does — sixteen times a second — which meant a write to the radio every 60 ms for no reason. Only a real change is passed on now, and each one is logged, so a transmit that does not happen can be traced to whoever asked for it."
|
||||
"Digital decodes: a station's grid square could be logged as its callsign when the message text was unusual. A grid in the callsign position is now refused.",
|
||||
"CW over CAT now works on a Kenwood. The KY command was sent in Elecraft's variable-length form; a Kenwood needs exactly 24 characters, so every message was refused.",
|
||||
"Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl.",
|
||||
"Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — grouped and searchable, with the chosen columns shown in publication order. Choose carefully: the page is public and the list includes addresses and e-mail."
|
||||
],
|
||||
"fr": [
|
||||
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif. Un mot non reconnu après CQ faisait sauter un cran au parseur, et un grid de quatre caractères passe tous les tests de forme d un indicatif — « CQ FOO JN36 » était donc lu comme un contact avec JN36, spotté, coloré et compté comme n importe quelle autre station. Un grid à la place de l indicatif est maintenant refusé.",
|
||||
"Le CAT partagé ne tombe plus quand le rig est occupé. Un Kenwood répond « ?; » un court instant en sortie d émission, c est-à-dire précisément au moment où le poll reprend — et ce seul refus faisait tomber tout le lien CAT. WSJT-X, qui émet à travers lui, perdait la radio en pleine séquence et Hamlib envoyait ensuite une fréquence non initialisée : l opérateur voyait une erreur de fréquence inquiétante dont la vraie cause était trois lignes plus haut. Un rig occupé est désormais encaissé sur trois polls au plus ; une vraie panne série tombe toujours immédiatement.",
|
||||
"Le CW par CAT fonctionne enfin sur un Kenwood. La commande KY avait été écrite d après celle d Elecraft, qui accepte une chaîne de longueur libre ; un Kenwood en exige exactement 24 caractères, donc chaque message était refusé et OpsLog annonçait que la radio ne savait pas faire de CW par CAT — envoyant des opérateurs acheter un keyer série dont ils n avaient pas besoin. Le point-virgule est aussi retiré du texte CW : il termine une trame CAT, donc un seul dans une macro coupait la commande.",
|
||||
"Le CAT partagé signale désormais un client mal configuré. Un logiciel réglé sur un MODÈLE de rig Kenwood ou Yaesu au lieu de Hamlib NET rigctl envoie des commandes rig brutes sur le port de partage ; la réponse qu il reçoit ne porte pas le terminateur qu il attend, il annonce donc un délai dépassé sans données et l opérateur en conclut que le partage CAT est cassé. Le log nomme maintenant l erreur et le réglage qui la corrige.",
|
||||
"Le CAT partagé signale désormais qu un autre programme lui a pris son port. Réussir à ouvrir le port n est pas la même chose qu être joignable : OpsLog écoute sur toutes les interfaces, et Windows autorise un second programme à ouvrir le MÊME port sur la seule adresse 127.0.0.1 — dès lors toutes les connexions locales vont à celui-là, pendant qu OpsLog attend des clients qui n arrivent jamais sans rien signaler. Constaté avec Nexus, qui lance son propre rigctld. Le log le dit maintenant au démarrage, et indique quoi faire.",
|
||||
"Le CAT partagé ne relaie plus un état PTT inchangé. Un client a le droit de répéter son PTT aussi souvent qu il veut, et certains le font — seize fois par seconde — ce qui provoquait une écriture vers la radio toutes les 60 ms sans aucune raison. Seul un vrai changement est transmis désormais, et chacun est journalisé : une émission qui ne part pas peut donc être remontée à celui qui l a demandée."
|
||||
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.",
|
||||
"Le CW par CAT fonctionne sur Kenwood. La commande KY partait sous la forme Elecraft à longueur libre ; un Kenwood exige exactement 24 caractères, donc chaque message était refusé.",
|
||||
"Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl.",
|
||||
"La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — groupés et cherchables, avec les colonnes choisies affichées dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,12 +18,17 @@ type Cfg = {
|
||||
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||||
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||||
};
|
||||
type Col = { key: string; header: string };
|
||||
type Col = { key: string; header: string; group: string };
|
||||
|
||||
export function WebPublishPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||||
const [cols, setCols] = useState<Col[]>([]);
|
||||
// With every ADIF field on offer the list is long, so it is searchable and
|
||||
// sectioned. Chosen columns are pulled to the top: after picking eight out of
|
||||
// a hundred, the question stops being 'what exists' and becomes 'what did I
|
||||
// pick, and in what order does it print'.
|
||||
const [colSearch, setColSearch] = useState('');
|
||||
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
@@ -137,12 +142,50 @@ export function WebPublishPanel() {
|
||||
|
||||
{/* ── Columns ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('wpub.columnsCount', { n: cfg.columns?.length ?? 0, total: cols.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CHOSEN, in publication order — this is the list that answers "what
|
||||
will the page look like", which a sectioned catalogue cannot. */}
|
||||
{(cfg.columns?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cols.map((c) => {
|
||||
{cfg.columns.map((k) => {
|
||||
const c = cols.find((x) => x.key === k);
|
||||
return (
|
||||
<button key={k} type="button" onClick={() => toggleCol(k)}
|
||||
title={t('wpub.removeColumn')}
|
||||
className="px-2 py-0.5 rounded-full border border-primary bg-primary text-primary-foreground text-[11px] font-medium">
|
||||
{c?.header ?? k} ×
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input className="h-7 text-xs" placeholder={t('wpub.columnsSearch')}
|
||||
value={colSearch} onChange={(e) => setColSearch(e.target.value)} />
|
||||
|
||||
{/* The catalogue: sectioned, and filtered as you type. 123 fields in
|
||||
one flat wrap is a wall nobody reads to the end of. */}
|
||||
<div className="max-h-64 overflow-auto rounded border border-border/60 p-2 space-y-2">
|
||||
{Array.from(new Set(cols.map((c) => c.group))).map((g) => {
|
||||
const q = colSearch.trim().toLowerCase();
|
||||
const inGroup = cols.filter((c) => c.group === g &&
|
||||
(!q || c.header.toLowerCase().includes(q) || c.key.toLowerCase().includes(q)));
|
||||
if (inGroup.length === 0) return null;
|
||||
return (
|
||||
<div key={g}>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{g}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{inGroup.map((c) => {
|
||||
const on = cfg.columns?.includes(c.key);
|
||||
return (
|
||||
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
|
||||
title={c.key}
|
||||
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{c.header}
|
||||
@@ -150,6 +193,10 @@ export function WebPublishPanel() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ const en: Dict = {
|
||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
|
||||
'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor',
|
||||
'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor',
|
||||
'adifmon.hint': 'Watch external ADIF files and import new QSOs automatically — e.g. fldigi logging RTTY, or N1MM/VarAC. Imported QSOs are enriched, de-duplicated and uploaded to your external services just like a QSO logged here.',
|
||||
'adifmon.enable': 'Enable ADIF monitor',
|
||||
'adifmon.empty': 'No file watched yet. Add an ADIF file below.',
|
||||
@@ -544,7 +544,7 @@ const fr: Dict = {
|
||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
|
||||
'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF',
|
||||
'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF',
|
||||
'adifmon.hint': "Surveille des fichiers ADIF externes et importe les nouveaux QSO automatiquement — ex. fldigi en RTTY, ou N1MM/VarAC. Les QSO importés sont enrichis, dédoublonnés et envoyés à tes services externes comme un QSO loggé ici.",
|
||||
'adifmon.enable': 'Activer le moniteur ADIF',
|
||||
'adifmon.empty': 'Aucun fichier surveillé. Ajoute un fichier ADIF ci-dessous.',
|
||||
|
||||
+185
-29
@@ -10,9 +10,13 @@
|
||||
// - The page is self-contained: no external CSS, font or script. It has to
|
||||
// work dropped into any hosting, including one that blocks third-party
|
||||
// requests, and it must not leak the reader's visit to anyone.
|
||||
// - Columns are a fixed, curated set rather than "every ADIF field". This is
|
||||
// a page shown to the public: RST and QSL status belong, the operator's
|
||||
// home address does not.
|
||||
// - Every ADIF field is offered, because an operator publishing a county hunt
|
||||
// or an award chase needs fields no curated list would have guessed. That
|
||||
// puts the judgement on them, and it is a real one: this page is PUBLIC, and
|
||||
// the catalogue includes the correspondent's address, e-mail and the
|
||||
// operator's own street. Nothing is published unless it is chosen, and the
|
||||
// default selection stays the eight columns a reader of someone else's log
|
||||
// actually looks for.
|
||||
package webpub
|
||||
|
||||
import (
|
||||
@@ -60,6 +64,9 @@ type Config struct {
|
||||
type Column struct {
|
||||
Key string
|
||||
Header string
|
||||
// Group is the section a picker shows this under. With every ADIF field on
|
||||
// offer, a flat list is unusable — the grouping IS what makes it navigable.
|
||||
Group string
|
||||
Value func(q *qso.QSO) string
|
||||
}
|
||||
|
||||
@@ -70,43 +77,188 @@ func str(p *int) string {
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
// Columns is the curated set, in default display order. Add here to offer a new
|
||||
// one; the stored config keeps keys, so order changes are safe.
|
||||
func str64(p *int64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(*p, 10)
|
||||
}
|
||||
|
||||
// flt drops a trailing ".0": a distance reads better as "1420" than "1420.0",
|
||||
// and a bearing as "142.5" keeps the half-degree that matters.
|
||||
func flt(p *float64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func stamp(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// Columns is every field a QSO can carry, in ADIF order, grouped for the picker.
|
||||
//
|
||||
// Generated from the qso.QSO struct rather than curated by hand: the previous
|
||||
// list held 23 of them, so anyone wanting to publish a county, a satellite pass
|
||||
// or an award reference simply could not. A hand list also silently rots — a new
|
||||
// ADIF field is added to the struct and nobody remembers this file exists.
|
||||
//
|
||||
// The stored configuration keeps KEYS, so order and grouping can change freely.
|
||||
var Columns = []Column{
|
||||
{"date", "Date", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
|
||||
{"time", "UTC", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
|
||||
{"callsign", "Call", func(q *qso.QSO) string { return q.Callsign }},
|
||||
{"band", "Band", func(q *qso.QSO) string { return q.Band }},
|
||||
{"mode", "Mode", func(q *qso.QSO) string { return q.Mode }},
|
||||
{"freq", "Freq", func(q *qso.QSO) string {
|
||||
// Date and time are one column in the database and two here: a log reads by
|
||||
// day, and a reader scanning for "that evening" should not have to parse a
|
||||
// timestamp. Frequency is stored in Hz and published in MHz.
|
||||
{"date", "Date", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
|
||||
{"time", "UTC", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
|
||||
{"freq", "Freq", "QSO", func(q *qso.QSO) string {
|
||||
if q.FreqHz == nil || *q.FreqHz == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
|
||||
}},
|
||||
{"rst_sent", "RST S", func(q *qso.QSO) string { return q.RSTSent }},
|
||||
{"rst_rcvd", "RST R", func(q *qso.QSO) string { return q.RSTRcvd }},
|
||||
{"name", "Name", func(q *qso.QSO) string { return q.Name }},
|
||||
{"qth", "QTH", func(q *qso.QSO) string { return q.QTH }},
|
||||
{"country", "Country", func(q *qso.QSO) string { return q.Country }},
|
||||
{"grid", "Grid", func(q *qso.QSO) string { return q.Grid }},
|
||||
{"dxcc", "DXCC", func(q *qso.QSO) string { return str(q.DXCC) }},
|
||||
{"cqz", "CQ", func(q *qso.QSO) string { return str(q.CQZ) }},
|
||||
{"ituz", "ITU", func(q *qso.QSO) string { return str(q.ITUZ) }},
|
||||
{"iota", "IOTA", func(q *qso.QSO) string { return q.IOTA }},
|
||||
{"pota", "POTA", func(q *qso.QSO) string { return q.POTARef }},
|
||||
{"sota", "SOTA", func(q *qso.QSO) string { return q.SOTARef }},
|
||||
{"qsl_sent", "QSL S", func(q *qso.QSO) string { return q.QSLSent }},
|
||||
{"qsl_rcvd", "QSL R", func(q *qso.QSO) string { return q.QSLRcvd }},
|
||||
{"lotw_rcvd", "LoTW", func(q *qso.QSO) string { return q.LOTWRcvd }},
|
||||
{"station", "Station", func(q *qso.QSO) string { return q.StationCallsign }},
|
||||
{"comment", "Comment", func(q *qso.QSO) string { return q.Comment }},
|
||||
{"callsign", "Callsign", "QSO", func(q *qso.QSO) string { return q.Callsign }},
|
||||
{"qso_date_off", "End", "QSO", func(q *qso.QSO) string { return stamp(q.QSODateOff) }},
|
||||
{"band", "Band", "QSO", func(q *qso.QSO) string { return q.Band }},
|
||||
{"band_rx", "Band RX", "QSO", func(q *qso.QSO) string { return q.BandRX }},
|
||||
{"mode", "Mode", "QSO", func(q *qso.QSO) string { return q.Mode }},
|
||||
{"submode", "Submode", "QSO", func(q *qso.QSO) string { return q.Submode }},
|
||||
{"freq_rx_hz", "Freq RX", "QSO", func(q *qso.QSO) string { return str64(q.FreqRXHz) }},
|
||||
{"rst_sent", "RST S", "QSO", func(q *qso.QSO) string { return q.RSTSent }},
|
||||
{"rst_rcvd", "RST R", "QSO", func(q *qso.QSO) string { return q.RSTRcvd }},
|
||||
{"name", "Name", "QSO", func(q *qso.QSO) string { return q.Name }},
|
||||
{"qth", "QTH", "QSO", func(q *qso.QSO) string { return q.QTH }},
|
||||
{"address", "Address", "QSO", func(q *qso.QSO) string { return q.Address }},
|
||||
{"email", "Email", "QSO", func(q *qso.QSO) string { return q.Email }},
|
||||
{"web", "Web", "QSO", func(q *qso.QSO) string { return q.Web }},
|
||||
{"grid", "Grid", "Location", func(q *qso.QSO) string { return q.Grid }},
|
||||
{"gridsquare_ext", "Grid ext", "Location", func(q *qso.QSO) string { return q.GridExt }},
|
||||
{"vucc_grids", "VUCC", "Location", func(q *qso.QSO) string { return q.VUCCGrids }},
|
||||
{"country", "Country", "Location", func(q *qso.QSO) string { return q.Country }},
|
||||
{"state", "State", "Location", func(q *qso.QSO) string { return q.State }},
|
||||
{"cnty", "County", "Location", func(q *qso.QSO) string { return q.County }},
|
||||
{"dxcc", "Dxcc", "Location", func(q *qso.QSO) string { return str(q.DXCC) }},
|
||||
{"cont", "Cont", "Location", func(q *qso.QSO) string { return q.Continent }},
|
||||
{"cqz", "CQ", "Location", func(q *qso.QSO) string { return str(q.CQZ) }},
|
||||
{"ituz", "ITU", "Location", func(q *qso.QSO) string { return str(q.ITUZ) }},
|
||||
{"iota", "Iota", "Awards", func(q *qso.QSO) string { return q.IOTA }},
|
||||
{"sota_ref", "SOTA", "Awards", func(q *qso.QSO) string { return q.SOTARef }},
|
||||
{"pota_ref", "POTA", "Awards", func(q *qso.QSO) string { return q.POTARef }},
|
||||
{"age", "Age", "QSO", func(q *qso.QSO) string { return str(q.Age) }},
|
||||
{"lat", "Lat", "Location", func(q *qso.QSO) string { return flt(q.Lat) }},
|
||||
{"lon", "Lon", "Location", func(q *qso.QSO) string { return flt(q.Lon) }},
|
||||
{"rig", "Rig", "QSO", func(q *qso.QSO) string { return q.Rig }},
|
||||
{"ant", "Ant", "QSO", func(q *qso.QSO) string { return q.Ant }},
|
||||
{"qsl_sent", "QSL S", "QSL", func(q *qso.QSO) string { return q.QSLSent }},
|
||||
{"qsl_rcvd", "QSL R", "QSL", func(q *qso.QSO) string { return q.QSLRcvd }},
|
||||
{"qsl_sent_date", "Qsl sent date", "QSL", func(q *qso.QSO) string { return q.QSLSentDate }},
|
||||
{"qsl_rcvd_date", "Qsl rcvd date", "QSL", func(q *qso.QSO) string { return q.QSLRcvdDate }},
|
||||
{"qsl_via", "Qsl via", "QSL", func(q *qso.QSO) string { return q.QSLVia }},
|
||||
{"qsl_msg", "Qsl msg", "QSL", func(q *qso.QSO) string { return q.QSLMsg }},
|
||||
{"qslmsg_rcvd", "Qslmsg rcvd", "QSL", func(q *qso.QSO) string { return q.QSLMsgRcvd }},
|
||||
{"lotw_sent", "LoTW S", "QSL", func(q *qso.QSO) string { return q.LOTWSent }},
|
||||
{"lotw_rcvd", "LoTW R", "QSL", func(q *qso.QSO) string { return q.LOTWRcvd }},
|
||||
{"lotw_sent_date", "Lotw sent date", "QSL", func(q *qso.QSO) string { return q.LOTWSentDate }},
|
||||
{"lotw_rcvd_date", "Lotw rcvd date", "QSL", func(q *qso.QSO) string { return q.LOTWRcvdDate }},
|
||||
{"eqsl_sent", "eQSL S", "QSL", func(q *qso.QSO) string { return q.EQSLSent }},
|
||||
{"eqsl_rcvd", "eQSL R", "QSL", func(q *qso.QSO) string { return q.EQSLRcvd }},
|
||||
{"eqsl_sent_date", "Eqsl sent date", "QSL", func(q *qso.QSO) string { return q.EQSLSentDate }},
|
||||
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
|
||||
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
|
||||
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
|
||||
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
|
||||
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
|
||||
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
|
||||
{"qrzcom_qso_upload_status", "Qrzcom qso upload status", "QSL", func(q *qso.QSO) string { return q.QRZComUploadStatus }},
|
||||
{"qrzcom_qso_download_date", "Qrzcom qso download date", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadDate }},
|
||||
{"qrzcom_qso_download_status", "Qrzcom qso download status", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadStatus }},
|
||||
{"contest_id", "Contest", "Contest", func(q *qso.QSO) string { return q.ContestID }},
|
||||
{"srx", "Srx", "Contest", func(q *qso.QSO) string { return str(q.SRX) }},
|
||||
{"stx", "Stx", "Contest", func(q *qso.QSO) string { return str(q.STX) }},
|
||||
{"srx_string", "SRX str", "QSO", func(q *qso.QSO) string { return q.SRXString }},
|
||||
{"stx_string", "STX str", "QSO", func(q *qso.QSO) string { return q.STXString }},
|
||||
{"check", "Check", "Contest", func(q *qso.QSO) string { return q.Check }},
|
||||
{"precedence", "Precedence", "Contest", func(q *qso.QSO) string { return q.Precedence }},
|
||||
{"arrl_sect", "Section", "Contest", func(q *qso.QSO) string { return q.ARRLSect }},
|
||||
{"prop_mode", "Prop", "QSO", func(q *qso.QSO) string { return q.PropMode }},
|
||||
{"sat_name", "Sat", "QSO", func(q *qso.QSO) string { return q.SatName }},
|
||||
{"sat_mode", "Sat mode", "QSO", func(q *qso.QSO) string { return q.SatMode }},
|
||||
{"ant_az", "Ant az", "QSO", func(q *qso.QSO) string { return flt(q.AntAz) }},
|
||||
{"ant_el", "Ant el", "QSO", func(q *qso.QSO) string { return flt(q.AntEl) }},
|
||||
{"ant_path", "Ant path", "QSO", func(q *qso.QSO) string { return q.AntPath }},
|
||||
{"station_callsign", "Station", "QSO", func(q *qso.QSO) string { return q.StationCallsign }},
|
||||
{"operator", "Operator", "QSO", func(q *qso.QSO) string { return q.Operator }},
|
||||
{"my_grid", "My grid", "My station", func(q *qso.QSO) string { return q.MyGrid }},
|
||||
{"my_gridsquare_ext", "My gridsquare ext", "My station", func(q *qso.QSO) string { return q.MyGridExt }},
|
||||
{"my_country", "My country", "My station", func(q *qso.QSO) string { return q.MyCountry }},
|
||||
{"my_state", "My state", "My station", func(q *qso.QSO) string { return q.MyState }},
|
||||
{"my_cnty", "My cnty", "My station", func(q *qso.QSO) string { return q.MyCounty }},
|
||||
{"my_iota", "My iota", "My station", func(q *qso.QSO) string { return q.MyIOTA }},
|
||||
{"my_sota_ref", "My sota ref", "My station", func(q *qso.QSO) string { return q.MySOTARef }},
|
||||
{"my_pota_ref", "My pota ref", "My station", func(q *qso.QSO) string { return q.MyPOTARef }},
|
||||
{"my_dxcc", "My dxcc", "My station", func(q *qso.QSO) string { return str(q.MyDXCC) }},
|
||||
{"my_cq_zone", "My cq zone", "My station", func(q *qso.QSO) string { return str(q.MyCQZone) }},
|
||||
{"my_itu_zone", "My itu zone", "My station", func(q *qso.QSO) string { return str(q.MyITUZone) }},
|
||||
{"my_lat", "My lat", "My station", func(q *qso.QSO) string { return flt(q.MyLat) }},
|
||||
{"my_lon", "My lon", "My station", func(q *qso.QSO) string { return flt(q.MyLon) }},
|
||||
{"my_street", "My street", "My station", func(q *qso.QSO) string { return q.MyStreet }},
|
||||
{"my_city", "My city", "My station", func(q *qso.QSO) string { return q.MyCity }},
|
||||
{"my_postal_code", "My postal code", "My station", func(q *qso.QSO) string { return q.MyPostalCode }},
|
||||
{"my_rig", "My rig", "My station", func(q *qso.QSO) string { return q.MyRig }},
|
||||
{"my_antenna", "My antenna", "My station", func(q *qso.QSO) string { return q.MyAntenna }},
|
||||
{"tx_pwr", "TX pwr", "QSO", func(q *qso.QSO) string { return flt(q.TXPower) }},
|
||||
{"comment", "Comment", "QSO", func(q *qso.QSO) string { return q.Comment }},
|
||||
{"notes", "Notes", "QSO", func(q *qso.QSO) string { return q.Notes }},
|
||||
{"sig", "Sig", "Awards", func(q *qso.QSO) string { return q.SIG }},
|
||||
{"sig_info", "Sig info", "Awards", func(q *qso.QSO) string { return q.SIGInfo }},
|
||||
{"my_sig", "My sig", "My station", func(q *qso.QSO) string { return q.MySIG }},
|
||||
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
||||
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
||||
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
||||
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }},
|
||||
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
||||
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
||||
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
||||
{"sfi", "SFI", "QSO", func(q *qso.QSO) string { return flt(q.SFI) }},
|
||||
{"skcc", "Skcc", "Awards", func(q *qso.QSO) string { return q.SKCC }},
|
||||
{"fists", "Fists", "Awards", func(q *qso.QSO) string { return q.FISTS }},
|
||||
{"ten_ten", "10-10", "Awards", func(q *qso.QSO) string { return q.TenTen }},
|
||||
{"contacted_op", "Op worked", "QSO", func(q *qso.QSO) string { return q.ContactedOp }},
|
||||
{"eq_call", "Eq call", "QSO", func(q *qso.QSO) string { return q.EqCall }},
|
||||
{"pfx", "Pfx", "Location", func(q *qso.QSO) string { return q.PFX }},
|
||||
{"my_name", "My name", "My station", func(q *qso.QSO) string { return q.MyName }},
|
||||
{"class", "Class", "Contest", func(q *qso.QSO) string { return q.Class }},
|
||||
{"darc_dok", "DOK", "Awards", func(q *qso.QSO) string { return q.DarcDOK }},
|
||||
{"my_darc_dok", "My darc dok", "My station", func(q *qso.QSO) string { return q.MyDarcDOK }},
|
||||
{"region", "Region", "Location", func(q *qso.QSO) string { return q.Region }},
|
||||
{"silent_key", "SK", "QSO", func(q *qso.QSO) string { return q.SilentKey }},
|
||||
{"swl", "Swl", "QSO", func(q *qso.QSO) string { return q.SWL }},
|
||||
{"qso_complete", "Complete", "QSO", func(q *qso.QSO) string { return q.QSOComplete }},
|
||||
{"qso_random", "Random", "QSO", func(q *qso.QSO) string { return q.QSORandom }},
|
||||
{"credit_granted", "Credit granted", "QSL", func(q *qso.QSO) string { return q.CreditGranted }},
|
||||
{"credit_submitted", "Credit sub.", "QSL", func(q *qso.QSO) string { return q.CreditSubmitted }},
|
||||
{"my_arrl_sect", "My arrl sect", "My station", func(q *qso.QSO) string { return q.MyARRLSect }},
|
||||
{"my_vucc_grids", "My vucc grids", "My station", func(q *qso.QSO) string { return q.MyVUCCGrids }},
|
||||
{"award_refs", "Award refs", "Awards", func(q *qso.QSO) string { return q.AwardRefs }},
|
||||
}
|
||||
|
||||
// DefaultColumns is what a fresh configuration publishes — the columns a reader
|
||||
// of someone else's log actually looks for.
|
||||
var DefaultColumns = []string{"date", "time", "callsign", "band", "mode", "rst_sent", "rst_rcvd", "country"}
|
||||
|
||||
// legacyKeys maps the short names the hand-written table used onto the ADIF
|
||||
// names the generated one uses. A configuration saved before this change still
|
||||
// publishes the same columns; without it, three of them would vanish silently on
|
||||
// upgrade, which is the worst way for a setting to change.
|
||||
var legacyKeys = map[string]string{
|
||||
"pota": "pota_ref",
|
||||
"sota": "sota_ref",
|
||||
"station": "station_callsign",
|
||||
}
|
||||
|
||||
func columnsFor(keys []string) []Column {
|
||||
if len(keys) == 0 {
|
||||
keys = DefaultColumns
|
||||
@@ -117,7 +269,11 @@ func columnsFor(keys []string) []Column {
|
||||
}
|
||||
out := make([]Column, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if c, ok := byKey[strings.TrimSpace(k)]; ok {
|
||||
k = strings.TrimSpace(k)
|
||||
if alias, ok := legacyKeys[k]; ok {
|
||||
k = alias
|
||||
}
|
||||
if c, ok := byKey[k]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user