mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-12 04:05:34 +02:00
Compare commits
19
Commits
6b29ffe3ba
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914a06cb7f | ||
|
|
b23884bde1 | ||
|
|
4ad1cf71dd
|
||
|
|
ad212e3e71 | ||
|
|
08d65a0e23 | ||
|
|
edf71b4105
|
||
|
|
885bf83c2f
|
||
|
|
634b88238d
|
||
|
|
830e4020a2 | ||
|
|
7ee50267ec | ||
|
|
113e843111 | ||
|
|
79161d2afa | ||
|
|
3ed1cad5ab | ||
|
|
75fe45b50b | ||
|
|
53555cbe69 | ||
|
|
595fb84362 | ||
|
|
9eb0550106 | ||
|
|
51aa04bfb5 | ||
|
|
6b0d699a98 |
@@ -551,6 +551,9 @@ jobs:
|
|||||||
name: Publish Flatpak OSTree Repo (nightly)
|
name: Publish Flatpak OSTree Repo (nightly)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-flatpak
|
needs: build-flatpak
|
||||||
|
concurrency:
|
||||||
|
group: flatpak-repo-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
|
|||||||
@@ -646,6 +646,9 @@ jobs:
|
|||||||
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-flatpak
|
needs: build-flatpak
|
||||||
|
concurrency:
|
||||||
|
group: flatpak-repo-publish
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install Flatpak tooling
|
- name: Install Flatpak tooling
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
From afb77c6a8674ff5571a9c57cbe7108b9a5189407 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Codex <codex@openai.com>
|
||||||
|
Date: Fri, 4 Sep 2026 00:17:15 +0300
|
||||||
|
Subject: [PATCH] Document v1.43 and fix release metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
.github/workflows/tagged-release.yml | 84 +++++++++----------
|
||||||
|
docs/PROJECT_CONTEXT.md | 4 +
|
||||||
|
github_docs/de-Changelog.md | 80 +++++++++++++++---
|
||||||
|
github_docs/en-Changelog.md | 80 +++++++++++++++---
|
||||||
|
.../kst4contest/ApplicationConstants.java | 2 +-
|
||||||
|
website/src/news/2026-09-03-version-1-43.md | 29 +++++++
|
||||||
|
6 files changed, 212 insertions(+), 67 deletions(-)
|
||||||
|
create mode 100644 website/src/news/2026-09-03-version-1-43.md
|
||||||
|
|
||||||
|
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
|
||||||
|
index d9133550..be7bf536 100644
|
||||||
|
--- a/.github/workflows/tagged-release.yml
|
||||||
|
+++ b/.github/workflows/tagged-release.yml
|
||||||
|
@@ -786,45 +786,45 @@ jobs:
|
||||||
|
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
||||||
|
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
||||||
|
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||||
|
-
|
||||||
|
- # The update feed is generated only after GitHub has published the
|
||||||
|
- # release. Otherwise the Releases API cannot return the release notes
|
||||||
|
- # belonging to the tag which triggered this workflow.
|
||||||
|
- - name: Set up Node.js for website build
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
- with:
|
||||||
|
- node-version: "24"
|
||||||
|
- cache: npm
|
||||||
|
- cache-dependency-path: website/package-lock.json
|
||||||
|
-
|
||||||
|
- - name: Build and validate website after release publication
|
||||||
|
- working-directory: website
|
||||||
|
- run: |
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
- npm run build
|
||||||
|
- env:
|
||||||
|
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
- - name: Verify Stable release in update feed
|
||||||
|
- if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
- working-directory: website
|
||||||
|
- run: npm run validate:version-info
|
||||||
|
- env:
|
||||||
|
- EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
|
||||||
|
-
|
||||||
|
- - name: Attach verified version info to tagged release
|
||||||
|
- run: >-
|
||||||
|
- gh release upload "${GITHUB_REF_NAME}"
|
||||||
|
- website/_site/kst4ContestVersionInfo.xml
|
||||||
|
- --clobber
|
||||||
|
- env:
|
||||||
|
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
- - name: Upload verified website artifact
|
||||||
|
- uses: actions/upload-artifact@v4.3.4
|
||||||
|
- with:
|
||||||
|
- name: kst4contest-website-${{ github.ref_name }}
|
||||||
|
- path: website/_site/
|
||||||
|
- if-no-files-found: error
|
||||||
|
- retention-days: 14
|
||||||
|
\ No newline at end of file
|
||||||
|
+
|
||||||
|
+ # The update feed is generated only after GitHub has published the
|
||||||
|
+ # release. Otherwise the Releases API cannot return the release notes
|
||||||
|
+ # belonging to the tag which triggered this workflow.
|
||||||
|
+ - name: Set up Node.js for website build
|
||||||
|
+ uses: actions/setup-node@v4
|
||||||
|
+ with:
|
||||||
|
+ node-version: "24"
|
||||||
|
+ cache: npm
|
||||||
|
+ cache-dependency-path: website/package-lock.json
|
||||||
|
+
|
||||||
|
+ - name: Build and validate website after release publication
|
||||||
|
+ working-directory: website
|
||||||
|
+ run: |
|
||||||
|
+ npm ci
|
||||||
|
+ npm test
|
||||||
|
+ npm run build
|
||||||
|
+ env:
|
||||||
|
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
+
|
||||||
|
+ - name: Verify Stable release in update feed
|
||||||
|
+ if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
+ working-directory: website
|
||||||
|
+ run: npm run validate:version-info
|
||||||
|
+ env:
|
||||||
|
+ EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
|
||||||
|
+
|
||||||
|
+ - name: Attach verified version info to tagged release
|
||||||
|
+ run: >-
|
||||||
|
+ gh release upload "${GITHUB_REF_NAME}"
|
||||||
|
+ website/_site/kst4ContestVersionInfo.xml
|
||||||
|
+ --clobber
|
||||||
|
+ env:
|
||||||
|
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
+
|
||||||
|
+ - name: Upload verified website artifact
|
||||||
|
+ uses: actions/upload-artifact@v4.3.4
|
||||||
|
+ with:
|
||||||
|
+ name: kst4contest-website-${{ github.ref_name }}
|
||||||
|
+ path: website/_site/
|
||||||
|
+ if-no-files-found: error
|
||||||
|
+ retention-days: 14
|
||||||
|
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
|
||||||
|
index b0ca4b63..c4d70f1f 100644
|
||||||
|
--- a/docs/PROJECT_CONTEXT.md
|
||||||
|
+++ b/docs/PROJECT_CONTEXT.md
|
||||||
|
@@ -162,6 +162,10 @@ The repository contains the KST4Contest website under `website/`, published sepa
|
||||||
|
|
||||||
|
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
|
||||||
|
|
||||||
|
+- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
|
||||||
|
+- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
|
||||||
|
+- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
|
||||||
|
+
|
||||||
|
## Important Decisions and Workarounds
|
||||||
|
|
||||||
|
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
|
||||||
|
diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md
|
||||||
|
index 2022f904..622bb446 100644
|
||||||
|
--- a/github_docs/de-Changelog.md
|
||||||
|
+++ b/github_docs/de-Changelog.md
|
||||||
|
@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
+## v1.43.1 (2026-09-03)
|
||||||
|
+
|
||||||
|
+**Korrigierte Versionsmetadaten**
|
||||||
|
+
|
||||||
|
+v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus.
|
||||||
|
+
|
||||||
|
+### Behoben
|
||||||
|
+
|
||||||
|
+- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten.
|
||||||
|
+
|
||||||
|
+- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen.
|
||||||
|
+
|
||||||
|
+Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
|
||||||
|
+
|
||||||
|
+Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## v1.43.0 (2026-09-03)
|
||||||
|
+
|
||||||
|
+**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität**
|
||||||
|
+
|
||||||
|
+v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte.
|
||||||
|
+
|
||||||
|
+### Neu
|
||||||
|
+
|
||||||
|
+- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
|
||||||
|
+
|
||||||
|
+- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
|
||||||
|
+
|
||||||
|
+- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt.
|
||||||
|
+
|
||||||
|
+- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs.
|
||||||
|
+
|
||||||
|
+### Geändert
|
||||||
|
+
|
||||||
|
+- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr.
|
||||||
|
+
|
||||||
|
+- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt.
|
||||||
|
+
|
||||||
|
+- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
|
||||||
|
+
|
||||||
|
+- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben.
|
||||||
|
+
|
||||||
|
+- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
|
||||||
|
+
|
||||||
|
+- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung.
|
||||||
|
+
|
||||||
|
+### Behoben
|
||||||
|
+
|
||||||
|
+- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben.
|
||||||
|
+
|
||||||
|
+- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
|
||||||
|
+
|
||||||
|
+### Dokumentation und Auslieferung
|
||||||
|
+
|
||||||
|
+- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert.
|
||||||
|
+
|
||||||
|
+- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher.
|
||||||
|
+
|
||||||
|
+- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
|
||||||
|
+
|
||||||
|
+Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
## v1.42.0 (2026-08-22)
|
||||||
|
|
||||||
|
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
|
||||||
|
@@ -38,21 +104,17 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||||
|
|
||||||
|
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
|
||||||
|
|
||||||
|
-- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
|
||||||
|
+- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
|
||||||
|
|
||||||
|
- **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
-- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen.
|
||||||
|
-
|
||||||
|
-- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter.
|
||||||
|
-
|
||||||
|
- **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus.
|
||||||
|
|
||||||
|
- **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden.
|
||||||
|
|
||||||
|
-- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
|
||||||
|
+- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
|
||||||
|
|
||||||
|
- **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet.
|
||||||
|
|
||||||
|
@@ -86,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||||
|
|
||||||
|
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
|
||||||
|
|
||||||
|
-- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status.
|
||||||
|
-
|
||||||
|
-- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden.
|
||||||
|
-
|
||||||
|
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
|
||||||
|
|
||||||
|
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
|
||||||
|
@@ -98,8 +156,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||||
|
|
||||||
|
- **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht.
|
||||||
|
|
||||||
|
-- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens.
|
||||||
|
-
|
||||||
|
- **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird.
|
||||||
|
|
||||||
|
- **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt.
|
||||||
|
diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md
|
||||||
|
index 1635ce44..af1cb47b 100644
|
||||||
|
--- a/github_docs/en-Changelog.md
|
||||||
|
+++ b/github_docs/en-Changelog.md
|
||||||
|
@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
+## v1.43.1 (2026-09-03)
|
||||||
|
+
|
||||||
|
+**Corrected version metadata**
|
||||||
|
+
|
||||||
|
+v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42.
|
||||||
|
+
|
||||||
|
+### Fixed
|
||||||
|
+
|
||||||
|
+- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds.
|
||||||
|
+
|
||||||
|
+- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped.
|
||||||
|
+
|
||||||
|
+Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
|
||||||
|
+
|
||||||
|
+The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## v1.43.0 (2026-09-03)
|
||||||
|
+
|
||||||
|
+**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility**
|
||||||
|
+
|
||||||
|
+v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map.
|
||||||
|
+
|
||||||
|
+### Added
|
||||||
|
+
|
||||||
|
+- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
|
||||||
|
+
|
||||||
|
+- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
|
||||||
|
+
|
||||||
|
+- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links.
|
||||||
|
+
|
||||||
|
+- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section.
|
||||||
|
+
|
||||||
|
+### Changed
|
||||||
|
+
|
||||||
|
+- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task.
|
||||||
|
+
|
||||||
|
+- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state.
|
||||||
|
+
|
||||||
|
+- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
|
||||||
|
+
|
||||||
|
+- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86).
|
||||||
|
+
|
||||||
|
+- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
|
||||||
|
+
|
||||||
|
+- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight.
|
||||||
|
+
|
||||||
|
+### Fixed
|
||||||
|
+
|
||||||
|
+- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85).
|
||||||
|
+
|
||||||
|
+- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
|
||||||
|
+
|
||||||
|
+### Documentation and packaging
|
||||||
|
+
|
||||||
|
+- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified.
|
||||||
|
+
|
||||||
|
+- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail.
|
||||||
|
+
|
||||||
|
+- The AUR package definitions were updated for v1.43.0.
|
||||||
|
+
|
||||||
|
+The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
## v1.42.0 (2026-08-22)
|
||||||
|
|
||||||
|
**Shared band context, session-based ON4KST connection and signed macOS packages**
|
||||||
|
@@ -38,21 +104,17 @@ v1.42 brings several previously separate calculations together. Band information
|
||||||
|
|
||||||
|
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
|
||||||
|
|
||||||
|
-- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
|
||||||
|
+- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
|
||||||
|
|
||||||
|
- **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
-- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links.
|
||||||
|
-
|
||||||
|
-- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact.
|
||||||
|
-
|
||||||
|
- **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
|
||||||
|
|
||||||
|
- **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
|
||||||
|
|
||||||
|
-- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
|
||||||
|
+- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
|
||||||
|
|
||||||
|
- **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable.
|
||||||
|
|
||||||
|
@@ -86,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information
|
||||||
|
|
||||||
|
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
|
||||||
|
|
||||||
|
-- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status.
|
||||||
|
-
|
||||||
|
-- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation.
|
||||||
|
-
|
||||||
|
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
|
||||||
|
|
||||||
|
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
|
||||||
|
@@ -98,8 +156,6 @@ v1.42 brings several previously separate calculations together. Band information
|
||||||
|
|
||||||
|
- **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received.
|
||||||
|
|
||||||
|
-- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign.
|
||||||
|
-
|
||||||
|
- **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot.
|
||||||
|
|
||||||
|
- **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably.
|
||||||
|
diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java
|
||||||
|
index 6da42ff4..71f64575 100644
|
||||||
|
--- a/src/main/java/kst4contest/ApplicationConstants.java
|
||||||
|
+++ b/src/main/java/kst4contest/ApplicationConstants.java
|
||||||
|
@@ -20,7 +20,7 @@ public class ApplicationConstants {
|
||||||
|
/**
|
||||||
|
* Version shown to the user and used for semantic version comparison.
|
||||||
|
*/
|
||||||
|
- public static final String APPLICATION_CURRENT_VERSION = "1.431";
|
||||||
|
+ public static final String APPLICATION_CURRENT_VERSION = "1.43.1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy numeric representation used only while older update feeds and
|
||||||
|
diff --git a/website/src/news/2026-09-03-version-1-43.md b/website/src/news/2026-09-03-version-1-43.md
|
||||||
|
new file mode 100644
|
||||||
|
index 00000000..30754c3d
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/website/src/news/2026-09-03-version-1-43.md
|
||||||
|
@@ -0,0 +1,29 @@
|
||||||
|
+---
|
||||||
|
+title: Version 1.43.1 released
|
||||||
|
+summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility
|
||||||
|
+date: 2026-09-03
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Version 1.43.1
|
||||||
|
+
|
||||||
|
+Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software.
|
||||||
|
+
|
||||||
|
+The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead.
|
||||||
|
+
|
||||||
|
+### What changed
|
||||||
|
+
|
||||||
|
+- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands.
|
||||||
|
+- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state.
|
||||||
|
+- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect.
|
||||||
|
+- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions.
|
||||||
|
+- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip.
|
||||||
|
+- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station.
|
||||||
|
+- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows.
|
||||||
|
+
|
||||||
|
+The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
|
||||||
|
+
|
||||||
|
+### Getting it
|
||||||
|
+
|
||||||
|
+Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow.
|
||||||
|
+
|
||||||
|
+The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73
|
||||||
|
--
|
||||||
|
2.51.1
|
||||||
|
|
||||||
+56
-1
@@ -1,6 +1,6 @@
|
|||||||
# KST4Contest Project Context
|
# KST4Contest Project Context
|
||||||
|
|
||||||
Last reviewed: 2026-08-28
|
Last reviewed: 2026-09-11
|
||||||
|
|
||||||
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
|
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m
|
|||||||
- `NOT-QRV` overrides positive inferred band-availability hints.
|
- `NOT-QRV` overrides positive inferred band-availability hints.
|
||||||
- Unknown/missing frequency, QRB, QTF or similar external data must remain unavailable rather than becoming a fabricated zero/default.
|
- Unknown/missing frequency, QRB, QTF or similar external data must remain unavailable rather than becoming a fabricated zero/default.
|
||||||
- Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default.
|
- Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default.
|
||||||
|
- Complete digit-only frequencies use their final three digits as the kHz part and are accepted only when the resulting MHz value lies within a supported `Band` range. The same full-frequency parser is used for station names and public or directed chat messages. Relative QRG rules and bare three-digit context handling remain separate.
|
||||||
|
|
||||||
### JavaFX/threading
|
### JavaFX/threading
|
||||||
|
|
||||||
@@ -49,6 +50,17 @@ JavaFX ObservableList / UI state
|
|||||||
|
|
||||||
`MessageBusManagementThread` must not directly iterate or mutate UI-bound JavaFX collections. UI-visible changes should cross the controller/UI boundary and run on the JavaFX Application Thread.
|
`MessageBusManagementThread` must not directly iterate or mutate UI-bound JavaFX collections. UI-visible changes should cross the controller/UI boundary and run on the JavaFX Application Thread.
|
||||||
|
|
||||||
|
## Configuration and Layout Persistence
|
||||||
|
|
||||||
|
- The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
|
||||||
|
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
|
||||||
|
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
|
||||||
|
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
|
||||||
|
- Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown.
|
||||||
|
- Selective layout writes update the XML already on disk, preserve unknown XML nodes and must not persist unconfirmed functional settings from the current UI. **Save Settings** remains the full settings writer and includes the current layout.
|
||||||
|
- Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing.
|
||||||
|
- Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file.
|
||||||
|
|
||||||
## External Interfaces
|
## External Interfaces
|
||||||
|
|
||||||
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
|
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
|
||||||
@@ -65,6 +77,15 @@ Known integration areas include:
|
|||||||
|
|
||||||
CR/LF framing, XML framing, ports/transports, callsign normalization and frequency formatting are protocol behaviour and must not be changed as incidental cleanup.
|
CR/LF framing, XML framing, ports/transports, callsign normalization and frequency formatting are protocol behaviour and must not be changed as incidental cleanup.
|
||||||
|
|
||||||
|
### Local DX Cluster output
|
||||||
|
|
||||||
|
- Local spots use a fixed 75-character, DXSpider-compatible payload line followed by two BEL characters and CRLF.
|
||||||
|
- The DX callsign begins in column 27 and occupies up to 12 characters. The 30-character comment begins in column 40, and the five-character UTC time begins in column 71.
|
||||||
|
- Spotter and frequency padding is calculated dynamically so frequencies from 50 MHz through 24 GHz do not shift the following fields.
|
||||||
|
- Comments are padded or truncated to exactly 30 characters. Automatic AirScout comments retain the locator first and use the compact form `JO51HK AP 1m/100%;4m/75%`.
|
||||||
|
- A DX callsign longer than 12 characters is rejected and logged rather than truncated.
|
||||||
|
- Trigger conditions, QRG recognition and normalisation, login, keepalive, multi-client delivery and the local-only trust boundary remain separate from line formatting.
|
||||||
|
|
||||||
### Logging and Worked-state persistence
|
### Logging and Worked-state persistence
|
||||||
|
|
||||||
- The Simplelogfile interpreter reads the selected text file after connection startup and then once per minute using a fixed built-in callsign pattern.
|
- The Simplelogfile interpreter reads the selected text file after connection startup and then once per minute using a fixed built-in callsign pattern.
|
||||||
@@ -73,12 +94,23 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
|||||||
- The interpreter only adds positive runtime marks. It does not remove existing marks during the current session and does not reset automatically when a new contest starts. A database reset does not modify the file; callsigns contained in it are marked as worked again during the next periodic evaluation.
|
- The interpreter only adds positive runtime marks. It does not remove existing marks during the current session and does not reset automatically when a new contest starts. A database reset does not modify the file; callsigns contained in it are marked as worked again during the next periodic evaluation.
|
||||||
- A missing selected file is created. Read, path and creation failures are contained so the periodic timer remains alive; successful creation triggers a one-time, non-blocking UI notice with the exact path and setup/contest checks.
|
- A missing selected file is created. Read, path and creation failures are contained so the periodic timer remains alive; successful creation triggers a one-time, non-blocking UI notice with the exact path and setup/contest checks.
|
||||||
- Network-derived and manually assigned Worked, NOT-QRV and worked-grid state continues to use SQLite with its established lifetime and reset behaviour.
|
- Network-derived and manually assigned Worked, NOT-QRV and worked-grid state continues to use SQLite with its established lifetime and reset behaviour.
|
||||||
|
- Each completed initial ON4KST user list loads one SQLite Worked/NOT-QRV snapshot. `ChatController` applies that snapshot by normalized base callsign to every new category and suffix variant before the completed category is published. The same event-driven path runs again after a reconnect; startup synchronization does not depend on a fixed-delay timer.
|
||||||
- Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG.
|
- Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG.
|
||||||
- UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
|
- UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
|
||||||
- A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
|
- A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
|
||||||
- External logger threads do not read or mutate the JavaFX user-list projection. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice.
|
- External logger threads do not read or mutate the JavaFX user-list projection. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice.
|
||||||
- The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state.
|
- The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state.
|
||||||
|
|
||||||
|
### Win-Test log recovery
|
||||||
|
|
||||||
|
- Win-Test only broadcasts new QSOs. A listener started later never sees the earlier ones, so KST4Contest pulls them with the Win-Test `IHAVE` / `NEEDQSO` protocol, ported from the wtKST `WtLogSync` implementation. The answers are ordinary `ADDQSO` packets and reuse the established Worked path; the recovery itself never touches database or UI.
|
||||||
|
- The recovery is not configurable. It is bound to the existing Win-Test network listener, runs automatically once a station is detected through `HELLO` or `STATUS`, and stays active so gaps caused by lost broadcasts are refetched.
|
||||||
|
- The station-name filter remains a QRG-sync setting. Log recovery covers every station in the network, because each band station of a multi-station setup keeps its own log and contributes per-band Worked state.
|
||||||
|
- A QSO is identified by `StationName@LogUniqueID` plus the Win-Test QSO number. That identity deduplicates the answers of overlapping requests, so a recovered log is written once instead of once per resend.
|
||||||
|
- Win-Test framing must be resolved on the raw datagram bytes: the checksum byte is not valid ASCII and would otherwise corrupt the trailing fields, which carry the log ID of `ADDQSO` and the run-length inventory of `IHAVE`. A broken checksum discards `IHAVE` only; the established handling of the other message types is unchanged and still does not verify checksums.
|
||||||
|
- Win-Test answers broadcasts only; an identical unicast request to the same station stays unanswered (verified against Win-Test). Outgoing Win-Test packets therefore derive their broadcast address from the source address of received Win-Test packets, with the configured address as fallback for a station behind a router. A configured address pointing at a non-existent network raises no send error, so it silently disabled both log recovery and SKED handover before. Only genuine Win-Test message types update that address; internal control packets such as the poison pill must not redirect outgoing traffic.
|
||||||
|
- `IHAVE` inventories are run-length encoded and may be split, so the announced first row is honoured instead of assuming that an inventory starts at QSO number one. A station that never sends a usable inventory is served by a blind block fallback starting at QSO number one.
|
||||||
|
|
||||||
### Terrain data providers
|
### Terrain data providers
|
||||||
|
|
||||||
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
||||||
@@ -105,6 +137,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
|||||||
- Contest operating speed and low-friction interaction are primary goals.
|
- Contest operating speed and low-friction interaction are primary goals.
|
||||||
- Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
|
- Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
|
||||||
- Map reset clears the selected target without changing zoom unless explicitly redesigned.
|
- Map reset clears the selected target without changing zoom unless explicitly redesigned.
|
||||||
|
- **Group nearby stations** re-renders only the existing station-marker layer from JavaScript `stationData`. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
|
||||||
|
- Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
|
||||||
- Station selection preserves the established `/cq callsign` prefill behaviour.
|
- Station selection preserves the established `/cq callsign` prefill behaviour.
|
||||||
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
|
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
|
||||||
|
|
||||||
@@ -138,6 +172,26 @@ The repository contains the KST4Contest website under `website/`, published sepa
|
|||||||
|
|
||||||
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
|
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
|
||||||
|
|
||||||
|
- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
|
||||||
|
- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
|
||||||
|
- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
|
||||||
|
|
||||||
|
### Server-side website statistics
|
||||||
|
|
||||||
|
- The home page can display a public visit total from the same-origin `GET /visitor-count.json` endpoint. The versioned public contract contains only `schemaVersion`, `visits`, `since` and `updatedAt`.
|
||||||
|
- `visits` is the sum of daily approximate unique visits since `since`, based on GoAccess visitor semantics. It is not a count of unique people and remains separate from page views.
|
||||||
|
- The home page validates the complete payload, formats the date and number for `en-GB`, and inserts the result through `textContent`. Missing, timed-out, failed or invalid responses leave the initially hidden element invisible and do not affect the rest of the page.
|
||||||
|
- The display makes no third-party request, sends no credentials and uses no cookies or local storage. The counter request is excluded from the statistic itself.
|
||||||
|
- The counter endpoint disables its own access log and serves the public JSON with a one-hour public cache policy and `X-Content-Type-Options: nosniff`.
|
||||||
|
- GoAccess is the server-side source. A registry keeps stable site IDs, hostnames, current analytics-log paths, activation dates, public-counter switches and output targets separate for each project subdomain. The Country database is `/var/lib/GeoIP/GeoLite2-Country.mmdb`. A combined report uses only the registered project sites; `stats.hamradioonline.de` is excluded.
|
||||||
|
- The regular generator passes each current analytics log and its optional uncompressed `.1` rotation directly to GoAccess and relies on the persistent GoAccess database for incremental processing. Logrotate therefore uses `delaycompress`. Older `.gz` rotations are not imported during regular runs, and missing Zlib support is an accepted, explicitly reported capability state for the GoAccess 1.8.1 production baseline.
|
||||||
|
- GoAccess 1.8.1 exposes the Country panel as `geolocation` in JSON. Combined report jobs explicitly enable `VIRTUAL_HOSTS` and require the resulting `vhosts` panel; individual site jobs do not enable it. Missing required panels invalidate the complete staged run.
|
||||||
|
- Node.js 18.19.1 is the production runtime baseline. `--check` requires readable input files, prepared writable output directories and GoAccess built with GeoIP2/MMDB support. OpenSSL and absent Zlib support remain informational. Dry-runs use temporary state and never acquire the production lock.
|
||||||
|
- The generator runs as `hamradio-analytics`. The state root is mode `0711`; only explicitly prepared report and public-output directories are shared read-only with Nginx through the `www-data` group. GoAccess databases and public counter state remain private. No ACL support is assumed.
|
||||||
|
- The protected statistics vhost is enabled in two stages: an IPv4-only HTTP bootstrap obtains the certificate through `/snap/bin/certbot`, then the final configuration retains an IPv4 HTTP block for the webroot ACME challenge and permanently redirects all other HTTP requests to HTTPS. The HTTPS block uses the existing Certbot TLS options and redirects authenticated requests from `/` to `/combined/`. IPv6 remains disabled until the DNS AAAA record has been confirmed.
|
||||||
|
- Dedicated analytics raw logs are retained for 14 days. IP addresses are anonymised before the detailed GoAccess aggregates are persisted for a rolling 395 days. Separate non-personal daily counter values remain available from activation onward so the public total does not shrink with the detailed retention window.
|
||||||
|
- Repository templates are installed with explicit Unix owners and modes because ZIP metadata created on Windows is not trusted. Production activation, credentials, password hashes, certificate keys, GeoIP acquisition credentials and backups remain outside the repository.
|
||||||
|
|
||||||
## Important Decisions and Workarounds
|
## Important Decisions and Workarounds
|
||||||
|
|
||||||
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
|
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
|
||||||
@@ -163,6 +217,7 @@ Before implementing planned items, re-check current decisions and obtain a fresh
|
|||||||
- Historical project context is useful but may be stale; current code/tests win.
|
- Historical project context is useful but may be stale; current code/tests win.
|
||||||
- External service/API behaviour must be verified against current upstream documentation when uncertain.
|
- External service/API behaviour must be verified against current upstream documentation when uncertain.
|
||||||
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
|
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
|
||||||
|
- `station_map_path_analysis.png` and `station_map_reset.png` predate the **Group nearby stations** checkbox in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
|
||||||
|
|
||||||
## Recent Significant Changes
|
## Recent Significant Changes
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,15 @@ Der Wert beeinflusst unter anderem:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nachrichtentabellen
|
## Tabellenbreiten und gekürzte Zellinhalte
|
||||||
|
|
||||||
|
Beim ersten brauchbaren Datenbestand richtet KST4Contest die Spalten der Benutzerliste, der Nachrichtenansichten, der DXCluster- und QSO-Tabellen sowie der Worked-Datenbank einmalig nach Überschrift und vorhandenem Inhalt aus. Bereits gespeicherte Breiten haben Vorrang. **Name**, **AP** und **NOT QRV @** werden dabei begrenzt, damit einzelne lange Werte nicht den restlichen Tabellenbereich verdrängen. **Message** und vergleichbare Freitextspalten bleiben flexibel und richten sich nicht nach der längsten Nachricht.
|
||||||
|
|
||||||
|
Manuell geänderte Spaltenbreiten werden automatisch gespeichert und beim nächsten Start wiederhergestellt. Spätere Nachrichten oder Stationsaktualisierungen überschreiben diese Auswahl nicht.
|
||||||
|
|
||||||
|
Passt ein normaler Textwert nicht vollständig in seine Zelle, zeigt ein Tooltip den vollständigen Wert. Der Tooltip erscheint nur bei tatsächlich gekürztem Text. Funktionale Tooltips, etwa für QRA-, Worked- oder Bandzustände, bleiben erhalten; bei gekürztem Zelltext stehen Volltext und Erklärung gemeinsam im Tooltip.
|
||||||
|
|
||||||
|
### Nachrichtentext und Links
|
||||||
|
|
||||||
KST4Contest zeigt Nachrichtentexte bewusst einzeilig an. So bleiben auch bei hohem Chat-Aufkommen viele Einträge gleichzeitig sichtbar. Der Nachteil liegt auf der Hand: Bei einer schmalen **Message**-Spalte passt nicht jede Nachricht vollständig in die Zeile.
|
KST4Contest zeigt Nachrichtentexte bewusst einzeilig an. So bleiben auch bei hohem Chat-Aufkommen viele Einträge gleichzeitig sichtbar. Der Nachteil liegt auf der Hand: Bei einer schmalen **Message**-Spalte passt nicht jede Nachricht vollständig in die Zeile.
|
||||||
|
|
||||||
@@ -327,7 +335,11 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
|
|||||||
3. aktualisiert den **Further Info**-Bereich und
|
3. aktualisiert den **Further Info**-Bereich und
|
||||||
4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor.
|
4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor.
|
||||||
|
|
||||||
Marker, die bei der aktuellen Zoomstufe zu dicht beieinanderliegen, werden als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus.
|
Ist **Group nearby stations** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker.
|
||||||
|
|
||||||
|
Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Group nearby stations** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten.
|
||||||
|
|
||||||
|
Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele.
|
||||||
|
|
||||||
Die Kopfzeile ergänzt bei ausgewählter Station:
|
Die Kopfzeile ergänzt bei ausgewählter Station:
|
||||||
|
|
||||||
@@ -432,7 +444,7 @@ Zusätzlich öffnet KST4Contest das Fenster **Cluster & QSO of the other**. Es z
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Die Position des vertikalen Dividers sowie die Fenstergröße werden zusammen mit den übrigen UI-Einstellungen gespeichert. Nach einer Änderung **Save Settings** verwenden.
|
Die Position des vertikalen Dividers sowie die Fenstergröße werden automatisch gespeichert. Die DXCluster- und QSO-Tabellen besitzen hier eigene Spaltenbreiten; Änderungen im Monitorfenster verändern daher nicht das Tabellenlayout der Hauptfenster-Tabs.
|
||||||
|
|
||||||
Das Fenster lässt sich über das Menü aus- und wieder einblenden:
|
Das Fenster lässt sich über das Menü aus- und wieder einblenden:
|
||||||
|
|
||||||
@@ -478,13 +490,13 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb
|
|||||||
|
|
||||||
## Fenstergrößen und Divider
|
## Fenstergrößen und Divider
|
||||||
|
|
||||||
Beim Klick auf **Save Settings** speichert KST4Contest die Größen der Programmfenster und die Positionen der relevanten Divider in der Konfigurationsdatei. Diese Werte werden beim nächsten Programmstart wiederverwendet.
|
KST4Contest speichert Größen und Positionen der Programmfenster, die relevanten Divider sowie manuell geänderte Tabellenbreiten automatisch nach einer kurzen Verzögerung in der Konfigurationsdatei. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. **Save Settings** ist dafür nicht erforderlich, speichert aber weiterhin den vollständigen aktuellen Stand einschließlich Layout.
|
||||||
|
|
||||||
Das Hauptfenster wird beim Start zusätzlich gegen den sichtbaren Bereich des primären Bildschirms geprüft. Ist die gespeicherte Größe zu groß, verkleinert und verschiebt KST4Contest das Fenster so, dass es wieder erreichbar bleibt. Die genaue Herleitung ist unter [Bildschirmgerechte Größe des Hauptfensters](de-Funktionen#bildschirmgerechte-größe-des-hauptfensters-ab-v141) beschrieben.
|
Das Hauptfenster wird beim Start zusätzlich gegen den sichtbaren Bereich des primären Bildschirms geprüft. Ist die gespeicherte Größe zu groß, verkleinert und verschiebt KST4Contest das Fenster so, dass es wieder erreichbar bleibt. Die genaue Herleitung ist unter [Bildschirmgerechte Größe des Hauptfensters](de-Funktionen#bildschirmgerechte-größe-des-hauptfensters-ab-v141) beschrieben.
|
||||||
|
|
||||||
Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, muss seine Größe manuell korrigiert und anschließend erneut mit **Save Settings** gespeichert werden.
|
Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, genügt eine manuelle Korrektur; die neue Größe wird automatisch gespeichert.
|
||||||
|
|
||||||
Bei einer ungünstigen Aufteilung sollten zuerst die Divider an eine brauchbare Position verschoben und die Einstellungen erneut gespeichert werden. Das Löschen der Konfigurationsdatei setzt zwar die UI-Werte zurück, entfernt aber auch die übrigen gespeicherten Programmeinstellungen und sollte deshalb nur verwendet werden, wenn sich die Oberfläche auf anderem Weg nicht mehr herstellen lässt.
|
Bei einer ungünstigen Aufteilung sollten zuerst die Divider und Spaltenbreiten wieder an brauchbare Positionen verschoben werden. Das Löschen der Konfigurationsdatei setzt zwar die UI-Werte zurück, entfernt aber auch die übrigen gespeicherten Programmeinstellungen und sollte deshalb nur verwendet werden, wenn sich die Oberfläche auf anderem Weg nicht mehr herstellen lässt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.43.1 (2026-09-03)
|
||||||
|
|
||||||
|
**Korrigierte Versionsmetadaten**
|
||||||
|
|
||||||
|
v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten.
|
||||||
|
|
||||||
|
- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen.
|
||||||
|
|
||||||
|
Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
|
||||||
|
|
||||||
|
Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.43.0 (2026-09-03)
|
||||||
|
|
||||||
|
**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität**
|
||||||
|
|
||||||
|
v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte.
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
|
||||||
|
|
||||||
|
- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
|
||||||
|
|
||||||
|
- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt.
|
||||||
|
|
||||||
|
- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr.
|
||||||
|
|
||||||
|
- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt.
|
||||||
|
|
||||||
|
- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
|
||||||
|
|
||||||
|
- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben.
|
||||||
|
|
||||||
|
- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
|
||||||
|
|
||||||
|
- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben.
|
||||||
|
|
||||||
|
- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
|
||||||
|
|
||||||
|
### Dokumentation und Auslieferung
|
||||||
|
|
||||||
|
- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert.
|
||||||
|
|
||||||
|
- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher.
|
||||||
|
|
||||||
|
- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
|
||||||
|
|
||||||
|
Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.42.0 (2026-08-22)
|
## v1.42.0 (2026-08-22)
|
||||||
|
|
||||||
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
|
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
|
||||||
@@ -82,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
|||||||
|
|
||||||
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
|
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
|
||||||
|
|
||||||
- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status.
|
|
||||||
|
|
||||||
- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden.
|
|
||||||
|
|
||||||
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
|
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
|
||||||
|
|
||||||
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
|
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ Die Schaltfläche **Send test spot** erzeugt folgenden Testeintrag:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Spotted callsign: DO5AMF
|
Spotted callsign: DO5AMF
|
||||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
Comment: DXC test: You donated $100!
|
||||||
Frequency: .300 des konfigurierten Fallback-Bandes
|
Frequency: .300 des konfigurierten Fallback-Bandes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -191,8 +191,18 @@ Ein Spot enthält:
|
|||||||
- den Locator,
|
- den Locator,
|
||||||
- die aktuelle UTC-Zeit.
|
- die aktuelle UTC-Zeit.
|
||||||
|
|
||||||
|
Die Nutzzeile folgt einem festen, DXSpider-kompatiblen 75-Zeichen-Format. Das DX-Rufzeichen beginnt in Spalte 27, das Kommentarfeld umfasst genau 30 Zeichen und die UTC-Zeit beginnt in Spalte 71. Kurze Kommentare werden mit Leerzeichen aufgefüllt, längere kontrolliert auf 30 Zeichen begrenzt. Unterschiedlich lange Spotter-Rufzeichen und Frequenzen bis 24 GHz verschieben die nachfolgenden Felder nicht.
|
||||||
|
|
||||||
|
Das vollständige DX-Rufzeichen wird nicht abgeschnitten. Ist es länger als zwölf Zeichen, verwirft KST4Contest den Spot stattdessen kontrolliert und protokolliert den Grund.
|
||||||
|
|
||||||
Bei automatisch erzeugten Richtungs-Spots kann KST4Contest bis zu zwei aktuelle AirScout-Einträge als zusätzliche AP-Information in den Kommentar aufnehmen. Fehlende AirScout-Daten verhindern den Spot nicht. Ein manuell über die Stationskarte ausgelöster Spot verwendet den Locator der ausgewählten Station ohne diese optionale Ergänzung.
|
Bei automatisch erzeugten Richtungs-Spots kann KST4Contest bis zu zwei aktuelle AirScout-Einträge als zusätzliche AP-Information in den Kommentar aufnehmen. Fehlende AirScout-Daten verhindern den Spot nicht. Ein manuell über die Stationskarte ausgelöster Spot verwendet den Locator der ausgewählten Station ohne diese optionale Ergänzung.
|
||||||
|
|
||||||
|
Ein kompakter Kommentar mit AirScout-Information sieht beispielsweise so aus:
|
||||||
|
|
||||||
|
```text
|
||||||
|
JO51HK AP 1m/100%;4m/75%
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Wenn kein Spot erscheint
|
## Wenn kein Spot erscheint
|
||||||
@@ -230,6 +240,24 @@ Prüfe zuerst, welche Frequenzen für die betreffende Station innerhalb der letz
|
|||||||
|
|
||||||
Ist kein aktueller Stationskontext vorhanden, prüfe die Auswahl unter **Fallback band for relative QRG detection**. Das Fallback wird nur benötigt, wenn sich das Band weder aus einer vollständigen Frequenz noch aus dem aktuellen Kontext des Absenders ergibt.
|
Ist kein aktueller Stationskontext vorhanden, prüfe die Auswahl unter **Fallback band for relative QRG detection**. Das Fallback wird nur benötigt, wenn sich das Band weder aus einer vollständigen Frequenz noch aus dem aktuellen Kontext des Absenders ergibt.
|
||||||
|
|
||||||
|
### Das Logprogramm läuft in einer eigenen Sandbox
|
||||||
|
|
||||||
|
Ein Logprogramm, das als Flatpak oder über eine Wine-Umgebung wie Bottles gestartet wird, benutzt die Netzwerkrechte dieser Sandbox. Teilt die Sandbox das Netzwerk des Rechners nicht, ist `127.0.0.1` darin nicht das `127.0.0.1`, auf dem KST4Contest lauscht. Die Verbindung wird dann abgewiesen, obwohl KST4Contest den Port korrekt meldet.
|
||||||
|
|
||||||
|
Bei einem Flatpak-Logprogramm lassen sich die Rechte so prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak info --show-permissions <Anwendungs-ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Abschnitt `[Context]` muss `shared=network` stehen. Nachträglich vergeben lässt es sich mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak override --user --share=network <Anwendungs-ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Gleiche gilt, wenn KST4Contest selbst als Flatpak läuft. Dessen veröffentlichtes Manifest enthält `--share=network` bereits, ein lauschender Port ist daher vom Rechner selbst und von anderen Anwendungen darauf erreichbar.
|
||||||
|
|
||||||
### Der Spot wird vom Logger ausgeblendet
|
### Der Spot wird vom Logger ausgeblendet
|
||||||
|
|
||||||
Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden.
|
Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden.
|
||||||
|
|||||||
@@ -84,13 +84,15 @@ KST4Contest wertet deshalb den Text jeder öffentlichen und gerichteten Chat-Nac
|
|||||||
|
|
||||||
| Schreibweise | Beispiel | Verarbeitung |
|
| Schreibweise | Beispiel | Verarbeitung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Vollständige Frequenz | `144.210`, `432,088`, `10368.100` | Das Band ergibt sich direkt aus der Frequenz. |
|
| Vollständige Frequenz | `144.210`, `432,088`, `144307`, `10368100` | Das Band ergibt sich direkt aus der Frequenz. |
|
||||||
| Relative Frequenz mit Punkt oder Komma | `.210`, `,088` | Das Band wird aus dem Stationskontext oder dem konfigurierten Fallback ergänzt. |
|
| Relative Frequenz mit Punkt oder Komma | `.210`, `,088` | Das Band wird aus dem Stationskontext oder dem konfigurierten Fallback ergänzt. |
|
||||||
| Dreistellige Frequenz mit Textkontext | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | Die Zahl wird als relative Frequenz behandelt. |
|
| Dreistellige Frequenz mit Textkontext | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | Die Zahl wird als relative Frequenz behandelt. |
|
||||||
| Dreistellige Zahl ohne Frequenzkontext | `210`, `599`, `144` | Die Zahl wird absichtlich nicht als QRG übernommen. |
|
| Dreistellige Zahl ohne Frequenzkontext | `210`, `599`, `144` | Die Zahl wird absichtlich nicht als QRG übernommen. |
|
||||||
|
|
||||||
Die letzte Einschränkung verhindert plausible, aber falsche Ergebnisse. Mit einem Fallback von `144 MHz` ließe sich ein Signalrapport `599` technisch problemlos zu `144.599 MHz` zusammensetzen. Das Ergebnis wäre formal gültig und fachlich trotzdem Unsinn.
|
Die letzte Einschränkung verhindert plausible, aber falsche Ergebnisse. Mit einem Fallback von `144 MHz` ließe sich ein Signalrapport `599` technisch problemlos zu `144.599 MHz` zusammensetzen. Das Ergebnis wäre formal gültig und fachlich trotzdem Unsinn.
|
||||||
|
|
||||||
|
Eine vollständige Frequenz kann auch ohne Punkt oder Komma geschrieben sein. KST4Contest behandelt dabei die letzten drei Ziffern als kHz-Anteil: `144307` im Namensfeld wird zu `144.307 MHz`, `10368100` in einer öffentlichen oder gerichteten Chatnachricht zu `10368.100 MHz`. Der Wert wird nur übernommen, wenn die daraus entstehende Frequenz innerhalb eines unterstützten Bandbereichs liegt.
|
||||||
|
|
||||||
### Wie wird das Band einer relativen QRG bestimmt?
|
### Wie wird das Band einer relativen QRG bestimmt?
|
||||||
|
|
||||||
KST4Contest verwendet folgende Reihenfolge:
|
KST4Contest verwendet folgende Reihenfolge:
|
||||||
@@ -829,7 +831,11 @@ Die Bandangaben verwenden dieselbe Herleitung wie die Bandspalten, der Filter **
|
|||||||
|
|
||||||
Treffen mehrere Zustände gleichzeitig zu, hat die für den Betrieb wichtigere Markierung Vorrang. Eine ausgewählte Station bleibt deshalb orange; eine Richtungsgelegenheit wird grün dargestellt, auch wenn das Rufzeichen bereits gearbeitet wurde.
|
Treffen mehrere Zustände gleichzeitig zu, hat die für den Betrieb wichtigere Markierung Vorrang. Eine ausgewählte Station bleibt deshalb orange; eine Richtungsgelegenheit wird grün dargestellt, auch wenn das Rufzeichen bereits gearbeitet wurde.
|
||||||
|
|
||||||
Bei niedrigen Zoomstufen werden räumlich dicht beieinanderliegende Stationen zu einem Cluster zusammengefasst. Die Zahl im Cluster gibt die Anzahl der enthaltenen Stationen an. Ein Klick zoomt weiter hinein, wählt aber noch keine einzelne Station aus. Die aktuell ausgewählte Station und grün markierte Richtungsgelegenheiten bleiben auch bei niedriger Zoomstufe als einzelne Marker sichtbar.
|
Mit der Checkbox **Group nearby stations** lässt sich die räumliche Gruppierung steuern. Ist sie ausgewählt, werden bei niedrigen Zoomstufen dicht beieinanderliegende Marker zu einem Bildschirm-Cluster zusammengefasst. Die Zahl im Cluster gibt die Anzahl der enthaltenen Stationen an. Ein Klick zoomt weiter hinein, wählt aber noch keine einzelne Station aus. Die aktuell ausgewählte Station und grün markierte Richtungsgelegenheiten bleiben auch bei niedriger Zoomstufe möglichst als einzelne Marker sichtbar.
|
||||||
|
|
||||||
|
Ist **Group nearby stations** nicht ausgewählt, erscheinen alle positionierbaren Stationen unabhängig von der Zoomstufe als einzelne Marker. Die Änderung wirkt sofort, ohne Stationsdaten neu zu laden oder Zoom, Kartenausschnitt und Auswahl zu verändern. KST4Contest speichert die Einstellung automatisch und stellt sie beim nächsten Start wieder her. Bestehende Installationen beginnen mit aktivierter Gruppierung und behalten damit zunächst das bisherige Verhalten.
|
||||||
|
|
||||||
|
Diese Bildschirm-Cluster sind nicht mit der Zusammenfassung von Chatvarianten zu verwechseln. Mehrere aktive Varianten desselben normalisierten Basisrufzeichens werden weiterhin zu einem geografischen Marker zusammengeführt. Das Ausschalten von **Group nearby stations** erzeugt daraus keine zusätzlichen Marker und verändert weder Chatidentitäten noch Filter oder Stationsdaten.
|
||||||
|
|
||||||
### Auswahl und geografische Hilfen
|
### Auswahl und geografische Hilfen
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
|
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
|
||||||
|
|
||||||
> **Wichtig**: Nach jeder Änderung unbedingt **„Save Settings"** klicken! Die Einstellungen werden unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`) gespeichert. Ab v1.21 werden auch Fenstergrößen und Divider-Positionen beim Speichern gesichert.
|
> **Wichtig**: Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,12 +379,14 @@ Folgende Einstellungen und Schaltflächen gehören zur lokalen DX-Cluster-Ausgab
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Spotted callsign: DO5AMF
|
Spotted callsign: DO5AMF
|
||||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
Comment: DXC test: You donated $100!
|
||||||
Frequency: .300 des ausgewählten Fallback-Bandes
|
Frequency: .300 des ausgewählten Fallback-Bandes
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei einem Fallback-Band von `144 MHz` wird daraus beispielsweise eine Frequenz von ungefähr `144.300 MHz`.
|
Bei einem Fallback-Band von `144 MHz` wird daraus beispielsweise eine Frequenz von ungefähr `144.300 MHz`.
|
||||||
|
|
||||||
|
Alle Spots verwenden eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit einem 30 Zeichen breiten Kommentarfeld. Längere Kommentare werden an dieser Protokollgrenze kontrolliert gekürzt; das DX-Rufzeichen wird dagegen nicht abgeschnitten. Rufzeichen mit mehr als zwölf Zeichen führen dazu, dass der betreffende Spot verworfen und protokolliert wird.
|
||||||
|
|
||||||
Der Kommentar des Testspots ist ein bewusst beibehaltenes Easteregg. Er hat keine technische Bedeutung und löst – trotz seiner erfreulich konkreten Formulierung – keine Zahlung aus. Entscheidend ist, dass der Spot im verbundenen Logprogramm erscheint.
|
Der Kommentar des Testspots ist ein bewusst beibehaltenes Easteregg. Er hat keine technische Bedeutung und löst – trotz seiner erfreulich konkreten Formulierung – keine Zahlung aus. Entscheidend ist, dass der Spot im verbundenen Logprogramm erscheint.
|
||||||
|
|
||||||
Der Test funktioniert nur, wenn
|
Der Test funktioniert nur, wenn
|
||||||
@@ -884,8 +886,11 @@ Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **W
|
|||||||
|
|
||||||
## Einstellungen speichern
|
## Einstellungen speichern
|
||||||
|
|
||||||
Nach **jeder** Änderung **„Save Settings"** klicken! Ohne Speichern gehen alle Änderungen beim nächsten Start verloren.
|
**Save Settings** speichert die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
|
||||||
|
|
||||||
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
|
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
|
||||||
- Ab v1.21: Fenstergrößen und Divider-Positionen werden ebenfalls gespeichert.
|
- Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen.
|
||||||
|
- Die Konfigurationsversion 6 ergänzt optionale Spaltenbreiten unter `guiOptions`. Ältere `preferences.xml`-Dateien bleiben lesbar; fehlen Breiten oder sind Einträge ungültig, ermittelt KST4Contest wieder brauchbare Anfangsbreiten.
|
||||||
|
- Die Konfigurationsversion 7 ergänzt `GUIstationMapClusteringEnabled` unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, bleibt die räumliche Kartengruppierung aktiviert.
|
||||||
|
- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten und die gespeicherte Auswahl für **Group nearby stations** verloren gehen.
|
||||||
- Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten.
|
- Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten.
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ Die Band-IDs für 50 und 70 MHz werden ebenso verarbeitet wie die VHF-, UHF- und
|
|||||||
|
|
||||||
Die Daten werden in derselben internen Datenbank abgelegt wie Worked-Informationen aus den übrigen QSO-UDP-Schnittstellen und nach einem Neustart wiederhergestellt.
|
Die Daten werden in derselben internen Datenbank abgelegt wie Worked-Informationen aus den übrigen QSO-UDP-Schnittstellen und nach einem Neustart wiederhergestellt.
|
||||||
|
|
||||||
|
#### Bereits geloggte QSOs nachladen
|
||||||
|
|
||||||
|
Win-Test sendet jedes neue QSO als Broadcast. QSOs, die vor dem Start von KST4Contest geloggt wurden, sind darin nicht enthalten. KST4Contest fordert diese QSOs deshalb selbst an, sobald der Win-Test-Netzwerk-Listener eine Win-Test-Station im Netzwerk erkennt.
|
||||||
|
|
||||||
|
Der Abgleich benötigt keine eigene Einstellung und keinen Bedienschritt:
|
||||||
|
|
||||||
|
- Win-Test meldet mit `IHAVE`, welche QSO-Nummern welches Logs es führt.
|
||||||
|
- KST4Contest fordert die fehlenden Bereiche mit `NEEDQSO` an, höchstens 50 QSOs pro Anfrage.
|
||||||
|
- Win-Test beantwortet die Anfrage mit gewöhnlichen `ADDQSO`-Paketen. Sie werden genauso ausgewertet wie ein live geloggtes QSO.
|
||||||
|
|
||||||
|
Bereits gearbeitete Stationen erscheinen dadurch auch dann als gearbeitet, wenn KST4Contest erst während des Contests gestartet wird. Der Abgleich bleibt anschließend aktiv und holt auch einzelne Pakete nach, die im laufenden Betrieb verloren gegangen sind. Bereits bekannte QSOs werden erkannt und nicht erneut gespeichert.
|
||||||
|
|
||||||
|
Sind mehrere Win-Test-Stationen im Netzwerk aktiv, wird jedes Log abgeglichen. Damit sind die bandbezogenen Worked-Markierungen aller Bandstationen vollständig. Der Stationsnamensfilter wirkt weiterhin nur auf die QRG-Synchronisation und schränkt den Logabgleich nicht ein.
|
||||||
|
|
||||||
|
Meldet eine erkannte Station kein auswertbares `IHAVE`, etwa bei einer älteren Win-Test-Version, fordert KST4Contest die QSOs blockweise ab QSO-Nummer 1 an, bis ein Block unbeantwortet bleibt.
|
||||||
|
|
||||||
|
Voraussetzung ist ein aktiviertes Win-Test-Netzwerk. Ist das Win-Test-Netzwerk oder der Listener in KST4Contest deaktiviert, findet kein Abgleich statt.
|
||||||
|
|
||||||
#### Skeds an Win-Test übergeben
|
#### Skeds an Win-Test übergeben
|
||||||
|
|
||||||
Mit **Create sked** wird zunächst ein interner KST4Contest-Sked angelegt. Ist der Win-Test-Netzwerk-Listener aktiviert, versucht KST4Contest anschließend automatisch, den Sked als `ADDSKED` an das Win-Test-Netzwerk zu übertragen.
|
Mit **Create sked** wird zunächst ein interner KST4Contest-Sked angelegt. Ist der Win-Test-Netzwerk-Listener aktiviert, versucht KST4Contest anschließend automatisch, den Sked als `ADDSKED` an das Win-Test-Netzwerk zu übertragen.
|
||||||
@@ -154,7 +172,11 @@ Im Reiter **TRX sync**:
|
|||||||
- `Use pass frequency from Win-Test STATUS`
|
- `Use pass frequency from Win-Test STATUS`
|
||||||
- `Win-Test station name filter`
|
- `Win-Test station name filter`
|
||||||
|
|
||||||
Das Win-Test-Netzwerk muss in Win-Test aktiviert sein. Bei mehreren Computern muss die Broadcast-Adresse das betreffende lokale Netzwerk erreichen. Der Stationsname sollte die sendende KST4Contest-Instanz innerhalb des Win-Test-Netzwerks eindeutig erkennen lassen.
|
Das Win-Test-Netzwerk muss in Win-Test aktiviert sein. Der Stationsname sollte die sendende KST4Contest-Instanz innerhalb des Win-Test-Netzwerks eindeutig erkennen lassen.
|
||||||
|
|
||||||
|
Die Broadcast-Adresse ermittelt KST4Contest selbst: Aus der Absenderadresse der empfangenen Win-Test-Pakete wird das passende lokale Netzwerk bestimmt und dessen Broadcast-Adresse verwendet. Die eingetragene Adresse dient als Rückfallebene, wenn kein lokales Netzwerk zur Win-Test-Station passt, etwa wenn Win-Test hinter einem Router liegt.
|
||||||
|
|
||||||
|
Das ist wichtig, weil Win-Test ausschließlich auf Broadcasts reagiert und eine Adresse in einem nicht vorhandenen Netzwerk keinen Fehler auslöst: Das Paket wird ohne Meldung weggeroutet. Eine veraltete Eintragung, etwa aus einem anderen Netzwerk, machte dadurch früher sowohl die Sked-Übergabe als auch den Logabgleich wirkungslos.
|
||||||
|
|
||||||
Ausführliche Beschreibung der Einstellungen: [Win-Test-Netzwerk-Listener](de-Konfiguration#win-test-netzwerk-listener-ab-v131)
|
Ausführliche Beschreibung der Einstellungen: [Win-Test-Netzwerk-Listener](de-Konfiguration#win-test-netzwerk-listener-ab-v131)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.43.1 (2026-09-03)
|
||||||
|
|
||||||
|
**Corrected version metadata**
|
||||||
|
|
||||||
|
v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds.
|
||||||
|
|
||||||
|
- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped.
|
||||||
|
|
||||||
|
Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
|
||||||
|
|
||||||
|
The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.43.0 (2026-09-03)
|
||||||
|
|
||||||
|
**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility**
|
||||||
|
|
||||||
|
v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
|
||||||
|
|
||||||
|
- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
|
||||||
|
|
||||||
|
- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links.
|
||||||
|
|
||||||
|
- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task.
|
||||||
|
|
||||||
|
- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state.
|
||||||
|
|
||||||
|
- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
|
||||||
|
|
||||||
|
- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86).
|
||||||
|
|
||||||
|
- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
|
||||||
|
|
||||||
|
- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85).
|
||||||
|
|
||||||
|
- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
|
||||||
|
|
||||||
|
### Documentation and packaging
|
||||||
|
|
||||||
|
- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified.
|
||||||
|
|
||||||
|
- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail.
|
||||||
|
|
||||||
|
- The AUR package definitions were updated for v1.43.0.
|
||||||
|
|
||||||
|
The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.42.0 (2026-08-22)
|
## v1.42.0 (2026-08-22)
|
||||||
|
|
||||||
**Shared band context, session-based ON4KST connection and signed macOS packages**
|
**Shared band context, session-based ON4KST connection and signed macOS packages**
|
||||||
@@ -82,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information
|
|||||||
|
|
||||||
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
|
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
|
||||||
|
|
||||||
- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status.
|
|
||||||
|
|
||||||
- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation.
|
|
||||||
|
|
||||||
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
|
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
|
||||||
|
|
||||||
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
|
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
|
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
|
||||||
|
|
||||||
> **Important**: Always click **"Save Settings"** after any change! Settings are stored in `~/.praktiKST/preferences.xml` on Linux and macOS and in `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows. From v1.21 onwards, window sizes and divider positions are also saved when you click Save.
|
> **Important**: Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -428,12 +428,14 @@ The following settings and controls belong to the local DX cluster output:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Spotted callsign: DO5AMF
|
Spotted callsign: DO5AMF
|
||||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
Comment: DXC test: You donated $100!
|
||||||
Frequency: .300 on the selected fallback band
|
Frequency: .300 on the selected fallback band
|
||||||
```
|
```
|
||||||
|
|
||||||
With `144 MHz` selected as the fallback band, the resulting frequency is approximately `144.300 MHz`.
|
With `144 MHz` selected as the fallback band, the resulting frequency is approximately `144.300 MHz`.
|
||||||
|
|
||||||
|
All spots use a fixed, DXSpider-compatible 75-character payload line with a 30-character comment field. Longer comments are deliberately truncated at this protocol boundary; the DX callsign is not. A callsign longer than twelve characters causes the affected spot to be rejected and logged.
|
||||||
|
|
||||||
The comment is a deliberately retained Easter egg. It has no technical meaning and, despite being remarkably specific, does not initiate a payment. Its practical purpose is to make the test spot easy to identify in the logging software.
|
The comment is a deliberately retained Easter egg. It has no technical meaning and, despite being remarkably specific, does not initiate a payment. Its practical purpose is to make the test spot easy to identify in the logging software.
|
||||||
|
|
||||||
The test works only if
|
The test works only if
|
||||||
@@ -941,8 +943,11 @@ Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows →
|
|||||||
|
|
||||||
## Saving Settings
|
## Saving Settings
|
||||||
|
|
||||||
Click **"Save Settings"** after **every** change! Without saving, all changes will be lost on the next start.
|
**Save Settings** stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
|
||||||
|
|
||||||
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
|
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
|
||||||
- From v1.21: Window sizes and divider positions are also saved.
|
- The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**.
|
||||||
|
- Configuration version 6 adds optional column-width entries below `guiOptions`. Older `preferences.xml` files remain readable. Missing or invalid widths simply cause KST4Contest to calculate useful initial widths again.
|
||||||
|
- Configuration version 7 adds `GUIstationMapClusteringEnabled` below `guiOptions`. If the entry is missing or unusable, spatial map clustering remains enabled.
|
||||||
|
- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Group nearby stations** choice may be lost.
|
||||||
- If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values.
|
- If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values.
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ The **Send test spot** button creates the following test entry:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Spotted callsign: DO5AMF
|
Spotted callsign: DO5AMF
|
||||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
Comment: DXC test: You donated $100!
|
||||||
Frequency: .300 on the configured fallback band
|
Frequency: .300 on the configured fallback band
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -191,12 +191,16 @@ A spot contains:
|
|||||||
- the locator; and
|
- the locator; and
|
||||||
- the current UTC time.
|
- the current UTC time.
|
||||||
|
|
||||||
|
The payload line uses a fixed, DXSpider-compatible 75-character format. The DX callsign starts in column 27, the comment field is exactly 30 characters wide and the UTC time starts in column 71. Short comments are padded with spaces; longer ones are deliberately limited to 30 characters. Different spotter-callsign lengths and frequencies up to 24 GHz do not move the following fields.
|
||||||
|
|
||||||
|
KST4Contest does not truncate the complete DX callsign. If it exceeds twelve characters, the spot is rejected in a controlled manner and the reason is logged.
|
||||||
|
|
||||||
For automatically generated directional spots, KST4Contest can add up to two current AirScout entries to the comment. Missing AirScout data does not prevent the spot from being sent. A spot triggered manually from the station map uses the selected station's locator without this optional addition.
|
For automatically generated directional spots, KST4Contest can add up to two current AirScout entries to the comment. Missing AirScout data does not prevent the spot from being sent. A spot triggered manually from the station map uses the selected station's locator without this optional addition.
|
||||||
|
|
||||||
An automatic comment with AirScout information may look like this:
|
An automatic comment with AirScout information may look like this:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
JN49GL , AP: 1min, 100%; 4min, 75%
|
JO51HK AP 1m/100%;4m/75%
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -236,6 +240,24 @@ First check which frequencies were detected for the station during the previous
|
|||||||
|
|
||||||
If no current station context exists, check **Fallback band for relative QRG detection**. The fallback is used only when the band cannot be determined from a complete frequency or the sender's current context.
|
If no current station context exists, check **Fallback band for relative QRG detection**. The fallback is used only when the band cannot be determined from a complete frequency or the sender's current context.
|
||||||
|
|
||||||
|
### The Logger Runs in Its Own Sandbox
|
||||||
|
|
||||||
|
A logging programme started as a Flatpak, or through a Wine environment such as Bottles, uses the network permissions of that sandbox. If the sandbox does not share the host network, `127.0.0.1` inside it is not the `127.0.0.1` on which KST4Contest is listening, and the connection is refused even though KST4Contest reports the port correctly.
|
||||||
|
|
||||||
|
For a Flatpak logger, check its permission with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak info --show-permissions <application id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `[Context]` section has to contain `shared=network`. It can be granted with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak override --user --share=network <application id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies when KST4Contest itself runs as a Flatpak. Its published manifest already contains `--share=network`, so a listening port is reachable from the host and from other applications on the same computer.
|
||||||
|
|
||||||
### The Logger Hides the Spot
|
### The Logger Hides the Spot
|
||||||
|
|
||||||
Try a spotter callsign which differs from the contest callsign. Depending on the logger, spots from the local callsign may be filtered or handled specially. KST4Contest itself does not require the two callsigns to differ.
|
Try a spotter callsign which differs from the contest callsign. Depending on the logger, spots from the local callsign may be filtered or handled specially. KST4Contest itself does not require the two callsigns to differ.
|
||||||
|
|||||||
@@ -84,13 +84,15 @@ KST4Contest therefore evaluates the text of every public and directed chat messa
|
|||||||
|
|
||||||
| Notation | Example | Processing |
|
| Notation | Example | Processing |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Complete frequency | `144.210`, `432,088`, `10368.100` | The frequency determines the band directly. |
|
| Complete frequency | `144.210`, `432,088`, `144307`, `10368100` | The frequency determines the band directly. |
|
||||||
| Relative frequency with a dot or comma | `.210`, `,088` | The band is added from the station context or configured fallback. |
|
| Relative frequency with a dot or comma | `.210`, `,088` | The band is added from the station context or configured fallback. |
|
||||||
| Three-digit frequency with text context | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | The number is treated as a relative frequency. |
|
| Three-digit frequency with text context | `qrg 210`, `freq is 210`, `on 210`, `210 MHz` | The number is treated as a relative frequency. |
|
||||||
| Three-digit number without frequency context | `210`, `599`, `144` | The number is deliberately not accepted as a QRG. |
|
| Three-digit number without frequency context | `210`, `599`, `144` | The number is deliberately not accepted as a QRG. |
|
||||||
|
|
||||||
The final restriction prevents plausible-looking but incorrect results. With a fallback of `144 MHz`, a signal report of `599` could easily be turned into `144.599 MHz`. The result would be formally valid and operationally useless.
|
The final restriction prevents plausible-looking but incorrect results. With a fallback of `144 MHz`, a signal report of `599` could easily be turned into `144.599 MHz`. The result would be formally valid and operationally useless.
|
||||||
|
|
||||||
|
A complete frequency may also be written without a dot or comma. KST4Contest treats the final three digits as the kHz part: `144307` in a station name becomes `144.307 MHz`, while `10368100` in a public or directed chat message becomes `10368.100 MHz`. The value is accepted only if the resulting frequency falls within a supported band range.
|
||||||
|
|
||||||
### How Is the Band of a Relative QRG Determined?
|
### How Is the Band of a Relative QRG Determined?
|
||||||
|
|
||||||
KST4Contest uses the following order:
|
KST4Contest uses the following order:
|
||||||
@@ -823,7 +825,11 @@ Marker colours provide a compact status indication:
|
|||||||
|
|
||||||
The selected state has the highest display priority, followed by the directional warning and Worked state. A selected station therefore remains orange even if it also meets one of the other conditions.
|
The selected state has the highest display priority, followed by the directional warning and Worked state. A selected station therefore remains orange even if it also meets one of the other conditions.
|
||||||
|
|
||||||
At lower zoom levels, nearby markers are combined into screen-based clusters. This is a display function and does not merge the underlying chat members. Selected stations and important directional candidates remain individually visible where possible.
|
The **Group nearby stations** checkbox controls spatial grouping. When selected, nearby markers are combined into screen-based clusters at lower zoom levels. The number inside a cluster shows how many stations it contains. Clicking a cluster zooms in but does not select an individual station. Selected stations and important directional candidates remain individually visible where possible.
|
||||||
|
|
||||||
|
Clearing **Group nearby stations** displays every positionable station as an individual marker at every zoom level. The change takes effect immediately without reloading station data or changing the current zoom, viewport or selection. KST4Contest saves the setting automatically and restores it at the next start. Existing installations initially keep clustering enabled and therefore retain the previous behaviour.
|
||||||
|
|
||||||
|
Screen-based clustering is separate from base-callsign aggregation. Active variants of the same normalised base callsign continue to share one geographical marker where applicable. Disabling **Group nearby stations** does not split that marker and does not change chat identities, filters or station data.
|
||||||
|
|
||||||
Clicking a station marker selects the corresponding active chat member in the main window. KST4Contest scrolls to the entry in the user list, updates the **Further Info** panel and prepares the complete visible callsign as the message target. The chat suffix and category therefore remain relevant even though several variants may share one map marker.
|
Clicking a station marker selects the corresponding active chat member in the main window. KST4Contest scrolls to the entry in the user list, updates the **Further Info** panel and prepares the complete visible callsign as the message target. The chat suffix and category therefore remain relevant even though several variants may share one map marker.
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ Band IDs for 50 and 70 MHz are processed in the same way as the VHF, UHF and SHF
|
|||||||
|
|
||||||
The information is written to the same internal database as Worked data received through the other QSO UDP interfaces and is restored after a restart.
|
The information is written to the same internal database as Worked data received through the other QSO UDP interfaces and is restored after a restart.
|
||||||
|
|
||||||
|
#### Recovering QSOs logged earlier
|
||||||
|
|
||||||
|
Win-Test broadcasts every new QSO. QSOs logged before KST4Contest was started are not part of those broadcasts. KST4Contest therefore requests them itself as soon as the Win-Test network listener detects a Win-Test station on the network.
|
||||||
|
|
||||||
|
The recovery needs no dedicated setting and no operating step:
|
||||||
|
|
||||||
|
- Win-Test announces with `IHAVE` which QSO numbers of which log it holds.
|
||||||
|
- KST4Contest requests the missing ranges with `NEEDQSO`, at most 50 QSOs per request.
|
||||||
|
- Win-Test answers with ordinary `ADDQSO` packets. They are processed exactly like a QSO logged live.
|
||||||
|
|
||||||
|
Stations already worked therefore appear as worked even when KST4Contest is started during the contest. The recovery stays active afterwards and also picks up individual packets lost during operation. Known QSOs are recognised and not stored again.
|
||||||
|
|
||||||
|
When several Win-Test stations are active on the network, every log is recovered. The per-band Worked marks of all band stations are then complete. The station name filter still applies to the QRG synchronisation only and does not restrict the log recovery.
|
||||||
|
|
||||||
|
If a detected station sends no usable `IHAVE`, for example an older Win-Test version, KST4Contest requests the QSOs in blocks starting at QSO number 1 until a block remains unanswered.
|
||||||
|
|
||||||
|
The Win-Test network must be enabled. No recovery takes place while the Win-Test network or the listener in KST4Contest is disabled.
|
||||||
|
|
||||||
#### Handing skeds over to Win-Test
|
#### Handing skeds over to Win-Test
|
||||||
|
|
||||||
Pressing **Create sked** first creates an internal KST4Contest sked. If the Win-Test network listener is enabled, KST4Contest then automatically attempts to send the sked to the Win-Test network as an `ADDSKED` packet.
|
Pressing **Create sked** first creates an internal KST4Contest sked. If the Win-Test network listener is enabled, KST4Contest then automatically attempts to send the sked to the Win-Test network as an `ADDSKED` packet.
|
||||||
@@ -154,7 +172,11 @@ In the **TRX sync** tab:
|
|||||||
- `Use pass frequency from Win-Test STATUS`
|
- `Use pass frequency from Win-Test STATUS`
|
||||||
- `Win-Test station name filter`
|
- `Win-Test station name filter`
|
||||||
|
|
||||||
The Win-Test network must be enabled in Win-Test. When several computers are used, the broadcast address must reach the relevant local network. The station name should identify the sending KST4Contest instance unambiguously within the Win-Test network.
|
The Win-Test network must be enabled in Win-Test. The station name should identify the sending KST4Contest instance unambiguously within the Win-Test network.
|
||||||
|
|
||||||
|
KST4Contest determines the broadcast address itself: the source address of the received Win-Test packets identifies the matching local network, and the broadcast address of that network is used. The configured address serves as the fallback when no local network matches the Win-Test station, for example when Win-Test is located behind a router.
|
||||||
|
|
||||||
|
This matters because Win-Test only reacts to broadcasts, and an address in a network that does not exist raises no error: the packet is routed away silently. An outdated entry, for instance from a different network, therefore used to disable both the sked handover and the log recovery.
|
||||||
|
|
||||||
Detailed settings: [Win-Test Network Listener](en-Configuration#win-test-network-listener-from-v131)
|
Detailed settings: [Win-Test Network Listener](en-Configuration#win-test-network-listener-from-v131)
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,15 @@ The value affects, among other things:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Message Tables
|
## Table Widths and Truncated Cell Values
|
||||||
|
|
||||||
|
When useful data first becomes available, KST4Contest sizes the columns of the user list, message views, DXCluster and QSO tables, and Worked database once from their headings and existing contents. Stored widths take precedence. The initial widths of **Name**, **AP** and **NOT QRV @** are capped so that one long value cannot displace the rest of the table. **Message** and similar free-text columns remain flexible and do not follow the longest message.
|
||||||
|
|
||||||
|
Manually changed column widths are saved automatically and restored at the next start. Later messages or station updates do not overwrite that choice.
|
||||||
|
|
||||||
|
If a normal text value does not fit in its cell, a tooltip shows the complete value. It appears only when the displayed text is actually truncated. Functional tooltips for QRA, Worked, band and similar states remain available. If such a cell is also truncated, the tooltip contains both the full value and the functional explanation.
|
||||||
|
|
||||||
|
### Message Text and Links
|
||||||
|
|
||||||
KST4Contest deliberately displays message text on a single line. This keeps a larger number of entries visible when chat activity is high. The disadvantage is obvious: if the **Message** column is narrow, not every message fits completely into its cell.
|
KST4Contest deliberately displays message text on a single line. This keeps a larger number of entries visible when chat activity is high. The disadvantage is obvious: if the **Message** column is narrow, not every message fits completely into its cell.
|
||||||
|
|
||||||
@@ -331,7 +339,11 @@ A single station marker can be selected directly. KST4Contest then:
|
|||||||
|
|
||||||
Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest.
|
Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest.
|
||||||
|
|
||||||
Markers which are too close together at the current zoom level are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked.
|
When **Group nearby stations** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level.
|
||||||
|
|
||||||
|
The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Group nearby stations** remains selected so existing installations retain the previous behaviour.
|
||||||
|
|
||||||
|
This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting.
|
||||||
|
|
||||||
For a selected station, the header additionally shows:
|
For a selected station, the header additionally shows:
|
||||||
|
|
||||||
@@ -436,7 +448,7 @@ KST4Contest additionally opens the **Cluster & QSO of the other** window. It sho
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
The vertical divider position and window size are stored together with the other UI settings. Use **Save Settings** after changing them.
|
The vertical divider position and window size are saved automatically. The DXCluster and QSO tables use their own column widths in this window, so changing the monitor layout does not alter the corresponding main-window tabs.
|
||||||
|
|
||||||
The window can be hidden and restored through:
|
The window can be hidden and restored through:
|
||||||
|
|
||||||
@@ -482,13 +494,13 @@ Functions which communicate with the server are available only after the ON4KST
|
|||||||
|
|
||||||
## Window Sizes and Dividers
|
## Window Sizes and Dividers
|
||||||
|
|
||||||
When **Save Settings** is clicked, KST4Contest stores the programme-window sizes and the positions of the relevant dividers in the configuration file. These values are reused at the next start.
|
KST4Contest automatically stores programme-window sizes and positions, relevant dividers and manually changed table-column widths in the configuration file after a short delay. Any pending layout update is written when the programme exits. **Save Settings** is not required for these changes, but still stores the complete current state, including the layout.
|
||||||
|
|
||||||
The main window is additionally checked against the visible area of the primary screen during startup. If the stored size is too large, KST4Contest reduces and moves the window so that it remains accessible. The complete process is described under [Screen-Aware Main Window Sizing](en-Features#screen-aware-main-window-sizing-from-v141).
|
The main window is additionally checked against the visible area of the primary screen during startup. If the stored size is too large, KST4Contest reduces and moves the window so that it remains accessible. The complete process is described under [Screen-Aware Main Window Sizing](en-Features#screen-aware-main-window-sizing-from-v141).
|
||||||
|
|
||||||
The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, its size must be corrected manually and stored again using **Save Settings**.
|
The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, correcting it manually is sufficient; the new size is saved automatically.
|
||||||
|
|
||||||
If the layout has become inconvenient, first move the dividers back to usable positions and save the settings again. Deleting the configuration file also resets the UI values, but it removes the other stored programme settings as well. It should therefore be used only when the interface cannot be restored in another way.
|
If the layout has become inconvenient, first move the dividers and column widths back to usable positions. Deleting the configuration file also resets the UI values, but it removes the other stored programme settings as well. It should therefore be used only when the interface cannot be restored in another way.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = kst4contest-bin
|
pkgbase = kst4contest-bin
|
||||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
|
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
|
||||||
pkgver = 1.42.0
|
pkgver = 1.44.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/praktimarc/kst4contest
|
url = https://github.com/praktimarc/kst4contest
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -10,7 +10,7 @@ pkgbase = kst4contest-bin
|
|||||||
provides = kst4contest
|
provides = kst4contest
|
||||||
conflicts = kst4contest
|
conflicts = kst4contest
|
||||||
conflicts = kst4contest-git
|
conflicts = kst4contest-git
|
||||||
source = KST4Contest-v1.42.0-archlinux-x86_64.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v1.42.0/KST4Contest-v1.42.0-archlinux-x86_64.pkg.tar.zst
|
source = KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v1.44.0/KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst
|
||||||
sha256sums = 3d8ac19c9f9d3ab0bdaf0ea621de0aa18c64f442c8776d28bfad607522aaf02b
|
sha256sums = 62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0
|
||||||
|
|
||||||
pkgname = kst4contest-bin
|
pkgname = kst4contest-bin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||||
pkgname=kst4contest-bin
|
pkgname=kst4contest-bin
|
||||||
pkgver=1.42.0
|
pkgver=1.44.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
|
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -10,7 +10,7 @@ depends=('gst-plugins-base' 'gst-plugins-good')
|
|||||||
provides=('kst4contest')
|
provides=('kst4contest')
|
||||||
conflicts=('kst4contest' 'kst4contest-git')
|
conflicts=('kst4contest' 'kst4contest-git')
|
||||||
source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst")
|
source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst")
|
||||||
sha256sums=('3d8ac19c9f9d3ab0bdaf0ea621de0aa18c64f442c8776d28bfad607522aaf02b')
|
sha256sums=('62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0')
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cp -a "${srcdir}/usr" "${pkgdir}/"
|
cp -a "${srcdir}/usr" "${pkgdir}/"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = kst4contest-git
|
pkgbase = kst4contest-git
|
||||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git)
|
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git)
|
||||||
pkgver = 1.42.0.r256.g8aadbb9
|
pkgver = 1.44.0.r299.g08d65a0e
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/praktimarc/kst4contest
|
url = https://github.com/praktimarc/kst4contest
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||||
pkgname=kst4contest-git
|
pkgname=kst4contest-git
|
||||||
pkgver=1.42.0.r256.g8aadbb9
|
pkgver=1.44.0.r299.g08d65a0e
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
|
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = kst4contest
|
pkgbase = kst4contest
|
||||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
|
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
|
||||||
pkgver = 1.42.0
|
pkgver = 1.44.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/praktimarc/kst4contest
|
url = https://github.com/praktimarc/kst4contest
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -12,7 +12,7 @@ pkgbase = kst4contest
|
|||||||
provides = kst4contest
|
provides = kst4contest
|
||||||
conflicts = kst4contest-bin
|
conflicts = kst4contest-bin
|
||||||
conflicts = kst4contest-git
|
conflicts = kst4contest-git
|
||||||
source = kst4contest-1.42.0.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.42.0.tar.gz
|
source = kst4contest-1.44.0.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.44.0.tar.gz
|
||||||
sha256sums = bd396387b8de41aac706458d5ebf64140ab3e83b8a7c710b66aa802bd48e804e
|
sha256sums = e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914
|
||||||
|
|
||||||
pkgname = kst4contest
|
pkgname = kst4contest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||||
pkgname=kst4contest
|
pkgname=kst4contest
|
||||||
pkgver=1.42.0
|
pkgver=1.44.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
|
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -11,7 +11,7 @@ makedepends=('java-environment=21' 'maven')
|
|||||||
provides=('kst4contest')
|
provides=('kst4contest')
|
||||||
conflicts=('kst4contest-bin' 'kst4contest-git')
|
conflicts=('kst4contest-bin' 'kst4contest-git')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
|
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
|
||||||
sha256sums=('bd396387b8de41aac706458d5ebf64140ab3e83b8a7c710b66aa802bd48e804e')
|
sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "${srcdir}/kst4contest-${pkgver}"
|
cd "${srcdir}/kst4contest-${pkgver}"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>de.x08</groupId>
|
<groupId>de.x08</groupId>
|
||||||
<artifactId>praktiKST</artifactId>
|
<artifactId>praktiKST</artifactId>
|
||||||
<version>1.42.0-nightly</version>
|
<version>1.44.0-nightly</version>
|
||||||
|
|
||||||
<name>praktiKST</name>
|
<name>praktiKST</name>
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ public class ApplicationConstants {
|
|||||||
/**
|
/**
|
||||||
* Version shown to the user and used for semantic version comparison.
|
* Version shown to the user and used for semantic version comparison.
|
||||||
*/
|
*/
|
||||||
public static final String APPLICATION_CURRENT_VERSION = "1.42";
|
public static final String APPLICATION_CURRENT_VERSION = "1.44.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy numeric representation used only while older update feeds and
|
* Legacy numeric representation used only while older update feeds and
|
||||||
* application versions still exist.
|
* application versions still exist.
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.42;
|
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.44;
|
||||||
|
|
||||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
|
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
|
||||||
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
|
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
|
||||||
|
|||||||
@@ -1204,7 +1204,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
InetAddress broadcastAddress = InetAddress.getByName(
|
InetAddress broadcastAddress =
|
||||||
|
winTestAddressResolver.resolveBroadcastAddress(
|
||||||
chatPreferences
|
chatPreferences
|
||||||
.getLogsynch_wintestNetworkBroadcastAddress()
|
.getLogsynch_wintestNetworkBroadcastAddress()
|
||||||
);
|
);
|
||||||
@@ -1642,11 +1643,26 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
// private String hostname = "109.90.0.130";
|
// private String hostname = "109.90.0.130";
|
||||||
private String hostname;
|
private String hostname;
|
||||||
// private String praktiKSTVersion = "praktiKST 1.0";
|
// private String praktiKSTVersion = "praktiKST 1.0";
|
||||||
private String praktiKSTVersionInfo = "2022-10 - 2022-12\ndeveloped by DO5AMF, Marc\nContact: praktimarc@gmail.com\nDonations via paypal are welcome";
|
private String praktiKSTVersionInfo = "2022-10 - 2022-12\ndeveloped by DO5AMF, Marc and DN9APW, Philipp Wagner\nContact: praktimarc@gmail.com\nDonations via paypal are welcome";
|
||||||
|
|
||||||
private int port = 23001; // kst4contest.test 4 23001 //TODO: auslagern in Chatprefs
|
private int port = 23001; // kst4contest.test 4 23001 //TODO: auslagern in Chatprefs
|
||||||
private ReadUDPbyUCXMessageThread readUDPbyUCXThread;
|
private ReadUDPbyUCXMessageThread readUDPbyUCXThread;
|
||||||
private ReadUDPByWintestThread readUDPByWintestThread;
|
private ReadUDPByWintestThread readUDPByWintestThread;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared resolver for the Win-Test broadcast address. Win-Test only reacts
|
||||||
|
* to broadcasts, so both the log synchronization and the SKED handover have
|
||||||
|
* to reach the network the station was actually heard on.
|
||||||
|
*/
|
||||||
|
private final WinTestNetworkAddressResolver winTestAddressResolver =
|
||||||
|
new WinTestNetworkAddressResolver();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resolver for outgoing Win-Test packets
|
||||||
|
*/
|
||||||
|
public WinTestNetworkAddressResolver getWinTestAddressResolver() {
|
||||||
|
return winTestAddressResolver;
|
||||||
|
}
|
||||||
private WriteThread writeThread;
|
private WriteThread writeThread;
|
||||||
private ReadThread readThread;
|
private ReadThread readThread;
|
||||||
private InputReaderThread consoleReader;
|
private InputReaderThread consoleReader;
|
||||||
@@ -1848,7 +1864,7 @@ private ObservableList<String>
|
|||||||
* assumption which one is the current run frequency. Therefore the legacy
|
* assumption which one is the current run frequency. Therefore the legacy
|
||||||
* frequency property is initialized only when exactly one explicit QRG exists.</p>
|
* frequency property is initialized only when exactly one explicit QRG exists.</p>
|
||||||
*/
|
*/
|
||||||
private void initializeFrequencyFromStationNameIfUnambiguous(
|
/* package */ void initializeFrequencyFromStationNameIfUnambiguous(
|
||||||
ChatMember member
|
ChatMember member
|
||||||
) {
|
) {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
@@ -1942,6 +1958,8 @@ private ObservableList<String>
|
|||||||
int categoryNumber = category.getCategoryNumber();
|
int categoryNumber = category.getCategoryNumber();
|
||||||
List<ChatMember> safeMembers = completeMembers == null
|
List<ChatMember> safeMembers = completeMembers == null
|
||||||
? List.of() : new ArrayList<>(completeMembers);
|
? List.of() : new ArrayList<>(completeMembers);
|
||||||
|
Map<String, ChatMember> workedDataFromDatabase =
|
||||||
|
loadWorkedStateForInitialUserList(safeMembers);
|
||||||
for (ChatMember member : safeMembers) {
|
for (ChatMember member : safeMembers) {
|
||||||
initializeFrequencyFromStationNameIfUnambiguous(member);
|
initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||||
}
|
}
|
||||||
@@ -1966,10 +1984,48 @@ private ObservableList<String>
|
|||||||
&& member.getChatCategory() != null
|
&& member.getChatCategory() != null
|
||||||
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
|
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
|
||||||
lst_chatMemberList.addAll(safeMembers);
|
lst_chatMemberList.addAll(safeMembers);
|
||||||
|
if (workedDataFromDatabase != null) {
|
||||||
|
getLst_DBBasedWkdCallSignList().setAll(
|
||||||
|
workedDataFromDatabase.values());
|
||||||
|
}
|
||||||
fireUserListUpdate("Complete ON4KST user list received");
|
fireUserListUpdate("Complete ON4KST user list received");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one database snapshot for a completed initial ON4KST user list and
|
||||||
|
* applies it before those members are published to the active model or UI.
|
||||||
|
* Every callsign variant receives the state stored for its normalized base
|
||||||
|
* callsign.
|
||||||
|
*
|
||||||
|
* @param initialMembers completed members of one chat category
|
||||||
|
* @return loaded snapshot, or {@code null} when the database read failed
|
||||||
|
*/
|
||||||
|
/* package */ Map<String, ChatMember> loadWorkedStateForInitialUserList(
|
||||||
|
Collection<ChatMember> initialMembers
|
||||||
|
) {
|
||||||
|
if (dbHandler == null) {
|
||||||
|
LOGGER.warning(
|
||||||
|
"Cannot load Worked state for initial ON4KST user list: "
|
||||||
|
+ "database is not initialized");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, ChatMember> workedDataFromDatabase =
|
||||||
|
dbHandler.fetchChatMemberWkdDataFromDB();
|
||||||
|
applyWorkedAndQrvStateFromDatabase(
|
||||||
|
initialMembers, workedDataFromDatabase);
|
||||||
|
return workedDataFromDatabase;
|
||||||
|
} catch (SQLException | RuntimeException exception) {
|
||||||
|
LOGGER.log(
|
||||||
|
Level.WARNING,
|
||||||
|
"Could not load Worked state for completed initial ON4KST user list",
|
||||||
|
exception);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a member from the thread-safe active model. This avoids reading the
|
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||||
* TableView backing list from MessageBusManagementThread.
|
* TableView backing list from MessageBusManagementThread.
|
||||||
@@ -3854,15 +3910,6 @@ private ObservableList<String>
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
new Timer().schedule(new TimerTask() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
Thread.currentThread().setName("fetchWorkedFromDBTimer");
|
|
||||||
refreshWorkedStateAndDatabaseListFromDatabase();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
// new Timer().schedule(new TimerTask() {
|
// new Timer().schedule(new TimerTask() {
|
||||||
// HashMap<String, ChatMember> getWorkedDataFromDb;
|
// HashMap<String, ChatMember> getWorkedDataFromDb;
|
||||||
//
|
//
|
||||||
@@ -3966,23 +4013,34 @@ private ObservableList<String>
|
|||||||
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
||||||
|
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
helper_applyWorkedAndQrvStateFromDatabase(finalWorkedDataFromDatabase);
|
applyWorkedAndQrvStateFromDatabase(
|
||||||
|
activeChatMembersByCallAndCategory.values(),
|
||||||
|
finalWorkedDataFromDatabase);
|
||||||
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
|
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
|
||||||
fireUserListUpdate("Worked database state refreshed");
|
fireUserListUpdate("Worked database state refreshed");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the worked and not-QRV state from the database snapshot to all active
|
* Applies the worked and not-QRV state from a database snapshot to chat members.
|
||||||
* chatmember objects that are currently visible in the live chat list.
|
* Database rows are keyed by normalized base callsign, so all active category
|
||||||
|
* and suffix variants receive the same persisted state.
|
||||||
*
|
*
|
||||||
|
* @param chatMembers members that should receive persisted state
|
||||||
* @param workedDataFromDatabase map keyed by normalized raw callsign
|
* @param workedDataFromDatabase map keyed by normalized raw callsign
|
||||||
*/
|
*/
|
||||||
private void helper_applyWorkedAndQrvStateFromDatabase(HashMap<String, ChatMember> workedDataFromDatabase) {
|
/* package */ static void applyWorkedAndQrvStateFromDatabase(
|
||||||
|
Collection<ChatMember> chatMembers,
|
||||||
|
Map<String, ChatMember> workedDataFromDatabase
|
||||||
|
) {
|
||||||
|
if (chatMembers == null || workedDataFromDatabase == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) {
|
for (ChatMember activeChatMember : chatMembers) {
|
||||||
|
if (activeChatMember == null) {
|
||||||
ChatMember activeChatMember = (ChatMember) iterator.next();
|
continue;
|
||||||
|
}
|
||||||
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
|
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
|
||||||
|
|
||||||
if (storedChatMemberState == null) {
|
if (storedChatMemberState == null) {
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats local DX Cluster spots using the fixed-column layout emitted by
|
||||||
|
* DXSpider and accepted by common logging programs.
|
||||||
|
*/
|
||||||
|
final class DXClusterSpotFormatter {
|
||||||
|
|
||||||
|
/** Length of the DX Cluster line before BEL and CRLF framing. */
|
||||||
|
/* package */
|
||||||
|
static final int LINE_LENGTH = 75;
|
||||||
|
/** One-based column in which the spotted callsign starts. */
|
||||||
|
/* package */
|
||||||
|
static final int DX_CALL_COLUMN = 27;
|
||||||
|
/** Width of the fixed comment field. */
|
||||||
|
/* package */
|
||||||
|
static final int COMMENT_LENGTH = 30;
|
||||||
|
/** One-based column in which the UTC time starts. */
|
||||||
|
/* package */
|
||||||
|
static final int TIME_COLUMN = 71;
|
||||||
|
|
||||||
|
/** Zero-based exclusive end position of the frequency field. */
|
||||||
|
private static final int FREQUENCY_END = 24;
|
||||||
|
/** Maximum width of the spotted callsign field. */
|
||||||
|
private static final int DX_CALL_LENGTH = 12;
|
||||||
|
/** Required width of the HHMMZ time field. */
|
||||||
|
private static final int TIME_LENGTH = 5;
|
||||||
|
/** Minimum separator width between spotter and frequency. */
|
||||||
|
private static final int MIN_FREQUENCY_GAP = 1;
|
||||||
|
/** Wire framing appended to every formatted line. */
|
||||||
|
private static final String PAYLOAD_SUFFIX = "\u0007\u0007\r\n";
|
||||||
|
|
||||||
|
private DXClusterSpotFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the fixed 75-character payload line without wire framing. */
|
||||||
|
/* package */
|
||||||
|
static String formatLine(
|
||||||
|
final String spotterCallSign,
|
||||||
|
final String frequency,
|
||||||
|
final String dxCallSign,
|
||||||
|
final String comment,
|
||||||
|
final String time
|
||||||
|
) {
|
||||||
|
final String spotter = requireValue(
|
||||||
|
spotterCallSign,
|
||||||
|
"spotter callsign"
|
||||||
|
)
|
||||||
|
.toUpperCase(Locale.ROOT);
|
||||||
|
final String frequencyValue = requireValue(frequency, "frequency");
|
||||||
|
final String dxCall = requireValue(dxCallSign, "DX callsign")
|
||||||
|
.toUpperCase(Locale.ROOT);
|
||||||
|
final String timeValue = requireValue(time, "time");
|
||||||
|
|
||||||
|
validateDxCall(dxCall);
|
||||||
|
validateTime(timeValue);
|
||||||
|
|
||||||
|
final String prefix = "DX de " + spotter + ":";
|
||||||
|
final int frequencyPadding = calculateFrequencyPadding(
|
||||||
|
prefix,
|
||||||
|
frequencyValue
|
||||||
|
);
|
||||||
|
final String normalizedComment = normalizeComment(comment);
|
||||||
|
|
||||||
|
final String line = prefix
|
||||||
|
+ " ".repeat(frequencyPadding)
|
||||||
|
+ frequencyValue
|
||||||
|
+ " "
|
||||||
|
+ padRight(dxCall, DX_CALL_LENGTH)
|
||||||
|
+ " "
|
||||||
|
+ padRight(normalizedComment, COMMENT_LENGTH)
|
||||||
|
+ " "
|
||||||
|
+ timeValue;
|
||||||
|
|
||||||
|
if (line.length() != LINE_LENGTH) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"DX Cluster formatter produced "
|
||||||
|
+ line.length()
|
||||||
|
+ " characters instead of "
|
||||||
|
+ LINE_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds one complete ASCII spot payload including BEL and CRLF framing. */
|
||||||
|
/* package */
|
||||||
|
static byte[] formatPayload(
|
||||||
|
final String spotterCallSign,
|
||||||
|
final String frequency,
|
||||||
|
final String dxCallSign,
|
||||||
|
final String comment,
|
||||||
|
final String time
|
||||||
|
) {
|
||||||
|
return (formatLine(
|
||||||
|
spotterCallSign,
|
||||||
|
frequency,
|
||||||
|
dxCallSign,
|
||||||
|
comment,
|
||||||
|
time
|
||||||
|
) + PAYLOAD_SUFFIX).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateDxCall(final String dxCall) {
|
||||||
|
if (dxCall.length() > DX_CALL_LENGTH) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"DX callsign exceeds 12 characters: " + dxCall
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTime(final String time) {
|
||||||
|
if (time.length() != TIME_LENGTH) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"DX Cluster time must contain exactly five characters"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int calculateFrequencyPadding(
|
||||||
|
final String prefix,
|
||||||
|
final String frequency
|
||||||
|
) {
|
||||||
|
final int padding = FREQUENCY_END
|
||||||
|
- prefix.length()
|
||||||
|
- frequency.length();
|
||||||
|
|
||||||
|
if (padding < MIN_FREQUENCY_GAP) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Spotter callsign and frequency do not fit the DX Cluster prefix"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(final String comment) {
|
||||||
|
final String normalized = comment == null ? "" : comment.trim();
|
||||||
|
|
||||||
|
return normalized.length() > COMMENT_LENGTH
|
||||||
|
? normalized.substring(0, COMMENT_LENGTH)
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireValue(
|
||||||
|
final String value,
|
||||||
|
final String fieldName
|
||||||
|
) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"DX Cluster " + fieldName + " is missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String padRight(final String value, final int length) {
|
||||||
|
return value + " ".repeat(length - value.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@ public class DXClusterThreadPooledServer implements Runnable {
|
|||||||
public boolean broadcastSingleDXClusterEntryToLoggers(
|
public boolean broadcastSingleDXClusterEntryToLoggers(
|
||||||
ChatMember chatMember
|
ChatMember chatMember
|
||||||
) {
|
) {
|
||||||
|
final byte[] clusterPayload;
|
||||||
final String clusterMessage;
|
final String clusterMessage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -162,24 +163,27 @@ public class DXClusterThreadPooledServer implements Runnable {
|
|||||||
.getNotify_optionalFrequencyPrefix()
|
.getNotify_optionalFrequencyPrefix()
|
||||||
);
|
);
|
||||||
|
|
||||||
clusterMessage =
|
clusterPayload = DXClusterSpotFormatter.formatPayload(
|
||||||
"DX de "
|
chatController
|
||||||
+ chatController
|
|
||||||
.getChatPreferences()
|
.getChatPreferences()
|
||||||
.getNotify_DXCSrv_SpottersCallSign()
|
.getNotify_DXCSrv_SpottersCallSign()
|
||||||
.getValue()
|
.getValue(),
|
||||||
+ ": "
|
frequency,
|
||||||
+ frequency
|
chatMember.getCallSign(),
|
||||||
+ " "
|
chatMember.getQra(),
|
||||||
+ chatMember.getCallSign().toUpperCase()
|
new Utils4KST()
|
||||||
+ " "
|
|
||||||
+ chatMember.getQra().toUpperCase()
|
|
||||||
+ " "
|
|
||||||
+ new Utils4KST()
|
|
||||||
.time_generateCurrenthhmmZTimeStringForClusterMessage()
|
.time_generateCurrenthhmmZTimeStringForClusterMessage()
|
||||||
+ ((char) 7)
|
);
|
||||||
+ ((char) 7)
|
clusterMessage = new String(
|
||||||
+ "\r\n";
|
clusterPayload,
|
||||||
|
StandardCharsets.US_ASCII
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
LOGGER.log(
|
||||||
|
Level.WARNING,
|
||||||
|
"DX Cluster spot rejected: " + exception.getMessage()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
LOGGER.log(
|
LOGGER.log(
|
||||||
Level.SEVERE,
|
Level.SEVERE,
|
||||||
@@ -204,11 +208,7 @@ public class DXClusterThreadPooledServer implements Runnable {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
OutputStream output = socket.getOutputStream();
|
OutputStream output = socket.getOutputStream();
|
||||||
output.write(
|
output.write(clusterPayload);
|
||||||
clusterMessage.getBytes(
|
|
||||||
StandardCharsets.US_ASCII
|
|
||||||
)
|
|
||||||
);
|
|
||||||
output.flush();
|
output.flush();
|
||||||
deliveredClients++;
|
deliveredClients++;
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package kst4contest.controller;
|
|
||||||
|
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
|
||||||
import kst4contest.model.ChatMember;
|
|
||||||
import kst4contest.model.ChatPreferences;
|
|
||||||
|
|
||||||
public class DXClusterThreadPooledServerTest {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
|
|
||||||
ChatController client = new ChatController();
|
|
||||||
ChatPreferences testPreferences = new ChatPreferences();
|
|
||||||
testPreferences.setStn_loginCallSign("DM5M");
|
|
||||||
|
|
||||||
client.setChatPreferences(testPreferences);
|
|
||||||
DXClusterThreadPooledServer dxClusterServer = new DXClusterThreadPooledServer(8000, client, client);
|
|
||||||
|
|
||||||
new Thread(dxClusterServer).start();
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
|
||||||
Thread.sleep(10 * 1000);
|
|
||||||
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>ready.....go!");
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatMember test = new ChatMember();
|
|
||||||
test.setCallSign("DL5ASG");
|
|
||||||
test.setQra("JO51HK");
|
|
||||||
test.setFrequency(new SimpleStringProperty("144776.0"));
|
|
||||||
|
|
||||||
dxClusterServer.broadcastSingleDXClusterEntryToLoggers(test);
|
|
||||||
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// Thread.sleep(20 * 3333);
|
|
||||||
// } catch (InterruptedException e) {
|
|
||||||
// e.printStackTrace();
|
|
||||||
// }
|
|
||||||
// System.out.println("Stopping Server");
|
|
||||||
// server.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
/*
|
/*
|
||||||
* Frequency formats handled by the smart parser:
|
* Frequency formats handled by the smart parser:
|
||||||
*
|
*
|
||||||
* Group 1: full frequencies, for example 144.210 or 10368.100
|
* Group 1: full frequencies, for example 144.210, 144210 or 10368100
|
||||||
* Group 2: relative frequencies with a separator, for example .210 or ,210
|
* Group 2: relative frequencies with a separator, for example .210 or ,210
|
||||||
* Group 3: bare three-digit values, for example 210
|
* Group 3: bare three-digit values, for example 210
|
||||||
*
|
*
|
||||||
@@ -66,7 +66,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* would be converted into plausible but incorrect frequencies.
|
* would be converted into plausible but incorrect frequencies.
|
||||||
*/
|
*/
|
||||||
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
||||||
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
"(?<![A-Z0-9])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})(?![A-Z0-9])"
|
||||||
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||||
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
||||||
);
|
);
|
||||||
@@ -279,7 +279,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* @param message message whose text is inspected
|
* @param message message whose text is inspected
|
||||||
* @param prefs preferences containing the global fallback band
|
* @param prefs preferences containing the global fallback band
|
||||||
*/
|
*/
|
||||||
private void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
|
/* package */ void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) {
|
||||||
if (message == null || message.getMessageText() == null) {
|
if (message == null || message.getMessageText() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1847,14 +1847,14 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* @param sender station for which the DX Cluster spot is generated
|
* @param sender station for which the DX Cluster spot is generated
|
||||||
* @return locator with up to two optional AP entries
|
* @return locator with up to two optional AP entries
|
||||||
*/
|
*/
|
||||||
private String buildDxClusterSpotComment(ChatMember sender) {
|
static String buildDxClusterSpotComment(ChatMember sender) {
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
String locator = sender.getQra() == null
|
String locator = sender.getQra() == null
|
||||||
? ""
|
? ""
|
||||||
: sender.getQra().trim();
|
: sender.getQra().trim().toUpperCase(Locale.ROOT);
|
||||||
|
|
||||||
AirPlaneReflectionInfo reflectionInfo =
|
AirPlaneReflectionInfo reflectionInfo =
|
||||||
sender.getAirPlaneReflectInfo();
|
sender.getAirPlaneReflectInfo();
|
||||||
@@ -1882,7 +1882,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
aircraftComments.add(
|
aircraftComments.add(
|
||||||
aircraft.getArrivingDurationMinutes()
|
aircraft.getArrivingDurationMinutes()
|
||||||
+ "min, "
|
+ "m/"
|
||||||
+ aircraft.getPotential()
|
+ aircraft.getPotential()
|
||||||
+ "%"
|
+ "%"
|
||||||
);
|
);
|
||||||
@@ -1893,11 +1893,11 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String apComment =
|
String apComment =
|
||||||
"AP: " + String.join("; ", aircraftComments);
|
"AP " + String.join(";", aircraftComments);
|
||||||
|
|
||||||
return locator.isEmpty()
|
return locator.isEmpty()
|
||||||
? apComment
|
? apComment
|
||||||
: locator + " , " + apComment;
|
: locator + " " + apComment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import java.net.*;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -31,18 +32,40 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
|
|
||||||
private static final int BUFFER_SIZE = 4096;
|
private static final int BUFFER_SIZE = 4096;
|
||||||
|
|
||||||
private final Map<Integer, String> receivedQsos = new ConcurrentHashMap<>();
|
|
||||||
private long lastPacketTime = 0;
|
private long lastPacketTime = 0;
|
||||||
|
|
||||||
private String myStation = "DO5AMF";
|
private String myStation = "DO5AMF";
|
||||||
|
|
||||||
private String targetStation = "";
|
private String targetStation = "";
|
||||||
private String stationID = "";
|
private String stationID = "";
|
||||||
private int lastKnownQso = 0;
|
|
||||||
|
|
||||||
private ThreadStatusCallback callBackToController;
|
private ThreadStatusCallback callBackToController;
|
||||||
private String ThreadNickName = "Wintest-msg";
|
private String ThreadNickName = "Wintest-msg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of fields of a complete ADDQSO packet, including message type,
|
||||||
|
* source and destination.
|
||||||
|
*/
|
||||||
|
private static final int ADDQSO_FIELD_COUNT = 24;
|
||||||
|
|
||||||
|
/** Field position of the Win-Test QSO number inside an ADDQSO packet. */
|
||||||
|
private static final int ADDQSO_QSO_NUMBER_INDEX = 11;
|
||||||
|
|
||||||
|
/** Field position of the logging station name inside an ADDQSO packet. */
|
||||||
|
private static final int ADDQSO_STATION_NAME_INDEX = 3;
|
||||||
|
|
||||||
|
private final WinTestLogSyncService logSyncService;
|
||||||
|
|
||||||
|
private WinTestLogSyncService.SyncState lastReportedSyncState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last IHAVE payload seen per station. Win-Test repeats the inventory
|
||||||
|
* periodically, so tracing only the changes keeps the output readable.
|
||||||
|
*/
|
||||||
|
private final Map<String, String> lastTracedIhaveByStation = new HashMap<>();
|
||||||
|
|
||||||
|
private final WinTestNetworkAddressResolver addressResolver;
|
||||||
|
|
||||||
|
|
||||||
public ReadUDPByWintestThread(ChatController client, ThreadStatusCallback callback) {
|
public ReadUDPByWintestThread(ChatController client, ThreadStatusCallback callback) {
|
||||||
|
|
||||||
@@ -51,6 +74,21 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn
|
this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn
|
||||||
this.PORT = client.getChatPreferences().getLogsynch_wintestNetworkPort();
|
this.PORT = client.getChatPreferences().getLogsynch_wintestNetworkPort();
|
||||||
|
|
||||||
|
WinTestNetworkAddressResolver sharedAddressResolver =
|
||||||
|
client.getWinTestAddressResolver();
|
||||||
|
this.addressResolver = sharedAddressResolver != null
|
||||||
|
? sharedAddressResolver
|
||||||
|
: new WinTestNetworkAddressResolver();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preferences are read late on purpose: station name, port and broadcast
|
||||||
|
* address can be changed while the listener is running.
|
||||||
|
*/
|
||||||
|
this.logSyncService = new WinTestLogSyncService(
|
||||||
|
this::sendNeedQso,
|
||||||
|
this::resolveOwnWinTestStationName
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -85,35 +123,113 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
try {
|
try {
|
||||||
|
/*
|
||||||
|
* DatagramPacket keeps the length of the previous datagram, so
|
||||||
|
* without resetting it a long packet would be truncated after a
|
||||||
|
* short one. A truncated packet loses its trailing fields and
|
||||||
|
* its checksum.
|
||||||
|
*/
|
||||||
|
packet.setLength(buffer.length);
|
||||||
socket.receive(packet);
|
socket.receive(packet);
|
||||||
String msg = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim();
|
processWinTestDatagram(
|
||||||
processWinTestMessage(msg);
|
packet.getData(), packet.getLength(), packet.getAddress());
|
||||||
} catch (SocketTimeoutException e) {
|
} catch (SocketTimeoutException e) {
|
||||||
// checkForMissingQsos();
|
logSyncService.tick();
|
||||||
|
reportSyncStateIfChanged();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
//TODO: here is something to catch
|
//TODO: here is something to catch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Win-Test framing of a received datagram and processes it.
|
||||||
|
*
|
||||||
|
* <p>The checksum byte and the NUL terminator are removed on the raw bytes
|
||||||
|
* before any text parsing, so the trailing fields of the packet stay
|
||||||
|
* readable. Afterwards the log synchronization gets its chance to request
|
||||||
|
* QSOs that were logged before this listener was started.</p>
|
||||||
|
*
|
||||||
|
* @param datagram raw datagram buffer
|
||||||
|
* @param length number of valid bytes in the buffer
|
||||||
|
*/
|
||||||
|
void processWinTestDatagram(byte[] datagram, int length, InetAddress source) {
|
||||||
|
if (datagram == null || length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, length);
|
||||||
|
|
||||||
|
if (packet != null && isWinTestStationMessage(packet.getMessageType())) {
|
||||||
|
/*
|
||||||
|
* Win-Test only answers broadcasts. Remembering where its packets
|
||||||
|
* come from keeps outgoing requests on the network the station
|
||||||
|
* actually lives in, even when the configured broadcast address
|
||||||
|
* belongs to a different or no longer existing network.
|
||||||
|
*/
|
||||||
|
addressResolver.rememberStationAddress(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet == null) {
|
||||||
|
/*
|
||||||
|
* The datagram does not follow the Win-Test framing. It still
|
||||||
|
* reaches the established text handling, which also recognizes the
|
||||||
|
* poison pill that stops this listener.
|
||||||
|
*/
|
||||||
|
processWinTestPacket(
|
||||||
|
null,
|
||||||
|
new String(datagram, 0, length, StandardCharsets.US_ASCII).trim()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processWinTestPacket(packet, packet.getMessageText());
|
||||||
|
|
||||||
|
logSyncService.tick();
|
||||||
|
reportSyncStateIfChanged();
|
||||||
|
}
|
||||||
|
|
||||||
void processWinTestMessage(String msg) {
|
void processWinTestMessage(String msg) {
|
||||||
|
processWinTestPacket(WinTestPacket.fromMessageText(msg), msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes one Win-Test message.
|
||||||
|
*
|
||||||
|
* @param packet parsed packet, or {@code null} when the message does not
|
||||||
|
* follow the Win-Test framing
|
||||||
|
* @param msg complete message text
|
||||||
|
*/
|
||||||
|
private void processWinTestPacket(WinTestPacket packet, String msg) {
|
||||||
// System.out.println("Wintest-Message received: " + msg);
|
// System.out.println("Wintest-Message received: " + msg);
|
||||||
|
|
||||||
|
if (msg == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lastPacketTime = System.currentTimeMillis();
|
lastPacketTime = System.currentTimeMillis();
|
||||||
|
|
||||||
if (msg.startsWith("HELLO:")) { //Client Signon of wintest
|
if (msg.startsWith("HELLO:")) { //Client Signon of wintest
|
||||||
parseHello(msg);
|
parseHello(msg);
|
||||||
try {
|
|
||||||
// send_needqso();
|
|
||||||
}catch (Exception e) {
|
|
||||||
System.out.println("Error: ");
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (packet != null) {
|
||||||
|
System.out.println("[WinTest RX] HELLO from " + packet.getSource());
|
||||||
|
logSyncService.onStationSeen(packet.getSource());
|
||||||
|
}
|
||||||
|
|
||||||
} else if (msg.startsWith("ADDQSO:")) { //adding qso to wintest log
|
} else if (msg.startsWith("ADDQSO:")) { //adding qso to wintest log
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
if (packet != null && !packet.getDestination().isEmpty()) {
|
||||||
|
/*
|
||||||
|
* A directed ADDQSO is the answer to one of our NEEDQSO
|
||||||
|
* requests. Tracing it separates a missing answer from a
|
||||||
|
* failing evaluation of the answer.
|
||||||
|
*/
|
||||||
|
System.out.println("[WinTest RX] ADDQSO answer from "
|
||||||
|
+ packet.getSource() + " to " + packet.getDestination());
|
||||||
|
}
|
||||||
|
|
||||||
parseAddQso(msg);
|
parseAddQso(msg);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true);
|
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true);
|
||||||
@@ -123,8 +239,19 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
} else if (msg.startsWith("STATUS")) {
|
} else if (msg.startsWith("STATUS")) {
|
||||||
parseStatus(msg);
|
parseStatus(msg);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* HELLO is only sent when a log is opened, so a listener that was
|
||||||
|
* started later learns about a station from its periodic STATUS.
|
||||||
|
* The configured station-name filter stays a QRG-sync setting: in a
|
||||||
|
* multi-station setup every band station keeps its own log, and all
|
||||||
|
* of them contribute Worked state.
|
||||||
|
*/
|
||||||
|
if (packet != null) {
|
||||||
|
logSyncService.onStationSeen(packet.getSource());
|
||||||
|
}
|
||||||
|
|
||||||
} else if (msg.startsWith("IHAVE:")) { //periodical message of wintest, which qsos are in the log
|
} else if (msg.startsWith("IHAVE:")) { //periodical message of wintest, which qsos are in the log
|
||||||
// parseIHave(msg); //TODO
|
parseIHave(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
||||||
@@ -138,6 +265,130 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the periodic Win-Test log inventory to the log synchronization.
|
||||||
|
*
|
||||||
|
* <p>A packet with a broken checksum is discarded here. The run-length
|
||||||
|
* inventory is the last field of an IHAVE packet, so a corrupted packet
|
||||||
|
* would announce QSO ranges that do not exist. The established handling of
|
||||||
|
* the other message types is deliberately left unchanged, because it never
|
||||||
|
* verified the checksum.</p>
|
||||||
|
*
|
||||||
|
* @param packet received IHAVE packet
|
||||||
|
*/
|
||||||
|
private void parseIHave(WinTestPacket packet) {
|
||||||
|
if (packet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.isChecksumPresent() && !packet.isChecksumValid()) {
|
||||||
|
System.out.println("[WinTest] IHAVE with invalid checksum ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String tracedPayload = String.join(" ", packet.getDataTokens());
|
||||||
|
if (!tracedPayload.equals(lastTracedIhaveByStation.put(packet.getSource(), tracedPayload))) {
|
||||||
|
System.out.println("[WinTest RX] IHAVE from " + packet.getSource()
|
||||||
|
+ " to '" + packet.getDestination() + "': " + tracedPayload
|
||||||
|
+ (WinTestIhaveInventory.fromPacket(packet).isEmpty()
|
||||||
|
? " <-- not usable as inventory" : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
logSyncService.onIhaveReceived(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a change of the log-synchronization progress to the controller.
|
||||||
|
*/
|
||||||
|
private void reportSyncStateIfChanged() {
|
||||||
|
WinTestLogSyncService.SyncState currentSyncState = logSyncService.getState();
|
||||||
|
|
||||||
|
if (currentSyncState == lastReportedSyncState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReportedSyncState = currentSyncState;
|
||||||
|
|
||||||
|
ThreadStateMessage threadStateMessage = new ThreadStateMessage(
|
||||||
|
this.ThreadNickName, true, "log sync: " + currentSyncState, false);
|
||||||
|
callBackToController.onThreadStatus(ThreadNickName, threadStateMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a NEEDQSO request as a UDP broadcast.
|
||||||
|
*
|
||||||
|
* <p>The framing follows the wtKST implementation exactly, including the
|
||||||
|
* leading blank of the data part:</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* NEEDQSO: "KST4Contest" "STN1" "STN1@44510" 1 50{checksum}\0
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param targetStation Win-Test station the request is addressed to
|
||||||
|
* @param logId log identity in the form {@code StationName@LogUniqueID}
|
||||||
|
* @param countFrom first requested QSO number
|
||||||
|
* @param countTo last requested QSO number
|
||||||
|
*/
|
||||||
|
private void sendNeedQso(String targetStation, String logId, long countFrom, long countTo) {
|
||||||
|
String data = " \"" + logId + "\" " + countFrom + " " + countTo;
|
||||||
|
|
||||||
|
WinTestMessage needQsoMessage = new WinTestMessage(
|
||||||
|
WinTestMessage.MessageType.NEEDQSO,
|
||||||
|
resolveOwnWinTestStationName(),
|
||||||
|
targetStation,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
try (DatagramSocket sendSocket = new DatagramSocket()) {
|
||||||
|
sendSocket.setBroadcast(true);
|
||||||
|
sendSocket.setReuseAddress(true);
|
||||||
|
|
||||||
|
byte[] messageBytes = needQsoMessage.toBytes();
|
||||||
|
InetAddress broadcastAddress = addressResolver.resolveBroadcastAddress(
|
||||||
|
client.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
|
||||||
|
int targetPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
|
||||||
|
|
||||||
|
sendSocket.send(new DatagramPacket(
|
||||||
|
messageBytes, messageBytes.length, broadcastAddress, targetPort));
|
||||||
|
|
||||||
|
System.out.println("[WinTest LogSync] NEEDQSO to " + targetStation
|
||||||
|
+ " for " + logId + " " + countFrom + "-" + countTo);
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
System.out.println("[WinTest LogSync] NEEDQSO could not be sent: "
|
||||||
|
+ exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return own station name in the Win-Test network, never blank
|
||||||
|
*/
|
||||||
|
private String resolveOwnWinTestStationName() {
|
||||||
|
String configuredStationName =
|
||||||
|
client.getChatPreferences().getLogsynch_wintestNetworkStationNameOfKST();
|
||||||
|
|
||||||
|
if (configuredStationName == null || configuredStationName.isBlank()) {
|
||||||
|
return "KST4Contest";
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredStationName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a message type identifies a genuine Win-Test station.
|
||||||
|
*
|
||||||
|
* <p>Internal control messages such as the poison pill must not influence
|
||||||
|
* the address of outgoing Win-Test packets.</p>
|
||||||
|
*
|
||||||
|
* @param messageType message type of a received packet
|
||||||
|
* @return {@code true} for a Win-Test station message
|
||||||
|
*/
|
||||||
|
private static boolean isWinTestStationMessage(String messageType) {
|
||||||
|
return "HELLO".equals(messageType)
|
||||||
|
|| "STATUS".equals(messageType)
|
||||||
|
|| "IHAVE".equals(messageType)
|
||||||
|
|| "ADDQSO".equals(messageType);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* parsing of the hello message of wintest:
|
* parsing of the hello message of wintest:
|
||||||
* "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985"
|
* "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985"
|
||||||
@@ -346,15 +597,6 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// private void send_needqso() throws IOException {
|
|
||||||
// String payload = String.format("NEEDQSO:\"%s\" \"%s\" \"%s\" %d %d?\0",
|
|
||||||
// "DO5AMF", "STN1", stationID, 1, 9999);
|
|
||||||
// InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
|
||||||
// byte[] bytes = payload.getBytes(StandardCharsets.US_ASCII);
|
|
||||||
// bytes[bytes.length - 2] = util_calculateChecksum((bytes));
|
|
||||||
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
|
|
||||||
// }
|
|
||||||
|
|
||||||
// private void send_hello() throws IOException {
|
// private void send_hello() throws IOException {
|
||||||
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
|
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
|
||||||
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
|
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
|
||||||
@@ -390,6 +632,61 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
return packetFields.length > 3 ? packetFields[3] : "";
|
return packetFields.length > 3 ? packetFields[3] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the log identity of an ADDQSO packet.
|
||||||
|
*
|
||||||
|
* <p>Win-Test numbers the QSOs of every log continuously, so a QSO is only
|
||||||
|
* identified by the combination of the logging station, the unique log ID
|
||||||
|
* and the QSO number. The log ID is the last field of the packet.</p>
|
||||||
|
*
|
||||||
|
* @param packetFields fields of the ADDQSO packet
|
||||||
|
* @return identity in the form {@code StationName@LogUniqueID}, or
|
||||||
|
* {@code null} when the packet does not carry both values
|
||||||
|
*/
|
||||||
|
static String extractLogIdFromWinTestAddQso(List<String> packetFields) {
|
||||||
|
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String stationName = packetFields.get(ADDQSO_STATION_NAME_INDEX);
|
||||||
|
String logUniqueId = packetFields.get(packetFields.size() - 1);
|
||||||
|
|
||||||
|
if (stationName == null || stationName.isBlank()
|
||||||
|
|| logUniqueId == null || logUniqueId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stationName.trim() + "@" + logUniqueId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the Win-Test QSO number of an ADDQSO packet.
|
||||||
|
*
|
||||||
|
* <p>Win-Test sends {@code 0} instead of {@code 1} for the first QSO of a
|
||||||
|
* log in some situations. wtKST corrects that the same way.</p>
|
||||||
|
*
|
||||||
|
* @param packetFields fields of the ADDQSO packet
|
||||||
|
* @return QSO number, or {@code 0} when the packet carries no usable value
|
||||||
|
*/
|
||||||
|
static long extractQsoNumberFromWinTestAddQso(List<String> packetFields) {
|
||||||
|
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
String rawQsoNumber = packetFields.get(ADDQSO_QSO_NUMBER_INDEX);
|
||||||
|
|
||||||
|
if (rawQsoNumber == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long qsoNumber = Long.parseLong(rawQsoNumber.trim());
|
||||||
|
return qsoNumber <= 0L ? 1L : qsoNumber;
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the locator from a Win-Test ADDQSO packet.
|
* Extracts the locator from a Win-Test ADDQSO packet.
|
||||||
*
|
*
|
||||||
@@ -443,6 +740,17 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
*/
|
*/
|
||||||
private void parseAddQso(String msg) {
|
private void parseAddQso(String msg) {
|
||||||
try {
|
try {
|
||||||
|
List<String> packetFields = WinTestPacket.tokenize(msg);
|
||||||
|
String logId = extractLogIdFromWinTestAddQso(packetFields);
|
||||||
|
long qsoNumber = extractQsoNumberFromWinTestAddQso(packetFields);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The QSO number is registered before any validation. Otherwise the
|
||||||
|
* log synchronization would request a QSO with unusable content
|
||||||
|
* over and over again.
|
||||||
|
*/
|
||||||
|
boolean isUnknownQso = logSyncService.registerReceivedQso(logId, qsoNumber);
|
||||||
|
|
||||||
String[] quotedParts = msg == null ? new String[0] : msg.split("\"");
|
String[] quotedParts = msg == null ? new String[0] : msg.split("\"");
|
||||||
String callSign = quotedParts.length > 7 ? quotedParts[7] : "";
|
String callSign = quotedParts.length > 7 ? quotedParts[7] : "";
|
||||||
String rawBandId = extractBandIdFromWinTestAddQso(msg);
|
String rawBandId = extractBandIdFromWinTestAddQso(msg);
|
||||||
@@ -455,6 +763,16 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isUnknownQso) {
|
||||||
|
/*
|
||||||
|
* Win-Test resends known QSOs when a NEEDQSO request overlaps
|
||||||
|
* with QSOs that already arrived as a broadcast. Worked state
|
||||||
|
* and database entry exist in that case, so repeating the write
|
||||||
|
* would only cost time during the initial log recovery.
|
||||||
|
*/
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (loggedBand == null && !rawBandId.isEmpty()) {
|
if (loggedBand == null && !rawBandId.isEmpty()) {
|
||||||
System.out.println("[WinTestUDPRcvr: warning] Unknown band ID: " + rawBandId);
|
System.out.println("[WinTestUDPRcvr: warning] Unknown band ID: " + rawBandId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory of one Win-Test log, transported in an {@code IHAVE} packet.
|
||||||
|
*
|
||||||
|
* <p>Win-Test announces which QSO numbers of a log a station currently holds.
|
||||||
|
* To keep the packet short the inventory is run-length encoded:</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* IHAVE: "Shack" "" "Shack@9" E 1 1 911-1-117
|
||||||
|
* ^logId ^ ^ ^ ^run lengths
|
||||||
|
* origin | initial state
|
||||||
|
* first row
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>The run lengths alternate between present and missing QSOs, starting with
|
||||||
|
* the state given by {@code InitialState} at QSO number {@code FirstRow}. The
|
||||||
|
* example above therefore means: QSOs 1 to 911 are present, QSO 912 is missing
|
||||||
|
* and QSOs 913 to 1029 are present again.</p>
|
||||||
|
*
|
||||||
|
* <p>Unlike the wtKST implementation this parser honours {@code FirstRow}
|
||||||
|
* instead of assuming that every inventory starts at QSO number one. Win-Test
|
||||||
|
* splits long inventories into several packets, and a split inventory starts at
|
||||||
|
* a higher first row.</p>
|
||||||
|
*/
|
||||||
|
public final class WinTestIhaveInventory {
|
||||||
|
|
||||||
|
/** Where the sending station got the log from. */
|
||||||
|
public enum Origin {
|
||||||
|
/** The station owns the log or the operator is logged on there. */
|
||||||
|
OWNER,
|
||||||
|
/** The station only mirrors a log owned by somebody else. */
|
||||||
|
LOGGED_ELSE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protects against endless loops caused by a corrupted run-length chain. */
|
||||||
|
private static final int MAX_SEGMENTS = 512;
|
||||||
|
|
||||||
|
private static final int EXPECTED_FIELD_COUNT = 5;
|
||||||
|
|
||||||
|
private final String logId;
|
||||||
|
private final Origin origin;
|
||||||
|
private final List<WinTestLogSegment> segments;
|
||||||
|
|
||||||
|
private WinTestIhaveInventory(String logId, Origin origin, List<WinTestLogSegment> segments) {
|
||||||
|
this.logId = logId;
|
||||||
|
this.origin = origin;
|
||||||
|
this.segments = segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an {@code IHAVE} packet.
|
||||||
|
*
|
||||||
|
* @param packet received packet
|
||||||
|
* @return inventory, or an empty value when the packet is not a usable
|
||||||
|
* {@code IHAVE} announcement
|
||||||
|
*/
|
||||||
|
public static Optional<WinTestIhaveInventory> fromPacket(WinTestPacket packet) {
|
||||||
|
if (packet == null || !"IHAVE".equals(packet.getMessageType())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> fields = packet.getDataTokens();
|
||||||
|
if (fields.size() != EXPECTED_FIELD_COUNT) {
|
||||||
|
/*
|
||||||
|
* Win-Test versions before 1.29 use a shorter IHAVE format without
|
||||||
|
* run-length encoding. It carries no usable range information, so
|
||||||
|
* the blind fallback of the sync service has to take over.
|
||||||
|
*/
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String parsedLogId = fields.get(0) == null ? "" : fields.get(0).trim();
|
||||||
|
if (parsedLogId.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Origin parsedOrigin = parseOrigin(fields.get(1));
|
||||||
|
|
||||||
|
long firstRow = parseUnsignedValue(fields.get(2));
|
||||||
|
long initialState = parseUnsignedValue(fields.get(3));
|
||||||
|
|
||||||
|
if (firstRow < 1L || initialState < 0L || initialState > 1L) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WinTestLogSegment> parsedSegments =
|
||||||
|
parseRunLengths(fields.get(4), firstRow, initialState == 1L);
|
||||||
|
|
||||||
|
if (parsedSegments == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(new WinTestIhaveInventory(parsedLogId, parsedOrigin, parsedSegments));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Origin parseOrigin(String rawOrigin) {
|
||||||
|
if (rawOrigin == null) {
|
||||||
|
return Origin.OWNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedOrigin = rawOrigin.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
if ("E".equals(normalizedOrigin) || "LOGGEDELSE".equals(normalizedOrigin)) {
|
||||||
|
return Origin.LOGGED_ELSE;
|
||||||
|
}
|
||||||
|
return Origin.OWNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands the hyphen-separated run lengths into ranges.
|
||||||
|
*
|
||||||
|
* @param rawRunLengths run-length chain such as {@code 911-1-117}
|
||||||
|
* @param firstRow QSO number the first run starts at
|
||||||
|
* @param startsPresent {@code true} when the first run describes present QSOs
|
||||||
|
* @return ranges of present QSOs, or {@code null} for an unusable chain
|
||||||
|
*/
|
||||||
|
private static List<WinTestLogSegment> parseRunLengths(
|
||||||
|
String rawRunLengths,
|
||||||
|
long firstRow,
|
||||||
|
boolean startsPresent
|
||||||
|
) {
|
||||||
|
if (rawRunLengths == null || rawRunLengths.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] runLengths = rawRunLengths.trim().split("-");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A chain that starts with present QSOs has to end with a present run,
|
||||||
|
* so its length is odd. A chain that starts with missing QSOs needs an
|
||||||
|
* even length for the same reason.
|
||||||
|
*/
|
||||||
|
if (startsPresent) {
|
||||||
|
if (runLengths.length % 2 == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (runLengths.length % 2 == 1 || runLengths.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WinTestLogSegment> parsedSegments = new ArrayList<>();
|
||||||
|
long cursor = firstRow;
|
||||||
|
boolean present = startsPresent;
|
||||||
|
|
||||||
|
for (String runLength : runLengths) {
|
||||||
|
long count = parseUnsignedValue(runLength);
|
||||||
|
if (count < 0L) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (present && count > 0L) {
|
||||||
|
if (parsedSegments.size() >= MAX_SEGMENTS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parsedSegments.add(new WinTestLogSegment(cursor, cursor + count - 1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor += count;
|
||||||
|
present = !present;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parseUnsignedValue(String rawValue) {
|
||||||
|
if (rawValue == null) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Long.parseLong(rawValue.trim());
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return log identity in the form {@code StationName@LogUniqueID}
|
||||||
|
*/
|
||||||
|
public String getLogId() {
|
||||||
|
return logId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Origin getOrigin() {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ranges of QSO numbers the announcing station holds
|
||||||
|
*/
|
||||||
|
public List<WinTestLogSegment> getSegments() {
|
||||||
|
return Collections.unmodifiableList(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return highest announced QSO number, or {@code 0} for an empty inventory
|
||||||
|
*/
|
||||||
|
public long getHighestQsoNumber() {
|
||||||
|
long highestQsoNumber = 0L;
|
||||||
|
for (WinTestLogSegment segment : segments) {
|
||||||
|
if (segment.getCountTo() > highestQsoNumber) {
|
||||||
|
highestQsoNumber = segment.getCountTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highestQsoNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return logId + " " + origin + " " + segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive range of Win-Test QSO numbers inside one log.
|
||||||
|
*
|
||||||
|
* <p>Win-Test numbers the QSOs of every log continuously. The {@code IHAVE}
|
||||||
|
* inventory of a log is therefore expressed as a list of ranges that are
|
||||||
|
* present in that log. A range is inclusive on both ends.</p>
|
||||||
|
*/
|
||||||
|
public final class WinTestLogSegment {
|
||||||
|
|
||||||
|
private final long countFrom;
|
||||||
|
private final long countTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param countFrom first QSO number of the range
|
||||||
|
* @param countTo last QSO number of the range
|
||||||
|
*/
|
||||||
|
public WinTestLogSegment(long countFrom, long countTo) {
|
||||||
|
this.countFrom = countFrom;
|
||||||
|
this.countTo = countTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCountFrom() {
|
||||||
|
return countFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCountTo() {
|
||||||
|
return countTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return number of QSOs covered by this range, never negative
|
||||||
|
*/
|
||||||
|
public long getCount() {
|
||||||
|
return countTo < countFrom ? 0L : countTo - countFrom + 1L;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object other) {
|
||||||
|
if (this == other) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(other instanceof WinTestLogSegment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WinTestLogSegment otherSegment = (WinTestLogSegment) other;
|
||||||
|
return countFrom == otherSegment.countFrom && countTo == otherSegment.countTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Long.hashCode(countFrom) * 31 + Long.hashCode(countTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return countFrom + "-" + countTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NavigableSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers the part of a Win-Test log that was written before KST4Contest was
|
||||||
|
* started.
|
||||||
|
*
|
||||||
|
* <p>Win-Test broadcasts every new QSO as an {@code ADDQSO} packet. A client
|
||||||
|
* that joins the network later never sees the QSOs logged before it started, so
|
||||||
|
* stations already worked would still be shown as not worked. Win-Test also
|
||||||
|
* offers a pull mechanism for exactly this situation, and this service is the
|
||||||
|
* port of the wtKST {@code WtLogSync} implementation of it:</p>
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>a Win-Test station announces itself with {@code HELLO} or, if its log
|
||||||
|
* was opened before we started listening, with its periodic
|
||||||
|
* {@code STATUS};</li>
|
||||||
|
* <li>its periodic {@code IHAVE} packets announce which QSO numbers of
|
||||||
|
* which log it holds;</li>
|
||||||
|
* <li>missing ranges are requested with {@code NEEDQSO}, at most
|
||||||
|
* {@value #MAX_QSOS_PER_REQUEST} QSOs per request;</li>
|
||||||
|
* <li>Win-Test answers with ordinary {@code ADDQSO} packets that are
|
||||||
|
* addressed to us instead of being broadcast.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Because the answers are ordinary {@code ADDQSO} packets, the recovered
|
||||||
|
* QSOs run through the same Worked handling as live QSOs. This service only
|
||||||
|
* decides what still has to be requested; it neither touches the database nor
|
||||||
|
* the user interface.</p>
|
||||||
|
*
|
||||||
|
* <p>If a station is known but no usable {@code IHAVE} inventory arrives within
|
||||||
|
* {@value #INVENTORY_GRACE_PERIOD_MS} ms, a blind fallback requests fixed
|
||||||
|
* blocks starting at QSO number one until a block stays unanswered. That covers
|
||||||
|
* Win-Test versions whose {@code IHAVE} format carries no run lengths.</p>
|
||||||
|
*
|
||||||
|
* <p>Deviation from wtKST: wtKST discards its whole QSO table whenever a
|
||||||
|
* {@code HELLO} arrives, because it displays that table. KST4Contest only
|
||||||
|
* accumulates Worked state, where a stale entry is harmless while a discarded
|
||||||
|
* one would cause the complete log to be requested and written again. The log
|
||||||
|
* identity {@code StationName@LogUniqueID} already changes when Win-Test opens
|
||||||
|
* a different log, so nothing is cleared here.</p>
|
||||||
|
*/
|
||||||
|
public class WinTestLogSyncService {
|
||||||
|
|
||||||
|
/** Win-Test answers at most this many QSOs for one NEEDQSO request. */
|
||||||
|
static final int MAX_QSOS_PER_REQUEST = 50;
|
||||||
|
|
||||||
|
/** Time after which an unanswered request is retried elsewhere. */
|
||||||
|
static final long REQUEST_TIMEOUT_MS = 5000L;
|
||||||
|
|
||||||
|
/** Shortest distance between two evaluations without a pending trigger. */
|
||||||
|
static final long TICK_INTERVAL_MS = 2000L;
|
||||||
|
|
||||||
|
/** Waiting time for a usable IHAVE before the blind fallback starts. */
|
||||||
|
static final long INVENTORY_GRACE_PERIOD_MS = 15000L;
|
||||||
|
|
||||||
|
/** Upper bound for the blind fallback, equals 10000 QSOs. */
|
||||||
|
static final int MAX_BLIND_BLOCKS = 200;
|
||||||
|
|
||||||
|
/** Guards the gap search against a corrupted inventory. */
|
||||||
|
private static final long MAX_SCANNED_QSO_NUMBERS = 200000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a NEEDQSO request to a Win-Test station.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface NeedQsoSender {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param targetStation Win-Test station name the request is sent to
|
||||||
|
* @param logId log identity in the form {@code StationName@LogUniqueID}
|
||||||
|
* @param countFrom first requested QSO number
|
||||||
|
* @param countTo last requested QSO number
|
||||||
|
*/
|
||||||
|
void sendNeedQso(String targetStation, String logId, long countFrom, long countTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress of the log recovery, used for status reporting. */
|
||||||
|
public enum SyncState {
|
||||||
|
/** No Win-Test station seen yet. */
|
||||||
|
IDLE,
|
||||||
|
/** A station is known, but nothing has been requested yet. */
|
||||||
|
STATION_DETECTED,
|
||||||
|
/** QSOs are being requested. */
|
||||||
|
SYNCING,
|
||||||
|
/** Everything announced by the known stations has been received. */
|
||||||
|
IN_SYNC
|
||||||
|
}
|
||||||
|
|
||||||
|
private final NeedQsoSender needQsoSender;
|
||||||
|
private final Supplier<String> ownStationNameSupplier;
|
||||||
|
private final LongSupplier clock;
|
||||||
|
|
||||||
|
/** Inventories per Win-Test station, keyed by log identity. */
|
||||||
|
private final Map<String, Map<String, WinTestIhaveInventory>> inventoriesByStation =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** QSO numbers already received, keyed by log identity. */
|
||||||
|
private final Map<String, NavigableSet<Long>> receivedQsoNumbersByLogId = new HashMap<>();
|
||||||
|
|
||||||
|
/** State of the blind fallback, keyed by log identity. */
|
||||||
|
private final Map<String, BlindScan> blindScansByLogId = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private PendingRequest pendingRequest;
|
||||||
|
private long firstStationSeenAtMs;
|
||||||
|
private boolean usableInventorySeen;
|
||||||
|
private long lastTickMs;
|
||||||
|
private boolean tickDueImmediately;
|
||||||
|
private SyncState state = SyncState.IDLE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param needQsoSender transport used for NEEDQSO requests
|
||||||
|
* @param ownStationNameSupplier own Win-Test station name, read late because
|
||||||
|
* it can be changed in the settings at runtime
|
||||||
|
*/
|
||||||
|
public WinTestLogSyncService(
|
||||||
|
NeedQsoSender needQsoSender,
|
||||||
|
Supplier<String> ownStationNameSupplier
|
||||||
|
) {
|
||||||
|
this(needQsoSender, ownStationNameSupplier, System::currentTimeMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param needQsoSender transport used for NEEDQSO requests
|
||||||
|
* @param ownStationNameSupplier own Win-Test station name
|
||||||
|
* @param clock time source in milliseconds
|
||||||
|
*/
|
||||||
|
WinTestLogSyncService(
|
||||||
|
NeedQsoSender needQsoSender,
|
||||||
|
Supplier<String> ownStationNameSupplier,
|
||||||
|
LongSupplier clock
|
||||||
|
) {
|
||||||
|
this.needQsoSender = needQsoSender;
|
||||||
|
this.ownStationNameSupplier = ownStationNameSupplier;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a Win-Test station seen in a HELLO or STATUS packet.
|
||||||
|
*
|
||||||
|
* @param stationName Win-Test station name
|
||||||
|
*/
|
||||||
|
public synchronized void onStationSeen(String stationName) {
|
||||||
|
if (stationName == null || stationName.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedStationName = stationName.trim();
|
||||||
|
if (normalizedStationName.equalsIgnoreCase(resolveOwnStationName())) {
|
||||||
|
// our own packets, nothing to synchronize from
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inventoriesByStation.putIfAbsent(normalizedStationName, new LinkedHashMap<>()) == null) {
|
||||||
|
tickDueImmediately = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstStationSeenAtMs == 0L) {
|
||||||
|
firstStationSeenAtMs = clock.getAsLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SyncState.IDLE) {
|
||||||
|
state = SyncState.STATION_DETECTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes over the inventory of an IHAVE packet.
|
||||||
|
*
|
||||||
|
* @param packet received IHAVE packet
|
||||||
|
*/
|
||||||
|
public synchronized void onIhaveReceived(WinTestPacket packet) {
|
||||||
|
if (packet == null || !packet.isAddressedTo(resolveOwnStationName())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<WinTestIhaveInventory> parsedInventory = WinTestIhaveInventory.fromPacket(packet);
|
||||||
|
if (parsedInventory.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WinTestIhaveInventory inventory = parsedInventory.get();
|
||||||
|
onStationSeen(packet.getSource());
|
||||||
|
|
||||||
|
Map<String, WinTestIhaveInventory> stationInventories =
|
||||||
|
inventoriesByStation.get(packet.getSource() == null ? "" : packet.getSource().trim());
|
||||||
|
|
||||||
|
if (stationInventories == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WinTestIhaveInventory previousInventory =
|
||||||
|
stationInventories.put(inventory.getLogId(), inventory);
|
||||||
|
|
||||||
|
if (previousInventory == null
|
||||||
|
|| !previousInventory.getSegments().equals(inventory.getSegments())) {
|
||||||
|
System.out.println("[WinTest LogSync] inventory of " + inventory.getLogId()
|
||||||
|
+ " from " + packet.getSource()
|
||||||
|
+ " (" + inventory.getOrigin() + "): " + inventory.getSegments());
|
||||||
|
}
|
||||||
|
|
||||||
|
usableInventorySeen = true;
|
||||||
|
blindScansByLogId.remove(inventory.getLogId());
|
||||||
|
tickDueImmediately = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a QSO received in an ADDQSO packet.
|
||||||
|
*
|
||||||
|
* @param logId log identity in the form {@code StationName@LogUniqueID}
|
||||||
|
* @param qsoNumber Win-Test QSO number inside that log
|
||||||
|
* @return {@code true} when this QSO was not known before, and therefore
|
||||||
|
* still has to be applied to Worked state and database
|
||||||
|
*/
|
||||||
|
public synchronized boolean registerReceivedQso(String logId, long qsoNumber) {
|
||||||
|
if (logId == null || logId.isBlank() || qsoNumber <= 0L) {
|
||||||
|
// without a usable identity the QSO cannot be deduplicated
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigableSet<Long> receivedQsoNumbers =
|
||||||
|
receivedQsoNumbersByLogId.computeIfAbsent(logId.trim(), key -> new TreeSet<>());
|
||||||
|
boolean isNewQso = receivedQsoNumbers.add(qsoNumber);
|
||||||
|
|
||||||
|
if (pendingRequest != null
|
||||||
|
&& pendingRequest.logId.equals(logId.trim())
|
||||||
|
&& qsoNumber >= pendingRequest.countFrom
|
||||||
|
&& qsoNumber <= pendingRequest.countTo) {
|
||||||
|
|
||||||
|
pendingRequest.answeredQsoCount++;
|
||||||
|
|
||||||
|
if (qsoNumber == pendingRequest.countTo
|
||||||
|
|| pendingRequest.answeredQsoCount >= pendingRequest.getRequestedQsoCount()) {
|
||||||
|
PendingRequest completedRequest = pendingRequest;
|
||||||
|
pendingRequest = null;
|
||||||
|
if (completedRequest.blind) {
|
||||||
|
finishBlindBlock(completedRequest);
|
||||||
|
}
|
||||||
|
tickDueImmediately = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNewQso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the recovery. Called after every received packet and on every
|
||||||
|
* receive timeout of the listener; an internal interval keeps the actual
|
||||||
|
* work rare while a satisfied request triggers the next one immediately.
|
||||||
|
*/
|
||||||
|
public synchronized void tick() {
|
||||||
|
long now = clock.getAsLong();
|
||||||
|
|
||||||
|
if (!tickDueImmediately && now - lastTickMs < TICK_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTickMs = now;
|
||||||
|
tickDueImmediately = false;
|
||||||
|
|
||||||
|
if (pendingRequest != null) {
|
||||||
|
if (now - pendingRequest.sentAtMs < REQUEST_TIMEOUT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePendingTimeout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestNextMissingRange(now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestNextBlindBlock(now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SyncState.SYNCING) {
|
||||||
|
state = SyncState.IN_SYNC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return current progress of the recovery
|
||||||
|
*/
|
||||||
|
public synchronized SyncState getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return number of QSO numbers known for the given log
|
||||||
|
*/
|
||||||
|
synchronized int getKnownQsoCount(String logId) {
|
||||||
|
NavigableSet<Long> receivedQsoNumbers = receivedQsoNumbersByLogId.get(logId);
|
||||||
|
return receivedQsoNumbers == null ? 0 : receivedQsoNumbers.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePendingTimeout() {
|
||||||
|
PendingRequest timedOutRequest = pendingRequest;
|
||||||
|
pendingRequest = null;
|
||||||
|
|
||||||
|
if (timedOutRequest.blind) {
|
||||||
|
finishBlindBlock(timedOutRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String alternativeStation =
|
||||||
|
findAlternativeStation(timedOutRequest.logId, timedOutRequest.targetStation);
|
||||||
|
|
||||||
|
System.out.println("[WinTest LogSync] no answer from " + timedOutRequest.targetStation
|
||||||
|
+ " for " + timedOutRequest.logId + " "
|
||||||
|
+ timedOutRequest.countFrom + "-" + timedOutRequest.countTo);
|
||||||
|
|
||||||
|
if (alternativeStation != null) {
|
||||||
|
sendRequest(
|
||||||
|
alternativeStation,
|
||||||
|
timedOutRequest.logId,
|
||||||
|
timedOutRequest.countFrom,
|
||||||
|
timedOutRequest.countTo,
|
||||||
|
false,
|
||||||
|
clock.getAsLong()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nobody else holds this log. The silent station is dropped and returns
|
||||||
|
* with its next STATUS or IHAVE packet.
|
||||||
|
*/
|
||||||
|
System.out.println("[WinTest LogSync] dropping silent station "
|
||||||
|
+ timedOutRequest.targetStation + ", waiting for its next STATUS or IHAVE");
|
||||||
|
inventoriesByStation.remove(timedOutRequest.targetStation);
|
||||||
|
tickDueImmediately = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findAlternativeStation(String logId, String excludedStation) {
|
||||||
|
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
|
||||||
|
: inventoriesByStation.entrySet()) {
|
||||||
|
|
||||||
|
if (station.getKey().equals(excludedStation)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (station.getValue().containsKey(logId)) {
|
||||||
|
return station.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks for the first announced QSO range that is still missing and
|
||||||
|
* requests it. Stations that own a log are preferred over stations that
|
||||||
|
* only mirror it.
|
||||||
|
*
|
||||||
|
* @param now current time in milliseconds
|
||||||
|
* @return {@code true} when a request was sent
|
||||||
|
*/
|
||||||
|
private boolean requestNextMissingRange(long now) {
|
||||||
|
for (int pass = 0; pass < 2; pass++) {
|
||||||
|
boolean preferOwner = pass == 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
|
||||||
|
: new ArrayList<>(inventoriesByStation.entrySet())) {
|
||||||
|
|
||||||
|
for (WinTestIhaveInventory inventory : new ArrayList<>(station.getValue().values())) {
|
||||||
|
boolean isOwner = inventory.getOrigin() == WinTestIhaveInventory.Origin.OWNER;
|
||||||
|
if (preferOwner != isOwner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long[] missingRange = findMissingRange(
|
||||||
|
inventory.getSegments(),
|
||||||
|
receivedQsoNumbersByLogId.get(inventory.getLogId())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingRange == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRequest(
|
||||||
|
station.getKey(),
|
||||||
|
inventory.getLogId(),
|
||||||
|
missingRange[0],
|
||||||
|
missingRange[1],
|
||||||
|
false,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the next missing QSO range of one log.
|
||||||
|
*
|
||||||
|
* <p>wtKST compares its own segment list against the announced one and
|
||||||
|
* derives the request bounds from the segment indices. Searching the gap
|
||||||
|
* directly produces the same ranges for the ordinary cases, cannot run past
|
||||||
|
* the end of either list, and never asks for QSO numbers that are already
|
||||||
|
* known.</p>
|
||||||
|
*
|
||||||
|
* @param segments ranges announced by the station
|
||||||
|
* @param receivedQsoNumbers QSO numbers already received for this log
|
||||||
|
* @return first missing range as {@code {countFrom, countTo}}, or
|
||||||
|
* {@code null} when nothing is missing
|
||||||
|
*/
|
||||||
|
static long[] findMissingRange(
|
||||||
|
List<WinTestLogSegment> segments,
|
||||||
|
NavigableSet<Long> receivedQsoNumbers
|
||||||
|
) {
|
||||||
|
if (segments == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long remainingScanBudget = MAX_SCANNED_QSO_NUMBERS;
|
||||||
|
|
||||||
|
for (WinTestLogSegment segment : segments) {
|
||||||
|
for (long qsoNumber = segment.getCountFrom();
|
||||||
|
qsoNumber <= segment.getCountTo();
|
||||||
|
qsoNumber++) {
|
||||||
|
|
||||||
|
remainingScanBudget--;
|
||||||
|
if (remainingScanBudget < 0L) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedQsoNumbers != null && receivedQsoNumbers.contains(qsoNumber)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long countFrom = qsoNumber;
|
||||||
|
long countTo = countFrom;
|
||||||
|
|
||||||
|
while (countTo < segment.getCountTo()
|
||||||
|
&& countTo - countFrom + 1L < MAX_QSOS_PER_REQUEST
|
||||||
|
&& (receivedQsoNumbers == null || !receivedQsoNumbers.contains(countTo + 1L))) {
|
||||||
|
countTo++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new long[] { countFrom, countTo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the next fixed block of a log whose station never sent a usable
|
||||||
|
* inventory.
|
||||||
|
*
|
||||||
|
* @param now current time in milliseconds
|
||||||
|
* @return {@code true} when a request was sent
|
||||||
|
*/
|
||||||
|
private boolean requestNextBlindBlock(long now) {
|
||||||
|
if (usableInventorySeen || firstStationSeenAtMs == 0L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (now - firstStationSeenAtMs < INVENTORY_GRACE_PERIOD_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String logId : new ArrayList<>(receivedQsoNumbersByLogId.keySet())) {
|
||||||
|
BlindScan blindScan = blindScansByLogId.computeIfAbsent(logId, key -> new BlindScan());
|
||||||
|
|
||||||
|
if (blindScan.completed || blindScan.requestedBlockCount >= MAX_BLIND_BLOCKS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String targetStation = resolveStationForLogId(logId);
|
||||||
|
if (targetStation == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
blindScan.requestedBlockCount++;
|
||||||
|
sendRequest(
|
||||||
|
targetStation,
|
||||||
|
logId,
|
||||||
|
blindScan.nextCountFrom,
|
||||||
|
blindScan.nextCountFrom + MAX_QSOS_PER_REQUEST - 1L,
|
||||||
|
true,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishBlindBlock(PendingRequest finishedRequest) {
|
||||||
|
BlindScan blindScan = blindScansByLogId.get(finishedRequest.logId);
|
||||||
|
if (blindScan == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("[WinTest LogSync] blind block " + finishedRequest.countFrom
|
||||||
|
+ "-" + finishedRequest.countTo + " of " + finishedRequest.logId
|
||||||
|
+ " answered with " + finishedRequest.answeredQsoCount + " QSOs");
|
||||||
|
|
||||||
|
if (finishedRequest.answeredQsoCount == 0) {
|
||||||
|
// the log ends before this block, nothing left to fetch
|
||||||
|
blindScan.completed = true;
|
||||||
|
} else {
|
||||||
|
blindScan.nextCountFrom = finishedRequest.countTo + 1L;
|
||||||
|
}
|
||||||
|
|
||||||
|
tickDueImmediately = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Win-Test station a log belongs to. The log identity carries
|
||||||
|
* the owning station name in front of the {@code @} separator.
|
||||||
|
*
|
||||||
|
* @param logId log identity
|
||||||
|
* @return station name to ask, or {@code null} when none is known
|
||||||
|
*/
|
||||||
|
private String resolveStationForLogId(String logId) {
|
||||||
|
int separatorIndex = logId.indexOf('@');
|
||||||
|
String ownerStationName = separatorIndex > 0 ? logId.substring(0, separatorIndex) : logId;
|
||||||
|
|
||||||
|
for (String stationName : inventoriesByStation.keySet()) {
|
||||||
|
if (stationName.equalsIgnoreCase(ownerStationName)) {
|
||||||
|
return stationName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ownerStationName.isBlank() ? null : ownerStationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendRequest(
|
||||||
|
String targetStation,
|
||||||
|
String logId,
|
||||||
|
long countFrom,
|
||||||
|
long countTo,
|
||||||
|
boolean blind,
|
||||||
|
long now
|
||||||
|
) {
|
||||||
|
pendingRequest = new PendingRequest(targetStation, logId, countFrom, countTo, blind, now);
|
||||||
|
state = SyncState.SYNCING;
|
||||||
|
|
||||||
|
try {
|
||||||
|
needQsoSender.sendNeedQso(targetStation, logId, countFrom, countTo);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
/*
|
||||||
|
* A failed transmission must not stop the receive loop. The pending
|
||||||
|
* request runs into its timeout and is retried from there.
|
||||||
|
*/
|
||||||
|
System.out.println(
|
||||||
|
"[WinTest LogSync] NEEDQSO could not be sent: " + exception.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveOwnStationName() {
|
||||||
|
if (ownStationNameSupplier == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
String ownStationName = ownStationNameSupplier.get();
|
||||||
|
return ownStationName == null ? "" : ownStationName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request that is waiting for its answer. */
|
||||||
|
private static final class PendingRequest {
|
||||||
|
|
||||||
|
private final String targetStation;
|
||||||
|
private final String logId;
|
||||||
|
private final long countFrom;
|
||||||
|
private final long countTo;
|
||||||
|
private final boolean blind;
|
||||||
|
private final long sentAtMs;
|
||||||
|
private int answeredQsoCount;
|
||||||
|
|
||||||
|
private PendingRequest(
|
||||||
|
String targetStation,
|
||||||
|
String logId,
|
||||||
|
long countFrom,
|
||||||
|
long countTo,
|
||||||
|
boolean blind,
|
||||||
|
long sentAtMs
|
||||||
|
) {
|
||||||
|
this.targetStation = targetStation;
|
||||||
|
this.logId = logId;
|
||||||
|
this.countFrom = countFrom;
|
||||||
|
this.countTo = countTo;
|
||||||
|
this.blind = blind;
|
||||||
|
this.sentAtMs = sentAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getRequestedQsoCount() {
|
||||||
|
return countTo - countFrom + 1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress of the blind fallback for one log. */
|
||||||
|
private static final class BlindScan {
|
||||||
|
private long nextCountFrom = 1L;
|
||||||
|
private int requestedBlockCount;
|
||||||
|
private boolean completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import java.nio.charset.StandardCharsets;
|
|||||||
*/
|
*/
|
||||||
public class WinTestMessage {
|
public class WinTestMessage {
|
||||||
|
|
||||||
/** Win-Test message types relevant for SKED management. */
|
/** Win-Test message types sent by KST4Contest. */
|
||||||
public enum MessageType {
|
public enum MessageType {
|
||||||
|
/** Requests a range of QSOs of one Win-Test log for log synchronization. */
|
||||||
|
NEEDQSO,
|
||||||
LOCKSKED,
|
LOCKSKED,
|
||||||
UNLOCKSKED,
|
UNLOCKSKED,
|
||||||
ADDSKED,
|
ADDSKED,
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InterfaceAddress;
|
||||||
|
import java.net.NetworkInterface;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the broadcast address used to talk to the Win-Test network.
|
||||||
|
*
|
||||||
|
* <p>Win-Test only reacts to broadcast packets; a unicast request to the same
|
||||||
|
* station stays unanswered. The configured broadcast address is therefore the
|
||||||
|
* one setting that silently disables every outgoing Win-Test feature when it is
|
||||||
|
* wrong: sending to an address outside the local networks succeeds without an
|
||||||
|
* error and the packet is routed away.</p>
|
||||||
|
*
|
||||||
|
* <p>Incoming Win-Test packets carry the information that is actually needed.
|
||||||
|
* The source address of a received packet identifies the network the station
|
||||||
|
* lives in, so the broadcast address of the matching local interface reaches it
|
||||||
|
* reliably. The configured address remains the fallback and keeps working for a
|
||||||
|
* station behind a router, where no local interface matches.</p>
|
||||||
|
*/
|
||||||
|
public class WinTestNetworkAddressResolver {
|
||||||
|
|
||||||
|
/** Last resort when neither a station nor a usable setting is available. */
|
||||||
|
private static final String LIMITED_BROADCAST_ADDRESS = "255.255.255.255";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the local broadcast address for a remote address.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface LocalBroadcastLookup {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param remoteAddress address a Win-Test packet was received from
|
||||||
|
* @return broadcast address of the matching local interface, or
|
||||||
|
* {@code null} when no local interface serves that network
|
||||||
|
*/
|
||||||
|
InetAddress findBroadcastFor(InetAddress remoteAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final LocalBroadcastLookup localBroadcastLookup;
|
||||||
|
|
||||||
|
private volatile InetAddress lastStationAddress;
|
||||||
|
|
||||||
|
private volatile String lastReportedBroadcastAddress;
|
||||||
|
|
||||||
|
public WinTestNetworkAddressResolver() {
|
||||||
|
this(WinTestNetworkAddressResolver::findLocalBroadcastAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param localBroadcastLookup interface lookup, replaceable for tests
|
||||||
|
*/
|
||||||
|
WinTestNetworkAddressResolver(LocalBroadcastLookup localBroadcastLookup) {
|
||||||
|
this.localBroadcastLookup = localBroadcastLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers where Win-Test packets come from.
|
||||||
|
*
|
||||||
|
* <p>Only addresses of real Win-Test stations may be passed in. Loopback and
|
||||||
|
* wildcard addresses are ignored, so an internal control packet cannot
|
||||||
|
* redirect outgoing Win-Test traffic.</p>
|
||||||
|
*
|
||||||
|
* @param stationAddress source address of a received Win-Test packet
|
||||||
|
*/
|
||||||
|
public void rememberStationAddress(InetAddress stationAddress) {
|
||||||
|
if (stationAddress == null
|
||||||
|
|| stationAddress.isLoopbackAddress()
|
||||||
|
|| stationAddress.isAnyLocalAddress()
|
||||||
|
|| !(stationAddress instanceof Inet4Address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastStationAddress = stationAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the broadcast address for outgoing Win-Test packets.
|
||||||
|
*
|
||||||
|
* <p>Order of preference: the broadcast address of the local interface that
|
||||||
|
* serves the last seen Win-Test station, then the configured address, then
|
||||||
|
* the limited broadcast address.</p>
|
||||||
|
*
|
||||||
|
* @param configuredBroadcastAddress address from the settings, may be blank
|
||||||
|
* @return address to send Win-Test packets to
|
||||||
|
* @throws UnknownHostException if the configured address cannot be resolved
|
||||||
|
* and the limited broadcast address fails too
|
||||||
|
*/
|
||||||
|
public InetAddress resolveBroadcastAddress(String configuredBroadcastAddress)
|
||||||
|
throws UnknownHostException {
|
||||||
|
|
||||||
|
InetAddress stationAddress = this.lastStationAddress;
|
||||||
|
|
||||||
|
if (stationAddress != null) {
|
||||||
|
InetAddress derivedBroadcastAddress =
|
||||||
|
localBroadcastLookup.findBroadcastFor(stationAddress);
|
||||||
|
|
||||||
|
if (derivedBroadcastAddress != null) {
|
||||||
|
reportDerivedAddress(derivedBroadcastAddress, configuredBroadcastAddress);
|
||||||
|
return derivedBroadcastAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredBroadcastAddress != null && !configuredBroadcastAddress.isBlank()) {
|
||||||
|
return InetAddress.getByName(configuredBroadcastAddress.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return InetAddress.getByName(LIMITED_BROADCAST_ADDRESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a derived address once as long as it stays the same, and points out
|
||||||
|
* a configured address that does not match the Win-Test network.
|
||||||
|
*/
|
||||||
|
private void reportDerivedAddress(
|
||||||
|
InetAddress derivedBroadcastAddress,
|
||||||
|
String configuredBroadcastAddress
|
||||||
|
) {
|
||||||
|
String derivedHostAddress = derivedBroadcastAddress.getHostAddress();
|
||||||
|
|
||||||
|
if (derivedHostAddress.equals(lastReportedBroadcastAddress)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReportedBroadcastAddress = derivedHostAddress;
|
||||||
|
|
||||||
|
String configuredHostAddress = configuredBroadcastAddress == null
|
||||||
|
? "" : configuredBroadcastAddress.trim();
|
||||||
|
|
||||||
|
if (derivedHostAddress.equals(configuredHostAddress)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("[WinTest] using broadcast address " + derivedHostAddress
|
||||||
|
+ " of the network Win-Test was heard on, configured is '"
|
||||||
|
+ configuredHostAddress + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches the local interfaces for the network a remote address belongs to.
|
||||||
|
*
|
||||||
|
* @param remoteAddress address of a Win-Test station
|
||||||
|
* @return broadcast address of the matching interface, or {@code null}
|
||||||
|
*/
|
||||||
|
static InetAddress findLocalBroadcastAddress(InetAddress remoteAddress) {
|
||||||
|
if (!(remoteAddress instanceof Inet4Address)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Enumeration<NetworkInterface> networkInterfaces =
|
||||||
|
NetworkInterface.getNetworkInterfaces();
|
||||||
|
|
||||||
|
while (networkInterfaces != null && networkInterfaces.hasMoreElements()) {
|
||||||
|
NetworkInterface networkInterface = networkInterfaces.nextElement();
|
||||||
|
|
||||||
|
for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) {
|
||||||
|
InetAddress broadcastAddress = interfaceAddress.getBroadcast();
|
||||||
|
|
||||||
|
if (broadcastAddress == null
|
||||||
|
|| !(interfaceAddress.getAddress() instanceof Inet4Address)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInSameSubnet(
|
||||||
|
interfaceAddress.getAddress(),
|
||||||
|
remoteAddress,
|
||||||
|
interfaceAddress.getNetworkPrefixLength())) {
|
||||||
|
return broadcastAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SocketException exception) {
|
||||||
|
System.out.println("[WinTest] could not inspect local interfaces: "
|
||||||
|
+ exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two IPv4 addresses up to the given network prefix length.
|
||||||
|
*
|
||||||
|
* @param localAddress address of a local interface
|
||||||
|
* @param remoteAddress address of the Win-Test station
|
||||||
|
* @param networkPrefixLength prefix length of the local interface
|
||||||
|
* @return {@code true} when both addresses share the same network
|
||||||
|
*/
|
||||||
|
static boolean isInSameSubnet(
|
||||||
|
InetAddress localAddress,
|
||||||
|
InetAddress remoteAddress,
|
||||||
|
int networkPrefixLength
|
||||||
|
) {
|
||||||
|
if (localAddress == null || remoteAddress == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] localBytes = localAddress.getAddress();
|
||||||
|
byte[] remoteBytes = remoteAddress.getAddress();
|
||||||
|
|
||||||
|
if (localBytes.length != remoteBytes.length
|
||||||
|
|| networkPrefixLength < 0
|
||||||
|
|| networkPrefixLength > localBytes.length * 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int remainingPrefixBits = networkPrefixLength;
|
||||||
|
|
||||||
|
for (int byteIndex = 0; byteIndex < localBytes.length && remainingPrefixBits > 0; byteIndex++) {
|
||||||
|
int comparedBits = Math.min(8, remainingPrefixBits);
|
||||||
|
int mask = (0xFF << (8 - comparedBits)) & 0xFF;
|
||||||
|
|
||||||
|
if ((localBytes[byteIndex] & mask) != (remoteBytes[byteIndex] & mask)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingPrefixBits -= comparedBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Win-Test network packet received over UDP.
|
||||||
|
*
|
||||||
|
* <p>This is the receiving counterpart of {@link WinTestMessage} and follows the
|
||||||
|
* same framing:</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* MESSAGETYPE: "src" "dst" data{checksum}\0
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>The checksum byte always has bit 7 set and is therefore not valid ASCII.
|
||||||
|
* Decoding the datagram as text before removing it turns the byte into a
|
||||||
|
* replacement character that sticks to the last data field. That is harmless
|
||||||
|
* for fields KST4Contest never reads, but the log synchronization needs exactly
|
||||||
|
* those trailing fields: the log ID of an {@code ADDQSO} packet and the
|
||||||
|
* run-length inventory of an {@code IHAVE} packet. The framing is therefore
|
||||||
|
* resolved on the raw bytes here, once, before any text parsing.</p>
|
||||||
|
*/
|
||||||
|
public final class WinTestPacket {
|
||||||
|
|
||||||
|
/** Quoted values stay one token, unquoted values are split at whitespace. */
|
||||||
|
private static final Pattern TOKEN_PATTERN = Pattern.compile("\"([^\"]*)\"|(\\S+)");
|
||||||
|
|
||||||
|
private final String messageType;
|
||||||
|
private final String source;
|
||||||
|
private final String destination;
|
||||||
|
private final String messageText;
|
||||||
|
private final List<String> dataTokens;
|
||||||
|
private final boolean checksumPresent;
|
||||||
|
private final boolean checksumValid;
|
||||||
|
|
||||||
|
private WinTestPacket(
|
||||||
|
String messageType,
|
||||||
|
String source,
|
||||||
|
String destination,
|
||||||
|
String messageText,
|
||||||
|
List<String> dataTokens,
|
||||||
|
boolean checksumPresent,
|
||||||
|
boolean checksumValid
|
||||||
|
) {
|
||||||
|
this.messageType = messageType;
|
||||||
|
this.source = source;
|
||||||
|
this.destination = destination;
|
||||||
|
this.messageText = messageText;
|
||||||
|
this.dataTokens = dataTokens;
|
||||||
|
this.checksumPresent = checksumPresent;
|
||||||
|
this.checksumValid = checksumValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a packet from a received datagram.
|
||||||
|
*
|
||||||
|
* <p>Trailing NUL bytes are removed first. If the resulting last byte has
|
||||||
|
* bit 7 set it is the Win-Test checksum: it is verified against the sum of
|
||||||
|
* all preceding bytes and removed before the message text is decoded.</p>
|
||||||
|
*
|
||||||
|
* @param datagram raw datagram buffer
|
||||||
|
* @param length number of valid bytes in the buffer
|
||||||
|
* @return parsed packet, or {@code null} when the datagram carries no message
|
||||||
|
*/
|
||||||
|
public static WinTestPacket fromDatagram(byte[] datagram, int length) {
|
||||||
|
if (datagram == null || length <= 0 || length > datagram.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int endIndex = length;
|
||||||
|
while (endIndex > 0 && datagram[endIndex - 1] == 0) {
|
||||||
|
endIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasChecksum = (datagram[endIndex - 1] & 0x80) != 0;
|
||||||
|
boolean isChecksumValid = false;
|
||||||
|
int textEndIndex = endIndex;
|
||||||
|
|
||||||
|
if (hasChecksum) {
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < endIndex - 1; index++) {
|
||||||
|
sum += datagram[index] & 0xFF;
|
||||||
|
}
|
||||||
|
byte expectedChecksum = (byte) ((sum | 0x80) & 0xFF);
|
||||||
|
isChecksumValid = expectedChecksum == datagram[endIndex - 1];
|
||||||
|
textEndIndex = endIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = new String(datagram, 0, textEndIndex, StandardCharsets.US_ASCII);
|
||||||
|
return fromMessageText(text, hasChecksum, isChecksumValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a packet from an already decoded message text without checksum
|
||||||
|
* information. Used for messages that reach the listener as text.
|
||||||
|
*
|
||||||
|
* @param messageText complete message text
|
||||||
|
* @return parsed packet, or {@code null} for an unusable message
|
||||||
|
*/
|
||||||
|
public static WinTestPacket fromMessageText(String messageText) {
|
||||||
|
return fromMessageText(messageText, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WinTestPacket fromMessageText(
|
||||||
|
String rawMessageText,
|
||||||
|
boolean checksumPresent,
|
||||||
|
boolean checksumValid
|
||||||
|
) {
|
||||||
|
if (rawMessageText == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = rawMessageText.trim();
|
||||||
|
int typeEndIndex = text.indexOf(": ");
|
||||||
|
if (typeEndIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String type = text.substring(0, typeEndIndex);
|
||||||
|
List<String> tokens = tokenize(text.substring(typeEndIndex + 2));
|
||||||
|
|
||||||
|
String packetSource = tokens.isEmpty() ? "" : tokens.get(0);
|
||||||
|
String packetDestination = tokens.size() > 1 ? tokens.get(1) : "";
|
||||||
|
List<String> data = tokens.size() > 2
|
||||||
|
? new ArrayList<>(tokens.subList(2, tokens.size()))
|
||||||
|
: new ArrayList<>();
|
||||||
|
|
||||||
|
return new WinTestPacket(
|
||||||
|
type,
|
||||||
|
packetSource,
|
||||||
|
packetDestination,
|
||||||
|
text,
|
||||||
|
data,
|
||||||
|
checksumPresent,
|
||||||
|
checksumValid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits Win-Test payload text into fields. Quoted values are kept together
|
||||||
|
* and empty quoted values are preserved, so field positions stay stable.
|
||||||
|
*
|
||||||
|
* @param text payload text
|
||||||
|
* @return field values without their surrounding quotes
|
||||||
|
*/
|
||||||
|
static List<String> tokenize(String text) {
|
||||||
|
List<String> tokens = new ArrayList<>();
|
||||||
|
if (text == null) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = TOKEN_PATTERN.matcher(text);
|
||||||
|
while (matcher.find()) {
|
||||||
|
tokens.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return message type such as {@code ADDQSO}, never {@code null}
|
||||||
|
*/
|
||||||
|
public String getMessageType() {
|
||||||
|
return messageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Win-Test station that sent the packet
|
||||||
|
*/
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return addressed Win-Test station, empty for a broadcast
|
||||||
|
*/
|
||||||
|
public String getDestination() {
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return complete message text without checksum byte and NUL terminator
|
||||||
|
*/
|
||||||
|
public String getMessageText() {
|
||||||
|
return messageText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return payload fields following source and destination
|
||||||
|
*/
|
||||||
|
public List<String> getDataTokens() {
|
||||||
|
return Collections.unmodifiableList(dataTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param index payload field position
|
||||||
|
* @return field value, or {@code null} when the field is missing
|
||||||
|
*/
|
||||||
|
public String getDataToken(int index) {
|
||||||
|
return index >= 0 && index < dataTokens.size() ? dataTokens.get(index) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isChecksumPresent() {
|
||||||
|
return checksumPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isChecksumValid() {
|
||||||
|
return checksumValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether this packet is meant for us.
|
||||||
|
*
|
||||||
|
* @param ownStationName own Win-Test station name
|
||||||
|
* @return {@code true} for a broadcast or for a packet addressed to us
|
||||||
|
*/
|
||||||
|
public boolean isAddressedTo(String ownStationName) {
|
||||||
|
if (destination == null || destination.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ownStationName != null && destination.equalsIgnoreCase(ownStationName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return messageType + ": src=" + source + " dst=" + destination
|
||||||
|
+ " fields=" + dataTokens.size()
|
||||||
|
+ (checksumPresent ? (checksumValid ? " checksum=ok" : " checksum=bad") : " checksum=none");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ import java.util.regex.Pattern;
|
|||||||
* Common parser for explicit amateur-radio frequencies embedded in text.
|
* Common parser for explicit amateur-radio frequencies embedded in text.
|
||||||
*
|
*
|
||||||
* <p>This parser deliberately handles only complete frequencies such as
|
* <p>This parser deliberately handles only complete frequencies such as
|
||||||
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
|
* 144.300, 432.357, 10368.100 or their compact digit-only forms. Relative
|
||||||
* bare values such as "210" require additional message context and remain the
|
* forms such as ".210" or ambiguous bare values such as "210" require
|
||||||
* responsibility of the chat-message parser.</p>
|
* additional message context and remain the responsibility of the
|
||||||
|
* chat-message parser.</p>
|
||||||
*/
|
*/
|
||||||
public final class FrequencyTextParser {
|
public final class FrequencyTextParser {
|
||||||
|
|
||||||
@@ -26,14 +27,18 @@ public final class FrequencyTextParser {
|
|||||||
* 432,357
|
* 432,357
|
||||||
* 10368.100
|
* 10368.100
|
||||||
* 144.300.03
|
* 144.300.03
|
||||||
|
* 144300
|
||||||
|
* 10368100
|
||||||
*
|
*
|
||||||
* At least two digits are required before the decimal separator. This
|
* At least two digits are required before a decimal separator. Compact
|
||||||
* intentionally prevents "1.2" from being interpreted as a frequency.
|
* values need at least five digits because their final three digits form
|
||||||
|
* the kHz part. This intentionally prevents "1.2" and bare values such as
|
||||||
|
* "210" from being interpreted as complete frequencies.
|
||||||
*/
|
*/
|
||||||
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
|
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
|
||||||
"(?<![A-Z0-9])"
|
"(?<![A-Z0-9])"
|
||||||
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
|
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
|
||||||
+ "(?!\\d)",
|
+ "(?![A-Z0-9])",
|
||||||
Pattern.CASE_INSENSITIVE
|
Pattern.CASE_INSENSITIVE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,12 +98,12 @@ public final class FrequencyTextParser {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String normalized =
|
final String trimmedFrequency = rawFrequency.trim();
|
||||||
normalizeFrequencyString(
|
final String normalized = trimmedFrequency.matches("\\d{5,8}")
|
||||||
rawFrequency
|
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
|
||||||
.trim()
|
+ "."
|
||||||
.replace(',', '.')
|
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
|
||||||
);
|
: normalizeFrequencyString(trimmedFrequency.replace(',', '.'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
double frequencyMHz =
|
double frequencyMHz =
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package kst4contest.model;
|
package kst4contest.model;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.util.ArrayList;
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.OptionalDouble;
|
||||||
|
|
||||||
import javax.xml.XMLConstants;
|
import javax.xml.XMLConstants;
|
||||||
import javax.xml.parsers.DocumentBuilder;
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
@@ -50,7 +55,7 @@ public class ChatPreferences {
|
|||||||
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
|
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
|
||||||
*/
|
*/
|
||||||
// private static final int CONFIG_VERSION = 2;
|
// private static final int CONFIG_VERSION = 2;
|
||||||
public static final int CONFIG_VERSION = 5;
|
public static final int CONFIG_VERSION = 7;
|
||||||
|
|
||||||
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
|
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
|
||||||
private static final String TAG_CONFIG_VERSION = "configVersion";
|
private static final String TAG_CONFIG_VERSION = "configVersion";
|
||||||
@@ -143,7 +148,7 @@ public class ChatPreferences {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
String programVersion = "Chat is powered by ON4KST \n\nUsage is free. You are welcome to support: \n\n- my project (donations, bugreports, good ideas are welcome), \n- ON4KST Servers, \n- AirScout developers and \n- OV3T (best AS-data provider of the world). \n\n73 de DO5AMF, Marc (DM5M / DARC X08)";
|
String programVersion = "Chat is powered by ON4KST \n\nUsage is free. You are welcome to support: \n\n- my project (donations, bugreports, good ideas are welcome), \n- ON4KST Servers, \n- AirScout developers and \n- OV3T (best AS-data provider of the world). \n\n73 de DO5AMF, Marc (DM5M / DARC X08)\nand DN9APW, Philipp Wagner";
|
||||||
String logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = "udpReaderBackup.txt";
|
String logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = "udpReaderBackup.txt";
|
||||||
String storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, PREFERENCES_FILE);
|
String storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, PREFERENCES_FILE);
|
||||||
String chatState; // working variable only for use by primarystage (title bar)
|
String chatState; // working variable only for use by primarystage (title bar)
|
||||||
@@ -341,6 +346,12 @@ public class ChatPreferences {
|
|||||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||||
private boolean GUIstationMapPathAnalysisVisible = true;
|
private boolean GUIstationMapPathAnalysisVisible = true;
|
||||||
|
private boolean GUIstationMapClusteringEnabled = true;
|
||||||
|
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
|
||||||
|
private static final double MIN_TABLE_COLUMN_WIDTH = 16.0;
|
||||||
|
private static final double MAX_TABLE_COLUMN_WIDTH = 10_000.0;
|
||||||
|
|
||||||
|
|
||||||
/*********************************************************************************
|
/*********************************************************************************
|
||||||
@@ -645,6 +656,39 @@ public class ChatPreferences {
|
|||||||
this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible;
|
this.GUIstationMapPathAnalysisVisible = GUIstationMapPathAnalysisVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isGUIstationMapClusteringEnabled() {
|
||||||
|
return GUIstationMapClusteringEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGUIstationMapClusteringEnabled(boolean GUIstationMapClusteringEnabled) {
|
||||||
|
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stored width for one stable table/leaf-column identity.
|
||||||
|
*
|
||||||
|
* @param tableId stable table layout identifier
|
||||||
|
* @param columnId stable leaf-column identifier
|
||||||
|
* @return stored pixel width, or empty when no usable value exists
|
||||||
|
*/
|
||||||
|
public synchronized OptionalDouble getTableColumnWidth(String tableId, String columnId) {
|
||||||
|
if (!isValidTableColumnIdentity(tableId, columnId)) {
|
||||||
|
return OptionalDouble.empty();
|
||||||
|
}
|
||||||
|
Double width = tableColumnWidths.get(tableColumnWidthKey(tableId, columnId));
|
||||||
|
return isValidTableColumnWidth(width) ? OptionalDouble.of(width) : OptionalDouble.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates one table leaf-column width in memory. Persistence is coordinated by
|
||||||
|
* the layout autosave layer.
|
||||||
|
*/
|
||||||
|
public synchronized void setTableColumnWidth(String tableId, String columnId, double width) {
|
||||||
|
if (isValidTableColumnIdentity(tableId, columnId) && isValidTableColumnWidth(width)) {
|
||||||
|
tableColumnWidths.put(tableColumnWidthKey(tableId, columnId), width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isGuiOptions_defaultFilterNothing() {
|
public boolean isGuiOptions_defaultFilterNothing() {
|
||||||
return guiOptions_defaultFilterNothing;
|
return guiOptions_defaultFilterNothing;
|
||||||
}
|
}
|
||||||
@@ -1389,7 +1433,7 @@ public class ChatPreferences {
|
|||||||
*
|
*
|
||||||
* @return true if the file writing was successful, else false
|
* @return true if the file writing was successful, else false
|
||||||
*/
|
*/
|
||||||
public boolean writePreferencesToXmlFile() {
|
public synchronized boolean writePreferencesToXmlFile() {
|
||||||
|
|
||||||
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
|
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
|
||||||
try {
|
try {
|
||||||
@@ -2085,27 +2129,25 @@ public class ChatPreferences {
|
|||||||
);
|
);
|
||||||
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
|
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
|
||||||
|
|
||||||
|
Element GUIstationMapClusteringEnabled = doc.createElement("GUIstationMapClusteringEnabled");
|
||||||
|
GUIstationMapClusteringEnabled.setTextContent(
|
||||||
|
String.valueOf(this.isGUIstationMapClusteringEnabled())
|
||||||
|
);
|
||||||
|
guiOptions.appendChild(GUIstationMapClusteringEnabled);
|
||||||
|
|
||||||
|
appendTableColumnWidths(doc, guiOptions);
|
||||||
|
|
||||||
/****************************************************************************************
|
/****************************************************************************************
|
||||||
****************************** now write this XML! *************************************
|
****************************** now write this XML! *************************************
|
||||||
****************************************************************************************/
|
****************************************************************************************/
|
||||||
|
|
||||||
writeXml(doc, System.out);
|
writeDocumentAtomically(doc);
|
||||||
|
|
||||||
// write dom document to a file
|
|
||||||
try (FileOutputStream output =
|
|
||||||
new FileOutputStream(storeAndRestorePreferencesFileName)) {
|
|
||||||
writeXml(doc, output);
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
} catch (TransformerException e) {
|
|
||||||
// TODO Auto-generated catch block
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} catch (ParserConfigurationException | TransformerException e1) {
|
} catch (ParserConfigurationException | TransformerException | IOException e1) {
|
||||||
// TODO Auto-generated catch block
|
// TODO Auto-generated catch block
|
||||||
e1.printStackTrace();
|
e1.printStackTrace();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -2117,6 +2159,113 @@ public class ChatPreferences {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes only layout values into the existing preferences document. Functional
|
||||||
|
* settings are deliberately read from disk and left untouched.
|
||||||
|
*/
|
||||||
|
public synchronized boolean writeLayoutPreferencesToXmlFile() {
|
||||||
|
Path preferencesPath = Path.of(storeAndRestorePreferencesFileName).toAbsolutePath();
|
||||||
|
if (!Files.isRegularFile(preferencesPath)) {
|
||||||
|
System.out.println("[ChatPreferences, Warning]: Cannot autosave layout because preferences.xml does not exist.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentBuilder documentBuilder = createSecureDocumentBuilderFactory().newDocumentBuilder();
|
||||||
|
Document document = documentBuilder.parse(preferencesPath.toFile());
|
||||||
|
Element root = document.getDocumentElement();
|
||||||
|
if (root == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertDirectChildText(document, root, TAG_CONFIG_VERSION, String.valueOf(CONFIG_VERSION));
|
||||||
|
Element guiOptions = getDirectChildElement(root, "guiOptions");
|
||||||
|
if (guiOptions == null) {
|
||||||
|
guiOptions = document.createElement("guiOptions");
|
||||||
|
root.appendChild(guiOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayoutElements(document, guiOptions);
|
||||||
|
removeDirectChildren(guiOptions, TAG_TABLE_COLUMN_WIDTH);
|
||||||
|
appendTableColumnWidths(document, guiOptions);
|
||||||
|
writeDocumentAtomically(document);
|
||||||
|
return true;
|
||||||
|
} catch (ParserConfigurationException | SAXException | IOException | TransformerException exception) {
|
||||||
|
exception.printStackTrace();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateLayoutElements(Document document, Element guiOptions) {
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIscn_ChatwindowMainSceneSizeHW",
|
||||||
|
getGUIscn_ChatwindowMainSceneSizeHW()[0] + ";" + getGUIscn_ChatwindowMainSceneSizeHW()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIclusterAndQSOMonStage_SceneSizeHW",
|
||||||
|
getGUIclusterAndQSOMonStage_SceneSizeHW()[0] + ";" + getGUIclusterAndQSOMonStage_SceneSizeHW()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIstage_updateStage_SceneSizeHW",
|
||||||
|
getGUIstage_updateStage_SceneSizeHW()[0] + ";" + getGUIstage_updateStage_SceneSizeHW()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIsettingsStageSceneSizeHW",
|
||||||
|
getGUIsettingsStageSceneSizeHW()[0] + ";" + getGUIsettingsStageSceneSizeHW()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIselectedCallSignSplitPane_dividerposition",
|
||||||
|
doubleArrayToCSVString(getGUIselectedCallSignSplitPane_dividerposition()));
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUImainWindowLeftSplitPane_dividerposition",
|
||||||
|
doubleArrayToCSVString(getGUImainWindowLeftSplitPane_dividerposition()));
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUImessageSectionSplitpane_dividerposition",
|
||||||
|
doubleArrayToCSVString(getGUImessageSectionSplitpane_dividerposition()));
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUImainWindowRightSplitPane_dividerposition",
|
||||||
|
doubleArrayToCSVString(getGUImainWindowRightSplitPane_dividerposition()));
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIpnl_directedMSGWin_dividerpositionDefault",
|
||||||
|
doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIstationMapStageSceneSizeHW",
|
||||||
|
getGUIstationMapStageSceneSizeHW()[0] + ";" + getGUIstationMapStageSceneSizeHW()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIstationMapStagePositionXY",
|
||||||
|
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
|
||||||
|
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
|
||||||
|
String.valueOf(isGUIstationMapClusteringEnabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendTableColumnWidths(Document document, Element guiOptions) {
|
||||||
|
for (Map.Entry<String, Double> entry : tableColumnWidths.entrySet()) {
|
||||||
|
if (!isValidTableColumnWidth(entry.getValue())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int separatorIndex = entry.getKey().indexOf('\u0000');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex >= entry.getKey().length() - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Element widthElement = document.createElement(TAG_TABLE_COLUMN_WIDTH);
|
||||||
|
widthElement.setAttribute("tableId", entry.getKey().substring(0, separatorIndex));
|
||||||
|
widthElement.setAttribute("columnId", entry.getKey().substring(separatorIndex + 1));
|
||||||
|
widthElement.setAttribute("pixels", String.valueOf(entry.getValue()));
|
||||||
|
guiOptions.appendChild(widthElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeDocumentAtomically(Document document) throws IOException, TransformerException {
|
||||||
|
Path target = Path.of(storeAndRestorePreferencesFileName).toAbsolutePath();
|
||||||
|
Path parent = target.getParent();
|
||||||
|
Path fileName = target.getFileName();
|
||||||
|
if (parent == null || fileName == null) {
|
||||||
|
throw new IOException("Preferences path has no parent directory: " + target);
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
Path temporary = Files.createTempFile(parent, fileName.toString(), ".tmp");
|
||||||
|
try {
|
||||||
|
try (OutputStream output = Files.newOutputStream(temporary)) {
|
||||||
|
writeXml(document, output);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException exception) {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// write doc to output stream
|
// write doc to output stream
|
||||||
private static void writeXml(Document doc, OutputStream output) throws TransformerException {
|
private static void writeXml(Document doc, OutputStream output) throws TransformerException {
|
||||||
|
|
||||||
@@ -2824,6 +2973,7 @@ public class ChatPreferences {
|
|||||||
* case read GUI options
|
* case read GUI options
|
||||||
*
|
*
|
||||||
***********************************************/
|
***********************************************/
|
||||||
|
this.setGUIstationMapClusteringEnabled(true);
|
||||||
list = doc.getElementsByTagName("guiOptions");
|
list = doc.getElementsByTagName("guiOptions");
|
||||||
if (list.getLength() != 0) {
|
if (list.getLength() != 0) {
|
||||||
|
|
||||||
@@ -2862,6 +3012,17 @@ public class ChatPreferences {
|
|||||||
"GUIstationMapPathAnalysisVisible"
|
"GUIstationMapPathAnalysisVisible"
|
||||||
));
|
));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Files written before config version 7 do not contain this value.
|
||||||
|
* Missing or malformed values keep clustering enabled so existing
|
||||||
|
* installations retain the established map behaviour.
|
||||||
|
*/
|
||||||
|
this.setGUIstationMapClusteringEnabled(getBooleanOrDefault(
|
||||||
|
element,
|
||||||
|
true,
|
||||||
|
"GUIstationMapClusteringEnabled"
|
||||||
|
));
|
||||||
|
|
||||||
// Splitpane divider positions
|
// Splitpane divider positions
|
||||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||||
if (s1 != null) {
|
if (s1 != null) {
|
||||||
@@ -2920,6 +3081,22 @@ public class ChatPreferences {
|
|||||||
if (s5 != null) {
|
if (s5 != null) {
|
||||||
this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5));
|
this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tableColumnWidths.clear();
|
||||||
|
NodeList widthElements = element.getElementsByTagName(TAG_TABLE_COLUMN_WIDTH);
|
||||||
|
for (int widthIndex = 0; widthIndex < widthElements.getLength(); widthIndex++) {
|
||||||
|
Node widthNode = widthElements.item(widthIndex);
|
||||||
|
if (!(widthNode instanceof Element widthElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String tableId = widthElement.getAttribute("tableId");
|
||||||
|
String columnId = widthElement.getAttribute("columnId");
|
||||||
|
double width = parseDoubleOrDefault(widthElement.getAttribute("pixels"), Double.NaN);
|
||||||
|
if (isValidTableColumnIdentity(tableId, columnId) && isValidTableColumnWidth(width)) {
|
||||||
|
tableColumnWidths.put(tableColumnWidthKey(tableId, columnId), width);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3100,6 +3277,63 @@ public class ChatPreferences {
|
|||||||
return (n instanceof Element) ? (Element) n : null;
|
return (n instanceof Element) ? (Element) n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DocumentBuilderFactory createSecureDocumentBuilderFactory()
|
||||||
|
throws ParserConfigurationException {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Element getDirectChildElement(Element parent, String tagName) {
|
||||||
|
for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) {
|
||||||
|
if (child instanceof Element element && tagName.equals(element.getTagName())) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void upsertDirectChildText(
|
||||||
|
Document document,
|
||||||
|
Element parent,
|
||||||
|
String tagName,
|
||||||
|
String value
|
||||||
|
) {
|
||||||
|
Element element = getDirectChildElement(parent, tagName);
|
||||||
|
if (element == null) {
|
||||||
|
element = document.createElement(tagName);
|
||||||
|
parent.appendChild(element);
|
||||||
|
}
|
||||||
|
element.setTextContent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeDirectChildren(Element parent, String tagName) {
|
||||||
|
for (Node child = parent.getFirstChild(); child != null; ) {
|
||||||
|
Node next = child.getNextSibling();
|
||||||
|
if (child instanceof Element element && tagName.equals(element.getTagName())) {
|
||||||
|
parent.removeChild(child);
|
||||||
|
}
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tableColumnWidthKey(String tableId, String columnId) {
|
||||||
|
if (!isValidTableColumnIdentity(tableId, columnId)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return tableId + '\u0000' + columnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidTableColumnIdentity(String tableId, String columnId) {
|
||||||
|
return tableId != null && !tableId.isBlank() && tableId.indexOf('\u0000') < 0
|
||||||
|
&& columnId != null && !columnId.isBlank() && columnId.indexOf('\u0000') < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidTableColumnWidth(Double width) {
|
||||||
|
return width != null && Double.isFinite(width)
|
||||||
|
&& width >= MIN_TABLE_COLUMN_WIDTH && width <= MAX_TABLE_COLUMN_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the text content of the first matching child tag (directly under {@code parent})
|
* Returns the text content of the first matching child tag (directly under {@code parent})
|
||||||
* or {@code defaultValue} if the tag does not exist or is empty.
|
* or {@code defaultValue} if the tag does not exist or is empty.
|
||||||
@@ -3139,6 +3373,20 @@ public class ChatPreferences {
|
|||||||
return "true".equalsIgnoreCase(v) || "1".equals(v) || "yes".equalsIgnoreCase(v);
|
return "true".equalsIgnoreCase(v) || "1".equals(v) || "yes".equalsIgnoreCase(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean getBooleanOrDefault(Element parent, boolean defaultValue, String... tagNames) {
|
||||||
|
String value = getText(parent, null, tagNames);
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if ("true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ("false".equalsIgnoreCase(value) || "0".equals(value) || "no".equalsIgnoreCase(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
private static int getInt(Element parent, int defaultValue, String... tagNames) {
|
private static int getInt(Element parent, int defaultValue, String... tagNames) {
|
||||||
String v = getText(parent, null, tagNames);
|
String v = getText(parent, null, tagNames);
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
private StationMapView stationMapView; //view class for the avl stn map
|
private StationMapView stationMapView; //view class for the avl stn map
|
||||||
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
|
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
|
||||||
|
private LayoutAutosave layoutAutosave;
|
||||||
|
|
||||||
private final Button btnConnectionStateIndicator = new Button("LINK");
|
private final Button btnConnectionStateIndicator = new Button("LINK");
|
||||||
private final Tooltip tipConnectionStateIndicator = new Tooltip();
|
private final Tooltip tipConnectionStateIndicator = new Tooltip();
|
||||||
@@ -186,7 +187,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stationMapView = new StationMapView(chatcontroller.getChatPreferences());
|
stationMapView = new StationMapView(
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
this::requestLayoutSave
|
||||||
|
);
|
||||||
stationMapBridge = new StationMapBridge(
|
stationMapBridge = new StationMapBridge(
|
||||||
chatcontroller,
|
chatcontroller,
|
||||||
tbl_chatMember,
|
tbl_chatMember,
|
||||||
@@ -537,7 +541,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
* Builds the tooltip shown on a band-status cell: a fixed legend plus this row's
|
* Builds the tooltip shown on a band-status cell: a fixed legend plus this row's
|
||||||
* resolved status for the given band.
|
* resolved status for the given band.
|
||||||
*/
|
*/
|
||||||
private Tooltip buildBandCellStatusTooltip(ChatMember chatMember, Band band, String status) {
|
private String buildBandCellStatusTooltipText(ChatMember chatMember, Band band, String status) {
|
||||||
StringBuilder tooltip = new StringBuilder("Band status:\n")
|
StringBuilder tooltip = new StringBuilder("Band status:\n")
|
||||||
.append("X = worked on this band\n")
|
.append("X = worked on this band\n")
|
||||||
.append("B+ = band available, not worked on this band yet (call already worked on another band)\n")
|
.append("B+ = band available, not worked on this band yet (call already worked on another band)\n")
|
||||||
@@ -551,30 +555,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
.append("Status: ").append(status == null || status.isBlank() ? "-" : status);
|
.append("Status: ").append(status == null || status.isBlank() ? "-" : status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Tooltip(tooltip.toString());
|
return tooltip.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a shared cell factory for one band-status column, attaching the
|
* Creates a shared cell factory for one band-status column, attaching the
|
||||||
* {@link #buildBandCellStatusTooltip(ChatMember, Band, String)} tooltip.
|
* {@link #buildBandCellStatusTooltipText(ChatMember, Band, String)} tooltip.
|
||||||
*/
|
*/
|
||||||
private Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>> createBandStatusCellFactory(Band band) {
|
private Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>> createBandStatusCellFactory(Band band) {
|
||||||
return column -> new TableCell<ChatMember, String>() {
|
return column -> new TruncatedTextTableCell<ChatMember>(
|
||||||
|
java.util.function.Function.identity(),
|
||||||
|
(member, status) -> buildBandCellStatusTooltipText(member, band, status)
|
||||||
|
) {
|
||||||
@Override
|
@Override
|
||||||
protected void updateItem(String item, boolean empty) {
|
protected void updateItem(String item, boolean empty) {
|
||||||
super.updateItem(item, empty);
|
super.updateItem(item, empty);
|
||||||
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
setText(null);
|
|
||||||
setTooltip(null);
|
|
||||||
setStyle("");
|
setStyle("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
|
|
||||||
|
|
||||||
setText(item);
|
|
||||||
setTooltip(buildBandCellStatusTooltip(member, band, item));
|
|
||||||
setAlignment(Pos.CENTER);
|
setAlignment(Pos.CENTER);
|
||||||
setStyle("-fx-font-weight: bold;");
|
setStyle("-fx-font-weight: bold;");
|
||||||
}
|
}
|
||||||
@@ -1374,6 +1374,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
||||||
// System.out.println("<<<<<<<<<<<<<<<<<<< devider " + selectedCallSignSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + selectedCallSignSplitPane.getDividers().size());
|
// System.out.println("<<<<<<<<<<<<<<<<<<< devider " + selectedCallSignSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + selectedCallSignSplitPane.getDividers().size());
|
||||||
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()[selectedCallSignSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()[selectedCallSignSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1649,9 +1650,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tbl_chatMemberTable.setTooltip(new Tooltip(
|
|
||||||
"Stations available \n\nUse right click to a station to select predefined texts\nor hit <strg> + <1> ... <9> to write textsnippet to selected station\n\nHit <enter> to send"));
|
|
||||||
|
|
||||||
TableColumn<ChatMember, String> callSignCol =
|
TableColumn<ChatMember, String> callSignCol =
|
||||||
new TableColumn<ChatMember, String>("Callsign");
|
new TableColumn<ChatMember, String>("Callsign");
|
||||||
|
|
||||||
@@ -1669,7 +1667,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
return new SimpleStringProperty(displayedCallsign);
|
return new SimpleStringProperty(displayedCallsign);
|
||||||
});
|
});
|
||||||
|
|
||||||
callSignCol.setCellFactory(column -> new TableCell<ChatMember, String>() {
|
callSignCol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void updateItem(String item, boolean empty) {
|
protected void updateItem(String item, boolean empty) {
|
||||||
@@ -1757,14 +1755,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
* <p>No font weight is changed here. The compact worked/grid status such as
|
* <p>No font weight is changed here. The compact worked/grid status such as
|
||||||
* {@code xo} is emphasized only in the worked-any column.</p>
|
* {@code xo} is emphasized only in the worked-any column.</p>
|
||||||
*/
|
*/
|
||||||
qraCol.setCellFactory(column -> new TableCell<ChatMember, String>() {
|
qraCol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>(
|
||||||
|
java.util.function.Function.identity(),
|
||||||
|
(member, value) -> {
|
||||||
|
String grossField = WorkedGrossFieldCache.extractGrossField(value);
|
||||||
|
boolean gridWorked = member != null
|
||||||
|
&& chatcontroller != null
|
||||||
|
&& chatcontroller.isGridSquareWorkedAny(member);
|
||||||
|
return "Grid status: "
|
||||||
|
+ (grossField == null ? "unknown" : grossField)
|
||||||
|
+ "\nGrid worked any: "
|
||||||
|
+ (gridWorked ? "yes" : "no");
|
||||||
|
}
|
||||||
|
) {
|
||||||
@Override
|
@Override
|
||||||
protected void updateItem(String item, boolean empty) {
|
protected void updateItem(String item, boolean empty) {
|
||||||
super.updateItem(item, empty);
|
super.updateItem(item, empty);
|
||||||
|
|
||||||
if (empty || item == null) {
|
if (empty || item == null) {
|
||||||
setText(null);
|
|
||||||
setTooltip(null);
|
|
||||||
setStyle("");
|
setStyle("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1774,16 +1782,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
&& chatcontroller != null
|
&& chatcontroller != null
|
||||||
&& chatcontroller.isGridSquareWorkedAny(member);
|
&& chatcontroller.isGridSquareWorkedAny(member);
|
||||||
|
|
||||||
String grossField = WorkedGrossFieldCache.extractGrossField(item);
|
|
||||||
|
|
||||||
setText(item);
|
|
||||||
setTooltip(new Tooltip(
|
|
||||||
"Grid status: "
|
|
||||||
+ (grossField == null ? "unknown" : grossField)
|
|
||||||
+ "\nGrid worked any: "
|
|
||||||
+ (gridWorked ? "yes" : "no")
|
|
||||||
));
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Important:
|
* Important:
|
||||||
* Do not style the cell unless the Grid color button is active AND the
|
* Do not style the cell unless the Grid color button is active AND the
|
||||||
@@ -1929,7 +1927,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
*/
|
*/
|
||||||
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>>() {
|
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMember, String>, TableCell<ChatMember, String>>() {
|
||||||
public TableCell call(TableColumn param) {
|
public TableCell call(TableColumn param) {
|
||||||
return new TableCell<ChatMember, String>() {
|
return new TruncatedTextTableCell<ChatMember>() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateItem(String item, boolean empty) {
|
public void updateItem(String item, boolean empty) {
|
||||||
@@ -1953,7 +1951,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
this.getStyleClass().add("table-cell-50PercentAP");
|
this.getStyleClass().add("table-cell-50PercentAP");
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2038,22 +2035,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
/**
|
/**
|
||||||
* Shows the compact worked/grid status and explains it by tooltip.
|
* Shows the compact worked/grid status and explains it by tooltip.
|
||||||
*/
|
*/
|
||||||
wkdAny_subcol.setCellFactory(column -> new TableCell<ChatMember, String>() {
|
wkdAny_subcol.setCellFactory(column -> new TruncatedTextTableCell<ChatMember>(
|
||||||
|
java.util.function.Function.identity(),
|
||||||
|
(member, value) -> buildWorkedAnyGridStatusTooltip(member)
|
||||||
|
) {
|
||||||
@Override
|
@Override
|
||||||
protected void updateItem(String item, boolean empty) {
|
protected void updateItem(String item, boolean empty) {
|
||||||
super.updateItem(item, empty);
|
super.updateItem(item, empty);
|
||||||
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
setText(null);
|
|
||||||
setTooltip(null);
|
|
||||||
setStyle("");
|
setStyle("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
|
|
||||||
|
|
||||||
setText(item);
|
|
||||||
setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
|
|
||||||
setAlignment(Pos.CENTER);
|
setAlignment(Pos.CENTER);
|
||||||
setStyle("-fx-font-weight: bold;");
|
setStyle("-fx-font-weight: bold;");
|
||||||
}
|
}
|
||||||
@@ -2389,7 +2382,38 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// }, new Date(), 5000);
|
// }, new Date(), 5000);
|
||||||
|
|
||||||
tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY);
|
tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY);
|
||||||
tbl_chatMemberTable.autosize();
|
applyTruncatedTextCells(
|
||||||
|
nameCol, qrBCol, qtfCol, tropoCol, priorityScoreCol,
|
||||||
|
lastActCol, notQRVCol, chatCategoryCol
|
||||||
|
);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_chatMemberTable,
|
||||||
|
"chat-members",
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("callsign", callSignCol),
|
||||||
|
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
|
||||||
|
TableLayoutManager.column("qra", qraCol),
|
||||||
|
TableLayoutManager.column("qrb", qrBCol),
|
||||||
|
TableLayoutManager.column("qtf", qtfCol),
|
||||||
|
TableLayoutManager.column("qrg", qrgCol),
|
||||||
|
TableLayoutManager.column("tropo", tropoCol),
|
||||||
|
TableLayoutManager.column("score", priorityScoreCol),
|
||||||
|
TableLayoutManager.column("activity", lastActCol),
|
||||||
|
TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190),
|
||||||
|
TableLayoutManager.column("worked-any", wkdAny_subcol),
|
||||||
|
TableLayoutManager.column("band-50", sixMCol_subcol),
|
||||||
|
TableLayoutManager.column("band-70", fourMCol_subcol),
|
||||||
|
TableLayoutManager.column("band-144", vhfCol_subcol),
|
||||||
|
TableLayoutManager.column("band-432", uhfCol_subcol),
|
||||||
|
TableLayoutManager.column("band-1296", shf23_subcol),
|
||||||
|
TableLayoutManager.column("band-2320", shf13_subcol),
|
||||||
|
TableLayoutManager.column("band-3400", shf9_subcol),
|
||||||
|
TableLayoutManager.column("band-5760", shf6_subcol),
|
||||||
|
TableLayoutManager.column("band-10g", shf3_subcol),
|
||||||
|
TableLayoutManager.column("not-qrv", notQRVCol).maximumInitialWidth(180),
|
||||||
|
TableLayoutManager.column("category", chatCategoryCol)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3120,6 +3144,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
|
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
|
||||||
tbl_furtherInfoAbtCallsignMSGTable.setItems(chatcontroller.getLst_selectedCallSignInfofilteredMessageList());
|
tbl_furtherInfoAbtCallsignMSGTable.setItems(chatcontroller.getLst_selectedCallSignInfofilteredMessageList());
|
||||||
|
applyTruncatedTextCells(
|
||||||
|
timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol,
|
||||||
|
qrgRXerCol, workedRXCol, workedTXCol
|
||||||
|
);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_furtherInfoAbtCallsignMSGTable,
|
||||||
|
"selected-station-messages",
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("time", timeCol),
|
||||||
|
TableLayoutManager.column("call-tx", callSignTRCVCol),
|
||||||
|
TableLayoutManager.column("call-rx", callSignRCVRCol),
|
||||||
|
TableLayoutManager.column("last-qrg-tx", qrgTXerCol),
|
||||||
|
TableLayoutManager.column("last-qrg-rx", qrgRXerCol),
|
||||||
|
TableLayoutManager.column("message", msgCol).flexible(360),
|
||||||
|
TableLayoutManager.column("worked-rx", workedRXCol),
|
||||||
|
TableLayoutManager.column("worked-tx", workedTXCol)
|
||||||
|
);
|
||||||
|
|
||||||
return tbl_furtherInfoAbtCallsignMSGTable;
|
return tbl_furtherInfoAbtCallsignMSGTable;
|
||||||
}
|
}
|
||||||
@@ -3281,11 +3323,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
Tab dxClusterMessagesTab = new Tab("DXCluster messages");
|
Tab dxClusterMessagesTab = new Tab("DXCluster messages");
|
||||||
dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots."));
|
dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots."));
|
||||||
dxClusterMessagesTab.setContent(initDXClusterTable());
|
dxClusterMessagesTab.setContent(initDXClusterTable("dx-cluster-main"));
|
||||||
|
|
||||||
Tab qsoOfTheOtherTab = new Tab("QSO of the other");
|
Tab qsoOfTheOtherTab = new Tab("QSO of the other");
|
||||||
qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember."));
|
qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember."));
|
||||||
qsoOfTheOtherTab.setContent(initChatToOtherMSGTable());
|
qsoOfTheOtherTab.setContent(initChatToOtherMSGTable("qso-other-main"));
|
||||||
|
|
||||||
bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab);
|
bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab);
|
||||||
bottomMessageTabs.getSelectionModel().select(publicMessagesTab);
|
bottomMessageTabs.getSelectionModel().select(publicMessagesTab);
|
||||||
@@ -3437,6 +3479,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ObservableList<ChatMessage> generalMSGList = chatcontroller.getLst_toAllMessageList();
|
ObservableList<ChatMessage> generalMSGList = chatcontroller.getLst_toAllMessageList();
|
||||||
tbl_generalMSGTable.setItems(generalMSGList);
|
tbl_generalMSGTable.setItems(generalMSGList);
|
||||||
|
applyTruncatedTextCells(timeCol, callSignCol, nameCol, categoryCol);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_generalMSGTable,
|
||||||
|
"public-messages",
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("time", timeCol),
|
||||||
|
TableLayoutManager.column("callsign", callSignCol),
|
||||||
|
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
|
||||||
|
TableLayoutManager.column("message", msgCol).flexible(360),
|
||||||
|
TableLayoutManager.column("last-qrg", qrgCol),
|
||||||
|
TableLayoutManager.column("category", categoryCol)
|
||||||
|
);
|
||||||
|
|
||||||
tbl_generalMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
|
tbl_generalMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -3660,7 +3715,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
*/
|
*/
|
||||||
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMessage, String>, TableCell<ChatMessage, String>>() {
|
airScoutCol.setCellFactory(new Callback<TableColumn<ChatMessage, String>, TableCell<ChatMessage, String>>() {
|
||||||
public TableCell call(TableColumn param) {
|
public TableCell call(TableColumn param) {
|
||||||
return new TableCell<ChatMessage, String>() {
|
return new TruncatedTextTableCell<ChatMessage>() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateItem(String item, boolean empty) {
|
public void updateItem(String item, boolean empty) {
|
||||||
@@ -3682,7 +3737,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3741,6 +3795,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ObservableList<ChatMessage> privateMSGList = chatcontroller.getLst_toMeMessageList();
|
ObservableList<ChatMessage> privateMSGList = chatcontroller.getLst_toMeMessageList();
|
||||||
tbl_privateMSGTable.setItems(privateMSGList);
|
tbl_privateMSGTable.setItems(privateMSGList);
|
||||||
|
applyTruncatedTextCells(timeCol, callSignCol, nameCol, qraCol, qrbCol, categoryCol);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_privateMSGTable,
|
||||||
|
"private-messages",
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("time", timeCol),
|
||||||
|
TableLayoutManager.column("callsign", callSignCol),
|
||||||
|
TableLayoutManager.column("name", nameCol).maximumInitialWidth(220),
|
||||||
|
TableLayoutManager.column("qra", qraCol),
|
||||||
|
TableLayoutManager.column("qrb", qrbCol),
|
||||||
|
TableLayoutManager.column("message", msgCol).flexible(360),
|
||||||
|
TableLayoutManager.column("last-qrg", qrgCol),
|
||||||
|
TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190),
|
||||||
|
TableLayoutManager.column("category", categoryCol)
|
||||||
|
);
|
||||||
|
|
||||||
tbl_privateMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
|
tbl_privateMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -3812,7 +3882,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
return tbl_privateMSGTable;
|
return tbl_privateMSGTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TableView<ClusterMessage> initDXClusterTable() {
|
private TableView<ClusterMessage> initDXClusterTable(String layoutId) {
|
||||||
|
|
||||||
TableView<ClusterMessage> tbl_DXCTable = new TableView<ClusterMessage>();
|
TableView<ClusterMessage> tbl_DXCTable = new TableView<ClusterMessage>();
|
||||||
// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here"));
|
// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here"));
|
||||||
@@ -3973,11 +4043,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ObservableList<ClusterMessage> clusterMSGList = chatcontroller.getLst_clusterMemberList();
|
ObservableList<ClusterMessage> clusterMSGList = chatcontroller.getLst_clusterMemberList();
|
||||||
tbl_DXCTable.setItems(clusterMSGList);
|
tbl_DXCTable.setItems(clusterMSGList);
|
||||||
|
applyTruncatedTextCells(
|
||||||
|
timeCol, callSignCol, locTXCol, callSignRXCol,
|
||||||
|
locRXCol, workedCol
|
||||||
|
);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_DXCTable,
|
||||||
|
layoutId,
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("time", timeCol),
|
||||||
|
TableLayoutManager.column("call-tx", callSignCol),
|
||||||
|
TableLayoutManager.column("locator-tx", locTXCol),
|
||||||
|
TableLayoutManager.column("call-rx", callSignRXCol),
|
||||||
|
TableLayoutManager.column("locator-rx", locRXCol),
|
||||||
|
TableLayoutManager.column("qrg", qrgCol),
|
||||||
|
TableLayoutManager.column("message", msgCol).flexible(360),
|
||||||
|
TableLayoutManager.column("worked", workedCol)
|
||||||
|
);
|
||||||
|
|
||||||
return tbl_DXCTable;
|
return tbl_DXCTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TableView<ChatMessage> initChatToOtherMSGTable() {
|
private TableView<ChatMessage> initChatToOtherMSGTable(String layoutId) {
|
||||||
|
|
||||||
TableView<ChatMessage> tbl_toOtherMSGTable = new TableView<ChatMessage>();
|
TableView<ChatMessage> tbl_toOtherMSGTable = new TableView<ChatMessage>();
|
||||||
// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here"));
|
// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here"));
|
||||||
@@ -4175,6 +4263,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
|
ObservableList<ChatMessage> toOtherMSGList = chatcontroller.getLst_toOtherMessageList();
|
||||||
tbl_toOtherMSGTable.setItems(toOtherMSGList);
|
tbl_toOtherMSGTable.setItems(toOtherMSGList);
|
||||||
|
applyTruncatedTextCells(
|
||||||
|
timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol,
|
||||||
|
callSignRCVRCol, qrgRXerCol, workedRXCol, categoryCol
|
||||||
|
);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_toOtherMSGTable,
|
||||||
|
layoutId,
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("time", timeCol),
|
||||||
|
TableLayoutManager.column("call-tx", callSignTRCVCol),
|
||||||
|
TableLayoutManager.column("last-qrg-tx", qrgTXerCol),
|
||||||
|
TableLayoutManager.column("worked-tx", workedTXCol),
|
||||||
|
TableLayoutManager.column("call-rx", callSignRCVRCol),
|
||||||
|
TableLayoutManager.column("last-qrg-rx", qrgRXerCol),
|
||||||
|
TableLayoutManager.column("worked-rx", workedRXCol),
|
||||||
|
TableLayoutManager.column("message", msgCol).flexible(360),
|
||||||
|
TableLayoutManager.column("category", categoryCol)
|
||||||
|
);
|
||||||
|
|
||||||
return tbl_toOtherMSGTable;
|
return tbl_toOtherMSGTable;
|
||||||
}
|
}
|
||||||
@@ -5261,6 +5368,28 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol);
|
tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol);
|
||||||
|
|
||||||
tbl_chatMemberWkdDBTable.setItems(chatcontroller.getLst_DBBasedWkdCallSignList());
|
tbl_chatMemberWkdDBTable.setItems(chatcontroller.getLst_DBBasedWkdCallSignList());
|
||||||
|
applyTruncatedTextCells(
|
||||||
|
callSignCol, wkdAny_subcol, sixMCol_subcol, fourMCol_subcol,
|
||||||
|
vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol,
|
||||||
|
shf9_subcol, shf6_subcol, shf3_subcol
|
||||||
|
);
|
||||||
|
TableLayoutManager.install(
|
||||||
|
tbl_chatMemberWkdDBTable,
|
||||||
|
"worked-database",
|
||||||
|
chatcontroller.getChatPreferences(),
|
||||||
|
layoutAutosave,
|
||||||
|
TableLayoutManager.column("callsign", callSignCol),
|
||||||
|
TableLayoutManager.column("worked-any", wkdAny_subcol),
|
||||||
|
TableLayoutManager.column("band-50", sixMCol_subcol),
|
||||||
|
TableLayoutManager.column("band-70", fourMCol_subcol),
|
||||||
|
TableLayoutManager.column("band-144", vhfCol_subcol),
|
||||||
|
TableLayoutManager.column("band-432", uhfCol_subcol),
|
||||||
|
TableLayoutManager.column("band-1296", shf23_subcol),
|
||||||
|
TableLayoutManager.column("band-2320", shf13_subcol),
|
||||||
|
TableLayoutManager.column("band-3400", shf9_subcol),
|
||||||
|
TableLayoutManager.column("band-5760", shf6_subcol),
|
||||||
|
TableLayoutManager.column("band-10g", shf3_subcol)
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer
|
// TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer
|
||||||
|
|
||||||
@@ -5624,7 +5753,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
Alert a = new Alert(AlertType.INFORMATION);
|
Alert a = new Alert(AlertType.INFORMATION);
|
||||||
|
|
||||||
a.setTitle("About kst4contest");
|
a.setTitle("About kst4contest");
|
||||||
a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION + ": ON4KST Chatclient by DO5AMF");
|
a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION
|
||||||
|
+ ": ON4KST Chatclient by DO5AMF and DN9APW");
|
||||||
a.setContentText(chatcontroller.getChatPreferences().getProgramVersion());
|
a.setContentText(chatcontroller.getChatPreferences().getProgramVersion());
|
||||||
a.show();
|
a.show();
|
||||||
}
|
}
|
||||||
@@ -6100,6 +6230,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
|
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
|
||||||
|
if (layoutAutosave != null) {
|
||||||
|
layoutAutosave.flushPending();
|
||||||
|
}
|
||||||
timer_buildWindowTitle.purge();
|
timer_buildWindowTitle.purge();
|
||||||
timer_buildWindowTitle.cancel();
|
timer_buildWindowTitle.cancel();
|
||||||
|
|
||||||
@@ -6119,6 +6252,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requestLayoutSave() {
|
||||||
|
if (layoutAutosave != null) {
|
||||||
|
layoutAutosave.requestSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Queue<Media> musicList = new LinkedList<Media>();
|
private Queue<Media> musicList = new LinkedList<Media>();
|
||||||
private MediaPlayer mediaPlayer ;
|
private MediaPlayer mediaPlayer ;
|
||||||
|
|
||||||
@@ -6544,6 +6683,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
ChatMember ownChatMemberObject = new ChatMember();
|
ChatMember ownChatMemberObject = new ChatMember();
|
||||||
|
|
||||||
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
|
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object
|
||||||
|
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
|
||||||
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
|
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
|
||||||
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
|
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
|
||||||
|
|
||||||
@@ -6687,6 +6827,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
|
||||||
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[1] = newWidthValue.doubleValue();
|
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[1] = newWidthValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6694,6 +6835,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
|
||||||
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[0] = newHeightValue.doubleValue();
|
chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[0] = newHeightValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7399,6 +7541,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
||||||
System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<< devider>>>>>> " + messageSectionSplitpane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + messageSectionSplitpane.getDividers().size());
|
System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<< devider>>>>>> " + messageSectionSplitpane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + messageSectionSplitpane.getDividers().size());
|
||||||
chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()[messageSectionSplitpane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()[messageSectionSplitpane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8582,6 +8725,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
||||||
System.out.println("<<<<<<<<<<<<<<<<<<< mainWindowLeftSplitPanedevider " + mainWindowLeftSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowLeftSplitPane.getDividers().size());
|
System.out.println("<<<<<<<<<<<<<<<<<<< mainWindowLeftSplitPanedevider " + mainWindowLeftSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowLeftSplitPane.getDividers().size());
|
||||||
chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[mainWindowLeftSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[mainWindowLeftSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8615,6 +8759,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
if (dividerIndex >= 0 && dividerIndex < storedPositions.length) {
|
if (dividerIndex >= 0 && dividerIndex < storedPositions.length) {
|
||||||
storedPositions[dividerIndex] = newDividerPosition.doubleValue();
|
storedPositions[dividerIndex] = newDividerPosition.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
} else {
|
} else {
|
||||||
// Avoid crashes if preferences are older than the current UI layout.
|
// Avoid crashes if preferences are older than the current UI layout.
|
||||||
System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index="
|
System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index="
|
||||||
@@ -8663,7 +8808,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
SplitPane pnl_directedMSGWin = new SplitPane();
|
SplitPane pnl_directedMSGWin = new SplitPane();
|
||||||
pnl_directedMSGWin.setOrientation(Orientation.VERTICAL);
|
pnl_directedMSGWin.setOrientation(Orientation.VERTICAL);
|
||||||
pnl_directedMSGWin.setDividerPositions(chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault());
|
pnl_directedMSGWin.setDividerPositions(chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault());
|
||||||
pnl_directedMSGWin.getItems().addAll(initDXClusterTable(), initChatToOtherMSGTable());
|
pnl_directedMSGWin.getItems().addAll(
|
||||||
|
initDXClusterTable("dx-cluster-monitor"),
|
||||||
|
initChatToOtherMSGTable("qso-other-monitor")
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8676,6 +8824,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number oldDividerPos, Number newDividerPosition) {
|
||||||
System.out.println("<<<<<<<<<<<<<<<<<<<|||||||||||||||||||| devider " + pnl_directedMSGWin.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + pnl_directedMSGWin.getDividers().size());
|
System.out.println("<<<<<<<<<<<<<<<<<<<|||||||||||||||||||| devider " + pnl_directedMSGWin.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + pnl_directedMSGWin.getDividers().size());
|
||||||
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault()[pnl_directedMSGWin.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault()[pnl_directedMSGWin.getDividers().indexOf(divider)] = newDividerPosition.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8689,6 +8838,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newHeightValue) {
|
||||||
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = newHeightValue.doubleValue();
|
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = newHeightValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8696,6 +8846,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
|
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number newWidthValue) {
|
||||||
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = newWidthValue.doubleValue();
|
chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = newWidthValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8809,6 +8960,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER);
|
System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER);
|
||||||
|
|
||||||
stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1]));
|
stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1]));
|
||||||
|
stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
|
});
|
||||||
|
stage_updateStage.getScene().heightProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = newValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// if (chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() > ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER) {
|
// if (chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() > ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER) {
|
||||||
@@ -10736,7 +10895,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
testSpot.setFrequency(
|
testSpot.setFrequency(
|
||||||
new SimpleStringProperty("300")
|
new SimpleStringProperty("300")
|
||||||
);
|
);
|
||||||
testSpot.setQra("Testing DXC-Spot: Congrats, you donated $100!");
|
testSpot.setQra("DXC test: You donated $100!");
|
||||||
testSpot.setCallSign("DO5AMF");
|
testSpot.setCallSign("DO5AMF");
|
||||||
|
|
||||||
if (!dxClusterServer
|
if (!dxClusterServer
|
||||||
@@ -11762,7 +11921,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
System.out.println("saved");
|
System.out.println("saved");
|
||||||
|
|
||||||
chatcontroller.getChatPreferences().writePreferencesToXmlFile();
|
if (chatcontroller.getChatPreferences().writePreferencesToXmlFile()
|
||||||
|
&& layoutAutosave != null) {
|
||||||
|
layoutAutosave.cancelPending();
|
||||||
|
}
|
||||||
Alert a = new Alert(AlertType.INFORMATION);
|
Alert a = new Alert(AlertType.INFORMATION);
|
||||||
|
|
||||||
a.setTitle("Info");
|
a.setTitle("Info");
|
||||||
@@ -11800,6 +11962,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// VBox vBox = new VBox(tabPaneOptions);
|
// VBox vBox = new VBox(tabPaneOptions);
|
||||||
settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]);
|
settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]);
|
||||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||||
|
settingsScene.widthProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = newValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
|
});
|
||||||
|
settingsScene.heightProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1] = newValue.doubleValue();
|
||||||
|
requestLayoutSave();
|
||||||
|
});
|
||||||
|
|
||||||
settingsStage.setScene(settingsScene);
|
settingsStage.setScene(settingsScene);
|
||||||
|
|
||||||
@@ -12515,13 +12685,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static <T> void applyQrgUiFormatting(TableColumn<T, String> col) {
|
private static <T> void applyQrgUiFormatting(TableColumn<T, String> col) {
|
||||||
col.setCellFactory(tc -> new TableCell<T, String>() {
|
col.setCellFactory(tc -> new TruncatedTextTableCell<>(Kst4ContestApplication::formatQrgForUi));
|
||||||
@Override
|
}
|
||||||
protected void updateItem(String item, boolean empty) {
|
|
||||||
super.updateItem(item, empty);
|
@SafeVarargs
|
||||||
setText(empty ? "" : formatQrgForUi(item));
|
private static <T> void applyTruncatedTextCells(TableColumn<T, String>... columns) {
|
||||||
|
for (TableColumn<T, String> column : columns) {
|
||||||
|
column.setCellFactory(ignored -> new TruncatedTextTableCell<>());
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import javafx.animation.PauseTransition;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import kst4contest.model.ChatPreferences;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesces JavaFX layout changes into selective preferences writes.
|
||||||
|
*/
|
||||||
|
public final class LayoutAutosave {
|
||||||
|
|
||||||
|
private static final Duration SAVE_DELAY = Duration.millis(750);
|
||||||
|
|
||||||
|
private final ChatPreferences preferences;
|
||||||
|
private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY);
|
||||||
|
private boolean pending;
|
||||||
|
|
||||||
|
public LayoutAutosave(ChatPreferences preferences) {
|
||||||
|
this.preferences = Objects.requireNonNull(preferences, "preferences");
|
||||||
|
saveDelay.setOnFinished(event -> flushPending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requestSave() {
|
||||||
|
if (!Platform.isFxApplicationThread()) {
|
||||||
|
Platform.runLater(this::requestSave);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = true;
|
||||||
|
saveDelay.playFromStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void flushPending() {
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDelay.stop();
|
||||||
|
pending = false;
|
||||||
|
preferences.writeLayoutPreferencesToXmlFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelPending() {
|
||||||
|
saveDelay.stop();
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.collections.ListChangeListener;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import kst4contest.model.ChatPreferences;
|
||||||
|
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.OptionalDouble;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies persisted leaf-column widths and performs one content-based initial
|
||||||
|
* sizing pass when no width has been stored yet.
|
||||||
|
*/
|
||||||
|
public final class TableLayoutManager {
|
||||||
|
|
||||||
|
private static final double CELL_HORIZONTAL_PADDING = 16.0;
|
||||||
|
private static final double DEFAULT_MINIMUM_WIDTH = 24.0;
|
||||||
|
|
||||||
|
private TableLayoutManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ColumnSpec column(String id, TableColumn<?, String> column) {
|
||||||
|
return new ColumnSpec(id, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <S> void install(
|
||||||
|
TableView<S> table,
|
||||||
|
String tableId,
|
||||||
|
ChatPreferences preferences,
|
||||||
|
LayoutAutosave autosave,
|
||||||
|
ColumnSpec... columnSpecs
|
||||||
|
) {
|
||||||
|
Objects.requireNonNull(table, "table");
|
||||||
|
Objects.requireNonNull(tableId, "tableId");
|
||||||
|
Objects.requireNonNull(preferences, "preferences");
|
||||||
|
Objects.requireNonNull(autosave, "autosave");
|
||||||
|
|
||||||
|
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
|
||||||
|
for (ColumnSpec spec : columnSpecs) {
|
||||||
|
if (spec == null || !spec.column.getColumns().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.column.prefWidthProperty().isBound()) {
|
||||||
|
spec.column.prefWidthProperty().unbind();
|
||||||
|
}
|
||||||
|
spec.column.setId(tableId + "." + spec.id);
|
||||||
|
|
||||||
|
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
|
||||||
|
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
|
||||||
|
states.put(spec.column, state);
|
||||||
|
|
||||||
|
if (storedWidth.isPresent()) {
|
||||||
|
setWidth(state, storedWidth.getAsDouble());
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
|
||||||
|
if (state.adjusting || !state.initialized || newWidth == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
|
||||||
|
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S> void scheduleInitialSizingWhenUsable(
|
||||||
|
TableView<S> table,
|
||||||
|
String tableId,
|
||||||
|
ChatPreferences preferences,
|
||||||
|
LayoutAutosave autosave,
|
||||||
|
Map<TableColumn<?, String>, ColumnState> states
|
||||||
|
) {
|
||||||
|
if (states.values().stream().noneMatch(state -> !state.initialized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table.getItems() != null && !table.getItems().isEmpty()) {
|
||||||
|
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final ListChangeListener<S>[] holder = new ListChangeListener[1];
|
||||||
|
holder[0] = change -> {
|
||||||
|
if (table.getItems() == null || table.getItems().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.getItems().removeListener(holder[0]);
|
||||||
|
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
|
||||||
|
};
|
||||||
|
table.getItems().addListener(holder[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S> void sizePendingColumns(
|
||||||
|
TableView<S> table,
|
||||||
|
String tableId,
|
||||||
|
ChatPreferences preferences,
|
||||||
|
LayoutAutosave autosave,
|
||||||
|
Map<TableColumn<?, String>, ColumnState> states
|
||||||
|
) {
|
||||||
|
for (ColumnState state : states.values()) {
|
||||||
|
if (state.initialized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double width = state.spec.flexible
|
||||||
|
? flexibleInitialWidth(table, state.spec)
|
||||||
|
: contentInitialWidth(table, state.spec);
|
||||||
|
setWidth(state, width);
|
||||||
|
state.initialized = true;
|
||||||
|
storeWidth(preferences, autosave, tableId, state.spec.id, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
|
||||||
|
Text measurement = new Text();
|
||||||
|
measurement.setFont(Font.getDefault());
|
||||||
|
double requiredWidth = measure(spec.column.getText(), measurement);
|
||||||
|
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
|
||||||
|
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
|
||||||
|
if (value == null || value.getValue() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
requiredWidth = Math.max(
|
||||||
|
requiredWidth,
|
||||||
|
measure(String.valueOf(value.getValue()), measurement)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return calculateInitialContentWidth(
|
||||||
|
requiredWidth,
|
||||||
|
spec.minimumWidth,
|
||||||
|
spec.maximumInitialWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
|
||||||
|
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
|
||||||
|
Text measurement = new Text();
|
||||||
|
measurement.setFont(Font.getDefault());
|
||||||
|
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
|
||||||
|
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S> void installEarlyManualResizeDetection(
|
||||||
|
TableView<S> table,
|
||||||
|
String tableId,
|
||||||
|
ChatPreferences preferences,
|
||||||
|
LayoutAutosave autosave,
|
||||||
|
Map<TableColumn<?, String>, ColumnState> states
|
||||||
|
) {
|
||||||
|
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
|
||||||
|
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
|
||||||
|
if (!isColumnHeaderEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widthsAtHeaderPress.clear();
|
||||||
|
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
|
||||||
|
});
|
||||||
|
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
|
||||||
|
if (widthsAtHeaderPress.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
states.forEach((column, state) -> {
|
||||||
|
Double oldWidth = widthsAtHeaderPress.get(column);
|
||||||
|
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.initialized = true;
|
||||||
|
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
|
||||||
|
});
|
||||||
|
widthsAtHeaderPress.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isColumnHeaderEvent(MouseEvent event) {
|
||||||
|
Object target = event.getTarget();
|
||||||
|
Node node = target instanceof Node ? (Node) target : null;
|
||||||
|
while (node != null && node.getParent() != null) {
|
||||||
|
if (node.getStyleClass().contains("column-header")
|
||||||
|
|| node.getStyleClass().contains("nested-column-header")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
node = node.getParent();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setWidth(ColumnState state, double width) {
|
||||||
|
state.adjusting = true;
|
||||||
|
try {
|
||||||
|
state.spec.column.setPrefWidth(width);
|
||||||
|
} finally {
|
||||||
|
state.adjusting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void storeWidth(
|
||||||
|
ChatPreferences preferences,
|
||||||
|
LayoutAutosave autosave,
|
||||||
|
String tableId,
|
||||||
|
String columnId,
|
||||||
|
double width
|
||||||
|
) {
|
||||||
|
preferences.setTableColumnWidth(tableId, columnId, width);
|
||||||
|
autosave.requestSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double measure(String value, Text measurement) {
|
||||||
|
measurement.setText(value == null ? "" : value);
|
||||||
|
return measurement.getLayoutBounds().getWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double clamp(double value, double minimum, double maximum) {
|
||||||
|
return Math.max(minimum, Math.min(value, maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a compact content width. Package-private for focused sizing tests.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
|
||||||
|
static double calculateInitialContentWidth(
|
||||||
|
final double measuredWidth,
|
||||||
|
final double minimum,
|
||||||
|
final double maximum
|
||||||
|
) {
|
||||||
|
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class ColumnSpec {
|
||||||
|
private final String id;
|
||||||
|
private final TableColumn<?, String> column;
|
||||||
|
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
|
||||||
|
private double maximumInitialWidth = Double.MAX_VALUE;
|
||||||
|
private double flexibleFallbackWidth = 320.0;
|
||||||
|
private boolean flexible;
|
||||||
|
|
||||||
|
private ColumnSpec(String id, TableColumn<?, String> column) {
|
||||||
|
if (id == null || id.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Column id must not be blank");
|
||||||
|
}
|
||||||
|
this.id = id;
|
||||||
|
this.column = Objects.requireNonNull(column, "column");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
|
||||||
|
this.maximumInitialWidth = maximumInitialWidth;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColumnSpec flexible(double fallbackWidth) {
|
||||||
|
flexible = true;
|
||||||
|
flexibleFallbackWidth = fallbackWidth;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ColumnState {
|
||||||
|
private final ColumnSpec spec;
|
||||||
|
private boolean adjusting;
|
||||||
|
private boolean initialized;
|
||||||
|
|
||||||
|
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
|
||||||
|
this.spec = spec;
|
||||||
|
initialized = !awaitingInitialSizing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import javafx.scene.control.TableCell;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays text normally and exposes the full value only when it is clipped.
|
||||||
|
* An optional functional explanation remains available and is combined with
|
||||||
|
* the full value when both are needed.
|
||||||
|
*/
|
||||||
|
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
|
||||||
|
|
||||||
|
private final Function<String, String> formatter;
|
||||||
|
private final BiFunction<S, String, String> functionalTooltipProvider;
|
||||||
|
private final Tooltip tooltip = new Tooltip();
|
||||||
|
private final Text textMeasurement = new Text();
|
||||||
|
private String fullText = "";
|
||||||
|
|
||||||
|
public TruncatedTextTableCell() {
|
||||||
|
this(Function.identity(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TruncatedTextTableCell(Function<String, String> formatter) {
|
||||||
|
this(formatter, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TruncatedTextTableCell(
|
||||||
|
Function<String, String> formatter,
|
||||||
|
BiFunction<S, String, String> functionalTooltipProvider
|
||||||
|
) {
|
||||||
|
this.formatter = formatter == null ? Function.identity() : formatter;
|
||||||
|
this.functionalTooltipProvider = functionalTooltipProvider;
|
||||||
|
tooltip.setWrapText(true);
|
||||||
|
tooltip.setMaxWidth(800);
|
||||||
|
tooltip.setShowDelay(Duration.millis(250));
|
||||||
|
tooltip.setShowDuration(Duration.seconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || item == null) {
|
||||||
|
fullText = "";
|
||||||
|
setText(null);
|
||||||
|
setGraphic(null);
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatted = formatter.apply(item);
|
||||||
|
fullText = formatted == null ? "" : formatted;
|
||||||
|
setText(fullText);
|
||||||
|
setGraphic(null);
|
||||||
|
updateTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void layoutChildren() {
|
||||||
|
super.layoutChildren();
|
||||||
|
updateTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTooltip() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String functionalText = resolveFunctionalTooltip();
|
||||||
|
boolean clipped = isTextClipped();
|
||||||
|
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
|
||||||
|
fullText,
|
||||||
|
clipped,
|
||||||
|
functionalText
|
||||||
|
);
|
||||||
|
if (tooltipText == null) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.setText(tooltipText);
|
||||||
|
setTooltip(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFunctionalTooltip() {
|
||||||
|
if (functionalTooltipProvider == null || getTableRow() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTextClipped() {
|
||||||
|
if (fullText.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
textMeasurement.setText(fullText);
|
||||||
|
textMeasurement.setFont(getFont());
|
||||||
|
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
|
||||||
|
double availableWidth = Math.max(0.0,
|
||||||
|
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
|
||||||
|
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure tooltip decisions kept separate from JavaFX controls for unit testing.
|
||||||
|
*/
|
||||||
|
final class TruncatedTextTooltipSupport {
|
||||||
|
|
||||||
|
private TruncatedTextTooltipSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTextClipped(double requiredWidth, double availableWidth) {
|
||||||
|
return requiredWidth > availableWidth + 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildTooltipText(String fullText, boolean clipped, String functionalText) {
|
||||||
|
boolean hasFunctionalText = functionalText != null && !functionalText.isBlank();
|
||||||
|
if (!clipped && !hasFunctionalText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!clipped) {
|
||||||
|
return functionalText;
|
||||||
|
}
|
||||||
|
if (!hasFunctionalText) {
|
||||||
|
return fullText;
|
||||||
|
}
|
||||||
|
return fullText + "\n\n" + functionalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
* - grid / beam / connection use non-interactive panes
|
* - grid / beam / connection use non-interactive panes
|
||||||
* - JavaScript errors are forwarded to Java through javaMapBridge
|
* - JavaScript errors are forwarded to Java through javaMapBridge
|
||||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||||
|
* - setStationClusteringEnabled(boolean) re-renders the existing station data
|
||||||
*
|
*
|
||||||
* Important:
|
* Important:
|
||||||
* This version intentionally uses integer Leaflet zoom levels again.
|
* This version intentionally uses integer Leaflet zoom levels again.
|
||||||
@@ -356,6 +357,7 @@ public final class MapHtmlResources {
|
|||||||
*/
|
*/
|
||||||
let stationData = [];
|
let stationData = [];
|
||||||
let stationsByCallsignRaw = {};
|
let stationsByCallsignRaw = {};
|
||||||
|
let stationClusteringEnabled = true;
|
||||||
|
|
||||||
let clustersById = {};
|
let clustersById = {};
|
||||||
let clusterSequence = 0;
|
let clusterSequence = 0;
|
||||||
@@ -810,13 +812,19 @@ public final class MapHtmlResources {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
if (!stationClusteringEnabled
|
||||||
|
|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||||
renderAllStationsIndividually();
|
renderAllStationsIndividually();
|
||||||
} else {
|
} else {
|
||||||
renderClusteredStations();
|
renderClusteredStations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStationClusteringEnabled(enabled) {
|
||||||
|
stationClusteringEnabled = Boolean(enabled);
|
||||||
|
renderStationMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zooms into a cluster.
|
* Zooms into a cluster.
|
||||||
*
|
*
|
||||||
@@ -1271,6 +1279,7 @@ public final class MapHtmlResources {
|
|||||||
getViewportState: getViewportState,
|
getViewportState: getViewportState,
|
||||||
setHome: setHome,
|
setHome: setHome,
|
||||||
setStations: setStations,
|
setStations: setStations,
|
||||||
|
setStationClusteringEnabled: setStationClusteringEnabled,
|
||||||
setBeam: setBeam,
|
setBeam: setBeam,
|
||||||
setConnection: setConnection,
|
setConnection: setConnection,
|
||||||
setProfileHoverPoint: setProfileHoverPoint,
|
setProfileHoverPoint: setProfileHoverPoint,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public final class StationMapView {
|
|||||||
private final Label detailPathModeValue = new Label("-");
|
private final Label detailPathModeValue = new Label("-");
|
||||||
|
|
||||||
private final ChatPreferences chatPreferences;
|
private final ChatPreferences chatPreferences;
|
||||||
|
private final Runnable layoutSaveRequester;
|
||||||
|
|
||||||
private final Stage stage = new Stage();
|
private final Stage stage = new Stage();
|
||||||
private final WebView webView = new WebView();
|
private final WebView webView = new WebView();
|
||||||
@@ -89,6 +90,9 @@ public final class StationMapView {
|
|||||||
|
|
||||||
private final Button resetViewButton = new Button("Reset view");
|
private final Button resetViewButton = new Button("Reset view");
|
||||||
private final Tooltip statusTooltip = new Tooltip();
|
private final Tooltip statusTooltip = new Tooltip();
|
||||||
|
private final CheckBox stationClusteringCheckBox = new CheckBox("Group nearby stations");
|
||||||
|
private final Tooltip stationClusteringTooltip = new Tooltip(
|
||||||
|
"Group nearby stations into clusters at lower zoom levels.");
|
||||||
|
|
||||||
private Runnable onResetView;
|
private Runnable onResetView;
|
||||||
|
|
||||||
@@ -180,7 +184,12 @@ public final class StationMapView {
|
|||||||
|
|
||||||
|
|
||||||
public StationMapView(ChatPreferences chatPreferences) {
|
public StationMapView(ChatPreferences chatPreferences) {
|
||||||
|
this(chatPreferences, () -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public StationMapView(ChatPreferences chatPreferences, Runnable layoutSaveRequester) {
|
||||||
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
|
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
|
||||||
|
this.layoutSaveRequester = Objects.requireNonNull(layoutSaveRequester, "layoutSaveRequester");
|
||||||
GuiUtils.applyApplicationIcon(stage);
|
GuiUtils.applyApplicationIcon(stage);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -327,6 +336,21 @@ public final class StationMapView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stationClusteringCheckBox.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
stationClusteringCheckBox.setTooltip(stationClusteringTooltip);
|
||||||
|
stationClusteringCheckBox.setAccessibleText("Group nearby stations");
|
||||||
|
stationClusteringCheckBox.setAccessibleHelp(stationClusteringTooltip.getText());
|
||||||
|
stationClusteringCheckBox.setSelected(chatPreferences.isGUIstationMapClusteringEnabled());
|
||||||
|
stationClusteringCheckBox.selectedProperty().addListener((obs, oldValue, newValue) -> {
|
||||||
|
boolean enabled = newValue;
|
||||||
|
chatPreferences.setGUIstationMapClusteringEnabled(enabled);
|
||||||
|
if (mapReady) {
|
||||||
|
executeMapScriptSafely(
|
||||||
|
"window.kstMapApi.setStationClusteringEnabled(" + enabled + ");");
|
||||||
|
}
|
||||||
|
layoutSaveRequester.run();
|
||||||
|
});
|
||||||
|
|
||||||
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
||||||
pathAnalysisVisibilityButton.setOnAction(event ->
|
pathAnalysisVisibilityButton.setOnAction(event ->
|
||||||
@@ -461,17 +485,25 @@ public final class StationMapView {
|
|||||||
stage.setY(pos[1]);
|
stage.setY(pos[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
stage.widthProperty().addListener((obs, oldValue, newValue) ->
|
stage.widthProperty().addListener((obs, oldValue, newValue) -> {
|
||||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue());
|
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue();
|
||||||
|
layoutSaveRequester.run();
|
||||||
|
});
|
||||||
|
|
||||||
stage.heightProperty().addListener((obs, oldValue, newValue) ->
|
stage.heightProperty().addListener((obs, oldValue, newValue) -> {
|
||||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue());
|
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue();
|
||||||
|
layoutSaveRequester.run();
|
||||||
|
});
|
||||||
|
|
||||||
stage.xProperty().addListener((obs, oldValue, newValue) ->
|
stage.xProperty().addListener((obs, oldValue, newValue) -> {
|
||||||
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue());
|
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue();
|
||||||
|
layoutSaveRequester.run();
|
||||||
|
});
|
||||||
|
|
||||||
stage.yProperty().addListener((obs, oldValue, newValue) ->
|
stage.yProperty().addListener((obs, oldValue, newValue) -> {
|
||||||
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue());
|
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue();
|
||||||
|
layoutSaveRequester.run();
|
||||||
|
});
|
||||||
|
|
||||||
stage.setOnShown(event -> Platform.runLater(() -> {
|
stage.setOnShown(event -> Platform.runLater(() -> {
|
||||||
webView.requestFocus();
|
webView.requestFocus();
|
||||||
@@ -521,6 +553,7 @@ public final class StationMapView {
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
triggerClusterSpotButton,
|
triggerClusterSpotButton,
|
||||||
resetViewButton,
|
resetViewButton,
|
||||||
|
stationClusteringCheckBox,
|
||||||
pathAnalysisHiddenHintLabel,
|
pathAnalysisHiddenHintLabel,
|
||||||
pathAnalysisVisibilityButton
|
pathAnalysisVisibilityButton
|
||||||
);
|
);
|
||||||
@@ -794,6 +827,9 @@ public final class StationMapView {
|
|||||||
window.setMember("javaMapBridge", javaMapBridge);
|
window.setMember("javaMapBridge", javaMapBridge);
|
||||||
|
|
||||||
executeMapScriptSafely("window.kstMapApi.init();");
|
executeMapScriptSafely("window.kstMapApi.init();");
|
||||||
|
executeMapScriptSafely(
|
||||||
|
"window.kstMapApi.setStationClusteringEnabled("
|
||||||
|
+ chatPreferences.isGUIstationMapClusteringEnabled() + ");");
|
||||||
|
|
||||||
mapReady = true;
|
mapReady = true;
|
||||||
applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive());
|
applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive());
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import kst4contest.model.ChatCategory;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ChatControllerInitialWorkedStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadsEachCompletedInitialListOnceAndAppliesStateToEveryVariant()
|
||||||
|
throws SQLException {
|
||||||
|
DBController database = mock(DBController.class);
|
||||||
|
ChatMember stored = member("9A0BB", 2);
|
||||||
|
stored.setWorked(true);
|
||||||
|
stored.setWorked144(true);
|
||||||
|
stored.setWorked10G(true);
|
||||||
|
stored.setQrv432(false);
|
||||||
|
|
||||||
|
HashMap<String, ChatMember> databaseSnapshot = new HashMap<>();
|
||||||
|
databaseSnapshot.put(stored.getCallSignRaw(), stored);
|
||||||
|
when(database.fetchChatMemberWkdDataFromDB())
|
||||||
|
.thenReturn(databaseSnapshot);
|
||||||
|
|
||||||
|
ChatController controller = new ChatController();
|
||||||
|
controller.setDbHandler(database);
|
||||||
|
|
||||||
|
ChatMember mainVariant = member("9A0BB-2", 2);
|
||||||
|
ChatMember secondVariant = member("9A0BB-70", 3);
|
||||||
|
ChatMember reconnectMainVariant = member("9A0BB-144", 2);
|
||||||
|
ChatMember reconnectSecondVariant = member("9A0BB-432", 3);
|
||||||
|
|
||||||
|
controller.loadWorkedStateForInitialUserList(List.of(mainVariant));
|
||||||
|
controller.loadWorkedStateForInitialUserList(List.of(secondVariant));
|
||||||
|
controller.loadWorkedStateForInitialUserList(
|
||||||
|
List.of(reconnectMainVariant));
|
||||||
|
controller.loadWorkedStateForInitialUserList(
|
||||||
|
List.of(reconnectSecondVariant));
|
||||||
|
|
||||||
|
verify(database, times(4)).fetchChatMemberWkdDataFromDB();
|
||||||
|
for (ChatMember variant : List.of(
|
||||||
|
mainVariant,
|
||||||
|
secondVariant,
|
||||||
|
reconnectMainVariant,
|
||||||
|
reconnectSecondVariant
|
||||||
|
)) {
|
||||||
|
assertTrue(variant.isWorked());
|
||||||
|
assertTrue(variant.isWorked144());
|
||||||
|
assertTrue(variant.isWorked10G());
|
||||||
|
assertFalse(variant.isQrv432());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsAmbiguousStationNameFromReplacingCompatibilityFrequency() {
|
||||||
|
ChatController controller = new ChatController();
|
||||||
|
ChatMember member = member("DL1ABC", 2);
|
||||||
|
member.setName("144307 and 432100");
|
||||||
|
|
||||||
|
controller.initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
member.getFrequency() == null
|
||||||
|
|| member.getFrequency().get() == null
|
||||||
|
|| member.getFrequency().get().isBlank()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMember member(String callSign, int categoryNumber) {
|
||||||
|
ChatMember member = new ChatMember();
|
||||||
|
member.setCallSign(callSign);
|
||||||
|
member.setChatCategory(new ChatCategory(categoryNumber));
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
|
class DXClusterSpotFormatterTest {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("supportedFrequencies")
|
||||||
|
void keepsFixedColumnsAcrossSupportedFrequencies(
|
||||||
|
String spotter,
|
||||||
|
String frequency
|
||||||
|
) {
|
||||||
|
String line = DXClusterSpotFormatter.formatLine(
|
||||||
|
spotter,
|
||||||
|
frequency,
|
||||||
|
"DL5ASG",
|
||||||
|
"JO51HK",
|
||||||
|
"1234Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(DXClusterSpotFormatter.LINE_LENGTH, line.length());
|
||||||
|
assertEquals(
|
||||||
|
"DL5ASG",
|
||||||
|
line.substring(
|
||||||
|
DXClusterSpotFormatter.DX_CALL_COLUMN - 1,
|
||||||
|
DXClusterSpotFormatter.DX_CALL_COLUMN - 1 + 6
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
"JO51HK",
|
||||||
|
line.substring(39, 45)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
"1234Z",
|
||||||
|
line.substring(DXClusterSpotFormatter.TIME_COLUMN - 1)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
frequency,
|
||||||
|
line.substring(0, 24).trim().replaceFirst("^DX de .+?:\\s*", "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void padsShortCommentsAndTruncatesLongCommentsToThirtyCharacters() {
|
||||||
|
String shortLine = DXClusterSpotFormatter.formatLine(
|
||||||
|
"DM5M",
|
||||||
|
"144205.0",
|
||||||
|
"DL5ASG",
|
||||||
|
"JO51HK",
|
||||||
|
"1234Z"
|
||||||
|
);
|
||||||
|
String longLine = DXClusterSpotFormatter.formatLine(
|
||||||
|
"DM5M",
|
||||||
|
"144205.0",
|
||||||
|
"DL5ASG",
|
||||||
|
"123456789012345678901234567890EXTRA",
|
||||||
|
"1234Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"JO51HK" + " ".repeat(24),
|
||||||
|
shortLine.substring(39, 69)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
"123456789012345678901234567890",
|
||||||
|
longLine.substring(39, 69)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsVariableDxCallsignsWithoutMovingTheComment() {
|
||||||
|
String twelveCharacterLine = DXClusterSpotFormatter.formatLine(
|
||||||
|
"DO5AMF",
|
||||||
|
"24048100.0",
|
||||||
|
"ABCDEFGHIJKL",
|
||||||
|
"JO51HK AP 1m/100%;4m/75%",
|
||||||
|
"2359Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("ABCDEFGHIJKL", twelveCharacterLine.substring(26, 38));
|
||||||
|
assertEquals(
|
||||||
|
"JO51HK AP 1m/100%;4m/75%" + " ".repeat(6),
|
||||||
|
twelveCharacterLine.substring(39, 69)
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> DXClusterSpotFormatter.formatLine(
|
||||||
|
"DO5AMF",
|
||||||
|
"144205.0",
|
||||||
|
"ABCDEFGHIJKLM",
|
||||||
|
"JO51HK",
|
||||||
|
"2359Z"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appendsExactlyTwoBellCharactersAndCrlf() {
|
||||||
|
byte[] payload = DXClusterSpotFormatter.formatPayload(
|
||||||
|
"DM5M",
|
||||||
|
"50200.0",
|
||||||
|
"DL5ASG",
|
||||||
|
"JO51HK",
|
||||||
|
"0000Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(DXClusterSpotFormatter.LINE_LENGTH + 4, payload.length);
|
||||||
|
assertEquals(7, payload[75]);
|
||||||
|
assertEquals(7, payload[76]);
|
||||||
|
assertEquals('\r', payload[77]);
|
||||||
|
assertEquals('\n', payload[78]);
|
||||||
|
assertEquals(
|
||||||
|
75,
|
||||||
|
new String(payload, 0, 75, StandardCharsets.US_ASCII).length()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream<Arguments> supportedFrequencies() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("DM5M", "50200.0"),
|
||||||
|
Arguments.of("DO5AMF", "70250.0"),
|
||||||
|
Arguments.of("DM5M", "144205.0"),
|
||||||
|
Arguments.of("DO5AMF", "432088.0"),
|
||||||
|
Arguments.of("DM5M", "1296338.0"),
|
||||||
|
Arguments.of("DO5AMF", "10368100.0"),
|
||||||
|
Arguments.of("DO5AMF", "24048100.0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import kst4contest.model.AirPlane;
|
||||||
|
import kst4contest.model.AirPlaneReflectionInfo;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class MessageBusManagementThreadDxClusterCommentTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsLocatorAndAddsCompactAirScoutInformation() {
|
||||||
|
ChatMember sender = new ChatMember();
|
||||||
|
sender.setQra("jo51hk");
|
||||||
|
|
||||||
|
AirPlane firstAircraft = new AirPlane();
|
||||||
|
firstAircraft.setArrivingDurationMinutes(1);
|
||||||
|
firstAircraft.setPotential(100);
|
||||||
|
|
||||||
|
AirPlane secondAircraft = new AirPlane();
|
||||||
|
secondAircraft.setArrivingDurationMinutes(4);
|
||||||
|
secondAircraft.setPotential(75);
|
||||||
|
|
||||||
|
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
|
||||||
|
reflectionInfo.setRisingAirplanes(
|
||||||
|
FXCollections.observableArrayList(
|
||||||
|
firstAircraft,
|
||||||
|
secondAircraft
|
||||||
|
)
|
||||||
|
);
|
||||||
|
sender.setAirPlaneReflectInfo(reflectionInfo);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"JO51HK AP 1m/100%;4m/75%",
|
||||||
|
MessageBusManagementThread.buildDxClusterSpotComment(sender)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsLocatorWhenAirScoutInformationIsMissing() {
|
||||||
|
ChatMember sender = new ChatMember();
|
||||||
|
sender.setQra("JO51HK");
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"JO51HK",
|
||||||
|
MessageBusManagementThread.buildDxClusterSpotComment(sender)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import kst4contest.model.Band;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import kst4contest.model.ChatMessage;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class MessageBusManagementThreadFrequencyTest {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {false, true})
|
||||||
|
void detectsCompactMicrowaveFrequencyInPublicAndDirectedMessages(
|
||||||
|
boolean directedMessage
|
||||||
|
) {
|
||||||
|
ChatController controller = mock(ChatController.class);
|
||||||
|
ThreadStatusCallback callback = mock(ThreadStatusCallback.class);
|
||||||
|
MessageBusManagementThread messageBus =
|
||||||
|
new MessageBusManagementThread(
|
||||||
|
controller,
|
||||||
|
callback,
|
||||||
|
1L,
|
||||||
|
new LinkedBlockingQueue<>(),
|
||||||
|
ignored -> true
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMember sender = member("DL1ABC");
|
||||||
|
ChatMessage message = new ChatMessage();
|
||||||
|
message.setSender(sender);
|
||||||
|
message.setReceiver(member(directedMessage ? "DL2XYZ" : "ALL"));
|
||||||
|
message.setMessageText("pse try 10368100");
|
||||||
|
|
||||||
|
messageBus.smartFrequencyExtraction(message, null);
|
||||||
|
|
||||||
|
verify(controller).applyDetectedFrequencyToActiveMembers(
|
||||||
|
sender,
|
||||||
|
Band.B_10G,
|
||||||
|
10368.100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMember member(String callSign) {
|
||||||
|
ChatMember member = new ChatMember();
|
||||||
|
member.setCallSign(callSign);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WinTestIhaveInventoryTest {
|
||||||
|
|
||||||
|
private static Optional<WinTestIhaveInventory> parse(String messageText) {
|
||||||
|
return WinTestIhaveInventory.fromPacket(WinTestPacket.fromMessageText(messageText));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inventoryStartingWithPresentQsosIsExpanded() {
|
||||||
|
Optional<WinTestIhaveInventory> inventory =
|
||||||
|
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" E 1 1 911-1-117");
|
||||||
|
|
||||||
|
assertTrue(inventory.isPresent());
|
||||||
|
assertEquals("STN1@9", inventory.get().getLogId());
|
||||||
|
assertEquals(WinTestIhaveInventory.Origin.LOGGED_ELSE, inventory.get().getOrigin());
|
||||||
|
assertEquals(
|
||||||
|
List.of(new WinTestLogSegment(1L, 911L), new WinTestLogSegment(913L, 1029L)),
|
||||||
|
inventory.get().getSegments());
|
||||||
|
assertEquals(1029L, inventory.get().getHighestQsoNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inventoryStartingWithMissingQsosIsExpanded() {
|
||||||
|
Optional<WinTestIhaveInventory> inventory =
|
||||||
|
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 0 30-5");
|
||||||
|
|
||||||
|
assertTrue(inventory.isPresent());
|
||||||
|
assertEquals(WinTestIhaveInventory.Origin.OWNER, inventory.get().getOrigin());
|
||||||
|
assertEquals(List.of(new WinTestLogSegment(31L, 35L)), inventory.get().getSegments());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitInventoryStartsAtItsFirstRow() {
|
||||||
|
/*
|
||||||
|
* Win-Test splits long inventories. The documented example "100 1 10-5-5"
|
||||||
|
* means: ten QSOs from 100, five missing, five present again.
|
||||||
|
*/
|
||||||
|
Optional<WinTestIhaveInventory> inventory =
|
||||||
|
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 100 1 10-5-5");
|
||||||
|
|
||||||
|
assertTrue(inventory.isPresent());
|
||||||
|
assertEquals(
|
||||||
|
List.of(new WinTestLogSegment(100L, 109L), new WinTestLogSegment(115L, 119L)),
|
||||||
|
inventory.get().getSegments());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runLengthChainWithWrongParityIsRejected() {
|
||||||
|
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 1 10-5").isEmpty());
|
||||||
|
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 0 10-5-5").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyInventoryWithoutRunLengthsIsRejected() {
|
||||||
|
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@169\" \"OWNER\" 2").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void otherMessageTypesAreRejected() {
|
||||||
|
assertTrue(parse("STATUS: \"STN1\" \"\" 0 12 0 0 0 1443210 0").isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WinTestLogSyncServiceTest {
|
||||||
|
|
||||||
|
private static final String LOG_ID = "STN1@44510";
|
||||||
|
|
||||||
|
private final List<String> sentRequests = new ArrayList<>();
|
||||||
|
|
||||||
|
private long currentTimeMs = 1_000_000L;
|
||||||
|
|
||||||
|
private WinTestLogSyncService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void createService() {
|
||||||
|
sentRequests.clear();
|
||||||
|
service = new WinTestLogSyncService(
|
||||||
|
(targetStation, logId, countFrom, countTo) ->
|
||||||
|
sentRequests.add(targetStation + " " + logId + " " + countFrom + "-" + countTo),
|
||||||
|
() -> "KST4Contest",
|
||||||
|
() -> currentTimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveIhave(String messageText) {
|
||||||
|
service.onIhaveReceived(WinTestPacket.fromMessageText(messageText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveQsos(long countFrom, long countTo) {
|
||||||
|
for (long qsoNumber = countFrom; qsoNumber <= countTo; qsoNumber++) {
|
||||||
|
service.registerReceivedQso(LOG_ID, qsoNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstBlockOfAnAnnouncedLogIsRequested() {
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(List.of("STN1 " + LOG_ID + " 1-50"), sentRequests);
|
||||||
|
assertEquals(WinTestLogSyncService.SyncState.SYNCING, service.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void answeredBlockTriggersTheNextBlockUntilTheLogIsComplete() {
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
receiveQsos(1L, 50L);
|
||||||
|
service.tick();
|
||||||
|
receiveQsos(51L, 100L);
|
||||||
|
service.tick();
|
||||||
|
receiveQsos(101L, 120L);
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
"STN1 " + LOG_ID + " 1-50",
|
||||||
|
"STN1 " + LOG_ID + " 51-100",
|
||||||
|
"STN1 " + LOG_ID + " 101-120"
|
||||||
|
),
|
||||||
|
sentRequests);
|
||||||
|
assertEquals(WinTestLogSyncService.SyncState.IN_SYNC, service.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyMissingQsoNumbersAreRequested() {
|
||||||
|
receiveQsos(1L, 10L);
|
||||||
|
receiveQsos(21L, 30L);
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 30");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(List.of("STN1 " + LOG_ID + " 11-20"), sentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadyKnownQsoIsReportedAsKnown() {
|
||||||
|
assertTrue(service.registerReceivedQso(LOG_ID, 5L));
|
||||||
|
assertFalse(service.registerReceivedQso(LOG_ID, 5L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void qsoWithoutUsableIdentityIsAlwaysTreatedAsNew() {
|
||||||
|
assertTrue(service.registerReceivedQso(null, 5L));
|
||||||
|
assertTrue(service.registerReceivedQso("", 5L));
|
||||||
|
assertTrue(service.registerReceivedQso(LOG_ID, 0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unansweredRequestIsRepeatedAtAnotherStationHoldingTheSameLog() {
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
|
||||||
|
receiveIhave("IHAVE: \"STN2\" \"\" \"" + LOG_ID + "\" E 1 1 120");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("STN1 " + LOG_ID + " 1-50", "STN2 " + LOG_ID + " 1-50"),
|
||||||
|
sentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void silentStationIsDroppedWhenNobodyElseHoldsTheLog() {
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
|
||||||
|
service.tick();
|
||||||
|
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(List.of("STN1 " + LOG_ID + " 1-50"), sentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blindFallbackRequestsFixedBlocksWithoutInventory() {
|
||||||
|
service.onStationSeen("STN1");
|
||||||
|
service.registerReceivedQso(LOG_ID, 7L);
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
assertEquals(List.of(), sentRequests);
|
||||||
|
|
||||||
|
currentTimeMs += WinTestLogSyncService.INVENTORY_GRACE_PERIOD_MS;
|
||||||
|
service.tick();
|
||||||
|
receiveQsos(1L, 50L);
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
|
||||||
|
service.tick();
|
||||||
|
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("STN1 " + LOG_ID + " 1-50", "STN1 " + LOG_ID + " 51-100"),
|
||||||
|
sentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inventoryStopsTheBlindFallback() {
|
||||||
|
service.onStationSeen("STN1");
|
||||||
|
service.registerReceivedQso(LOG_ID, 7L);
|
||||||
|
currentTimeMs += WinTestLogSyncService.INVENTORY_GRACE_PERIOD_MS;
|
||||||
|
|
||||||
|
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 10");
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(List.of("STN1 " + LOG_ID + " 1-6"), sentRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownPacketsDoNotStartASynchronization() {
|
||||||
|
service.onStationSeen("KST4Contest");
|
||||||
|
|
||||||
|
service.tick();
|
||||||
|
|
||||||
|
assertEquals(List.of(), sentRequests);
|
||||||
|
assertEquals(WinTestLogSyncService.SyncState.IDLE, service.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WinTestNetworkAddressResolverTest {
|
||||||
|
|
||||||
|
private static InetAddress address(String hostAddress) throws UnknownHostException {
|
||||||
|
return InetAddress.getByName(hostAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stationNetworkWinsOverConfiguredAddress() throws UnknownHostException {
|
||||||
|
WinTestNetworkAddressResolver resolver = new WinTestNetworkAddressResolver(
|
||||||
|
remoteAddress -> {
|
||||||
|
try {
|
||||||
|
return address("192.168.122.255");
|
||||||
|
} catch (UnknownHostException exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolver.rememberStationAddress(address("192.168.122.1"));
|
||||||
|
|
||||||
|
assertEquals(address("192.168.122.255"),
|
||||||
|
resolver.resolveBroadcastAddress("192.168.101.255"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configuredAddressIsUsedWhenNoLocalInterfaceServesTheStation()
|
||||||
|
throws UnknownHostException {
|
||||||
|
WinTestNetworkAddressResolver resolver =
|
||||||
|
new WinTestNetworkAddressResolver(remoteAddress -> null);
|
||||||
|
|
||||||
|
resolver.rememberStationAddress(address("10.9.8.7"));
|
||||||
|
|
||||||
|
assertEquals(address("192.168.101.255"),
|
||||||
|
resolver.resolveBroadcastAddress("192.168.101.255"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limitedBroadcastIsUsedWithoutStationAndWithoutSetting()
|
||||||
|
throws UnknownHostException {
|
||||||
|
WinTestNetworkAddressResolver resolver =
|
||||||
|
new WinTestNetworkAddressResolver(remoteAddress -> null);
|
||||||
|
|
||||||
|
assertEquals(address("255.255.255.255"), resolver.resolveBroadcastAddress(" "));
|
||||||
|
assertEquals(address("255.255.255.255"), resolver.resolveBroadcastAddress(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loopbackAndWildcardSourcesAreIgnored() throws UnknownHostException {
|
||||||
|
WinTestNetworkAddressResolver resolver = new WinTestNetworkAddressResolver(
|
||||||
|
remoteAddress -> {
|
||||||
|
throw new IllegalStateException("must not be asked for " + remoteAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolver.rememberStationAddress(address("127.0.0.1"));
|
||||||
|
resolver.rememberStationAddress(address("0.0.0.0"));
|
||||||
|
resolver.rememberStationAddress(null);
|
||||||
|
|
||||||
|
assertEquals(address("192.168.101.255"),
|
||||||
|
resolver.resolveBroadcastAddress("192.168.101.255"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void subnetComparisonHonoursThePrefixLength() throws UnknownHostException {
|
||||||
|
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
|
||||||
|
address("192.168.122.1"), address("192.168.122.203"), 24));
|
||||||
|
assertFalse(WinTestNetworkAddressResolver.isInSameSubnet(
|
||||||
|
address("192.168.101.5"), address("192.168.122.1"), 24));
|
||||||
|
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
|
||||||
|
address("172.19.0.1"), address("172.19.240.9"), 16));
|
||||||
|
assertFalse(WinTestNetworkAddressResolver.isInSameSubnet(
|
||||||
|
address("10.244.22.73"), address("10.244.23.1"), 24));
|
||||||
|
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
|
||||||
|
address("10.244.22.73"), address("10.244.23.1"), 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package kst4contest.controller;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WinTestPacketTest {
|
||||||
|
|
||||||
|
private static final String ADDQSO_MESSAGE =
|
||||||
|
"ADDQSO: \"STN1\" \"\" \"STN1\" 1762202297 1440000 0 12 0 0 0 2 2 "
|
||||||
|
+ "\"DM2RN\" \"599\" \"599001\" \"JO51UM\" \"\" \"\" 0 \"\" \"\" \"\" 44510";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a datagram exactly like Win-Test does: message text, checksum byte
|
||||||
|
* replacing the placeholder, NUL terminator.
|
||||||
|
*/
|
||||||
|
private static byte[] toDatagram(String messageText) {
|
||||||
|
byte[] datagram = (messageText + "?\0").getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < datagram.length - 2; index++) {
|
||||||
|
sum += datagram[index] & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
datagram[datagram.length - 2] = (byte) ((sum | 0x80) & 0xFF);
|
||||||
|
return datagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checksumByteAndTerminatorAreRemovedFromMessageText() {
|
||||||
|
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
|
||||||
|
|
||||||
|
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
|
||||||
|
|
||||||
|
assertEquals(ADDQSO_MESSAGE, packet.getMessageText());
|
||||||
|
assertTrue(packet.isChecksumPresent());
|
||||||
|
assertTrue(packet.isChecksumValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trailingLogIdStaysReadableAfterFramingIsResolved() {
|
||||||
|
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
|
||||||
|
|
||||||
|
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
|
||||||
|
List<String> packetFields = WinTestPacket.tokenize(packet.getMessageText());
|
||||||
|
|
||||||
|
assertEquals("STN1@44510",
|
||||||
|
ReadUDPByWintestThread.extractLogIdFromWinTestAddQso(packetFields));
|
||||||
|
assertEquals(2L,
|
||||||
|
ReadUDPByWintestThread.extractQsoNumberFromWinTestAddQso(packetFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void manipulatedChecksumIsDetected() {
|
||||||
|
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
|
||||||
|
datagram[datagram.length - 2] = (byte) 0xFF;
|
||||||
|
|
||||||
|
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
|
||||||
|
|
||||||
|
assertTrue(packet.isChecksumPresent());
|
||||||
|
assertFalse(packet.isChecksumValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sourceAndDestinationAreSeparatedFromPayload() {
|
||||||
|
byte[] datagram = toDatagram(
|
||||||
|
"IHAVE: \"STN1\" \"KST4Contest\" \"STN1@44510\" O 1 1 120");
|
||||||
|
|
||||||
|
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
|
||||||
|
|
||||||
|
assertEquals("IHAVE", packet.getMessageType());
|
||||||
|
assertEquals("STN1", packet.getSource());
|
||||||
|
assertEquals("KST4Contest", packet.getDestination());
|
||||||
|
assertEquals(List.of("STN1@44510", "O", "1", "1", "120"), packet.getDataTokens());
|
||||||
|
assertTrue(packet.isAddressedTo("KST4Contest"));
|
||||||
|
assertFalse(packet.isAddressedTo("STN2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyQuotedFieldsKeepFieldPositions() {
|
||||||
|
List<String> packetFields = WinTestPacket.tokenize(ADDQSO_MESSAGE);
|
||||||
|
|
||||||
|
assertEquals("ADDQSO:", packetFields.get(0));
|
||||||
|
assertEquals("STN1", packetFields.get(1));
|
||||||
|
assertEquals("", packetFields.get(2));
|
||||||
|
assertEquals("12", packetFields.get(7));
|
||||||
|
assertEquals("DM2RN", packetFields.get(13));
|
||||||
|
assertEquals(24, packetFields.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void messageWithoutWinTestFramingIsRejected() {
|
||||||
|
assertNull(WinTestPacket.fromMessageText("no win-test message"));
|
||||||
|
assertNull(WinTestPacket.fromDatagram(new byte[] { 0 }, 1));
|
||||||
|
assertNull(WinTestPacket.fromDatagram(null, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastPacketIsAcceptedForEveryStationName() {
|
||||||
|
WinTestPacket packet = WinTestPacket.fromMessageText(ADDQSO_MESSAGE);
|
||||||
|
|
||||||
|
assertTrue(packet.isAddressedTo("KST4Contest"));
|
||||||
|
assertFalse(packet.isChecksumPresent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package kst4contest.test;
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ChatPreferencesLayoutPersistenceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void columnWidthsSurviveFullXmlRoundTripAndLayoutsStayIndependent() {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
|
||||||
|
ChatPreferences written = preferencesAt(preferencesFile);
|
||||||
|
written.setTableColumnWidth("dx-cluster-main", "message", 410.5);
|
||||||
|
written.setTableColumnWidth("dx-cluster-monitor", "message", 275.25);
|
||||||
|
|
||||||
|
assertTrue(written.writePreferencesToXmlFile());
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertEquals(410.5,
|
||||||
|
restored.getTableColumnWidth("dx-cluster-main", "message").orElseThrow());
|
||||||
|
assertEquals(275.25,
|
||||||
|
restored.getTableColumnWidth("dx-cluster-monitor", "message").orElseThrow());
|
||||||
|
assertFalse(restored.getTableColumnWidth("qso-other-main", "message").isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyXmlWithoutColumnWidthsKeepsWidthsAbsent() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("legacy.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>5</configVersion>
|
||||||
|
<guiOptions>
|
||||||
|
<GUIscn_ChatwindowMainSceneSizeHW>768;1234</GUIscn_ChatwindowMainSceneSizeHW>
|
||||||
|
</guiOptions>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
|
||||||
|
assertFalse(restored.getTableColumnWidth("public-messages", "time").isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidColumnWidthEntriesAreIgnored() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("invalid.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>6</configVersion>
|
||||||
|
<guiOptions>
|
||||||
|
<tableColumnWidth tableId="public-messages" columnId="callsign" pixels="NaN"/>
|
||||||
|
<tableColumnWidth tableId="public-messages" columnId="name" pixels="-20"/>
|
||||||
|
<tableColumnWidth tableId="public-messages" columnId="category" pixels="999999"/>
|
||||||
|
<tableColumnWidth tableId="public-messages" columnId="time" pixels="88.5"/>
|
||||||
|
</guiOptions>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
|
||||||
|
assertFalse(restored.getTableColumnWidth("public-messages", "callsign").isPresent());
|
||||||
|
assertFalse(restored.getTableColumnWidth("public-messages", "name").isPresent());
|
||||||
|
assertFalse(restored.getTableColumnWidth("public-messages", "category").isPresent());
|
||||||
|
assertEquals(88.5,
|
||||||
|
restored.getTableColumnWidth("public-messages", "time").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectiveLayoutWritePreservesDiskSettingsAndUnknownXml() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("selective.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>5</configVersion>
|
||||||
|
<station>
|
||||||
|
<LoginCallSign>SAVED-CALL</LoginCallSign>
|
||||||
|
</station>
|
||||||
|
<futureExtension mode="keep-me"><value>42</value></futureExtension>
|
||||||
|
<guiOptions>
|
||||||
|
<GUIscn_ChatwindowMainSceneSizeHW>700;1100</GUIscn_ChatwindowMainSceneSizeHW>
|
||||||
|
<futureLayoutValue>untouched</futureLayoutValue>
|
||||||
|
</guiOptions>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences preferences = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(preferences.readPreferencesFromXmlFile());
|
||||||
|
preferences.setStn_loginCallSign("UNSAVED-CALL");
|
||||||
|
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
|
||||||
|
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
|
||||||
|
preferences.setGUIstationMapClusteringEnabled(false);
|
||||||
|
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
|
||||||
|
|
||||||
|
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
|
||||||
|
|
||||||
|
String writtenXml = Files.readString(preferencesFile);
|
||||||
|
assertTrue(writtenXml.contains("<LoginCallSign>SAVED-CALL</LoginCallSign>"));
|
||||||
|
assertFalse(writtenXml.contains("UNSAVED-CALL"));
|
||||||
|
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
|
||||||
|
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
|
||||||
|
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
|
||||||
|
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
|
||||||
|
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||||
|
+ "</GUIstationMapClusteringEnabled>"));
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
|
||||||
|
assertFalse(restored.isGUIstationMapClusteringEnabled());
|
||||||
|
assertEquals(123.75,
|
||||||
|
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatPreferences preferencesAt(Path preferencesFile) {
|
||||||
|
ChatPreferences preferences = new ChatPreferences();
|
||||||
|
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package kst4contest.test;
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ChatPreferencesStationMapClusteringTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clusteringIsEnabledByDefault() {
|
||||||
|
assertTrue(new ChatPreferences().isGUIstationMapClusteringEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledClusteringSurvivesFullXmlRoundTrip() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
|
||||||
|
ChatPreferences written = preferencesAt(preferencesFile);
|
||||||
|
written.setGUIstationMapClusteringEnabled(false);
|
||||||
|
|
||||||
|
assertTrue(written.writePreferencesToXmlFile());
|
||||||
|
|
||||||
|
String writtenXml = Files.readString(preferencesFile);
|
||||||
|
assertTrue(writtenXml.contains("<configVersion>7</configVersion>"));
|
||||||
|
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
|
||||||
|
+ "</GUIstationMapClusteringEnabled>"));
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertFalse(restored.isGUIstationMapClusteringEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void versionSixWithoutClusteringSettingKeepsClusteringEnabled() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("version-six.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>6</configVersion>
|
||||||
|
<guiOptions>
|
||||||
|
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
|
||||||
|
</guiOptions>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
restored.setGUIstationMapClusteringEnabled(false);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingGuiOptionsKeepsClusteringEnabled() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("missing.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>6</configVersion>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
restored.setGUIstationMapClusteringEnabled(false);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidClusteringValueKeepsClusteringEnabled() throws IOException {
|
||||||
|
Path preferencesFile = temporaryDirectory.resolve("invalid.xml");
|
||||||
|
Files.writeString(preferencesFile, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<praktiKST>
|
||||||
|
<configVersion>7</configVersion>
|
||||||
|
<guiOptions>
|
||||||
|
<GUIstationMapClusteringEnabled>sometimes</GUIstationMapClusteringEnabled>
|
||||||
|
</guiOptions>
|
||||||
|
</praktiKST>
|
||||||
|
""");
|
||||||
|
|
||||||
|
ChatPreferences restored = preferencesAt(preferencesFile);
|
||||||
|
restored.setGUIstationMapClusteringEnabled(false);
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile());
|
||||||
|
assertTrue(restored.isGUIstationMapClusteringEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatPreferences preferencesAt(Path preferencesFile) {
|
||||||
|
ChatPreferences preferences = new ChatPreferences();
|
||||||
|
preferences.setStoreAndRestorePreferencesFileName(preferencesFile.toString());
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package kst4contest.test;
|
||||||
|
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
import kst4contest.model.Band;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FrequencyTextParserRegressionTest {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("compactFrequenciesAcrossSupportedBands")
|
||||||
|
void detectsCompactFrequenciesAcrossSupportedBands(
|
||||||
|
String compactFrequency,
|
||||||
|
Band expectedBand,
|
||||||
|
double expectedFrequencyMHz
|
||||||
|
) {
|
||||||
|
FrequencyTextParser.DetectedFrequency detected =
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"QRV " + compactFrequency
|
||||||
|
).get(0);
|
||||||
|
|
||||||
|
assertEquals(expectedBand, detected.getBand());
|
||||||
|
assertEquals(
|
||||||
|
expectedFrequencyMHz,
|
||||||
|
detected.getFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsReferenceFrequencyInStationName() {
|
||||||
|
List<FrequencyTextParser.DetectedFrequency> detected =
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"Operator 144307"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(1, detected.size());
|
||||||
|
assertEquals(Band.B_144, detected.get(0).getBand());
|
||||||
|
assertEquals(
|
||||||
|
144.307,
|
||||||
|
detected.get(0).getFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCompactValuesOutsideSupportedBandRanges() {
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"146100 434100 99999"
|
||||||
|
).isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsBareThreeDigitValuesOutOfCompleteFrequencyDetection() {
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"210 599 144"
|
||||||
|
).isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream<Arguments> compactFrequenciesAcrossSupportedBands() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("50278", Band.B_50, 50.278),
|
||||||
|
Arguments.of("70200", Band.B_70, 70.200),
|
||||||
|
Arguments.of("145500", Band.B_144, 145.500),
|
||||||
|
Arguments.of("432100", Band.B_432, 432.100),
|
||||||
|
Arguments.of("1296100", Band.B_1296, 1296.100),
|
||||||
|
Arguments.of("2320100", Band.B_2320, 2320.100),
|
||||||
|
Arguments.of("3400100", Band.B_3400, 3400.100),
|
||||||
|
Arguments.of("5760100", Band.B_5760, 5760.100),
|
||||||
|
Arguments.of("10368100", Band.B_10G, 10368.100),
|
||||||
|
Arguments.of("24048100", Band.B_24G, 24048.100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kst4contest.test;
|
||||||
|
|
||||||
|
import kst4contest.view.map.MapHtmlResources;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class MapHtmlResourcesContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stationClusteringCanBeToggledWithoutReplacingStationData() {
|
||||||
|
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||||
|
|
||||||
|
assertTrue(html.contains("let stationClusteringEnabled = true;"));
|
||||||
|
assertTrue(html.contains("if (!stationClusteringEnabled"));
|
||||||
|
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
|
||||||
|
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
|
||||||
|
|
||||||
|
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
|
||||||
|
int setterEnd = html.indexOf('}', setterStart);
|
||||||
|
String setterBody = html.substring(setterStart, setterEnd);
|
||||||
|
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
|
||||||
|
int markerRender = setterBody.indexOf("renderStationMarkers();");
|
||||||
|
|
||||||
|
assertTrue(stateUpdate >= 0);
|
||||||
|
assertTrue(markerRender > stateUpdate);
|
||||||
|
assertFalse(setterBody.contains("stationData ="));
|
||||||
|
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class TableLayoutManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contentWidthUsesCompactPadding() {
|
||||||
|
assertEquals(116.0, TableLayoutManager.calculateInitialContentWidth(100.0, 24.0, 200.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contentWidthStillHonorsMinimumAndMaximum() {
|
||||||
|
assertEquals(24.0, TableLayoutManager.calculateInitialContentWidth(0.0, 24.0, 200.0));
|
||||||
|
assertEquals(200.0, TableLayoutManager.calculateInitialContentWidth(250.0, 24.0, 200.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class TruncatedTextTableCellTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainFullTextTooltipIsShownOnlyForClippedText() {
|
||||||
|
assertNull(TruncatedTextTooltipSupport.buildTooltipText("complete", false, null));
|
||||||
|
assertEquals("complete",
|
||||||
|
TruncatedTextTooltipSupport.buildTooltipText("complete", true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void functionalTooltipRemainsAndCombinesWithClippedValue() {
|
||||||
|
assertEquals("Worked status",
|
||||||
|
TruncatedTextTooltipSupport.buildTooltipText("X", false, "Worked status"));
|
||||||
|
assertEquals("Long value\n\nWorked status",
|
||||||
|
TruncatedTextTooltipSupport.buildTooltipText("Long value", true, "Worked status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clippingComparisonUsesAvailableRenderedWidth() {
|
||||||
|
assertFalse(TruncatedTextTooltipSupport.isTextClipped(100, 100));
|
||||||
|
assertTrue(TruncatedTextTooltipSupport.isTextClipped(102, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2391,3 +2391,15 @@ SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 true;
|
|||||||
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 432.135]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 432.135]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
LA0BY;Stefan @ hilltop;JO59IX;StringProperty [value: 144.062]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
LA0BY;Stefan @ hilltop;JO59IX;StringProperty [value: 144.062]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
LA0BY;null;JO49ML;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
LA0BY;null;JO49ML;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
OV3T;null;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
DK0TR;null;JO40QL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
DD0VF;null;JO61TB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F5DYD;null;JN03KG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F6DRO;null;JN03TJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F4VRB;null;IN98PT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
DK0TR;null;JO40QL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
DD0VF;null;JO61TB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F5DYD;null;JN03KG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F6DRO;null;JN03TJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
F4VRB;null;IN98PT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
|
DK5KMA;null;JO50IK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
# Server-side website statistics
|
||||||
|
|
||||||
|
This directory contains installation examples for the confirmed Ubuntu 24.04
|
||||||
|
server baseline and privacy-conscious traffic statistics. Nothing here
|
||||||
|
installs or activates the production service automatically.
|
||||||
|
|
||||||
|
The design has two separate outputs:
|
||||||
|
|
||||||
|
- private static GoAccess HTML and JSON reports for each registered project
|
||||||
|
subdomain and for all registered project subdomains combined;
|
||||||
|
- a small public `visitor-count.json` file for sites which explicitly enable
|
||||||
|
the counter.
|
||||||
|
|
||||||
|
The public number is the sum of daily approximate unique visits since the
|
||||||
|
configured activation date. GoAccess treats requests with the same IP address,
|
||||||
|
date and user agent as one visit. That is useful for a rough trend. It is not a
|
||||||
|
count of people.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
eligible page request
|
||||||
|
|
|
||||||
|
v
|
||||||
|
dedicated Nginx analytics log (14 days)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
GoAccess with IP anonymisation
|
||||||
|
|
|
||||||
|
+--> private per-site and combined HTML/JSON reports (395 days)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
durable daily counter state --> public visitor-count.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx writes a dedicated, reduced log. For each site, the generator gives the
|
||||||
|
existing, uncompressed `.1` rotation and then the current log directly to
|
||||||
|
GoAccess. GoAccess uses its persistent state to skip entries already
|
||||||
|
processed. The generator does not use an incremental shell pipeline or
|
||||||
|
decompress older rotations. Reports, database updates and public counter data
|
||||||
|
are first prepared in a staging directory. GoAccess output is validated
|
||||||
|
before any published report changes. The last valid report therefore survives
|
||||||
|
a failed GoAccess run.
|
||||||
|
|
||||||
|
The counter state is deliberately separate from the 395-day report database.
|
||||||
|
Each day is replaced with the latest value reported by GoAccess instead of
|
||||||
|
being added again. This makes repeated runs idempotent. Values older than 395
|
||||||
|
days remain in the counter state and continue to contribute to the public
|
||||||
|
total.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `generate-reports.js` validates configuration and state, runs GoAccess and
|
||||||
|
publishes outputs atomically.
|
||||||
|
- `sites.example.json` is the registry template.
|
||||||
|
- `goaccess.conf.template` is rendered per report with a private database path
|
||||||
|
and the configured GeoIP2 Country database.
|
||||||
|
- `nginx/` contains the reduced log format, request filters, public endpoint
|
||||||
|
and protected report-vhost examples.
|
||||||
|
- `systemd/` contains a hardened oneshot service and hourly timer.
|
||||||
|
- `logrotate/` retains 14 daily analytics-log rotations.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18.19.1 or newer;
|
||||||
|
- GoAccess with GeoIP2/MMDB support;
|
||||||
|
- one GeoIP2 **Country** database, not a City database;
|
||||||
|
- Nginx;
|
||||||
|
- an unprivileged service account, shown as `hamradio-analytics` in the
|
||||||
|
examples.
|
||||||
|
|
||||||
|
No npm package is required by the generator. GoAccess is the only external
|
||||||
|
program it starts.
|
||||||
|
|
||||||
|
The production compatibility baseline is GoAccess 1.8.1 built with
|
||||||
|
`--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is
|
||||||
|
not required for the regular operating mode because it reads only
|
||||||
|
uncompressed files. The configuration check reports the detected build
|
||||||
|
features and explicitly accepts this combination.
|
||||||
|
|
||||||
|
The confirmed production baseline is Node.js 18.19.1. The generator and tests
|
||||||
|
must remain compatible with it; upgrading Node.js is not part of this setup.
|
||||||
|
|
||||||
|
The service account needs read access to the dedicated analytics logs and
|
||||||
|
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. It needs write access only to its state,
|
||||||
|
report and public-output directories. Access is group-based. The setup does
|
||||||
|
not depend on ACLs or `setfacl`. Nginx receives read access to reports and the
|
||||||
|
public counter through the `www-data` group, but no write access.
|
||||||
|
|
||||||
|
## Installation and permissions
|
||||||
|
|
||||||
|
Do not trust Unix modes stored in a ZIP created on Windows. Install every
|
||||||
|
script, configuration and unit with an explicit owner, group and mode. The
|
||||||
|
following commands assume that the package has been unpacked into the current
|
||||||
|
directory. Create the dedicated, unprivileged service account once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
if ! getent passwd hamradio-analytics >/dev/null; then
|
||||||
|
sudo useradd --system --user-group --home-dir /nonexistent --no-create-home \
|
||||||
|
--shell /usr/sbin/nologin hamradio-analytics
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install the generator, configuration and units:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o root -g hamradio-analytics -m 0750 \
|
||||||
|
/opt/hamradioonline-analytics /etc/hamradioonline-analytics
|
||||||
|
sudo install -o root -g hamradio-analytics -m 0750 \
|
||||||
|
website/ops/analytics/generate-reports.js \
|
||||||
|
/opt/hamradioonline-analytics/generate-reports.js
|
||||||
|
sudo install -o root -g hamradio-analytics -m 0640 \
|
||||||
|
website/ops/analytics/goaccess.conf.template \
|
||||||
|
/etc/hamradioonline-analytics/goaccess.conf.template
|
||||||
|
sudo install -o root -g hamradio-analytics -m 0640 \
|
||||||
|
website/ops/analytics/sites.example.json \
|
||||||
|
/etc/hamradioonline-analytics/sites.json
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/systemd/hamradioonline-analytics.service.example \
|
||||||
|
/etc/systemd/system/hamradioonline-analytics.service
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/systemd/hamradioonline-analytics.timer.example \
|
||||||
|
/etc/systemd/system/hamradioonline-analytics.timer
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/logrotate/hamradioonline-analytics.example \
|
||||||
|
/etc/logrotate.d/hamradioonline-analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the writable tree deliberately. The state root is traversable but not
|
||||||
|
readable by Nginx. Only the report and public branches use the `www-data`
|
||||||
|
group and the set-group-ID bit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0711 \
|
||||||
|
/var/lib/hamradioonline-analytics
|
||||||
|
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0750 \
|
||||||
|
/var/lib/hamradioonline-analytics/db
|
||||||
|
sudo install -d -o hamradio-analytics -g www-data -m 2750 \
|
||||||
|
/var/lib/hamradioonline-analytics/reports \
|
||||||
|
/var/lib/hamradioonline-analytics/reports/combined \
|
||||||
|
/var/lib/hamradioonline-analytics/reports/kst4contest \
|
||||||
|
/var/lib/hamradioonline-analytics/public \
|
||||||
|
/var/lib/hamradioonline-analytics/public/kst4contest
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated HTML and JSON reports use mode `0640`. The public
|
||||||
|
`visitor-count.json` uses `0644`. Private GoAccess databases and
|
||||||
|
`public-counter-state.json` remain owned by `hamradio-analytics` and unreadable
|
||||||
|
by Nginx. The service unit uses `StateDirectoryMode=0711` to retain this
|
||||||
|
boundary after systemd has prepared the state directory.
|
||||||
|
|
||||||
|
Create the analytics log only when it does not already exist. Running
|
||||||
|
`install /dev/null` unconditionally would empty an existing log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
if [ ! -e /var/log/nginx/kst4contest-analytics.log ]; then
|
||||||
|
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
|
||||||
|
/var/log/nginx/kst4contest-analytics.log
|
||||||
|
fi
|
||||||
|
sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting log owner and mode must be
|
||||||
|
`www-data:hamradio-analytics 640`. Logrotate preserves that ownership. The
|
||||||
|
generator's configuration check fails clearly if required output directories
|
||||||
|
are missing or if the executing user cannot read an analytics log or the
|
||||||
|
Country database.
|
||||||
|
|
||||||
|
## Registry
|
||||||
|
|
||||||
|
Copy `sites.example.json` outside the checkout and adjust it to the private
|
||||||
|
server layout. Each site entry contains:
|
||||||
|
|
||||||
|
- a stable `id` used for state and the GoAccess database;
|
||||||
|
- its exact `hostname`;
|
||||||
|
- the current, uncompressed `analyticsLog` path;
|
||||||
|
- the `activatedOn` date used by the public counter;
|
||||||
|
- a `publicCounter` switch;
|
||||||
|
- the private `reportOutputDirectory`;
|
||||||
|
- a `publicJsonPath` when the public counter is enabled.
|
||||||
|
|
||||||
|
The top-level `combined.reportOutputDirectory` receives the combined report.
|
||||||
|
Only registered sites are included. The generator rejects
|
||||||
|
`stats.hamradioonline.de`, so the report host cannot accidentally become part
|
||||||
|
of the project statistics.
|
||||||
|
|
||||||
|
To add another project subdomain later, add one registry entry and one matching
|
||||||
|
dedicated `access_log` line to its Nginx server block. Do not enable a public
|
||||||
|
counter unless that site should publish one.
|
||||||
|
|
||||||
|
Treat `activatedOn` as persistent data. Once counting has started, changing it
|
||||||
|
would change the meaning of the total. The generator refuses to combine a new
|
||||||
|
activation date with existing counter state.
|
||||||
|
|
||||||
|
The generator derives the optional `.1` path from `analyticsLog`. It is valid
|
||||||
|
for `.1` not to exist before the first rotation. Do not enter a rotation or a
|
||||||
|
compressed `.gz` file in the registry.
|
||||||
|
|
||||||
|
## Nginx logging
|
||||||
|
|
||||||
|
Install the log-format and filter maps from `nginx/` in the `http` context.
|
||||||
|
Then add a dedicated analytics `access_log` to every registered project server
|
||||||
|
block. Keep the existing operational access log unless its replacement has
|
||||||
|
been reviewed separately. If the operational log is inherited from the
|
||||||
|
`http` context, repeat its directive in the server block before adding the
|
||||||
|
analytics log; an `access_log` at a lower level changes inheritance.
|
||||||
|
|
||||||
|
Install Nginx snippets explicitly as `root:root` with mode `0644`; do not copy
|
||||||
|
the modes from the ZIP. Nginx `map` exact-string keys use the path itself, for
|
||||||
|
example `/visitor-count.json`, without the location-modifier prefix `=`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/nginx/analytics-filters.conf.example \
|
||||||
|
/etc/nginx/snippets/hamradioonline-analytics-filters.conf
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/nginx/analytics-log.conf.example \
|
||||||
|
/etc/nginx/conf.d/hamradioonline-analytics-log.conf
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/nginx/public-counter.conf.example \
|
||||||
|
/etc/nginx/snippets/kst4contest-public-counter.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Prepare the public-counter include without enabling it in the active site yet.
|
||||||
|
Likewise, keep the statistics vhost disabled until the certificate bootstrap
|
||||||
|
step below.
|
||||||
|
|
||||||
|
The analytics format contains only:
|
||||||
|
|
||||||
|
- server name;
|
||||||
|
- client IP address;
|
||||||
|
- timestamp;
|
||||||
|
- method;
|
||||||
|
- normalized path without query string;
|
||||||
|
- protocol;
|
||||||
|
- status;
|
||||||
|
- transferred body size;
|
||||||
|
- user agent.
|
||||||
|
|
||||||
|
It does not contain a referrer, query string or authenticated user name. The
|
||||||
|
filter accepts only eligible page `GET` requests. It excludes the update feed,
|
||||||
|
public counter, sitemap, robots file, favicons, CSS, JavaScript, images, fonts,
|
||||||
|
source maps, manual assets and the listed monitoring paths. Known crawler user
|
||||||
|
agents are rejected before logging. GoAccess applies its own crawler list as a
|
||||||
|
second layer and treats unknown browsers or operating systems as crawlers.
|
||||||
|
|
||||||
|
Review the monitoring-path list against the real server before activation.
|
||||||
|
When a new health endpoint or asset family is added, update the filter first.
|
||||||
|
|
||||||
|
Test the complete Nginx configuration before reloading it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
## GoAccess reports
|
||||||
|
|
||||||
|
The template enables IP anonymisation before persistent aggregation, ignores
|
||||||
|
crawlers, keeps 395 days, and uses a separate persistent database for every
|
||||||
|
site and the combined report. It leaves only the panels needed here: visits by
|
||||||
|
day, requested pages, countries, HTTP status codes and virtual hosts. Host,
|
||||||
|
remote-user, referrer, keyphrase, operating-system, browser and other detailed
|
||||||
|
panels are disabled.
|
||||||
|
|
||||||
|
GoAccess 1.8.1 writes the Country panel under the JSON key `geolocation`.
|
||||||
|
Combined jobs explicitly pass `--enable-panel=VIRTUAL_HOSTS` and require the
|
||||||
|
resulting `vhosts` key. Site jobs do not enable that panel. The generator treats
|
||||||
|
either missing key as an invalid report rather than publishing incomplete
|
||||||
|
statistics.
|
||||||
|
|
||||||
|
The Country database is provided through the registry at
|
||||||
|
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. A file whose name contains `City` is
|
||||||
|
rejected. Do not replace it with a City database merely because one happens to
|
||||||
|
be available.
|
||||||
|
|
||||||
|
The generator supplies `--persist`, conditionally supplies `--restore`, and
|
||||||
|
uses an isolated `--db-path` through the rendered template. It passes the
|
||||||
|
uncompressed `.1` rotation, when present, and then the current log as direct
|
||||||
|
GoAccess arguments. This chronological order also covers entries appended
|
||||||
|
shortly before rotation. GoAccess tracks the processed files in its persistent
|
||||||
|
state and processes only new entries on later runs. The first successful run
|
||||||
|
creates each database. Later runs copy the last valid database into staging,
|
||||||
|
restore it and persist the updated result only after all reports have
|
||||||
|
succeeded.
|
||||||
|
|
||||||
|
Logrotate must use `delaycompress`, as shown in the example. This leaves `.1`
|
||||||
|
uncompressed for one rotation cycle. Older `.gz` files are not part of the
|
||||||
|
regular hourly run, and importing them is a separate maintenance task outside
|
||||||
|
this repository workflow. Do not add an unstable decompression pipeline to
|
||||||
|
the timer service.
|
||||||
|
|
||||||
|
If the generator is unavailable for longer than the uncompressed rotation
|
||||||
|
window, the regular run cannot recover entries found only in older `.gz`
|
||||||
|
files. Preserve those files under the raw-log retention policy and plan any
|
||||||
|
necessary historical import separately before resuming normal processing.
|
||||||
|
|
||||||
|
## Checking and running
|
||||||
|
|
||||||
|
Validate paths, registry values, the template contract and GoAccess
|
||||||
|
availability without producing reports:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u hamradio-analytics /usr/bin/node \
|
||||||
|
/opt/hamradioonline-analytics/generate-reports.js \
|
||||||
|
--registry /etc/hamradioonline-analytics/sites.json \
|
||||||
|
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
|
||||||
|
--check
|
||||||
|
```
|
||||||
|
|
||||||
|
The check prints the detected GoAccess version and whether GeoIP2/MMDB,
|
||||||
|
OpenSSL and Zlib build options are present. Missing GeoIP2/MMDB support is a
|
||||||
|
configuration error with exit code 2. OpenSSL remains informational. Missing
|
||||||
|
Zlib is supported for this operating mode and does not make the check fail.
|
||||||
|
The same message explains that only the current log and optional uncompressed
|
||||||
|
`.1` are processed and that older `.gz` files are not imported.
|
||||||
|
|
||||||
|
Exercise the complete GoAccess and output-validation path without changing
|
||||||
|
published reports, databases or counter state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u hamradio-analytics /usr/bin/node \
|
||||||
|
/opt/hamradioonline-analytics/generate-reports.js \
|
||||||
|
--registry /etc/hamradioonline-analytics/sites.json \
|
||||||
|
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Run without either flag to publish. A lock prevents concurrent production
|
||||||
|
runs. A dry-run uses a temporary working directory and deliberately neither
|
||||||
|
needs nor creates the production lock below `/run`. Configuration errors use
|
||||||
|
exit code 2, an active production lock uses exit code 3, and generation or
|
||||||
|
publication errors use exit code 1.
|
||||||
|
|
||||||
|
Before the first production run, seed any earlier daily values which must be
|
||||||
|
preserved into `public-counter-state.json`. There is no honest way to recreate
|
||||||
|
history which is no longer present in the raw logs. Back up this state file: it
|
||||||
|
is the durable source for public totals older than the detailed retention
|
||||||
|
window.
|
||||||
|
|
||||||
|
## Scheduling and report access
|
||||||
|
|
||||||
|
Install the systemd files as local units after adapting paths and permissions.
|
||||||
|
The timer runs hourly, catches up after downtime and adds a small random delay.
|
||||||
|
The service has no network access and only the documented read/write paths.
|
||||||
|
If the installed GoAccess build unexpectedly requires network access, find the
|
||||||
|
reason before weakening that restriction; local log processing and a local
|
||||||
|
Country database do not require it.
|
||||||
|
|
||||||
|
The statistics vhost serves static files over HTTPS and protects the complete
|
||||||
|
host with HTTP Basic Authentication. This includes `/`, its redirect to
|
||||||
|
`/combined/`, and every individual report. Store the password file outside
|
||||||
|
this repository. Prepare it so only root and the Nginx group can access it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o root -g www-data -m 0750 /etc/nginx/htpasswd
|
||||||
|
if [ -e /etc/nginx/htpasswd/hamradioonline-analytics ]; then
|
||||||
|
sudo htpasswd /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
|
||||||
|
else
|
||||||
|
sudo htpasswd -c /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
|
||||||
|
fi
|
||||||
|
sudo chown root:www-data /etc/nginx/htpasswd/hamradioonline-analytics
|
||||||
|
sudo chmod 0640 /etc/nginx/htpasswd/hamradioonline-analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose the account name locally and enter the password interactively. Never
|
||||||
|
store the resulting password hash in this repository or the installation ZIP.
|
||||||
|
The example opens no GoAccess WebSocket and no additional GoAccess port. Its
|
||||||
|
own access log is disabled and responses use a private, no-store cache policy.
|
||||||
|
|
||||||
|
Do not activate the final HTTPS vhost before its certificate files exist.
|
||||||
|
First install the temporary HTTP bootstrap without changing the parallel apt
|
||||||
|
Certbot installation or either renewal timer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o root -g root -m 0755 /var/lib/letsencrypt
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example \
|
||||||
|
/etc/nginx/sites-available/stats.hamradioonline.de
|
||||||
|
if [ ! -e /etc/nginx/sites-enabled/stats.hamradioonline.de ] && \
|
||||||
|
[ ! -L /etc/nginx/sites-enabled/stats.hamradioonline.de ]; then
|
||||||
|
sudo ln -s /etc/nginx/sites-available/stats.hamradioonline.de \
|
||||||
|
/etc/nginx/sites-enabled/stats.hamradioonline.de
|
||||||
|
fi
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
sudo /snap/bin/certbot certonly --webroot \
|
||||||
|
--webroot-path /var/lib/letsencrypt \
|
||||||
|
-d stats.hamradioonline.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Only after Certbot has created the certificate, replace the bootstrap with the
|
||||||
|
final vhost. This does not remove HTTP completely. The final file keeps an
|
||||||
|
IPv4 port 80 block for `/.well-known/acme-challenge/` so the certificate issued
|
||||||
|
with `--webroot` can be renewed automatically. Every other HTTP request is
|
||||||
|
redirected permanently to the same URI on HTTPS. The HTTPS block uses the
|
||||||
|
existing Ubuntu/Certbot TLS files
|
||||||
|
`/etc/letsencrypt/options-ssl-nginx.conf` and
|
||||||
|
`/etc/letsencrypt/ssl-dhparams.pem`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
website/ops/analytics/nginx/stats-vhost.conf.example \
|
||||||
|
/etc/nginx/sites-available/stats.hamradioonline.de
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
After activation, verify both authentication and renewal from the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -I https://stats.hamradioonline.de/
|
||||||
|
curl -I -u stats-reader https://stats.hamradioonline.de/
|
||||||
|
sudo /snap/bin/certbot renew --dry-run \
|
||||||
|
--cert-name stats.hamradioonline.de
|
||||||
|
```
|
||||||
|
|
||||||
|
The first HTTPS request must return `401`. The authenticated request must
|
||||||
|
return the redirect to `/combined/`. Enter the Basic Auth password
|
||||||
|
interactively; do not put it on the command line. The Certbot dry-run must
|
||||||
|
complete while the final vhost is active.
|
||||||
|
|
||||||
|
The templates listen on IPv4 only. Add an IPv6 listener later, after the AAAA
|
||||||
|
record for `stats.hamradioonline.de` has been confirmed and tested.
|
||||||
|
|
||||||
|
The public counter location serves only `visitor-count.json` through an exact
|
||||||
|
Nginx `alias`; it does not modify the deployed Eleventy release directory. Its
|
||||||
|
own access log is disabled. Responses use
|
||||||
|
`Cache-Control: public, max-age=3600` and
|
||||||
|
`X-Content-Type-Options: nosniff`. The file contains the schema version, total,
|
||||||
|
activation date and update time. It contains no IP address, user agent,
|
||||||
|
hostname or per-day detail.
|
||||||
|
|
||||||
|
Use this rollout order:
|
||||||
|
|
||||||
|
1. Install the server files with explicit modes. Prepare directories, log
|
||||||
|
ownership, filters and still-inactive Nginx and systemd configuration.
|
||||||
|
2. Push the website changes, including the Privacy Policy, and let the existing
|
||||||
|
deployment cron job publish them. Until the JSON endpoint exists, the
|
||||||
|
visitor count remains hidden automatically.
|
||||||
|
3. Verify the published Privacy Policy. Only then activate analytics logging,
|
||||||
|
the report generator and timer, the public counter location and the
|
||||||
|
protected statistics vhost.
|
||||||
|
4. Run `nginx -t` before every Nginx reload and perform the real `--check` and
|
||||||
|
`--dry-run` on the server before the first production generation.
|
||||||
|
|
||||||
|
A short period in which the Privacy Policy is already visible but logging is
|
||||||
|
not yet active is acceptable. Starting analytics logging before publishing the
|
||||||
|
updated policy is not.
|
||||||
|
|
||||||
|
## Retention and recovery
|
||||||
|
|
||||||
|
- Dedicated analytics raw logs: 14 days through the Logrotate example.
|
||||||
|
- Anonymised detailed GoAccess aggregates: rolling 395 days.
|
||||||
|
- Public daily counter values: retained from activation onward.
|
||||||
|
|
||||||
|
Back up the counter state and, if fast report recovery matters, the GoAccess
|
||||||
|
database directories. Reports themselves are derived output. To recover, stop
|
||||||
|
the timer, restore the state and database directories with their ownership,
|
||||||
|
run `--check`, then run `--dry-run` before publishing again.
|
||||||
|
|
||||||
|
The repository contains no password, password hash, MaxMind download key,
|
||||||
|
server IP address, TLS private key or private backup destination. Keep it that
|
||||||
|
way.
|
||||||
@@ -0,0 +1,879 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const STATE_SCHEMA_VERSION = 1;
|
||||||
|
const PUBLIC_SCHEMA_VERSION = 1;
|
||||||
|
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
||||||
|
const RESERVED_STATS_HOST = "stats.hamradioonline.de";
|
||||||
|
|
||||||
|
class ConfigurationError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConfigurationError";
|
||||||
|
this.exitCode = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LockError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "LockError";
|
||||||
|
this.exitCode = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, label) {
|
||||||
|
let parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(`${label} is not valid JSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDate(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
|
||||||
|
if (date.getUTCFullYear() !== year
|
||||||
|
|| date.getUTCMonth() !== month - 1
|
||||||
|
|| date.getUTCDate() !== day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAbsolutePath(value, label) {
|
||||||
|
if (typeof value !== "string" || /[\r\n\0]/.test(value) || !path.isAbsolute(value)) {
|
||||||
|
throw new ConfigurationError(`${label} must be an absolute path`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(parent, candidate) {
|
||||||
|
const relative = path.relative(parent, candidate);
|
||||||
|
return relative !== ""
|
||||||
|
&& relative !== ".."
|
||||||
|
&& !relative.startsWith(`..${path.sep}`)
|
||||||
|
&& !path.isAbsolute(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRegistry(registry) {
|
||||||
|
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
|
||||||
|
throw new ConfigurationError("registry must be an object");
|
||||||
|
}
|
||||||
|
if (registry.schemaVersion !== 1) {
|
||||||
|
throw new ConfigurationError("registry schemaVersion must be 1");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(registry.sites) || registry.sites.length === 0) {
|
||||||
|
throw new ConfigurationError("registry.sites must contain at least one site");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateDirectory = requireAbsolutePath(
|
||||||
|
registry.stateDirectory,
|
||||||
|
"registry.stateDirectory"
|
||||||
|
);
|
||||||
|
const counterStatePath = requireAbsolutePath(
|
||||||
|
registry.counterStatePath,
|
||||||
|
"registry.counterStatePath"
|
||||||
|
);
|
||||||
|
if (!isWithin(stateDirectory, counterStatePath)) {
|
||||||
|
throw new ConfigurationError("counterStatePath must be below stateDirectory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set();
|
||||||
|
const hostnames = new Set();
|
||||||
|
const analyticsLogPaths = new Set();
|
||||||
|
const outputDirectories = new Set();
|
||||||
|
const publicJsonPaths = new Set();
|
||||||
|
const sites = registry.sites.map((site, index) => {
|
||||||
|
const label = `registry.sites[${index}]`;
|
||||||
|
|
||||||
|
if (!site || typeof site !== "object" || Array.isArray(site)) {
|
||||||
|
throw new ConfigurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
if (typeof site.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(site.id)) {
|
||||||
|
throw new ConfigurationError(`${label}.id is invalid`);
|
||||||
|
}
|
||||||
|
if (site.id === "combined") {
|
||||||
|
throw new ConfigurationError(`${label}.id is reserved`);
|
||||||
|
}
|
||||||
|
if (ids.has(site.id)) {
|
||||||
|
throw new ConfigurationError(`duplicate site id: ${site.id}`);
|
||||||
|
}
|
||||||
|
ids.add(site.id);
|
||||||
|
|
||||||
|
if (typeof site.hostname !== "string"
|
||||||
|
|| !/^[a-z0-9.-]+$/.test(site.hostname)
|
||||||
|
|| site.hostname.includes("..")) {
|
||||||
|
throw new ConfigurationError(`${label}.hostname is invalid`);
|
||||||
|
}
|
||||||
|
const hostname = site.hostname.toLowerCase();
|
||||||
|
if (hostname === RESERVED_STATS_HOST) {
|
||||||
|
throw new ConfigurationError(`${RESERVED_STATS_HOST} must not be registered`);
|
||||||
|
}
|
||||||
|
if (hostnames.has(hostname)) {
|
||||||
|
throw new ConfigurationError(`duplicate hostname: ${hostname}`);
|
||||||
|
}
|
||||||
|
hostnames.add(hostname);
|
||||||
|
|
||||||
|
const activatedOn = parseIsoDate(site.activatedOn);
|
||||||
|
if (!activatedOn) {
|
||||||
|
throw new ConfigurationError(`${label}.activatedOn must be a valid ISO date`);
|
||||||
|
}
|
||||||
|
if (typeof site.publicCounter !== "boolean") {
|
||||||
|
throw new ConfigurationError(`${label}.publicCounter must be boolean`);
|
||||||
|
}
|
||||||
|
const analyticsLog = requireAbsolutePath(
|
||||||
|
site.analyticsLog,
|
||||||
|
`${label}.analyticsLog`
|
||||||
|
);
|
||||||
|
if (/\.(?:\d+|gz)$/i.test(path.basename(analyticsLog))) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`${label}.analyticsLog must identify the current uncompressed log`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (analyticsLogPaths.has(analyticsLog)) {
|
||||||
|
throw new ConfigurationError(`analytics log is registered more than once: ${analyticsLog}`);
|
||||||
|
}
|
||||||
|
analyticsLogPaths.add(analyticsLog);
|
||||||
|
|
||||||
|
const reportOutputDirectory = requireAbsolutePath(
|
||||||
|
site.reportOutputDirectory,
|
||||||
|
`${label}.reportOutputDirectory`
|
||||||
|
);
|
||||||
|
if (!isWithin(stateDirectory, reportOutputDirectory)) {
|
||||||
|
throw new ConfigurationError(`${label}.reportOutputDirectory must be below stateDirectory`);
|
||||||
|
}
|
||||||
|
if (outputDirectories.has(reportOutputDirectory)) {
|
||||||
|
throw new ConfigurationError(`duplicate report output directory: ${reportOutputDirectory}`);
|
||||||
|
}
|
||||||
|
outputDirectories.add(reportOutputDirectory);
|
||||||
|
|
||||||
|
const publicJsonPath = site.publicCounter
|
||||||
|
? requireAbsolutePath(site.publicJsonPath, `${label}.publicJsonPath`)
|
||||||
|
: null;
|
||||||
|
if (publicJsonPath && !isWithin(stateDirectory, publicJsonPath)) {
|
||||||
|
throw new ConfigurationError(`${label}.publicJsonPath must be below stateDirectory`);
|
||||||
|
}
|
||||||
|
if (publicJsonPath === counterStatePath) {
|
||||||
|
throw new ConfigurationError(`${label}.publicJsonPath conflicts with counterStatePath`);
|
||||||
|
}
|
||||||
|
if (publicJsonPath && publicJsonPaths.has(publicJsonPath)) {
|
||||||
|
throw new ConfigurationError(`duplicate public JSON path: ${publicJsonPath}`);
|
||||||
|
}
|
||||||
|
if (publicJsonPath) {
|
||||||
|
publicJsonPaths.add(publicJsonPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: site.id,
|
||||||
|
hostname,
|
||||||
|
activatedOn,
|
||||||
|
publicCounter: site.publicCounter,
|
||||||
|
analyticsLog,
|
||||||
|
reportOutputDirectory,
|
||||||
|
publicJsonPath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registry.combined || typeof registry.combined !== "object") {
|
||||||
|
throw new ConfigurationError("registry.combined must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedReportOutputDirectory = requireAbsolutePath(
|
||||||
|
registry.combined.reportOutputDirectory,
|
||||||
|
"registry.combined.reportOutputDirectory"
|
||||||
|
);
|
||||||
|
if (!isWithin(stateDirectory, combinedReportOutputDirectory)) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
"registry.combined.reportOutputDirectory must be below stateDirectory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (outputDirectories.has(combinedReportOutputDirectory)) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`duplicate report output directory: ${combinedReportOutputDirectory}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
stateDirectory,
|
||||||
|
counterStatePath,
|
||||||
|
lockFile: requireAbsolutePath(registry.lockFile, "registry.lockFile"),
|
||||||
|
geoIpCountryDatabase: requireAbsolutePath(
|
||||||
|
registry.geoIpCountryDatabase,
|
||||||
|
"registry.geoIpCountryDatabase"
|
||||||
|
),
|
||||||
|
combined: {
|
||||||
|
reportOutputDirectory: combinedReportOutputDirectory
|
||||||
|
},
|
||||||
|
sites
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnalyticsLogs(site) {
|
||||||
|
const logs = [];
|
||||||
|
const candidates = [
|
||||||
|
{ path: `${site.analyticsLog}.1`, required: false },
|
||||||
|
{ path: site.analyticsLog, required: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(candidate.path);
|
||||||
|
} catch (error) {
|
||||||
|
if (!candidate.required && error.code === "ENOENT") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new ConfigurationError(`analytics log is not readable: ${candidate.path}`);
|
||||||
|
}
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new ConfigurationError(`analytics log is not a file: ${candidate.path}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate.path, fs.constants.R_OK);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`analytics log is not readable by the current user: ${candidate.path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logs.push(candidate.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWritableDirectory(directory, label) {
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(directory);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(`${label} does not exist: ${directory}`);
|
||||||
|
}
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new ConfigurationError(`${label} is not a directory: ${directory}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.accessSync(directory, fs.constants.W_OK | fs.constants.X_OK);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`${label} is not writable by the current user: ${directory}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInputs(registry, configTemplate) {
|
||||||
|
const requiredConfigParts = [
|
||||||
|
"{{DB_PATH}}",
|
||||||
|
"{{RESTORE_DIRECTIVE}}",
|
||||||
|
"{{GEOIP_COUNTRY_DATABASE}}",
|
||||||
|
"persist true",
|
||||||
|
"anonymize-ip true",
|
||||||
|
"ignore-crawlers true",
|
||||||
|
"unknowns-as-crawlers true",
|
||||||
|
"keep-last 395"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const required of requiredConfigParts) {
|
||||||
|
if (!configTemplate.includes(required)) {
|
||||||
|
throw new ConfigurationError(`GoAccess template is missing: ${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsLogsBySite = new Map(registry.sites.map(site => [
|
||||||
|
site.id,
|
||||||
|
resolveAnalyticsLogs(site)
|
||||||
|
]));
|
||||||
|
|
||||||
|
let geoStats;
|
||||||
|
try {
|
||||||
|
geoStats = fs.statSync(registry.geoIpCountryDatabase);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`GeoIP Country database is not readable: ${registry.geoIpCountryDatabase}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!geoStats.isFile() || /city/i.test(path.basename(registry.geoIpCountryDatabase))) {
|
||||||
|
throw new ConfigurationError("geoIpCountryDatabase must be a Country database file");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.accessSync(registry.geoIpCountryDatabase, fs.constants.R_OK);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`GeoIP Country database is not readable by the current user: `
|
||||||
|
+ registry.geoIpCountryDatabase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireWritableDirectory(registry.stateDirectory, "stateDirectory");
|
||||||
|
requireWritableDirectory(
|
||||||
|
registry.combined.reportOutputDirectory,
|
||||||
|
"combined report output directory"
|
||||||
|
);
|
||||||
|
for (const site of registry.sites) {
|
||||||
|
requireWritableDirectory(
|
||||||
|
site.reportOutputDirectory,
|
||||||
|
`report output directory for ${site.id}`
|
||||||
|
);
|
||||||
|
if (site.publicCounter) {
|
||||||
|
requireWritableDirectory(
|
||||||
|
path.dirname(site.publicJsonPath),
|
||||||
|
`public output directory for ${site.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return analyticsLogsBySite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
|
||||||
|
const rendered = template
|
||||||
|
.replaceAll("{{DB_PATH}}", dbPath)
|
||||||
|
.replaceAll(
|
||||||
|
"{{RESTORE_DIRECTIVE}}",
|
||||||
|
restore ? "restore true" : "# restore is disabled until a database exists"
|
||||||
|
)
|
||||||
|
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase);
|
||||||
|
|
||||||
|
if (/{{[A-Z_]+}}/.test(rendered)) {
|
||||||
|
throw new ConfigurationError("GoAccess template contains an unknown placeholder");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRunGoAccess({ binary, args }) {
|
||||||
|
const result = spawnSync(binary, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
timeout: 30 * 60 * 1000,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`could not start GoAccess: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const detail = (result.stderr || result.stdout || "no diagnostic output").trim();
|
||||||
|
throw new Error(`GoAccess failed with exit code ${result.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCheckGoAccess(binary) {
|
||||||
|
const result = spawnSync(binary, ["--version"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 10000,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
const detail = result.error
|
||||||
|
? result.error.message
|
||||||
|
: (result.stderr || result.stdout || "no diagnostic output").trim();
|
||||||
|
throw new ConfigurationError(`GoAccess is not available: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseGoAccessVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoAccessVersion(output) {
|
||||||
|
const versionMatch = /GoAccess\s+-\s+([0-9]+(?:\.[0-9]+)+)/i.exec(output);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: versionMatch ? versionMatch[1] : "unknown",
|
||||||
|
geoIpMmdb: /--enable-geoip=mmdb\b/i.test(output),
|
||||||
|
openSsl: /--with-openssl\b/i.test(output),
|
||||||
|
zlib: /--with-zlib\b/i.test(output)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGoAccessCheck(capabilities) {
|
||||||
|
const detected = [
|
||||||
|
`GeoIP2/MMDB ${capabilities.geoIpMmdb ? "enabled" : "not detected"}`,
|
||||||
|
`OpenSSL ${capabilities.openSsl ? "enabled" : "not detected"}`,
|
||||||
|
`Zlib ${capabilities.zlib ? "enabled" : "not detected"}`
|
||||||
|
].join(", ");
|
||||||
|
const summary = `GoAccess ${capabilities.version}: ${detected}.`;
|
||||||
|
|
||||||
|
if (capabilities.zlib) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${summary} This build has no Zlib support. That is valid for regular mode: `
|
||||||
|
+ "the current analytics log and its optional uncompressed .1 rotation are read "
|
||||||
|
+ "directly; older .gz logs are not imported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoAccessDate(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parseIsoDate(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (/^\d{8}$/.test(value)) {
|
||||||
|
return parseIsoDate(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^(\d{2})\/([A-Za-z]{3})\/(\d{4})$/.exec(value);
|
||||||
|
const months = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
|
||||||
|
};
|
||||||
|
|
||||||
|
return match && months[match[2]]
|
||||||
|
? parseIsoDate(`${match[3]}-${months[match[2]]}-${match[1]}`)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReport(report, combined) {
|
||||||
|
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
||||||
|
throw new Error("GoAccess JSON report must be an object");
|
||||||
|
}
|
||||||
|
const requiredPanels = ["visitors", "requests", "status_codes", "geolocation"];
|
||||||
|
if (combined) {
|
||||||
|
requiredPanels.push("vhosts");
|
||||||
|
}
|
||||||
|
if (!report.general || typeof report.general !== "object") {
|
||||||
|
throw new Error("GoAccess JSON report has no general summary");
|
||||||
|
}
|
||||||
|
for (const panel of requiredPanels) {
|
||||||
|
if (!report[panel] || !Array.isArray(report[panel].data)) {
|
||||||
|
throw new Error(`GoAccess JSON report has no ${panel} panel`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractDailyVisits(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAndValidateReport(jsonPath, htmlPath, combined) {
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`GoAccess JSON output is invalid: ${error.message}`);
|
||||||
|
}
|
||||||
|
validateReport(report, combined);
|
||||||
|
|
||||||
|
const html = fs.readFileSync(htmlPath, "utf8");
|
||||||
|
if (html.length < 100 || !/<html(?:\s|>)/i.test(html)) {
|
||||||
|
throw new Error("GoAccess HTML output is missing or implausibly small");
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDailyVisits(report) {
|
||||||
|
const daily = {};
|
||||||
|
|
||||||
|
for (const row of report.visitors.data) {
|
||||||
|
const date = parseGoAccessDate(row && row.data);
|
||||||
|
const visits = row && row.visitors && row.visitors.count;
|
||||||
|
|
||||||
|
if (!date || !Number.isSafeInteger(visits) || visits < 0) {
|
||||||
|
throw new Error("GoAccess visitors panel contains invalid daily data");
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(daily, date)) {
|
||||||
|
throw new Error(`GoAccess visitors panel contains duplicate date ${date}`);
|
||||||
|
}
|
||||||
|
daily[date] = visits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return daily;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCounterState(statePath) {
|
||||||
|
if (!fs.existsSync(statePath)) {
|
||||||
|
return { schemaVersion: STATE_SCHEMA_VERSION, sites: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readJson(statePath, "counter state");
|
||||||
|
if (state.schemaVersion !== STATE_SCHEMA_VERSION
|
||||||
|
|| !state.sites || typeof state.sites !== "object" || Array.isArray(state.sites)) {
|
||||||
|
throw new ConfigurationError("counter state has an unsupported structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [siteId, site] of Object.entries(state.sites)) {
|
||||||
|
if (!site || typeof site !== "object" || !parseIsoDate(site.since)
|
||||||
|
|| !site.dailyVisits || typeof site.dailyVisits !== "object"
|
||||||
|
|| Array.isArray(site.dailyVisits)) {
|
||||||
|
throw new ConfigurationError(`counter state for ${siteId} is invalid`);
|
||||||
|
}
|
||||||
|
for (const [date, visits] of Object.entries(site.dailyVisits)) {
|
||||||
|
if (!parseIsoDate(date) || !Number.isSafeInteger(visits) || visits < 0) {
|
||||||
|
throw new ConfigurationError(`counter state value for ${siteId}/${date} is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounterState(state, site, report) {
|
||||||
|
const current = state.sites[site.id];
|
||||||
|
if (current && current.since !== site.activatedOn) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`activation date for ${site.id} differs from the existing counter state`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current && current.hostname !== site.hostname) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
`hostname for ${site.id} differs from the existing counter state`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyVisits = current ? { ...current.dailyVisits } : {};
|
||||||
|
for (const [date, visits] of Object.entries(extractDailyVisits(report))) {
|
||||||
|
if (date >= site.activatedOn) {
|
||||||
|
dailyVisits[date] = visits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.sites[site.id] = {
|
||||||
|
hostname: site.hostname,
|
||||||
|
since: site.activatedOn,
|
||||||
|
dailyVisits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicPayload(state, site, now) {
|
||||||
|
const siteState = state.sites[site.id];
|
||||||
|
let visits = 0;
|
||||||
|
|
||||||
|
for (const [date, value] of Object.entries(siteState.dailyVisits)) {
|
||||||
|
if (date >= site.activatedOn) {
|
||||||
|
visits += value;
|
||||||
|
if (!Number.isSafeInteger(visits) || visits > MAX_SAFE_INTEGER) {
|
||||||
|
throw new Error(`public visit total for ${site.id} exceeds the safe integer range`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: PUBLIC_SCHEMA_VERSION,
|
||||||
|
visits,
|
||||||
|
since: site.activatedOn,
|
||||||
|
updatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWriteFile(destination, content, mode = 0o640) {
|
||||||
|
const directory = path.dirname(destination);
|
||||||
|
const temporary = path.join(
|
||||||
|
directory,
|
||||||
|
`.${path.basename(destination)}.${process.pid}.${Date.now()}.tmp`
|
||||||
|
);
|
||||||
|
const handle = fs.openSync(temporary, "wx", mode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(handle, content);
|
||||||
|
fs.fchmodSync(handle, mode);
|
||||||
|
fs.fsyncSync(handle);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(handle);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.renameSync(temporary, destination);
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(temporary, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicCopyFile(source, destination) {
|
||||||
|
atomicWriteFile(destination, fs.readFileSync(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceDirectory(source, destination) {
|
||||||
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||||
|
const backup = `${destination}.previous-${process.pid}`;
|
||||||
|
const hadDestination = fs.existsSync(destination);
|
||||||
|
|
||||||
|
if (fs.existsSync(backup)) {
|
||||||
|
throw new Error(`stale database backup blocks replacement: ${backup}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hadDestination) {
|
||||||
|
fs.renameSync(destination, backup);
|
||||||
|
}
|
||||||
|
fs.renameSync(source, destination);
|
||||||
|
if (hadDestination) {
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!fs.existsSync(destination) && fs.existsSync(backup)) {
|
||||||
|
fs.renameSync(backup, destination);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireLock(lockPath) {
|
||||||
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||||
|
let descriptor;
|
||||||
|
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(lockPath, "wx", 0o640);
|
||||||
|
fs.writeFileSync(descriptor, `${process.pid}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new LockError(`another analytics run is active (${lockPath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
fs.rmSync(lockPath, { force: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReportJob(id, logs, outputDirectory, combined) {
|
||||||
|
return { id, logs, outputDirectory, combined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareReport(job, context) {
|
||||||
|
const jobDirectory = path.join(context.runDirectory, job.id);
|
||||||
|
const stagedDb = path.join(jobDirectory, "db");
|
||||||
|
const currentDb = path.join(context.registry.stateDirectory, "db", job.id);
|
||||||
|
const outputJson = path.join(jobDirectory, "report.json");
|
||||||
|
const outputHtml = path.join(jobDirectory, "report.html");
|
||||||
|
const runConfig = path.join(jobDirectory, "goaccess.conf");
|
||||||
|
let hasDatabase = false;
|
||||||
|
|
||||||
|
if (fs.existsSync(currentDb)) {
|
||||||
|
if (!fs.statSync(currentDb).isDirectory()) {
|
||||||
|
throw new Error(`GoAccess database path is not a directory: ${currentDb}`);
|
||||||
|
}
|
||||||
|
hasDatabase = fs.readdirSync(currentDb).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(jobDirectory, { recursive: true });
|
||||||
|
if (hasDatabase) {
|
||||||
|
fs.cpSync(currentDb, stagedDb, { recursive: true, errorOnExist: true });
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(stagedDb);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(runConfig, renderGoAccessConfig(
|
||||||
|
context.configTemplate,
|
||||||
|
stagedDb,
|
||||||
|
hasDatabase,
|
||||||
|
context.registry.geoIpCountryDatabase
|
||||||
|
));
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
...job.logs,
|
||||||
|
"--no-global-config",
|
||||||
|
"--config-file", runConfig
|
||||||
|
];
|
||||||
|
if (job.combined) {
|
||||||
|
args.push("--enable-panel=VIRTUAL_HOSTS");
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
"--output", outputJson,
|
||||||
|
"--output", outputHtml
|
||||||
|
);
|
||||||
|
context.runGoAccess({
|
||||||
|
binary: context.goaccessBinary,
|
||||||
|
args,
|
||||||
|
id: job.id,
|
||||||
|
combined: job.combined,
|
||||||
|
outputJson,
|
||||||
|
outputHtml,
|
||||||
|
dbPath: stagedDb
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
report: readAndValidateReport(outputJson, outputHtml, job.combined),
|
||||||
|
outputJson,
|
||||||
|
outputHtml,
|
||||||
|
stagedDb,
|
||||||
|
currentDb
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReports(options, dependencies = {}) {
|
||||||
|
const registryPath = path.resolve(options.registryPath);
|
||||||
|
const configTemplatePath = path.resolve(options.configTemplatePath);
|
||||||
|
const registry = validateRegistry(readJson(registryPath, "site registry"));
|
||||||
|
const configTemplate = fs.readFileSync(configTemplatePath, "utf8");
|
||||||
|
const runGoAccess = dependencies.runGoAccess || defaultRunGoAccess;
|
||||||
|
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
|
||||||
|
const now = dependencies.now ? dependencies.now() : new Date();
|
||||||
|
|
||||||
|
const analyticsLogsBySite = validateInputs(registry, configTemplate);
|
||||||
|
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
|
||||||
|
if (!goAccess || !goAccess.geoIpMmdb) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
"GoAccess must be built with GeoIP2/MMDB support (--enable-geoip=mmdb)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.check) {
|
||||||
|
return { checked: true, sites: registry.sites.length, goAccess };
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseLock = options.dryRun || dependencies.skipLock
|
||||||
|
? () => {}
|
||||||
|
: acquireLock(registry.lockFile);
|
||||||
|
let runDirectory;
|
||||||
|
|
||||||
|
try {
|
||||||
|
runDirectory = fs.mkdtempSync(path.join(
|
||||||
|
options.dryRun ? os.tmpdir() : registry.stateDirectory,
|
||||||
|
".analytics-run-"
|
||||||
|
));
|
||||||
|
|
||||||
|
const jobs = registry.sites.map(site => createReportJob(
|
||||||
|
site.id,
|
||||||
|
analyticsLogsBySite.get(site.id),
|
||||||
|
site.reportOutputDirectory,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
jobs.push(createReportJob(
|
||||||
|
"combined",
|
||||||
|
registry.sites.flatMap(site => analyticsLogsBySite.get(site.id)),
|
||||||
|
registry.combined.reportOutputDirectory,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
registry,
|
||||||
|
configTemplate,
|
||||||
|
goaccessBinary: options.goaccessBinary || "goaccess",
|
||||||
|
runGoAccess,
|
||||||
|
runDirectory
|
||||||
|
};
|
||||||
|
const prepared = jobs.map(job => prepareReport(job, context));
|
||||||
|
const state = loadCounterState(registry.counterStatePath);
|
||||||
|
|
||||||
|
for (const site of registry.sites.filter(entry => entry.publicCounter)) {
|
||||||
|
const generated = prepared.find(entry => entry.id === site.id);
|
||||||
|
updateCounterState(state, site, generated.report);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicFiles = registry.sites
|
||||||
|
.filter(site => site.publicCounter)
|
||||||
|
.map(site => ({
|
||||||
|
destination: site.publicJsonPath,
|
||||||
|
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
for (const report of prepared) {
|
||||||
|
atomicCopyFile(
|
||||||
|
report.outputJson,
|
||||||
|
path.join(report.outputDirectory, "report.json")
|
||||||
|
);
|
||||||
|
atomicCopyFile(
|
||||||
|
report.outputHtml,
|
||||||
|
path.join(report.outputDirectory, "report.html")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const report of prepared) {
|
||||||
|
replaceDirectory(report.stagedDb, report.currentDb);
|
||||||
|
}
|
||||||
|
atomicWriteFile(
|
||||||
|
registry.counterStatePath,
|
||||||
|
`${JSON.stringify(state, null, 2)}\n`
|
||||||
|
);
|
||||||
|
for (const publicFile of publicFiles) {
|
||||||
|
atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked: false,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
sites: registry.sites.length,
|
||||||
|
reports: prepared.length,
|
||||||
|
publicCounters: publicFiles.length
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (runDirectory && fs.existsSync(runDirectory)) {
|
||||||
|
fs.rmSync(runDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" };
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === "--check") {
|
||||||
|
options.check = true;
|
||||||
|
} else if (argument === "--dry-run") {
|
||||||
|
options.dryRun = true;
|
||||||
|
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new ConfigurationError(`${argument} requires a value`);
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
if (argument === "--registry") options.registryPath = value;
|
||||||
|
if (argument === "--config-template") options.configTemplatePath = value;
|
||||||
|
if (argument === "--goaccess") options.goaccessBinary = value;
|
||||||
|
} else {
|
||||||
|
throw new ConfigurationError(`unknown argument: ${argument}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.registryPath || !options.configTemplatePath) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
"usage: generate-reports.js --registry FILE --config-template FILE "
|
||||||
|
+ "[--goaccess FILE] [--check|--dry-run]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.check && options.dryRun) {
|
||||||
|
throw new ConfigurationError("--check and --dry-run are mutually exclusive");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
const result = generateReports(parseArguments(process.argv.slice(2)));
|
||||||
|
const action = result.checked ? "Configuration check" : "Analytics generation";
|
||||||
|
if (result.checked) {
|
||||||
|
process.stdout.write(`${formatGoAccessCheck(result.goAccess)}\n`);
|
||||||
|
}
|
||||||
|
process.stdout.write(`${action} completed: ${JSON.stringify(result)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`analytics: ${error.message}\n`);
|
||||||
|
process.exitCode = error.exitCode || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ConfigurationError,
|
||||||
|
extractDailyVisits,
|
||||||
|
formatGoAccessCheck,
|
||||||
|
generateReports,
|
||||||
|
parseGoAccessVersion,
|
||||||
|
parseArguments,
|
||||||
|
publicPayload,
|
||||||
|
updateCounterState,
|
||||||
|
validateRegistry,
|
||||||
|
validateReport
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Rendered by generate-reports.js. Do not use this file without replacing
|
||||||
|
# all {{...}} placeholders.
|
||||||
|
|
||||||
|
datetime-format %Y-%m-%dT%H:%M:%S%z
|
||||||
|
log-format %v\t%h\t%x\t%m\t%U\t%H\t%s\t%b\t"%u"
|
||||||
|
|
||||||
|
anonymize-ip true
|
||||||
|
anonymize-level 2
|
||||||
|
ignore-crawlers true
|
||||||
|
unknowns-as-crawlers true
|
||||||
|
keep-last 395
|
||||||
|
|
||||||
|
persist true
|
||||||
|
{{RESTORE_DIRECTIVE}}
|
||||||
|
db-path {{DB_PATH}}
|
||||||
|
|
||||||
|
geoip-database {{GEOIP_COUNTRY_DATABASE}}
|
||||||
|
|
||||||
|
json-pretty-print true
|
||||||
|
no-progress true
|
||||||
|
no-parsing-spinner true
|
||||||
|
no-color true
|
||||||
|
agent-list false
|
||||||
|
http-method true
|
||||||
|
http-protocol true
|
||||||
|
max-items 500
|
||||||
|
|
||||||
|
# The Nginx analytics log contains $uri rather than $request_uri, so query
|
||||||
|
# strings never reach GoAccess. These panels are deliberately unavailable.
|
||||||
|
ignore-panel HOSTS
|
||||||
|
ignore-panel OS
|
||||||
|
ignore-panel BROWSERS
|
||||||
|
ignore-panel REFERRERS
|
||||||
|
ignore-panel REFERRING_SITES
|
||||||
|
ignore-panel KEYPHRASES
|
||||||
|
ignore-panel REMOTE_USER
|
||||||
|
ignore-panel REQUESTS_STATIC
|
||||||
|
ignore-panel VISIT_TIMES
|
||||||
|
ignore-panel NOT_FOUND
|
||||||
|
ignore-panel ASN
|
||||||
|
ignore-panel MIME_TYPE
|
||||||
|
ignore-panel TLS_TYPE
|
||||||
|
ignore-panel CACHE_STATUS
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/var/log/nginx/*-analytics.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
# Keep .1 uncompressed because the regular generator reads it directly.
|
||||||
|
delaycompress
|
||||||
|
create 0640 www-data hamradio-analytics
|
||||||
|
sharedscripts
|
||||||
|
postrotate
|
||||||
|
invoke-rc.d nginx rotate >/dev/null 2>&1
|
||||||
|
endscript
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Only page GET requests which are candidates for the reach statistics enter
|
||||||
|
# the dedicated analytics log. GoAccess performs the second bot-classification
|
||||||
|
# layer, including unknown browsers and operating systems.
|
||||||
|
map $request_method $hamradioonline_analytics_method {
|
||||||
|
default 0;
|
||||||
|
GET 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
map $uri $hamradioonline_analytics_path {
|
||||||
|
default 1;
|
||||||
|
|
||||||
|
/visitor-count.json 0;
|
||||||
|
/kst4ContestVersionInfo.xml 0;
|
||||||
|
/sitemap.xml 0;
|
||||||
|
/robots.txt 0;
|
||||||
|
/favicon.ico 0;
|
||||||
|
/assets/favicon.svg 0;
|
||||||
|
/health 0;
|
||||||
|
/healthz 0;
|
||||||
|
/ping 0;
|
||||||
|
/status 0;
|
||||||
|
|
||||||
|
~*^/(?:assets|manual/assets)/ 0;
|
||||||
|
~*\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$ 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
map $http_user_agent $hamradioonline_analytics_known_bot {
|
||||||
|
default 0;
|
||||||
|
~*(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioonline_analytics_known_bot"
|
||||||
|
$hamradioonline_analytics_loggable {
|
||||||
|
default 0;
|
||||||
|
"1:1:0" 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Include in the Nginx http context. $uri is the normalized path and excludes
|
||||||
|
# the query string. The format intentionally omits referrer and remote user.
|
||||||
|
log_format hamradioonline_analytics
|
||||||
|
'$server_name\t$remote_addr\t$time_iso8601\t$request_method\t$uri\t'
|
||||||
|
'$server_protocol\t$status\t$body_bytes_sent\t"$http_user_agent"';
|
||||||
|
|
||||||
|
# Include the maps below in the Nginx http context as well.
|
||||||
|
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
|
||||||
|
|
||||||
|
# Add this extra log to each registered project server block. Keep the
|
||||||
|
# existing operational access_log directive; do not replace it implicitly.
|
||||||
|
#
|
||||||
|
# access_log /var/log/nginx/kst4contest-analytics.log
|
||||||
|
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Include inside the kst4contest.hamradioonline.de HTTPS server block. Grant
|
||||||
|
# the Nginx worker read access to the file and directory, but no write access.
|
||||||
|
location = /visitor-count.json {
|
||||||
|
alias /var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json;
|
||||||
|
default_type application/json;
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "public, max-age=3600" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Temporary HTTP-only virtual host for initial certificate provisioning.
|
||||||
|
# Replace it with stats-vhost.conf.example after Certbot has succeeded.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name stats.hamradioonline.de;
|
||||||
|
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /var/lib/letsencrypt;
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Keep this HTTP server active so Certbot can renew the webroot certificate.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name stats.hamradioonline.de;
|
||||||
|
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /var/lib/letsencrypt;
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# This server exposes static reports only. Provision the certificate and the
|
||||||
|
# htpasswd file outside the repository. Do not add this host to sites.json.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name stats.hamradioonline.de;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/stats.hamradioonline.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/stats.hamradioonline.de/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
root /var/lib/hamradioonline-analytics/reports;
|
||||||
|
index report.html;
|
||||||
|
|
||||||
|
auth_basic "Private project statistics";
|
||||||
|
auth_basic_user_file /etc/nginx/htpasswd/hamradioonline-analytics;
|
||||||
|
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "private, no-store" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
try_files /__no_report_at_root__ @combined_reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @combined_reports {
|
||||||
|
return 302 /combined/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# IPv6 is deliberately omitted until the DNS AAAA record has been confirmed.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"stateDirectory": "/var/lib/hamradioonline-analytics",
|
||||||
|
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
|
||||||
|
"lockFile": "/run/hamradioonline-analytics/generator.lock",
|
||||||
|
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
|
||||||
|
"combined": {
|
||||||
|
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
|
||||||
|
},
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"id": "kst4contest",
|
||||||
|
"hostname": "kst4contest.hamradioonline.de",
|
||||||
|
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
|
||||||
|
"activatedOn": "2026-09-11",
|
||||||
|
"publicCounter": true,
|
||||||
|
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
|
||||||
|
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Generate private GoAccess reports and public project counters
|
||||||
|
After=nginx.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=hamradio-analytics
|
||||||
|
Group=hamradio-analytics
|
||||||
|
UMask=0027
|
||||||
|
Environment=LC_TIME=C
|
||||||
|
ExecStart=/usr/bin/node /opt/hamradioonline-analytics/generate-reports.js --registry /etc/hamradioonline-analytics/sites.json --config-template /etc/hamradioonline-analytics/goaccess.conf.template
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
PrivateNetwork=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
ReadOnlyPaths=/etc/hamradioonline-analytics /opt/hamradioonline-analytics /var/log/nginx /var/lib/GeoIP/GeoLite2-Country.mmdb
|
||||||
|
ReadWritePaths=/var/lib/hamradioonline-analytics
|
||||||
|
StateDirectory=hamradioonline-analytics
|
||||||
|
StateDirectoryMode=0711
|
||||||
|
RuntimeDirectory=hamradioonline-analytics
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run hamradioonline analytics once per hour
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=4m
|
||||||
|
AccuracySec=1m
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Generated
+3
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "kst4contest-website",
|
"name": "kst4contest-website",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.19.1"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
"markdown-it-anchor": "^9.2.0"
|
"markdown-it-anchor": "^9.2.0"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "kst4contest-website",
|
"name": "kst4contest-website",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.19.1"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "eleventy --serve",
|
"start": "eleventy --serve",
|
||||||
"build": "eleventy && npm run validate:version-info",
|
"build": "eleventy && npm run validate:version-info",
|
||||||
|
|||||||
@@ -81,5 +81,8 @@
|
|||||||
{% if heroFx %}
|
{% if heroFx %}
|
||||||
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
|
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if visitorCount %}
|
||||||
|
<script src="/assets/js/visitor-count.js?v={{ build.version }}" defer></script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -659,6 +659,12 @@ a:hover {
|
|||||||
padding: clamp(28px, 5vw, 60px);
|
padding: clamp(28px, 5vw, 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visitor-count {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
color: var(--soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.manual-content {
|
.manual-content {
|
||||||
max-width: 980px;
|
max-width: 980px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const VISITOR_COUNT_ENDPOINT = "/visitor-count.json";
|
||||||
|
const VISITOR_COUNT_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
function parseIsoDate(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
|
||||||
|
if (date.getUTCFullYear() !== year
|
||||||
|
|| date.getUTCMonth() !== month - 1
|
||||||
|
|| date.getUTCDate() !== day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIsoTimestamp(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
||||||
|
|
||||||
|
if (!match || !parseIsoDate(match[1])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = Number(match[2]);
|
||||||
|
const minute = Number(match[3]);
|
||||||
|
const second = Number(match[4]);
|
||||||
|
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
|
||||||
|
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
|
||||||
|
|
||||||
|
return hour <= 23
|
||||||
|
&& minute <= 59
|
||||||
|
&& second <= 59
|
||||||
|
&& offsetHour <= 23
|
||||||
|
&& offsetMinute <= 59
|
||||||
|
&& Number.isFinite(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVisitorCount(data) {
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.schemaVersion !== 1
|
||||||
|
|| !Number.isSafeInteger(data.visits)
|
||||||
|
|| data.visits < 0
|
||||||
|
|| !isIsoTimestamp(data.updatedAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = typeof data.since === "string"
|
||||||
|
? parseIsoDate(data.since)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return since ? { visits: data.visits, since } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVisitorCount(data) {
|
||||||
|
const valid = validateVisitorCount(data);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC"
|
||||||
|
}).format(valid.since);
|
||||||
|
const visits = new Intl.NumberFormat("en-GB").format(valid.visits);
|
||||||
|
|
||||||
|
return `Visits since ${date}: ${visits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVisitorCount({
|
||||||
|
element,
|
||||||
|
fetchImpl = globalThis.fetch,
|
||||||
|
AbortControllerImpl = globalThis.AbortController,
|
||||||
|
timeoutMs = VISITOR_COUNT_TIMEOUT_MS
|
||||||
|
}) {
|
||||||
|
if (!element || typeof fetchImpl !== "function"
|
||||||
|
|| typeof AbortControllerImpl !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.hidden = true;
|
||||||
|
element.textContent = "";
|
||||||
|
|
||||||
|
const controller = new AbortControllerImpl();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(VISITOR_COUNT_ENDPOINT, {
|
||||||
|
credentials: "omit",
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = formatVisitorCount(await response.json());
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = text;
|
||||||
|
element.hidden = false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const element = document.querySelector("[data-visitor-count]");
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
loadVisitorCount({ element });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
formatVisitorCount,
|
||||||
|
loadVisitorCount,
|
||||||
|
parseIsoDate,
|
||||||
|
validateVisitorCount
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -141,10 +141,12 @@ Every generated spot contains:
|
|||||||
|
|
||||||
Automatically generated directional spots can additionally include up to two current AirScout entries. A manually triggered map spot uses the selected station's locator without this optional addition.
|
Automatically generated directional spots can additionally include up to two current AirScout entries. A manually triggered map spot uses the selected station's locator without this optional addition.
|
||||||
|
|
||||||
|
The payload is a fixed, DXSpider-compatible 75-character line. The DX callsign begins in column 27, the 30-character comment begins in column 40 and the UTC time begins in column 71. Short comments are padded; longer comments are limited to the available field. A DX callsign longer than twelve characters is rejected rather than silently truncated.
|
||||||
|
|
||||||
An example comment with AirScout information may look like this:
|
An example comment with AirScout information may look like this:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
JN49GL , AP: 1min, 100%; 4min, 75%
|
JO51HK AP 1m/100%;4m/75%
|
||||||
```
|
```
|
||||||
|
|
||||||
AirScout information is optional. A missing AirScout response does not prevent an automatic directional spot from being sent.
|
AirScout information is optional. A missing AirScout response does not prevent an automatic directional spot from being sent.
|
||||||
@@ -199,6 +201,8 @@ A missing password is acceptable inside the intended station network. It is not
|
|||||||
|
|
||||||
Use **Send test spot** after the logger has connected.
|
Use **Send test spot** after the logger has connected.
|
||||||
|
|
||||||
|
The test uses `DO5AMF` with the comment `DXC test: You donated $100!` and `.300` on the configured fallback band.
|
||||||
|
|
||||||
A successful test confirms that at least one client received the generated spot. If the test works but real spots do not appear, the TCP connection is probably not the problem. In that case, check the conditions used for the actual directional opportunity:
|
A successful test confirms that at least one client received the generated spot. If the test works but real spots do not appear, the TCP connection is probably not the problem. In that case, check the conditions used for the actual directional opportunity:
|
||||||
|
|
||||||
- Were valid locators available?
|
- Were valid locators available?
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ The general UDP listener processes packets from UCXLog, N1MM+, QARTest and DXLog
|
|||||||
|
|
||||||
Win-Test uses a separate listener for its native network protocol. KST4Contest resolves the Win-Test band ID, including 50 and 70 MHz, and stores the resulting Worked information in the same internal database.
|
Win-Test uses a separate listener for its native network protocol. KST4Contest resolves the Win-Test band ID, including 50 and 70 MHz, and stores the resulting Worked information in the same internal database.
|
||||||
|
|
||||||
|
QSOs logged before KST4Contest was started are recovered from Win-Test as well. As soon as a Win-Test station is detected, the missing part of its log is requested over the native protocol, so stations already worked are marked as worked even when the client joins the contest late. The recovery needs no setting, keeps running to close gaps caused by lost packets, and covers every log in a multi-station network.
|
||||||
|
|
||||||
STATUS packets can also update the local QRG when QRG synchronisation is enabled and valid packets actually arrive. Enabling the source alone does not supply a frequency. In multi-operator networks, a station-name filter prevents STATUS packets from another operating position from replacing the frequency of the intended radio.
|
STATUS packets can also update the local QRG when QRG synchronisation is enabled and valid packets actually arrive. Enabling the source alone does not supply a frequency. In multi-operator networks, a station-name filter prevents STATUS packets from another operating position from replacing the frequency of the intended radio.
|
||||||
|
|
||||||
Win-Test can additionally receive skeds created in KST4Contest. The handover only takes place when a QRG matching the selected band can be determined. No fixed fallback frequency is inserted merely to make the packet technically valid.
|
Win-Test can additionally receive skeds created in KST4Contest. The handover only takes place when a QRG matching the selected band can be determined. No fixed fallback frequency is inserted merely to make the packet technically valid.
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ Complete frequencies provide their band directly. Examples include:
|
|||||||
```text
|
```text
|
||||||
144.210
|
144.210
|
||||||
432,088
|
432,088
|
||||||
|
144307
|
||||||
10368.100
|
10368.100
|
||||||
|
10368100
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The separator is optional for a complete frequency. For a digit-only value, KST4Contest treats the final three digits as the kHz part. This turns `144307` in a station name into `144.307 MHz` and `10368100` in a public or directed chat message into `10368.100 MHz`. The result still has to fall within one of the supported band ranges.
|
||||||
|
|
||||||
Relative forms omit the band and need additional context:
|
Relative forms omit the band and need additional context:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ The map retains the context already known by KST4Contest:
|
|||||||
|
|
||||||
Only stations with a usable six-character locator can be positioned. Active chat variants of the same normalised base callsign are combined into one marker, while their actual message destinations remain separate.
|
Only stations with a usable six-character locator can be positioned. Active chat variants of the same normalised base callsign are combined into one marker, while their actual message destinations remain separate.
|
||||||
|
|
||||||
|
At lower zoom levels, **Group nearby stations** keeps dense map areas readable by combining close markers into screen-based clusters. The grouping can be disabled immediately without changing the current viewport or selection, and KST4Contest remembers the choice. This setting does not split the geographical marker shared by active variants of the same base callsign.
|
||||||
|
|
||||||
Green has a specific meaning: it marks a directional opportunity derived from directed ON4KST messages. It does not merely mean that the marker happens to lie inside the local antenna sector.
|
Green has a specific meaning: it marks a directional opportunity derived from directed ON4KST messages. It does not merely mean that the marker happens to lie inside the local antenna sector.
|
||||||
|
|
||||||
## Selection remains connected to the chat
|
## Selection remains connected to the chat
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ layout: base.njk
|
|||||||
title: KST4Contest – ON4KST Contest Client for VHF, UHF and SHF
|
title: KST4Contest – ON4KST Contest Client for VHF, UHF and SHF
|
||||||
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
|
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
|
||||||
heroFx: true
|
heroFx: true
|
||||||
|
visitorCount: true
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="hero hero-split">
|
<section class="hero hero-split">
|
||||||
@@ -150,6 +151,8 @@ heroFx: true
|
|||||||
<a class="button secondary" href="/roadmap/">Development status</a>
|
<a class="button secondary" href="/roadmap/">Development status</a>
|
||||||
<a class="button ghost" href="/support/">Support development</a>
|
<a class="button ghost" href="/support/">Support development</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="visitor-count" data-visitor-count hidden aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Version 1.43.1 released
|
||||||
|
summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility
|
||||||
|
date: 2026-09-03
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 1.43.1
|
||||||
|
|
||||||
|
Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software.
|
||||||
|
|
||||||
|
The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands.
|
||||||
|
- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state.
|
||||||
|
- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect.
|
||||||
|
- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions.
|
||||||
|
- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip.
|
||||||
|
- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station.
|
||||||
|
- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows.
|
||||||
|
|
||||||
|
The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
|
||||||
|
|
||||||
|
### Getting it
|
||||||
|
|
||||||
|
Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow.
|
||||||
|
|
||||||
|
The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73
|
||||||
@@ -7,7 +7,7 @@ description: Privacy policy for the KST4Contest website.
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<p class="badge">Privacy</p>
|
<p class="badge">Privacy</p>
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p class="lead">Information about data processing on this static website.</p>
|
<p class="lead">Information about data processing on this website.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section narrow">
|
<section class="section narrow">
|
||||||
@@ -30,12 +30,46 @@ description: Privacy policy for the KST4Contest website.
|
|||||||
<h2>Legal basis</h2>
|
<h2>Legal basis</h2>
|
||||||
<p>
|
<p>
|
||||||
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
|
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
|
||||||
of the website.
|
of the website and in understanding the approximate use of its project pages.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Cookies and tracking</h2>
|
<h2>Server-side reach statistics</h2>
|
||||||
<p>
|
<p>
|
||||||
This website currently does not use cookies, analytics tracking, advertising pixels or user profiling.
|
The website uses server-side reach statistics to understand approximate demand and which
|
||||||
|
project pages are used. Measurement is based on a dedicated, reduced server access log. It
|
||||||
|
does not use cookies, an analytics or tracking script, advertising pixels, local storage,
|
||||||
|
third-party requests or user profiles.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The analytics log contains the server name, IP address, time, HTTP method, normalised path
|
||||||
|
without a query string, protocol, response status, transferred size and user agent. It does
|
||||||
|
not contain a referrer or authenticated user name. Known crawlers are filtered, and unknown
|
||||||
|
browsers or operating systems are treated as crawlers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
IP addresses are anonymised before data is stored in the statistics aggregates. The dedicated
|
||||||
|
analytics raw logs are retained for 14 days. Anonymised detailed aggregates are retained on a
|
||||||
|
rolling basis for 395 days. They include countries, but no city or host statistics. Countries
|
||||||
|
are derived with a Country database only.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A visit is an approximate daily value based on the combination of IP address, date and user
|
||||||
|
agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
|
||||||
|
remain separate measures. Bots, monitoring requests, software update checks and static assets
|
||||||
|
are excluded as far as they can be identified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Public visitor count</h2>
|
||||||
|
<p>
|
||||||
|
The home page displays an element containing the total number of these approximate daily visits
|
||||||
|
since the stated activation date. The display loads a same-origin JSON file and remains hidden
|
||||||
|
if that file is unavailable or invalid. This request is not included in the statistic.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The public file contains only the counter value, activation date, schema version and update time.
|
||||||
|
It contains no personal data or daily breakdown. The non-personal daily counter values are kept
|
||||||
|
permanently so that the total does not decrease when detailed aggregates reach the end of their
|
||||||
|
395-day retention period.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>External links</h2>
|
<h2>External links</h2>
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const {
|
||||||
|
formatGoAccessCheck,
|
||||||
|
generateReports,
|
||||||
|
parseGoAccessVersion,
|
||||||
|
validateReport
|
||||||
|
} = require("../ops/analytics/generate-reports");
|
||||||
|
|
||||||
|
const GOACCESS_WITHOUT_ZLIB = {
|
||||||
|
version: "1.8.1",
|
||||||
|
geoIpMmdb: true,
|
||||||
|
openSsl: true,
|
||||||
|
zlib: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function goAccessReport(dailyVisits, combined = false) {
|
||||||
|
const report = {
|
||||||
|
general: { total_requests: 1 },
|
||||||
|
visitors: {
|
||||||
|
data: Object.entries(dailyVisits).map(([date, count]) => ({
|
||||||
|
data: date.replaceAll("-", ""),
|
||||||
|
visitors: { count }
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
requests: { data: [] },
|
||||||
|
status_codes: { data: [] },
|
||||||
|
geolocation: { data: [] }
|
||||||
|
};
|
||||||
|
if (combined) {
|
||||||
|
report.vhosts = { data: [] };
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(siteDefinitions) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
|
||||||
|
const stateDirectory = path.join(root, "state");
|
||||||
|
const geoIpCountryDatabase = path.join(root, "GeoLite2-Country.mmdb");
|
||||||
|
const configTemplatePath = path.join(root, "goaccess.conf.template");
|
||||||
|
const registryPath = path.join(root, "sites.json");
|
||||||
|
fs.writeFileSync(geoIpCountryDatabase, "test database placeholder");
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(__dirname, "../ops/analytics/goaccess.conf.template"),
|
||||||
|
configTemplatePath
|
||||||
|
);
|
||||||
|
|
||||||
|
const sites = siteDefinitions.map((definition, index) => {
|
||||||
|
const id = definition.id || `site-${index}`;
|
||||||
|
const log = path.join(root, `${id}.log`);
|
||||||
|
fs.writeFileSync(log, "example log line\n");
|
||||||
|
if (definition.rotated !== false) {
|
||||||
|
fs.writeFileSync(`${log}.1`, "rotated example log line\n");
|
||||||
|
}
|
||||||
|
if (definition.compressed) {
|
||||||
|
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
hostname: definition.hostname || `${id}.example.test`,
|
||||||
|
analyticsLog: log,
|
||||||
|
activatedOn: definition.activatedOn || "2026-01-01",
|
||||||
|
publicCounter: definition.publicCounter,
|
||||||
|
reportOutputDirectory: path.join(stateDirectory, "reports", id),
|
||||||
|
...(definition.publicCounter
|
||||||
|
? { publicJsonPath: path.join(stateDirectory, "public", `${id}.json`) }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const registry = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
stateDirectory,
|
||||||
|
counterStatePath: path.join(stateDirectory, "counter-state.json"),
|
||||||
|
lockFile: path.join(root, "run", "generator.lock"),
|
||||||
|
geoIpCountryDatabase,
|
||||||
|
combined: {
|
||||||
|
reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
|
||||||
|
},
|
||||||
|
sites
|
||||||
|
};
|
||||||
|
fs.mkdirSync(stateDirectory, { recursive: true });
|
||||||
|
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
|
||||||
|
for (const site of sites) {
|
||||||
|
fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
|
||||||
|
if (site.publicCounter) {
|
||||||
|
fs.mkdirSync(path.dirname(site.publicJsonPath), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(registryPath, JSON.stringify(registry));
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
registry,
|
||||||
|
registryPath,
|
||||||
|
configTemplatePath,
|
||||||
|
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeGoAccess(reports, calls, failureId) {
|
||||||
|
return invocation => {
|
||||||
|
calls.push(invocation);
|
||||||
|
if (invocation.id === failureId) {
|
||||||
|
throw new Error("simulated GoAccess failure");
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
invocation.outputJson,
|
||||||
|
JSON.stringify(reports[invocation.id])
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
invocation.outputHtml,
|
||||||
|
`<!doctype html><html><body>${"report".repeat(30)}</body></html>`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(invocation.dbPath, "persisted.db"), invocation.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(testFixture, reports, calls = [], failureId) {
|
||||||
|
return generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
goaccessBinary: "fake-goaccess"
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
|
||||||
|
runGoAccess: fakeGoAccess(reports, calls, failureId),
|
||||||
|
now: () => new Date("2026-09-11T07:00:00Z"),
|
||||||
|
skipLock: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("replaces repeated daily values and retains older public days", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
|
||||||
|
fs.writeFileSync(testFixture.registry.counterStatePath, JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
sites: {
|
||||||
|
alpha: {
|
||||||
|
hostname: "alpha.example.test",
|
||||||
|
since: "2026-01-01",
|
||||||
|
dailyVisits: { "2026-01-01": 7 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
run(testFixture, {
|
||||||
|
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||||
|
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||||
|
});
|
||||||
|
run(testFixture, {
|
||||||
|
alpha: goAccessReport({ "2026-09-11": 5 }),
|
||||||
|
combined: goAccessReport({ "2026-09-11": 5 }, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = JSON.parse(fs.readFileSync(
|
||||||
|
testFixture.registry.counterStatePath,
|
||||||
|
"utf8"
|
||||||
|
));
|
||||||
|
const published = JSON.parse(fs.readFileSync(
|
||||||
|
testFixture.registry.sites[0].publicJsonPath,
|
||||||
|
"utf8"
|
||||||
|
));
|
||||||
|
assert.deepEqual(state.sites.alpha.dailyVisits, {
|
||||||
|
"2026-01-01": 7,
|
||||||
|
"2026-09-11": 5
|
||||||
|
});
|
||||||
|
assert.deepEqual(published, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
visits: 12,
|
||||||
|
since: "2026-01-01",
|
||||||
|
updatedAt: "2026-09-11T07:00:00Z"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps valid outputs unchanged when a GoAccess job fails", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
const reportDirectory = testFixture.registry.sites[0].reportOutputDirectory;
|
||||||
|
fs.mkdirSync(reportDirectory, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(testFixture.registry.sites[0].publicJsonPath), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(reportDirectory, "report.json"), "old-json");
|
||||||
|
fs.writeFileSync(path.join(reportDirectory, "report.html"), "old-html");
|
||||||
|
fs.writeFileSync(testFixture.registry.counterStatePath, "old-state");
|
||||||
|
fs.writeFileSync(testFixture.registry.sites[0].publicJsonPath, "old-public");
|
||||||
|
|
||||||
|
assert.throws(() => run(testFixture, {
|
||||||
|
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||||
|
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||||
|
}, [], "combined"), /simulated GoAccess failure/);
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.json"), "utf8"), "old-json");
|
||||||
|
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.html"), "utf8"), "old-html");
|
||||||
|
assert.equal(fs.readFileSync(testFixture.registry.counterStatePath, "utf8"), "old-state");
|
||||||
|
assert.equal(fs.readFileSync(testFixture.registry.sites[0].publicJsonPath, "utf8"), "old-public");
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processes subdomains separately and together without publishing disabled counters", () => {
|
||||||
|
const testFixture = fixture([
|
||||||
|
{ id: "alpha", publicCounter: true, compressed: true },
|
||||||
|
{ id: "bravo", publicCounter: false, rotated: false, compressed: true }
|
||||||
|
]);
|
||||||
|
const calls = [];
|
||||||
|
try {
|
||||||
|
const result = run(testFixture, {
|
||||||
|
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||||
|
bravo: goAccessReport({ "2026-09-11": 4 }),
|
||||||
|
combined: goAccessReport({ "2026-09-11": 6 }, true)
|
||||||
|
}, calls);
|
||||||
|
|
||||||
|
assert.equal(result.reports, 3);
|
||||||
|
assert.deepEqual(calls.map(call => call.id), ["alpha", "bravo", "combined"]);
|
||||||
|
const alphaLogs = [
|
||||||
|
`${testFixture.registry.sites[0].analyticsLog}.1`,
|
||||||
|
testFixture.registry.sites[0].analyticsLog
|
||||||
|
];
|
||||||
|
const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
|
||||||
|
assert.deepEqual(calls[0].args.slice(0, 2), alphaLogs);
|
||||||
|
assert.deepEqual(calls[1].args.slice(0, 1), bravoLogs);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls[2].args.slice(0, 3),
|
||||||
|
[...alphaLogs, ...bravoLogs]
|
||||||
|
);
|
||||||
|
assert.equal(calls[0].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
|
||||||
|
assert.equal(calls[1].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
|
||||||
|
assert.equal(calls[2].args.includes("--enable-panel=VIRTUAL_HOSTS"), true);
|
||||||
|
assert.equal(calls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(
|
||||||
|
testFixture.registry.stateDirectory,
|
||||||
|
"public",
|
||||||
|
"bravo.json"
|
||||||
|
)), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(
|
||||||
|
testFixture.registry.combined.reportOutputDirectory,
|
||||||
|
"report.html"
|
||||||
|
)), true);
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates GoAccess 1.8.1 panel names strictly", () => {
|
||||||
|
const siteReport = goAccessReport({ "2026-09-11": 3 });
|
||||||
|
const combinedReport = goAccessReport({ "2026-09-11": 3 }, true);
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => validateReport(siteReport, false));
|
||||||
|
assert.doesNotThrow(() => validateReport(combinedReport, true));
|
||||||
|
|
||||||
|
const missingGeolocation = goAccessReport({ "2026-09-11": 3 });
|
||||||
|
delete missingGeolocation.geolocation;
|
||||||
|
missingGeolocation.geo_location = { data: [] };
|
||||||
|
assert.throws(
|
||||||
|
() => validateReport(missingGeolocation, false),
|
||||||
|
/GoAccess JSON report has no geolocation panel/
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingVhosts = goAccessReport({ "2026-09-11": 3 }, true);
|
||||||
|
delete missingVhosts.vhosts;
|
||||||
|
missingVhosts.virtual_hosts = { data: [] };
|
||||||
|
assert.throws(
|
||||||
|
() => validateReport(missingVhosts, true),
|
||||||
|
/GoAccess JSON report has no vhosts panel/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dry-run validates generated data without changing production paths", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
const blockedLockParent = path.join(testFixture.root, "blocked-lock-parent");
|
||||||
|
fs.writeFileSync(blockedLockParent, "not a directory");
|
||||||
|
testFixture.registry.lockFile = path.join(blockedLockParent, "generator.lock");
|
||||||
|
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
|
||||||
|
|
||||||
|
const result = generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
goaccessBinary: "fake-goaccess",
|
||||||
|
dryRun: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
|
||||||
|
runGoAccess: fakeGoAccess({
|
||||||
|
alpha: goAccessReport({ "2026-09-11": 3 }),
|
||||||
|
combined: goAccessReport({ "2026-09-11": 3 }, true)
|
||||||
|
}, []),
|
||||||
|
now: () => new Date("2026-09-11T07:00:00Z")
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.dryRun, true);
|
||||||
|
assert.equal(fs.statSync(blockedLockParent).isFile(), true);
|
||||||
|
assert.equal(fs.readdirSync(
|
||||||
|
testFixture.registry.sites[0].reportOutputDirectory
|
||||||
|
).length, 0);
|
||||||
|
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
|
||||||
|
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration check accepts and explains a GoAccess build without Zlib", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
|
||||||
|
try {
|
||||||
|
const versionOutput = [
|
||||||
|
"GoAccess - 1.8.1.",
|
||||||
|
"Build configure arguments:",
|
||||||
|
" --enable-geoip=mmdb",
|
||||||
|
" --with-openssl"
|
||||||
|
].join("\n");
|
||||||
|
const capabilities = parseGoAccessVersion(versionOutput);
|
||||||
|
const result = generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
goaccessBinary: "fake-goaccess",
|
||||||
|
check: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => capabilities,
|
||||||
|
runGoAccess: () => {
|
||||||
|
throw new Error("configuration check must not generate reports");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const message = formatGoAccessCheck(result.goAccess);
|
||||||
|
|
||||||
|
assert.deepEqual(capabilities, GOACCESS_WITHOUT_ZLIB);
|
||||||
|
assert.equal(result.checked, true);
|
||||||
|
assert.match(message, /Zlib not detected/);
|
||||||
|
assert.match(message, /no Zlib support/);
|
||||||
|
assert.match(message, /valid for regular mode/);
|
||||||
|
assert.match(message, /optional uncompressed \.1 rotation/);
|
||||||
|
assert.match(message, /older \.gz logs are not imported/);
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects Zlib support when it is present", () => {
|
||||||
|
const capabilities = parseGoAccessVersion([
|
||||||
|
"GoAccess - 1.8.1.",
|
||||||
|
"Build configure arguments:",
|
||||||
|
" --enable-geoip=mmdb --with-openssl --with-zlib"
|
||||||
|
].join("\n"));
|
||||||
|
|
||||||
|
assert.equal(capabilities.zlib, true);
|
||||||
|
assert.match(formatGoAccessCheck(capabilities), /Zlib enabled/);
|
||||||
|
assert.doesNotMatch(formatGoAccessCheck(capabilities), /older \.gz logs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration check rejects GoAccess without GeoIP2 MMDB support", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
assert.throws(() => generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
check: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => ({
|
||||||
|
version: "1.8.1",
|
||||||
|
geoIpMmdb: false,
|
||||||
|
openSsl: true,
|
||||||
|
zlib: false
|
||||||
|
})
|
||||||
|
}), error => error.exitCode === 2
|
||||||
|
&& /must be built with GeoIP2\/MMDB support/.test(error.message));
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration check verifies analytics-log readability", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
|
||||||
|
const originalAccessSync = fs.accessSync;
|
||||||
|
try {
|
||||||
|
fs.accessSync = (filePath, mode) => {
|
||||||
|
if (filePath === testFixture.registry.sites[0].analyticsLog
|
||||||
|
&& mode === fs.constants.R_OK) {
|
||||||
|
const error = new Error("permission denied");
|
||||||
|
error.code = "EACCES";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return originalAccessSync(filePath, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(() => generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
check: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||||
|
}), /analytics log is not readable by the current user/);
|
||||||
|
} finally {
|
||||||
|
fs.accessSync = originalAccessSync;
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration check reports missing output directories", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
fs.rmSync(testFixture.registry.sites[0].reportOutputDirectory, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(() => generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
check: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||||
|
}), /report output directory for alpha does not exist/);
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a compressed log as the regular analytics input", () => {
|
||||||
|
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
|
||||||
|
try {
|
||||||
|
testFixture.registry.sites[0].analyticsLog += ".2.gz";
|
||||||
|
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
|
||||||
|
|
||||||
|
assert.throws(() => generateReports({
|
||||||
|
registryPath: testFixture.registryPath,
|
||||||
|
configTemplatePath: testFixture.configTemplatePath,
|
||||||
|
check: true
|
||||||
|
}, {
|
||||||
|
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
|
||||||
|
}), /must identify the current uncompressed log/);
|
||||||
|
} finally {
|
||||||
|
testFixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Nginx filters exclude non-page traffic before analytics logging", () => {
|
||||||
|
const source = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/nginx/analytics-filters.conf.example"
|
||||||
|
), "utf8");
|
||||||
|
|
||||||
|
for (const excluded of [
|
||||||
|
"/visitor-count.json",
|
||||||
|
"/kst4ContestVersionInfo.xml",
|
||||||
|
"/sitemap.xml",
|
||||||
|
"/robots.txt",
|
||||||
|
"manual/assets",
|
||||||
|
"healthz"
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(excluded.replaceAll("/", "\\/"), "i"));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(source, /^\s*=\//m);
|
||||||
|
assert.match(source, /^\s*\/visitor-count\.json\s+0;/m);
|
||||||
|
assert.match(source, /^\s*\/health\s+0;/m);
|
||||||
|
assert.match(source, /\|map\|/);
|
||||||
|
assert.match(source, /\$uri/);
|
||||||
|
assert.match(source, /known_bot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server templates use the production GeoIP path and permission model", () => {
|
||||||
|
const registry = JSON.parse(fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/sites.example.json"
|
||||||
|
), "utf8"));
|
||||||
|
const service = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/systemd/hamradioonline-analytics.service.example"
|
||||||
|
), "utf8");
|
||||||
|
const generator = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/generate-reports.js"
|
||||||
|
), "utf8");
|
||||||
|
|
||||||
|
assert.equal(registry.geoIpCountryDatabase, "/var/lib/GeoIP/GeoLite2-Country.mmdb");
|
||||||
|
assert.match(service, /\/var\/lib\/GeoIP\/GeoLite2-Country\.mmdb/);
|
||||||
|
assert.match(service, /^StateDirectoryMode=0711$/m);
|
||||||
|
assert.match(generator, /fs\.fchmodSync\(handle, mode\)/);
|
||||||
|
assert.match(generator, /publicFile\.destination, publicFile\.content, 0o644/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public counter template disables logging and sets cache and type headers", () => {
|
||||||
|
const source = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/nginx/public-counter.conf.example"
|
||||||
|
), "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /^\s*access_log off;$/m);
|
||||||
|
assert.match(source, /Cache-Control "public, max-age=3600" always/);
|
||||||
|
assert.match(source, /X-Content-Type-Options "nosniff" always/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("statistics vhost preserves ACME renewal and protects HTTPS reports", () => {
|
||||||
|
const source = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/nginx/stats-vhost.conf.example"
|
||||||
|
), "utf8");
|
||||||
|
const bootstrap = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example"
|
||||||
|
), "utf8");
|
||||||
|
const httpsOffset = source.search(/server\s*\{\s*listen 443 ssl http2;/);
|
||||||
|
assert.notEqual(httpsOffset, -1);
|
||||||
|
const httpSource = source.slice(0, httpsOffset);
|
||||||
|
const httpsSource = source.slice(httpsOffset);
|
||||||
|
|
||||||
|
assert.match(httpSource, /^\s*listen 80;$/m);
|
||||||
|
assert.match(httpSource, /^\s*access_log off;$/m);
|
||||||
|
assert.match(httpSource, /location \^~ \/\.well-known\/acme-challenge\//);
|
||||||
|
assert.match(httpSource, /root \/var\/lib\/letsencrypt;/);
|
||||||
|
assert.match(httpSource, /return 301 https:\/\/\$host\$request_uri;/);
|
||||||
|
assert.match(httpsSource, /^\s*listen 443 ssl http2;$/m);
|
||||||
|
assert.match(httpsSource, /^\s*auth_basic "Private project statistics";$/m);
|
||||||
|
assert.match(httpsSource, /^\s*access_log off;$/m);
|
||||||
|
assert.match(httpsSource, /location = \/[\s\S]*return 302 \/combined\/;/);
|
||||||
|
assert.match(httpsSource, /include \/etc\/letsencrypt\/options-ssl-nginx\.conf;/);
|
||||||
|
assert.match(httpsSource, /ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams\.pem;/);
|
||||||
|
assert.doesNotMatch(source, /listen \[::\]/);
|
||||||
|
assert.match(bootstrap, /^\s*listen 80;$/m);
|
||||||
|
assert.doesNotMatch(bootstrap, /ssl_certificate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logrotate uses the Ubuntu Nginx rotation action", () => {
|
||||||
|
const source = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../ops/analytics/logrotate/hamradioonline-analytics.example"
|
||||||
|
), "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /invoke-rc\.d nginx rotate >\/dev\/null 2>&1/);
|
||||||
|
assert.match(source, /^\s*delaycompress$/m);
|
||||||
|
assert.match(source, /^\s*rotate 14$/m);
|
||||||
|
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("website package records the Node.js 18.19.1 baseline", () => {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json")));
|
||||||
|
const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "../package-lock.json")));
|
||||||
|
|
||||||
|
assert.equal(packageJson.engines.node, ">=18.19.1");
|
||||||
|
assert.equal(packageLock.packages[""].engines.node, ">=18.19.1");
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const {
|
||||||
|
formatVisitorCount,
|
||||||
|
loadVisitorCount,
|
||||||
|
validateVisitorCount
|
||||||
|
} = require("../src/assets/js/visitor-count");
|
||||||
|
|
||||||
|
const VALID_DATA = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
visits: 1234,
|
||||||
|
since: "2026-09-11",
|
||||||
|
updatedAt: "2026-09-11T07:00:00Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
function element() {
|
||||||
|
return { hidden: true, textContent: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("formats valid data for the English website", async () => {
|
||||||
|
const target = element();
|
||||||
|
let request;
|
||||||
|
const shown = await loadVisitorCount({
|
||||||
|
element: target,
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
request = { url, options };
|
||||||
|
return { ok: true, json: async () => VALID_DATA };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(shown, true);
|
||||||
|
assert.equal(target.hidden, false);
|
||||||
|
assert.equal(target.textContent, "Visits since 11 September 2026: 1,234");
|
||||||
|
assert.equal(request.url, "/visitor-count.json");
|
||||||
|
assert.equal(request.options.credentials, "omit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsupported schemas and invalid visit counts", () => {
|
||||||
|
for (const changes of [
|
||||||
|
{ schemaVersion: 2 },
|
||||||
|
{ visits: -1 },
|
||||||
|
{ visits: 1.5 },
|
||||||
|
{ visits: Number.MAX_SAFE_INTEGER + 1 }
|
||||||
|
]) {
|
||||||
|
assert.equal(validateVisitorCount({ ...VALID_DATA, ...changes }), null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid dates and timestamps without timezone shifts", () => {
|
||||||
|
for (const changes of [
|
||||||
|
{ since: "2026-02-30" },
|
||||||
|
{ since: "11-09-2026" },
|
||||||
|
{ updatedAt: "2026-09-11" },
|
||||||
|
{ updatedAt: "2026-02-30T07:00:00Z" },
|
||||||
|
{ updatedAt: "2026-09-11T25:00:00Z" },
|
||||||
|
{ updatedAt: "not-a-timestamp" }
|
||||||
|
]) {
|
||||||
|
assert.equal(formatVisitorCount({ ...VALID_DATA, ...changes }), null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the element hidden for HTTP and fetch failures", async () => {
|
||||||
|
for (const fetchImpl of [
|
||||||
|
async () => ({ ok: false }),
|
||||||
|
async () => { throw new Error("network failure"); }
|
||||||
|
]) {
|
||||||
|
const target = element();
|
||||||
|
assert.equal(await loadVisitorCount({ element: target, fetchImpl }), false);
|
||||||
|
assert.equal(target.hidden, true);
|
||||||
|
assert.equal(target.textContent, "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts a slow request and keeps the element hidden", async () => {
|
||||||
|
const target = element();
|
||||||
|
let aborted = false;
|
||||||
|
const fetchImpl = (url, options) => new Promise((resolve, reject) => {
|
||||||
|
options.signal.addEventListener("abort", () => {
|
||||||
|
aborted = true;
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await loadVisitorCount({
|
||||||
|
element: target,
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 5
|
||||||
|
}), false);
|
||||||
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(target.hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses textContent and loads the script only for the home page", () => {
|
||||||
|
const script = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../src/assets/js/visitor-count.js"
|
||||||
|
), "utf8");
|
||||||
|
const home = fs.readFileSync(path.join(__dirname, "../src/index.njk"), "utf8");
|
||||||
|
const layout = fs.readFileSync(path.join(
|
||||||
|
__dirname,
|
||||||
|
"../src/_layouts/base.njk"
|
||||||
|
), "utf8");
|
||||||
|
const sourceRoot = path.join(__dirname, "../src");
|
||||||
|
const otherPages = fs.readdirSync(sourceRoot, { recursive: true })
|
||||||
|
.filter(entry => entry.endsWith(".njk") && entry !== "index.njk");
|
||||||
|
|
||||||
|
assert.doesNotMatch(script, /innerHTML/);
|
||||||
|
assert.match(script, /textContent/);
|
||||||
|
assert.match(home, /^visitorCount: true$/m);
|
||||||
|
assert.match(layout, /\{% if visitorCount %\}/);
|
||||||
|
assert.equal(
|
||||||
|
otherPages.some(entry => fs.readFileSync(path.join(sourceRoot, entry), "utf8")
|
||||||
|
.includes("visitorCount: true")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user