Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 |
@@ -10249,14 +10249,26 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
|||||||
}
|
}
|
||||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||||
if !ok {
|
if !ok {
|
||||||
// No exception COVERS this QSO's date. If the call nonetheless HAS a
|
// No exception covers this QSO's date, so the question becomes which
|
||||||
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
|
// country file knows the PREFIX better.
|
||||||
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older
|
//
|
||||||
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 →
|
// ClubLog's prefix table is date-ranged and cty.dat's is not, and that
|
||||||
// England for a 2012 contact). Ordinary calls (no exception) are left to
|
// is not a detail: cty.dat describes the world as it is TODAY. Niue
|
||||||
// cty.dat.
|
// moved to E6, so ZK2 went back to New Zealand there — and every ZK2
|
||||||
if a.clublog.HasException(q.Callsign) {
|
// contact ever made was silently relabelled New Zealand, taking a
|
||||||
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
// confirmed entity out of an operator's DXCC with it. ZK1 loses the
|
||||||
|
// Cook Islands the same way. ClubLog still knows both.
|
||||||
|
//
|
||||||
|
// It only speaks when it HAS an answer (E6, TO5A and their like are not
|
||||||
|
// in its prefix table at all), and never over an exact "=CALLSIGN" entry
|
||||||
|
// in cty.dat: that is somebody having looked at this very callsign, and
|
||||||
|
// a prefix rule does not overrule it.
|
||||||
|
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
||||||
|
ctyExact := false
|
||||||
|
if m, mok := a.dxcc.Lookup(q.Callsign); mok {
|
||||||
|
ctyExact = m.Exact
|
||||||
|
}
|
||||||
|
if !ctyExact {
|
||||||
e, ok = pe, true
|
e, ok = pe, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11719,7 +11731,14 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
|||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
svc := extsvc.Service(service)
|
svc := extsvc.Service(service)
|
||||||
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
|
// Cloudlog/Wavelog has no sent-status column ON PURPOSE — it dedupes
|
||||||
|
// server-side, which is exactly why an explicit selection needs no column to
|
||||||
|
// be safe. HAMLOG.online and HamQTH keep theirs in ADIF extras.
|
||||||
|
if svc == extsvc.ServiceHamlog {
|
||||||
|
return extsvc.ErrHamlogClosed
|
||||||
|
}
|
||||||
|
if uploadColumnFor(service) == "" &&
|
||||||
|
svc != extsvc.ServiceHamQTH && svc != extsvc.ServiceCloudlog {
|
||||||
return fmt.Errorf("unknown service %q", service)
|
return fmt.Errorf("unknown service %q", service)
|
||||||
}
|
}
|
||||||
cfg := a.loadExternalServices()
|
cfg := a.loadExternalServices()
|
||||||
@@ -11943,7 +11962,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
|
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH || svc == extsvc.ServiceCloudlog {
|
||||||
// One record per request, extras-stamped services. Hamlog used to fall
|
// One record per request, extras-stamped services. Hamlog used to fall
|
||||||
// through to the QRZ branch below and got uploaded to the WRONG service
|
// through to the QRZ branch below and got uploaded to the WRONG service
|
||||||
// with the QRZ key — the context menu offered what the loop never handled.
|
// with the QRZ key — the context menu offered what the loop never handled.
|
||||||
@@ -11963,10 +11982,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
var res extsvc.UploadResult
|
var res extsvc.UploadResult
|
||||||
var err error
|
var err error
|
||||||
if svc == extsvc.ServiceHamlog {
|
switch svc {
|
||||||
|
case extsvc.ServiceHamlog:
|
||||||
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
|
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
|
||||||
} else {
|
case extsvc.ServiceHamQTH:
|
||||||
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
|
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
|
||||||
|
default:
|
||||||
|
res, err = extsvc.UploadCloudlog(ctx, nil, cfg.Cloudlog, rec)
|
||||||
}
|
}
|
||||||
if err == nil && res.OK {
|
if err == nil && res.OK {
|
||||||
a.markExtUploaded(svc, id, res.LogID)
|
a.markExtUploaded(svc, id, res.LogID)
|
||||||
@@ -12022,6 +12044,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||||
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||||
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
|
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
|
||||||
|
extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
|
||||||
}[svc]
|
}[svc]
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = string(svc)
|
label = string(svc)
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.11",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operator’s DXCC with it. ZK1 lost the Cook Islands the same way. ClubLog’s prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
|
||||||
|
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.com’s per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operator’s real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
|
||||||
|
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
|
||||||
|
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
|
||||||
|
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel qu’il est AUJOURD’HUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de l’opérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès qu’aucune exception par indicatif ne s’applique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle n’a pas de réponse c’est cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
|
||||||
|
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QU’UNE paire représentative par entité, et OpsLog l’imposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). C’est un crédit WAZ pour une zone jamais travaillée. L’entité vient toujours de cty.dat, qui fait autorité sur ce qu’un indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
|
||||||
|
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook l’a rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec l’apparence de ce qu’a dit la page — c’est ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
|
||||||
|
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu d’envoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut d’envoi par QSO — volontairement, puisqu’il dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite n’a besoin d’aucun statut pour être sûre : c’est précisément pourquoi cette absence cesse de compter ici.",
|
||||||
|
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface d’envoi n’accepte rien d’autre : la case d’envoi automatique et l’entrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations s’importent toujours depuis un fichier (ce qui n’a jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code d’envoi est conservé intact pour le jour où les clés reviendraient."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.10",
|
"version": "0.27.10",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||||
|
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||||
|
//
|
||||||
|
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||||
|
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||||
|
// reason for enabling its country file.
|
||||||
|
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||||
|
dir := filepath.Join("build", "bin", "data")
|
||||||
|
dm := dxcc.NewManager(dir)
|
||||||
|
if err := dm.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
cm := clublog.NewManager("", dir)
|
||||||
|
if err := cm.EnsureLoaded(); err != nil {
|
||||||
|
t.Skipf("ClubLog country file not available here: %v", err)
|
||||||
|
}
|
||||||
|
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||||
|
|
||||||
|
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
want int // ADIF entity
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"ZK2KK", 188, "Niue — the reported case"},
|
||||||
|
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||||
|
if !a.applyClublogException(&q, true) {
|
||||||
|
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.DXCC == nil || *q.DXCC != c.want {
|
||||||
|
got := 0
|
||||||
|
if q.DXCC != nil {
|
||||||
|
got = *q.DXCC
|
||||||
|
}
|
||||||
|
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||||
|
// rather than blanked: silence is not an answer.
|
||||||
|
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||||
|
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||||
|
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
|
||||||
{ service: 'hamqth', name: 'HamQTH' },
|
{ service: 'hamqth', name: 'HamQTH' },
|
||||||
|
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
|
|||||||
@@ -6422,11 +6422,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{/* The site stopped issuing API keys and its upload API takes
|
||||||
<Checkbox
|
nothing else, so an auto-upload switch here would arm something
|
||||||
checked={hamlog.auto_upload}
|
that can only fail. Their confirmations still arrive as a file,
|
||||||
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })}
|
which never needed a key. */}
|
||||||
/>
|
<p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
|
||||||
|
{t('es.hamlogClosed')}
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
|
||||||
|
<Checkbox checked={false} disabled />
|
||||||
{t('es.autoUpload')}
|
{t('es.autoUpload')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ const en: Dict = {
|
|||||||
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
||||||
'cat.ubOk': 'Connected — the antenna responded with a status frame.',
|
'cat.ubOk': 'Connected — the antenna responded with a status frame.',
|
||||||
// External services (repeated labels)
|
// External services (repeated labels)
|
||||||
'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogClosed': 'HAMLOG.online no longer issues API keys, so uploading is not possible. Their confirmations can still be imported from a file: QSL Manager → HAMLOG.online → Import confirmations.', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||||
// External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs
|
// External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs
|
||||||
'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign",
|
'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign",
|
||||||
'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password',
|
'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password',
|
||||||
@@ -900,7 +900,7 @@ const fr: Dict = {
|
|||||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||||
'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.",
|
'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.",
|
||||||
'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez l’indicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez l’indicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogClosed': 'HAMLOG.online ne délivre plus de clé API : l’envoi n’est donc pas possible. Leurs confirmations restent importables depuis un fichier : QSL Manager → HAMLOG.online → Importer les confirmations.', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||||
// Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA
|
// Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA
|
||||||
'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif",
|
'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif",
|
||||||
'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log',
|
'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log',
|
||||||
|
|||||||
+11
-1
@@ -40,6 +40,14 @@ type Match struct {
|
|||||||
Continent string `json:"continent"`
|
Continent string `json:"continent"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
|
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
|
||||||
|
//
|
||||||
|
// The two carry very different authority. A prefix match is a rule of thumb
|
||||||
|
// about a block of callsigns; an exact entry is somebody having looked at
|
||||||
|
// THIS callsign and written down where it was. A second country file may
|
||||||
|
// improve on the first kind and should not be allowed to overrule the
|
||||||
|
// second.
|
||||||
|
Exact bool `json:"exact,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type prefixEntry struct {
|
type prefixEntry struct {
|
||||||
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
|
|||||||
return Match{}, false
|
return Match{}, false
|
||||||
}
|
}
|
||||||
if e, ok := db.exact[call]; ok {
|
if e, ok := db.exact[call]; ok {
|
||||||
return materialize(e), true
|
m := materialize(e)
|
||||||
|
m.Exact = true
|
||||||
|
return m, true
|
||||||
}
|
}
|
||||||
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
||||||
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
|
|||||||
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrHamlogClosed is why nothing is sent to HAMLOG.online any more.
|
||||||
|
//
|
||||||
|
// The site stopped issuing API keys, and the upload API takes nothing else. An
|
||||||
|
// operator without a key cannot obtain one, and one WITH an old key is the
|
||||||
|
// exception this cannot be built around — so the door is closed here rather
|
||||||
|
// than left ajar for a request that can only fail.
|
||||||
|
//
|
||||||
|
// The code stays: their confirmations still arrive as an ADIF FILE (QSL Manager
|
||||||
|
// → HAMLOG.online → Import confirmations), which never needed a key, and the
|
||||||
|
// sent/received state already in operators' logs stays readable, filterable and
|
||||||
|
// bulk-editable.
|
||||||
|
var ErrHamlogClosed = errors.New("hamlog: HAMLOG.online no longer issues API keys, so uploading is not possible — their confirmations can still be imported from a file")
|
||||||
|
|
||||||
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
return UploadResult{}, ErrHamlogClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadHamlogLive is the upload as it was, kept whole against the day keys
|
||||||
|
// come back. Nothing calls it.
|
||||||
|
func uploadHamlogLive(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
key := strings.TrimSpace(cfg.APIKey)
|
key := strings.TrimSpace(cfg.APIKey)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(tc.body))
|
_, _ = w.Write([]byte(tc.body))
|
||||||
}))
|
}))
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
||||||
srv.Close()
|
srv.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: %v", tc.body, err)
|
t.Fatalf("%s: %v", tc.body, err)
|
||||||
@@ -80,8 +81,21 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
|
|
||||||
// Nothing leaves without a key, and the message says where to get one.
|
// Nothing leaves without a key, and the message says where to get one.
|
||||||
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
||||||
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>")
|
_, err := uploadHamlogLive(context.Background(), nil, "http://example.invalid", ServiceConfig{}, "<eor>")
|
||||||
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
||||||
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The door is shut: HAMLOG.online stopped issuing API keys, so an upload that
|
||||||
|
// could only fail is refused before it is attempted. The request-shape tests
|
||||||
|
// above still cover the code kept against the day keys come back.
|
||||||
|
func TestUploadHamlogIsClosed(t *testing.T) {
|
||||||
|
res, err := UploadHamlog(context.Background(), nil, ServiceConfig{APIKey: "KEY123"}, "<eor>")
|
||||||
|
if !errors.Is(err, ErrHamlogClosed) {
|
||||||
|
t.Fatalf("err = %v, want ErrHamlogClosed", err)
|
||||||
|
}
|
||||||
|
if res.OK {
|
||||||
|
t.Error("a refused upload reported success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-15
@@ -84,9 +84,10 @@ type Deps struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
|
hamlogClosedOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
@@ -230,13 +231,13 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
m.route(ServiceCloudlog, id, c)
|
m.route(ServiceCloudlog, id, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HAMLOG.online — one API key and nothing else to get wrong.
|
// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
|
||||||
if h := cfg.Hamlog; h.AutoUpload {
|
// session rather than per QSO, because an operator who left the switch on
|
||||||
if h.APIKey == "" {
|
// deserves to know why nothing leaves, and does not deserve it every minute.
|
||||||
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
|
if cfg.Hamlog.AutoUpload {
|
||||||
} else {
|
m.hamlogClosedOnce.Do(func() {
|
||||||
m.route(ServiceHamlog, id, h)
|
m.logf("extsvc: %v", ErrHamlogClosed)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
// HamQTH — the callbook credentials double as the logbook login.
|
// HamQTH — the callbook credentials double as the logbook login.
|
||||||
if h := cfg.HamQTH; h.AutoUpload {
|
if h := cfg.HamQTH; h.AutoUpload {
|
||||||
@@ -296,9 +297,7 @@ func (m *Manager) onCloseServices() []Service {
|
|||||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||||
out = append(out, ServiceCloudlog)
|
out = append(out, ServiceCloudlog)
|
||||||
}
|
}
|
||||||
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
|
|
||||||
out = append(out, ServiceHamlog)
|
|
||||||
}
|
|
||||||
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
|
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
|
||||||
out = append(out, ServiceHamQTH)
|
out = append(out, ServiceHamQTH)
|
||||||
}
|
}
|
||||||
@@ -348,8 +347,7 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||||
case ServiceCloudlog:
|
case ServiceCloudlog:
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||||
case ServiceHamlog:
|
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
|
|
||||||
case ServiceHamQTH:
|
case ServiceHamQTH:
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
|
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
r.Callsign = call
|
r.Callsign = call
|
||||||
r.Source = p.Name()
|
r.Source = p.Name()
|
||||||
r.FetchedAt = time.Now().UTC()
|
r.FetchedAt = time.Now().UTC()
|
||||||
fillFromDXCC(&r, dxcc)
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
|
// Cached BEFORE the cty.dat pass, so the row is a copy of the
|
||||||
|
// callbook page rather than of our conclusions about it. Every
|
||||||
|
// read runs the pass again (see the cache-hit path above), so a
|
||||||
|
// later cty.dat update reaches old rows — and a value we derived
|
||||||
|
// can never come back looking like something the page said.
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r)
|
||||||
|
fillFromDXCC(&r, dxcc)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
if !saysNothingAboutLocation(call) {
|
if !saysNothingAboutLocation(call) {
|
||||||
clearHomeLocation(&r)
|
clearHomeLocation(&r)
|
||||||
}
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
|
||||||
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,11 +446,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
r.Continent = cont
|
r.Continent = cont
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if cqz != 0 {
|
// Zones FILL, they do not override.
|
||||||
|
//
|
||||||
|
// The rule above is right for the country and wrong for the zones, because
|
||||||
|
// they answer different questions. An entity is what a callsign IS, and
|
||||||
|
// cty.dat is the authority on that. A zone is where the station SITS, and a
|
||||||
|
// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
|
||||||
|
// and cty.dat carries one representative pair for the whole country. Stamping
|
||||||
|
// it on every UA0 threw away the callbook's per-station answer and recorded a
|
||||||
|
// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
|
||||||
|
// and was logged 17/30.
|
||||||
|
//
|
||||||
|
// So the callbook wins where it spoke, and cty.dat fills the silence.
|
||||||
|
if cqz != 0 && r.CQZ == 0 {
|
||||||
r.CQZ = cqz
|
r.CQZ = cqz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if ituz != 0 {
|
if ituz != 0 && r.ITUZ == 0 {
|
||||||
r.ITUZ = ituz
|
r.ITUZ = ituz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// asiaticRussia stands in for cty.dat: one representative zone pair for a
|
||||||
|
// country eight CQ zones wide.
|
||||||
|
func asiaticRussia(cqz, ituz int) fakeDXCC {
|
||||||
|
return fakeDXCC{"RU0LL": {num: 15, country: "Asiatic Russia", cont: "AS", cqz: cqz, ituz: ituz}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone is where the station SITS; an entity is what the callsign IS. Asiatic
|
||||||
|
// Russia spans CQ 16-23 and ITU 20-34, and cty.dat carries one representative
|
||||||
|
// pair for the whole country — so stamping it over a callbook's per-station
|
||||||
|
// answer records a WAZ credit for a zone the operator never worked.
|
||||||
|
//
|
||||||
|
// Reported with real callsigns: RU0LL is CQ 19 / ITU 34 and was logged 17/30.
|
||||||
|
func TestCallbookZonesSurviveTheEntityDefault(t *testing.T) {
|
||||||
|
// What QRZ said about this very station.
|
||||||
|
r := Result{Callsign: "RU0LL", CQZ: 19, ITUZ: 34}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 19 || r.ITUZ != 34 {
|
||||||
|
t.Errorf("callbook zones were overwritten: CQ%d ITU%d, want CQ19 ITU34", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
if r.Country != "Asiatic Russia" {
|
||||||
|
t.Errorf("the ENTITY must still come from cty.dat, got %q", r.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityZonesFillSilence(t *testing.T) {
|
||||||
|
// A callbook that says nothing about zones still gets an answer.
|
||||||
|
r := Result{Callsign: "RU0LL"}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 17 || r.ITUZ != 30 {
|
||||||
|
t.Errorf("empty zones were not filled: CQ%d ITU%d", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user