Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 |
@@ -329,6 +329,7 @@ const (
|
||||
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
keyScpClublog = "scp.clublog" // merge Club Log's weekly SCP list (~180k calls)
|
||||
|
||||
// Web publishing: the whole config as one JSON blob. A single key rather than
|
||||
// twenty: it is read and written as a unit by one settings panel, and the FTP
|
||||
@@ -356,6 +357,7 @@ const (
|
||||
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
|
||||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
|
||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||
@@ -421,6 +423,8 @@ const (
|
||||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
||||
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
|
||||
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
|
||||
|
||||
keyExtClublogLastDownload = "extsvc.clublog.last_download" // YYYY-MM-DD of last Club Log matches pull
|
||||
)
|
||||
|
||||
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||
@@ -436,6 +440,7 @@ type QSLDefaults struct {
|
||||
EQSLSent string `json:"eqsl_sent"`
|
||||
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||
ClublogStatus string `json:"clublog_status"`
|
||||
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||||
@@ -1129,7 +1134,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
conn, err := db.Open(a.dbPath)
|
||||
if err != nil {
|
||||
a.startupErr = "cannot open db: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
// In the rotating log too: the GUI subsystem discards stdout, and this
|
||||
// exact failure once hid for a whole morning behind a println.
|
||||
applog.Printf("startup: %s", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.db = conn
|
||||
@@ -1395,6 +1402,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
|
||||
// enabled, auto-(re)download it if missing or older than a week.
|
||||
a.scp = scp.NewManager(dataDir)
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
|
||||
a.scp.SetClublogEnabled(true)
|
||||
}
|
||||
go func() {
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return
|
||||
@@ -3146,6 +3156,7 @@ type ScpStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count"`
|
||||
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
||||
Clublog bool `json:"clublog"` // Club Log source merged in
|
||||
}
|
||||
|
||||
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
|
||||
@@ -3157,6 +3168,7 @@ func (a *App) GetScpStatus() ScpStatus {
|
||||
}
|
||||
if a.scp != nil {
|
||||
st.Count = a.scp.Count()
|
||||
st.Clublog = a.scp.ClublogEnabled()
|
||||
if u := a.scp.Updated(); !u.IsZero() {
|
||||
st.Updated = u.UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -3164,6 +3176,34 @@ func (a *App) GetScpStatus() ScpStatus {
|
||||
return st
|
||||
}
|
||||
|
||||
// SetScpClublogEnabled merges (or stops merging) Club Log's weekly SCP list
|
||||
// into the Super Check Partial call list, and refreshes the download so the
|
||||
// change is visible without waiting for the weekly cycle.
|
||||
func (a *App) SetScpClublogEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyScpClublog, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.scp != nil {
|
||||
a.scp.SetClublogEnabled(on)
|
||||
if on {
|
||||
go func() {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: downloaded %d callsigns (with Club Log list)", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: download failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
|
||||
// download when the list is missing or stale.
|
||||
func (a *App) SetScpEnabled(on bool) error {
|
||||
@@ -6889,6 +6929,7 @@ var bulkFieldColumns = map[string]string{
|
||||
"qrz_sent": "qrzcom_qso_upload_status",
|
||||
"qrz_rcvd": "qrzcom_qso_download_status",
|
||||
"clublog_sent": "clublog_qso_upload_status",
|
||||
"clublog_rcvd": "clublog_qso_download_status",
|
||||
"hrdlog_sent": "hrdlog_qso_upload_status",
|
||||
// Old ids kept so anything holding one keeps working.
|
||||
"qrz_upload": "qrzcom_qso_upload_status",
|
||||
@@ -6905,6 +6946,7 @@ var bulkFieldColumns = map[string]string{
|
||||
"qrz_sent_date": "qrzcom_qso_upload_date",
|
||||
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
||||
"clublog_sent_date": "clublog_qso_upload_date",
|
||||
"clublog_rcvd_date": "clublog_qso_download_date",
|
||||
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
||||
// My station / operator
|
||||
"station_callsign": "station_callsign",
|
||||
@@ -10898,7 +10940,7 @@ func defaultQSLDefaults() QSLDefaults {
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
EQSLSent: "R", EQSLRcvd: "N",
|
||||
LOTWSent: "R", LOTWRcvd: "N",
|
||||
ClublogStatus: "R", HRDLogStatus: "R",
|
||||
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||
QRZComStatus: "R", QRZComCfm: "N",
|
||||
}
|
||||
}
|
||||
@@ -10917,7 +10959,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
||||
)
|
||||
@@ -10931,6 +10973,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
||||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||
@@ -10954,6 +10997,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
||||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
|
||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||
@@ -11006,6 +11050,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
||||
fill(&q.EQSLSent, d.EQSLSent)
|
||||
fill(&q.EQSLRcvd, d.EQSLRcvd)
|
||||
fill(&q.ClublogUploadStatus, d.ClublogStatus)
|
||||
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
|
||||
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
||||
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
||||
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
||||
@@ -12935,6 +12980,90 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
||||
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
case extsvc.ServiceClublog:
|
||||
sinceDate := resolveSince(keyExtClublogLastDownload)
|
||||
if sinceDate != "" {
|
||||
emit("Downloading Club Log matches (matched since " + sinceDate + ")…")
|
||||
} else {
|
||||
emit("Downloading ALL Club Log matches (full pull — the date filter is faster next time)…")
|
||||
}
|
||||
matches, err := extsvc.DownloadClublogMatches(ctx, nil, cfg.Clublog, sinceDate)
|
||||
if err != nil {
|
||||
emit("Download failed: " + err.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
mIdx, kerr := a.qso.BuildMatchIndex(ctx, a.uploadOwnerCall(extsvc.ServiceClublog))
|
||||
if kerr != nil {
|
||||
emit("Error reading local log: " + kerr.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
// Club Log pairs QSOs within ±15 minutes, same as LoTW.
|
||||
const clublogMatchWindow = 15 * time.Minute
|
||||
today := time.Now().UTC().Format("20060102")
|
||||
// A Club Log match is Club Log's own kind of confirmation, so NEW is
|
||||
// judged only against other Club Log matches.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"clublog_qso_download_status"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []string
|
||||
for _, m := range matches {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
total++
|
||||
id, found := int64(0), false
|
||||
if m.Band != "" && m.Mode != "" {
|
||||
id, found = mIdx.Match(m.Callsign, m.Band, m.Mode, m.When, clublogMatchWindow)
|
||||
}
|
||||
if !found && m.Band != "" {
|
||||
// Club Log reports "false" for modes it can't infer — fall back to
|
||||
// call+band+time, still unambiguous inside a 15-minute window.
|
||||
id, found = mIdx.MatchBand(m.Callsign, m.Band, m.When, clublogMatchWindow)
|
||||
}
|
||||
if found {
|
||||
if e := a.qso.MarkClublogConfirmed(ctx, id, today); e == nil {
|
||||
matched++
|
||||
}
|
||||
} else {
|
||||
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
|
||||
m.Callsign, m.When.Format("2006-01-02 15:04Z"), m.Band, m.Mode))
|
||||
}
|
||||
it := ConfirmationItem{
|
||||
Callsign: m.Callsign,
|
||||
QSODate: m.When.Format(time.RFC3339),
|
||||
Band: m.Band, Mode: m.Mode,
|
||||
}
|
||||
if m.DXCC != 0 {
|
||||
it.Country = dxcc.NameForDXCC(m.DXCC)
|
||||
it.NewDXCC = !sets.DXCC[m.DXCC]
|
||||
it.NewBand = !sets.Band[qso.BandKey(m.DXCC, m.Band)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)]
|
||||
sets.DXCC[m.DXCC] = true
|
||||
sets.Band[qso.BandKey(m.DXCC, m.Band)] = true
|
||||
sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
emit(fmt.Sprintf("Matched %d of %d Club Log match(es)", matched, total))
|
||||
if addNotFound && len(unmatched) > 0 {
|
||||
// A match means BOTH sides uploaded this QSO — one Club Log can't find
|
||||
// locally was deleted or edited here, not missed; adding a skeleton
|
||||
// row from call/band/time alone would just create a duplicate shadow.
|
||||
emit("Club Log matches carry too little detail to add missing QSOs — unmatched ones are listed below.")
|
||||
}
|
||||
for _, u := range unmatched {
|
||||
emit(" ⚠ no local QSO for: " + u)
|
||||
}
|
||||
if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtClublogLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
default:
|
||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||
}
|
||||
@@ -16098,6 +16227,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
a.restartAsync("tuner", a.startTunerGenius)
|
||||
a.restartAsync("psu", a.startPSU)
|
||||
a.startQSORecorderIfEnabled()
|
||||
// The watchlist's Club Log enrichment (OQRS/QSO-count badges) reads
|
||||
// per-profile settings too — rebuilt so a switch doesn't keep showing the
|
||||
// previous profile's view of who is worth chasing.
|
||||
a.startWatchlistClubLog()
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
@@ -18549,19 +18682,51 @@ func (a *App) startAmps() {
|
||||
old := a.ampInsts
|
||||
a.ampInsts = map[string]*ampInst{}
|
||||
a.ampsMu.Unlock()
|
||||
for _, inst := range old {
|
||||
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||
list, err := a.GetAmplifiers()
|
||||
if err != nil {
|
||||
for _, inst := range old {
|
||||
go inst.stopAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
// An amplifier whose configuration did not change keeps its RUNNING client
|
||||
// across a settings save. Rebuilding closes the serial port, and closing a
|
||||
// COM port makes the Windows driver drop DTR/RTS — which on a KPA500 is the
|
||||
// POWER SWITCH: every save of any Settings page was switching the amplifier
|
||||
// off (F1TPL's report). Reuse also spares the other amps a reconnect blink.
|
||||
reused := map[string]bool{}
|
||||
for _, c := range list {
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
if o, ok := old[c.ID]; ok && o.cfg == c {
|
||||
reused[c.ID] = true
|
||||
if o.pgxl != nil && a.pgxl == nil {
|
||||
a.pgxl = o.pgxl
|
||||
}
|
||||
if o.spe != nil && a.spe == nil {
|
||||
a.spe = o.spe
|
||||
}
|
||||
if o.acom != nil && a.acom == nil {
|
||||
a.acom = o.acom
|
||||
}
|
||||
a.ampsMu.Lock()
|
||||
a.ampInsts[c.ID] = o
|
||||
a.ampsMu.Unlock()
|
||||
}
|
||||
}
|
||||
for id, inst := range old {
|
||||
if reused[id] {
|
||||
continue
|
||||
}
|
||||
// Stop() can block up to the dial timeout waiting for an in-progress
|
||||
// connect; tear down in the background so saving Settings (this runs on
|
||||
// the Wails RPC goroutine) doesn't freeze the UI.
|
||||
go inst.stopAll()
|
||||
}
|
||||
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||
list, err := a.GetAmplifiers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range list {
|
||||
if !c.Enabled {
|
||||
if !c.Enabled || reused[c.ID] {
|
||||
continue
|
||||
}
|
||||
isPGXL := c.Type == "" || c.Type == "pgxl"
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
|
||||
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
|
||||
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
|
||||
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
|
||||
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500’s power switch. An unchanged amplifier now keeps its running connection across saves.",
|
||||
"Column picker: columns are listed alphabetically inside each group.",
|
||||
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
|
||||
],
|
||||
"fr": [
|
||||
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
|
||||
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP.",
|
||||
"Corrigé : l’application pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne s’applique pas, et un échec de démarrage est écrit dans le log.",
|
||||
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
|
||||
"KPA500 : sauvegarder n’importe quelle page des réglages n’éteint plus l’ampli. Une sauvegarde reconstruisait chaque connexion d’ampli, et fermer le port COM relâche DTR/RTS — l’interrupteur d’alimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
|
||||
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
|
||||
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.4",
|
||||
"date": "",
|
||||
|
||||
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||
|
||||
@@ -119,7 +119,10 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
||||
|
||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||
return (
|
||||
<div className="relative h-full w-full min-h-0">
|
||||
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||
// beat the app menus and the Settings dialog. A stacking context of our own
|
||||
// keeps all of it inside this panel.
|
||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
|
||||
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
|
||||
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||
|
||||
@@ -71,7 +71,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
|
||||
@@ -235,6 +235,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
@@ -784,7 +786,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
// Alphabetical within the group: the catalog's order is the
|
||||
// GRID's column order, which appends newcomers at the end — so
|
||||
// the two ClubLog rows sat at opposite ends of the Uploads box.
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group)
|
||||
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
||||
@@ -1822,7 +1822,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
eqsl_sent: string; eqsl_rcvd: string;
|
||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
hamlog_status: string; hamlog_confirmed: string;
|
||||
};
|
||||
@@ -1830,7 +1830,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
lotw_sent: '', lotw_rcvd: '',
|
||||
eqsl_sent: '', eqsl_rcvd: '',
|
||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
||||
qrzcom_confirmed: '',
|
||||
hamlog_status: '', hamlog_confirmed: '',
|
||||
});
|
||||
@@ -1884,6 +1884,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
|
||||
const downloadScp = async () => {
|
||||
setScpBusy(true);
|
||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
@@ -5708,7 +5709,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||||
{renderSelect('clublog_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
{/* HRDLog */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
@@ -7306,6 +7310,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||
{t('scp.enable')}
|
||||
</label>
|
||||
{scp.enabled && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
|
||||
<Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
|
||||
onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
|
||||
{t('scp.clublog')}
|
||||
</label>
|
||||
)}
|
||||
{scp.enabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.4';
|
||||
export const APP_VERSION = '0.27.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -1202,6 +1202,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetSpotMax(arg1:number):Promise<void>;
|
||||
|
||||
@@ -2342,6 +2342,10 @@ export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpClublogEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -3294,6 +3294,7 @@ export namespace main {
|
||||
eqsl_sent: string;
|
||||
eqsl_rcvd: string;
|
||||
clublog_status: string;
|
||||
clublog_confirmed: string;
|
||||
hrdlog_status: string;
|
||||
qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
@@ -3313,6 +3314,7 @@ export namespace main {
|
||||
this.eqsl_sent = source["eqsl_sent"];
|
||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||
this.clublog_status = source["clublog_status"];
|
||||
this.clublog_confirmed = source["clublog_confirmed"];
|
||||
this.hrdlog_status = source["hrdlog_status"];
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||
@@ -3852,6 +3854,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
updated?: string;
|
||||
clublog: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScpStatus(source);
|
||||
@@ -3862,6 +3865,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.count = source["count"];
|
||||
this.updated = source["updated"];
|
||||
this.clublog = source["clublog"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
@@ -5269,6 +5273,8 @@ export namespace qso {
|
||||
qrzcom_qso_upload_status?: string;
|
||||
qrzcom_qso_download_date?: string;
|
||||
qrzcom_qso_download_status?: string;
|
||||
clublog_qso_download_date?: string;
|
||||
clublog_qso_download_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -5411,6 +5417,8 @@ export namespace qso {
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||
this.clublog_qso_download_date = source["clublog_qso_download_date"];
|
||||
this.clublog_qso_download_status = source["clublog_qso_download_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
w("CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
|
||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
// App-defined pair (no standard ADIF field): Club Log's log-match download.
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
|
||||
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
|
||||
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||
}
|
||||
|
||||
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
|
||||
// already there, or the statement shapes a table this database does not hold.
|
||||
// Scoped to shaping statements only — a CREATE TABLE or data statement that
|
||||
// fails must still fail the migration.
|
||||
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "no such table") {
|
||||
head := strings.ToLower(strings.TrimSpace(stmt))
|
||||
return strings.HasPrefix(head, "alter table") ||
|
||||
strings.HasPrefix(head, "create index") ||
|
||||
strings.HasPrefix(head, "create unique index") ||
|
||||
strings.HasPrefix(head, "drop ")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
// Same self-healing as the MySQL path, plus one case it never
|
||||
// meets: a table-shaping statement aimed at a table this
|
||||
// database legitimately does not have. A split settings
|
||||
// database dropped its qso table when the QSOs moved to the
|
||||
// logbook, but its role is still RoleAll — so a later
|
||||
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
|
||||
// failure that silently kills the whole startup (v0.27.4+).
|
||||
if isIgnorableSQLiteDDLError(err, stmt) {
|
||||
continue
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Club Log log-matching (getmatches.php): a QSO both sides uploaded to Club
|
||||
-- Log is a confirmation in its own right. Mirrors qrzcom_qso_download_*.
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_download_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_download_status TEXT;
|
||||
@@ -0,0 +1,126 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Club Log's log-matching API. A "match" is a QSO that BOTH stations uploaded
|
||||
// to Club Log, paired within ±15 minutes — Club Log's own equivalent of a LoTW
|
||||
// confirmation. getmatches.php returns a JSON array of 5-element arrays:
|
||||
//
|
||||
// [["G0LGJ/M","223","2005-07-16 08:00:00","20","CW"], …]
|
||||
// callsign dxcc qso datetime (UTC) band mode ("false" when unknown)
|
||||
//
|
||||
// The optional start date filters on when Club Log COMPLETED the match (not
|
||||
// the QSO date), which is exactly what an incremental "since last download"
|
||||
// pull wants.
|
||||
const clublogMatchesURL = "https://clublog.org/getmatches.php"
|
||||
|
||||
// ClublogMatch is one confirmed pairing from getmatches.php.
|
||||
type ClublogMatch struct {
|
||||
Callsign string
|
||||
DXCC int
|
||||
When time.Time
|
||||
Band string // ADIF band ("20m", "70cm"); "" if the id is unknown
|
||||
Mode string // "" when Club Log doesn't know it
|
||||
}
|
||||
|
||||
// clublogBandNames maps Club Log's numeric band ids to ADIF band names. The
|
||||
// ids are the wavelength number; the only trap is that ids past the metre
|
||||
// bands are centimetres (the docs' own example: 70 = 70CM).
|
||||
var clublogBandNames = map[string]string{
|
||||
"2200": "2200m", "630": "630m", "160": "160m", "80": "80m", "60": "60m",
|
||||
"40": "40m", "30": "30m", "20": "20m", "17": "17m", "15": "15m",
|
||||
"12": "12m", "10": "10m", "8": "8m", "6": "6m", "5": "5m", "4": "4m",
|
||||
"2": "2m", "70": "70cm", "23": "23cm", "13": "13cm", "9": "9cm", "3": "3cm",
|
||||
}
|
||||
|
||||
// DownloadClublogMatches pulls the account's log matches for cfg.Callsign,
|
||||
// optionally only those Club Log completed since sinceDate ("2006-01-02").
|
||||
func DownloadClublogMatches(ctx context.Context, client *http.Client, cfg ServiceConfig, sinceDate string) ([]ClublogMatch, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
switch {
|
||||
case email == "":
|
||||
return nil, fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return nil, fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return nil, fmt.Errorf("clublog: callsign not set")
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("api", clublogAppAPIKey)
|
||||
v.Set("email", email)
|
||||
v.Set("password", cfg.Password)
|
||||
v.Set("callsign", call)
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(sinceDate)); err == nil {
|
||||
v.Set("startyear", strconv.Itoa(t.Year()))
|
||||
v.Set("startmonth", strconv.Itoa(int(t.Month())))
|
||||
v.Set("startday", strconv.Itoa(t.Day()))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogMatchesURL+"?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
text := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if looksLikeHTML(text) || len(text) > 300 {
|
||||
return nil, fmt.Errorf("clublog: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("clublog: HTTP %d: %s", resp.StatusCode, text)
|
||||
}
|
||||
if looksLikeHTML(text) {
|
||||
return nil, fmt.Errorf("clublog: got a web page instead of matches — check email/password/callsign")
|
||||
}
|
||||
var raw [][]any
|
||||
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||
return nil, fmt.Errorf("clublog: bad matches JSON: %w", err)
|
||||
}
|
||||
str := func(x any) string {
|
||||
switch t := x.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
out := make([]ClublogMatch, 0, len(raw))
|
||||
for _, rec := range raw {
|
||||
if len(rec) < 5 {
|
||||
continue
|
||||
}
|
||||
m := ClublogMatch{Callsign: strings.ToUpper(strings.TrimSpace(str(rec[0])))}
|
||||
m.DXCC, _ = strconv.Atoi(str(rec[1]))
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", str(rec[2])); err == nil {
|
||||
m.When = t.UTC()
|
||||
}
|
||||
m.Band = clublogBandNames[strings.TrimSpace(str(rec[3]))]
|
||||
if md := strings.TrimSpace(str(rec[4])); md != "" && !strings.EqualFold(md, "false") {
|
||||
m.Mode = strings.ToUpper(md)
|
||||
}
|
||||
if m.Callsign == "" || m.When.IsZero() {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+71
-31
@@ -123,6 +123,10 @@ type QSO struct {
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
|
||||
// Club Log match download (getmatches.php) — app-defined, no standard ADIF
|
||||
// field exists; exported/imported as CLUBLOG_QSO_DOWNLOAD_*.
|
||||
ClublogDownloadDate string `json:"clublog_qso_download_date,omitempty"`
|
||||
ClublogDownloadStatus string `json:"clublog_qso_download_status,omitempty"`
|
||||
|
||||
// --- Contest ---
|
||||
ContestID string `json:"contest_id,omitempty"`
|
||||
@@ -261,6 +265,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
|
||||
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
||||
qrzcom_qso_download_date, qrzcom_qso_download_status,
|
||||
clublog_qso_download_date, clublog_qso_download_status,
|
||||
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
|
||||
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
|
||||
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
|
||||
@@ -336,6 +341,7 @@ func (q *QSO) args() []any {
|
||||
q.HRDLogUploadDate, q.HRDLogUploadStatus,
|
||||
q.QRZComUploadDate, q.QRZComUploadStatus,
|
||||
q.QRZComDownloadDate, q.QRZComDownloadStatus,
|
||||
q.ClublogDownloadDate, q.ClublogDownloadStatus,
|
||||
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
|
||||
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
|
||||
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
|
||||
@@ -771,30 +777,32 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||
// zones, lat/lon) that are meaningless shared.
|
||||
var bulkEditableCols = map[string]bool{
|
||||
// QSL / upload status
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"clublog_qso_download_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
"qsl_sent_date": true,
|
||||
"qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true,
|
||||
"lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true,
|
||||
"eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"clublog_qso_download_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
"operator": true,
|
||||
@@ -1339,15 +1347,17 @@ var filterableColumns = map[string]bool{
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true, "clublog_qso_download_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
|
||||
// also chronological — "before 20240101" works with no date parsing.
|
||||
"qsl_sent_date": true, "qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true, "lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"clublog_qso_upload_date": true, "clublog_qso_download_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"prop_mode": true, "sat_name": true,
|
||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
|
||||
@@ -3264,6 +3274,7 @@ type matchRef struct {
|
||||
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
|
||||
type MatchIndex struct {
|
||||
byMode map[string][]matchRef // call|band|canonMode → refs
|
||||
byBand map[string][]matchRef // call|band → refs (mode-blind fallback)
|
||||
}
|
||||
|
||||
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
|
||||
@@ -3299,7 +3310,7 @@ func parseQSODate(s string) time.Time {
|
||||
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
|
||||
// another (e.g. TM2Q).
|
||||
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
|
||||
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||
idx := &MatchIndex{byMode: map[string][]matchRef{}, byBand: map[string][]matchRef{}}
|
||||
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
|
||||
var args []any
|
||||
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
|
||||
@@ -3327,6 +3338,11 @@ func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchInd
|
||||
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
|
||||
mk := matchKeyMode(call, band, mode)
|
||||
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
|
||||
if idx.byBand == nil { // tests build the index literally, without byBand
|
||||
idx.byBand = map[string][]matchRef{}
|
||||
}
|
||||
bk := strings.ToUpper(call) + "|" + strings.ToLower(band)
|
||||
idx.byBand[bk] = append(idx.byBand[bk], matchRef{when: when, id: id})
|
||||
}
|
||||
|
||||
// Add registers a QSO in the index (exported wrapper for callers that inserted a
|
||||
@@ -3342,6 +3358,12 @@ func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window tim
|
||||
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
|
||||
}
|
||||
|
||||
// MatchBand matches ignoring the mode — for confirmations whose source doesn't
|
||||
// carry one (Club Log reports "false" for modes it can't infer).
|
||||
func (idx *MatchIndex) MatchBand(call, band string, when time.Time, window time.Duration) (int64, bool) {
|
||||
return closestRef(idx.byBand[strings.ToUpper(call)+"|"+strings.ToLower(band)], when, window)
|
||||
}
|
||||
|
||||
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
|
||||
var best int64
|
||||
bestD := window + time.Second
|
||||
@@ -3500,10 +3522,11 @@ func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
"lotw_rcvd": true,
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"lotw_rcvd": true,
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_download_status": true,
|
||||
}
|
||||
|
||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
|
||||
@@ -3560,6 +3583,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkClublogConfirmed stamps CLUBLOG_QSO_DOWNLOAD_STATUS=Y and the date on a
|
||||
// QSO Club Log reports as matched. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkClublogConfirmed(ctx context.Context, id int64, date string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET clublog_qso_download_status = 'Y', clublog_qso_download_date = ?,
|
||||
updated_at = ? WHERE id = ?`,
|
||||
date, db.NowISO(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark clublog confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||
//
|
||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||
@@ -3638,6 +3674,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
clublogDlDate, clublogDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
@@ -3682,6 +3719,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&hrdlogDate, &hrdlogStatus,
|
||||
&qrzcomDate, &qrzcomStatus,
|
||||
&qrzcomDlDate, &qrzcomDlStatus,
|
||||
&clublogDlDate, &clublogDlStatus,
|
||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||
@@ -3779,6 +3817,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.QRZComUploadStatus = qrzcomStatus.String
|
||||
q.QRZComDownloadDate = qrzcomDlDate.String
|
||||
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
||||
q.ClublogDownloadDate = clublogDlDate.String
|
||||
q.ClublogDownloadStatus = clublogDlStatus.String
|
||||
q.ContestID = contestID.String
|
||||
if srx.Valid {
|
||||
v := int(srx.Int64)
|
||||
|
||||
+88
-20
@@ -13,6 +13,7 @@ package scp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,14 @@ import (
|
||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
||||
|
||||
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
|
||||
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
|
||||
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
|
||||
// when the Club Log source is enabled.
|
||||
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
|
||||
|
||||
const cacheFile = "MASTER.SCP"
|
||||
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
|
||||
|
||||
// Result is the two suggestion lists for a typed fragment.
|
||||
type Result struct {
|
||||
@@ -44,6 +53,7 @@ type Manager struct {
|
||||
updated time.Time // when the cache was last refreshed
|
||||
dir string
|
||||
client *http.Client
|
||||
clublog atomic.Bool // merge Club Log's list into the master list
|
||||
}
|
||||
|
||||
// NewManager loads any on-disk cache and returns a ready manager.
|
||||
@@ -56,14 +66,30 @@ func NewManager(dataDir string) *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
|
||||
|
||||
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
|
||||
// caches so the in-memory list reflects the choice immediately.
|
||||
func (m *Manager) SetClublogEnabled(on bool) {
|
||||
if m.clublog.Swap(on) != on {
|
||||
m.loadCache()
|
||||
}
|
||||
}
|
||||
|
||||
// ClublogEnabled reports whether the Club Log source is merged in.
|
||||
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
|
||||
|
||||
func (m *Manager) loadCache() {
|
||||
data, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
data, _ := os.ReadFile(m.path())
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
if data == nil && extra == nil {
|
||||
return
|
||||
}
|
||||
m.parse(data)
|
||||
m.parse(data, extra)
|
||||
if fi, e := os.Stat(m.path()); e == nil {
|
||||
m.mu.Lock()
|
||||
m.updated = fi.ModTime()
|
||||
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: read: %w", err)
|
||||
}
|
||||
n := m.parse(body)
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
|
||||
extra = cl
|
||||
} else {
|
||||
// The master list alone is still worth having; fall back to any
|
||||
// cached Club Log list rather than dropping the source silently.
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
}
|
||||
n := m.parse(body, extra)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
||||
}
|
||||
@@ -102,25 +138,57 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
|
||||
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
|
||||
}
|
||||
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog read: %w", err)
|
||||
}
|
||||
_ = os.WriteFile(m.clublogPath(), body, 0o644)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// parse loads the SCP bytes into the sorted call slice and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
func (m *Manager) parse(datasets ...[]byte) int {
|
||||
seen := make(map[string]struct{}, 1<<17)
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the master file is one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
if !plausibleCall(line) {
|
||||
for _, data := range datasets {
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the files are one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
if !plausibleCall(line) {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -190,6 +190,8 @@ var Columns = []Column{
|
||||
{"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 }},
|
||||
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
|
||||
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
|
||||
{"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 }},
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.4"
|
||||
appVersion = "0.27.5"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user