mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 11:45:27 +02:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548f95c9bb
|
||
|
|
1204acaa82
|
||
|
|
174c77a037
|
||
|
|
e5e6036188
|
||
|
|
3b631e25f6
|
||
|
|
e435368840
|
||
|
|
c579105bcb
|
||
|
|
74ac1139ad
|
||
|
|
63a4bae858
|
||
|
|
5086eae816
|
||
|
|
2023b2cb28
|
||
|
|
681419f169
|
||
|
|
4ad1cf71dd
|
||
|
|
ad212e3e71 | ||
|
|
08d65a0e23 | ||
|
|
edf71b4105
|
||
|
|
885bf83c2f
|
||
|
|
634b88238d
|
||
|
|
830e4020a2 | ||
|
|
7ee50267ec | ||
|
|
113e843111 | ||
|
|
79161d2afa | ||
|
|
3ed1cad5ab | ||
|
|
75fe45b50b | ||
|
|
53555cbe69 | ||
|
|
595fb84362 | ||
|
|
9eb0550106 | ||
|
|
51aa04bfb5 | ||
|
|
6b0d699a98 | ||
|
|
6b29ffe3ba | ||
|
|
08b109106e | ||
|
|
21b2d9970a | ||
|
|
94fc13e3c3 | ||
|
|
d475c6b2c4 | ||
|
|
211be6081d | ||
|
|
b550d79b4b | ||
|
|
352cdcceb2 | ||
|
|
905ce5766e | ||
|
|
2803f7d8e3 | ||
|
|
4a8e08a331 | ||
|
|
a24db90ad2 | ||
|
|
d91b119112 | ||
|
|
345c4adfc3 | ||
|
|
0e8cd06e43 |
@@ -551,6 +551,9 @@ jobs:
|
||||
name: Publish Flatpak OSTree Repo (nightly)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-flatpak
|
||||
concurrency:
|
||||
group: flatpak-repo-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Install Flatpak tooling
|
||||
|
||||
@@ -646,6 +646,9 @@ jobs:
|
||||
name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-flatpak
|
||||
concurrency:
|
||||
group: flatpak-repo-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Install Flatpak tooling
|
||||
@@ -786,45 +789,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 }}
|
||||
# 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: 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: 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: 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: 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: 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
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# Konzept: In-App-Update für KST4Contest
|
||||
|
||||
> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` (Version 1.50.0) und wurden gegen den Branch verifiziert.
|
||||
|
||||
## Ziel
|
||||
|
||||
Stable-Updates werden nach Zustimmung des Nutzers heruntergeladen. KST4Contest beendet sich danach kontrolliert und startet den plattformspezifischen Aktualisierungsvorgang. Updates dürfen den Programmstart nicht blockieren und während eines Contests nicht erzwungen werden.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Der vorhandene Mechanismus erfüllt das nicht:
|
||||
|
||||
- `UpdateChecker.downloadLatestVersionInfoXML()` (`src/main/java/kst4contest/controller/UpdateChecker.java:54-71`) benutzt `new URL(...).openStream()` + `Files.copy(..., REPLACE_EXISTING)` **direkt auf die Zieldatei** — ohne Timeouts, ohne HTTP-Statusprüfung, ohne temporäre Datei. Ein Fehlschlag beschädigt zusätzlich die lokale Feed-Kopie.
|
||||
- Der Aufruf steht **synchron im Konstruktor** von `ChatController` (`ChatController.java:2937-2941`); `ChatController` wird in `Kst4ContestApplication.start(Stage)` erzeugt (`Kst4ContestApplication.java:6971`, `start` ab `:6914`). Der Netzwerkaufruf läuft damit unbegrenzt **auf dem JavaFX-Application-Thread während des Starts** — genau der Punkt „darf den Programmstart nicht blockieren".
|
||||
- `parseUpdateXMLFile()` (`UpdateChecker.java:73-236`) liest Kindelemente **positionsabhängig** über einen Zähler in `String[7]` bzw. `String[3]` (`:144-145`, `:182-183`). Ein achtes Kindelement in `<changeLog>` erzeugt eine `ArrayIndexOutOfBoundsException` und killt den gesamten Check.
|
||||
- Die Anzeige ist ein eigenes `Stage` mit `setAlwaysOnTop(true)` (`Kst4ContestApplication.java:9162`), inline in `start()` aufgebaut (`:9152-9294`) und beim Start automatisch geöffnet (`:9280`). Es gibt **keinen Menüpunkt** zum erneuten Prüfen. Ein Catch-All bei `:9289-9292` schaltet die Funktion stillschweigend ab, sobald der Feed fehlt oder nicht passt.
|
||||
- Toter Code: `main()` (`:27-34`) und ein Testdatenblock (`:208-234`).
|
||||
|
||||
## Bestätigte Festlegungen
|
||||
|
||||
| Punkt | Entscheidung |
|
||||
|---|---|
|
||||
| Contest-Schutz | Hinweis wird unterdrückt, solange eine ON4KST-Verbindung besteht |
|
||||
| Update-Starter | Kein eigenes Starter-Programm; Installer/Paketverwaltung wird direkt gestartet, danach beendet sich KST4Contest |
|
||||
| Flatpak | Nur Hinweis mit Update-Befehl, kein Download, kein Artefakt |
|
||||
| AppImage | Kontrollierter Dateiaustausch, kein `AppImageUpdate` |
|
||||
| Kanal | Ausschließlich Stable |
|
||||
| Feed-Auslieferung | Bleibt wie bisher: der Webserver zieht das Repo alle 5 Minuten per Git und baut die Website selbst |
|
||||
| Zuschnitt | Drei Stufen |
|
||||
| Signatur | SignPath Foundation, als **optionaler additiver** CI-Schritt; unsigniert bleibt lauffähig |
|
||||
| Windows-ZIP | Bleibt als portable Alternative |
|
||||
|
||||
## Was unverändert bleibt
|
||||
|
||||
1. **Alte Clients (≤ 1.44) am erweiterten Feed.** `UpdateChecker` sucht per `getElementsByTagName` nach `latestVersion`, `versionNumber`, `semanticVersion`, `adminMessage`, `majorChanges`, `latestVersionPathOnWebserver`, `changeLog`, `bug`. Das ist eine **Descendant-Suche** — deshalb kommt der neue Block als *Geschwister* neben `<latestVersion>` und benutzt ausschließlich neue Elementnamen.
|
||||
2. **`<changeLog>` behält exakt seine 7 Kinder** (`changedVersionNumber, date, description, added, changed, fixed, removed`) in unveränderter Reihenfolge, `<bug>` seine Struktur. `<needUpdateSinceLastVersion>`, `<roadmap>`, `<bugsReported>` bleiben unangetastet.
|
||||
3. **`~/.praktiKST/`** — nur ein neues Unterverzeichnis `updates/`. `kst4ContestVersionInfo.xml` behält Name und Ort, damit ein Downgrade auf 1.44 funktioniert.
|
||||
4. **Windows-ZIP als Portable** — die App verändert ihr eigenes Verzeichnis nie und führt für diesen Typ keinen Installer aus.
|
||||
5. **Kein Eingriff** in Flatpak-, AUR-, DEB-, RPM- oder Arch-Installationen; **kein `sudo`/`pkexec`** aus der Anwendung heraus.
|
||||
6. **Offlinebetrieb** — ein fehlgeschlagener Check ist stumm (nur `java.util.logging`), der Programmstart ist davon vollständig entkoppelt.
|
||||
7. **Legacy-Vergleich** — `APPLICATION_CURRENTVERSIONNUMBER` und `<versionNumber>` bleiben als Fallback; `VersionUtils.compareStableVersions` wird wiederverwendet und nicht verändert.
|
||||
8. **Bei unbekannter Installationsart** wird ausschließlich die Downloadseite geöffnet.
|
||||
|
||||
---
|
||||
|
||||
## Stufe 1 — Feed, Paketkennzeichnung, Prüfdienst, Hinweis-UI
|
||||
|
||||
Kein Download, kein Installationsstart.
|
||||
|
||||
### 1a. Installationsart zur Buildzeit kennzeichnen
|
||||
|
||||
Es existiert heute **keinerlei** Build-Metadatum: kein `--java-options`, keine `.properties` unter `src`, kein Resource-Filtering in `pom.xml`, keine Manifest-Einträge.
|
||||
|
||||
**Gewählter Mechanismus:** jpackage-Flag `--java-options -Dkst4contest.packageType=<typ>`.
|
||||
|
||||
| Alternative | Warum nicht |
|
||||
|---|---|
|
||||
| Gefilterte `.properties`-Resource | **Alle Pakettypen entstehen aus demselben Maven-Artefakt**; die Unterscheidung fällt erst im jpackage-Aufruf. Lokale Builds und Tests bekämen einen falschen Marker. |
|
||||
| Manifest-Eintrag | Gleiches JAR-Problem, mehr Bewegungsteile. |
|
||||
| Verzeichnis-/Prozessheuristik | Laut Zielbild ausdrücklich ausgeschlossen; bei ZIP-Portable ohnehin frei verschiebbar. |
|
||||
| Reines Env-Sniffing | Erkennt Flatpak und AppImage, aber nicht deb/rpm/arch/zip/msi. |
|
||||
|
||||
Vorteile: keine Auswirkung auf `mvn`/Tests/lokale Entwicklung (Property fehlt → `UNKNOWN` → sicherer Fallback); der Wert landet in `lib/app/<Name>.cfg` des app-image und wird dadurch vom AUR-`-bin`-Repack (`packaging/aur/kst4contest-bin/PKGBUILD` macht nur `cp -a usr`) und vom Flatpak-Build (bindet das app-image als `type: dir`-Source ein) **automatisch mitkopiert**.
|
||||
|
||||
Werte: `windows-zip`, `windows-msi`, `macos-dmg`, `linux-appimage`, `flatpak`, `linux-deb`, `linux-rpm`, `linux-arch`.
|
||||
|
||||
**15 Aufrufstellen:**
|
||||
|
||||
| Datei | Zeilen | Wert |
|
||||
|---|---|---|
|
||||
| `.github/workflows/tagged-release.yml` | 53 / 104 / 184 / 238 / 292 / 403 | `windows-zip` / `linux-appimage` / `linux-deb` / `linux-rpm` / `linux-arch` / `flatpak` |
|
||||
| `.github/workflows/nightly-artifacts.yml` | 69 / 127 / 214 / 275 / 338 / 458 | dieselbe Reihenfolge |
|
||||
| `packaging/macos/build-signed-dmg.sh` | 72 | `macos-dmg` |
|
||||
| `packaging/aur/kst4contest/PKGBUILD` | 44 | `linux-arch` |
|
||||
| `packaging/aur/kst4contest-git/PKGBUILD` | 37 | `linux-arch` |
|
||||
|
||||
`packaging/aur/kst4contest-bin/PKGBUILD` erbt korrekt — **keine Änderung**.
|
||||
|
||||
PowerShell-Syntax (tagged-release:53, nightly:69) braucht einfache Anführungszeichen, sonst frisst PowerShell das `-D`:
|
||||
`--java-options '-Dkst4contest.packageType=windows-zip'`
|
||||
|
||||
**Auflösung zur Laufzeit** (`RuntimePackageTypeResolver`), bewusst konservativ:
|
||||
|
||||
1. `System.getProperty("kst4contest.packageType")`; unbekannter Wert → weiter.
|
||||
2. `/.flatpak-info` vorhanden **oder** `FLATPAK_ID` gesetzt → `FLATPAK`. Diese Prüfung **überschreibt** die Property — eine Fehlklassifikation richtet ausgerechnet im Flatpak den größten Schaden an (Download in eine Sandbox, die den Host nie aktualisieren kann).
|
||||
3. `APPIMAGE` gesetzt → `LINUX_APPIMAGE`.
|
||||
4. sonst `UNKNOWN`.
|
||||
|
||||
### 1b. Feed erweitern
|
||||
|
||||
Der Feed wird **von der Website** erzeugt (`website/src/_data/versionInfo.js`, ausgeliefert über `website/src/version-info.njk`), gespeist aus der GitHub-Releases-API — nicht aus den Build-Jobs. Der Webserver zieht das Repository alle 5 Minuten per Git und baut selbst; eine geänderte `versionInfo.js` wird dadurch automatisch wirksam, **ohne** Upload-Schritt in den Workflows.
|
||||
|
||||
**Platzierung: neues Top-Level-Element als Geschwister von `<latestVersion>`.** Ein Kindelement *innerhalb* `<latestVersion>` wäre riskant, weil der alte Parser dort per Descendant-Suche liest; als Geschwister ist jede Kollision strukturell ausgeschlossen.
|
||||
|
||||
```xml
|
||||
<updatePackages formatVersion="1" semanticVersion="1.45.0">
|
||||
<updatePackage>
|
||||
<packageType>linux-deb</packageType>
|
||||
<operatingSystem>linux</operatingSystem>
|
||||
<architecture>x86_64</architecture>
|
||||
<fileName>KST4Contest-v1.45.0-debian-amd64.deb</fileName>
|
||||
<downloadUrl>https://github.com/praktimarc/kst4contest/releases/download/v1.45.0/…</downloadUrl>
|
||||
<fileSizeBytes>142335488</fileSizeBytes>
|
||||
<sha256>9f2c…</sha256> <!-- 64 hex, klein -->
|
||||
<signatureState>unsigned</signatureState> <!-- unsigned | authenticode | apple-notarized | gpg-repo -->
|
||||
<releaseNotesUrl>…/releases/tag/v1.45.0</releaseNotesUrl>
|
||||
<installHint>package-manager</installHint> <!-- run | package-manager | portable | hint-only -->
|
||||
</updatePackage>
|
||||
<updatePackage>
|
||||
<packageType>flatpak</packageType>
|
||||
<operatingSystem>linux</operatingSystem>
|
||||
<architecture>x86_64</architecture>
|
||||
<signatureState>gpg-repo</signatureState>
|
||||
<releaseNotesUrl>…/releases/tag/v1.45.0</releaseNotesUrl>
|
||||
<installHint>hint-only</installHint>
|
||||
<hintCommand>flatpak update de.x08.KST4Contest</hintCommand>
|
||||
<!-- bewusst ohne downloadUrl/sha256 -->
|
||||
</updatePackage>
|
||||
</updatePackages>
|
||||
```
|
||||
|
||||
**Datenherkunft in `versionInfo.js`:**
|
||||
|
||||
- `fileName`, `downloadUrl`, `fileSizeBytes` stehen **bereits in der vorhandenen `/releases`-Antwort** (`release.assets[]` mit `name`, `browser_download_url`, `size`) — kein zusätzlicher API-Aufruf. Aktuell werden die Assets weggeworfen.
|
||||
- `packageType`/`operatingSystem`/`architecture` über einen deterministischen Klassifikator auf die Assetnamen, die die Workflows fest vergeben (`tagged-release.yml:783-791`): `-windows-x64.zip`, `-linux-x86_64.AppImage`, `-debian-amd64.deb`, `-fedora-x86_64.rpm`, `-archlinux-*.pkg.tar.zst`, `-macos-*.dmg`. `.flatpakref` erzeugt keinen Download, sondern den synthetischen Hint-Eintrag. Manuals und der Feed selbst werden übersprungen.
|
||||
- **`sha256` muss der Release-Workflow erzeugen.** Im Job `release-tag` liegen alle Artefakte bereits unter `release-assets/*`, bevor `ncipollo/release-action` läuft. Dort nach den `download-artifact`-Schritten und **vor** „Create tagged release":
|
||||
```yaml
|
||||
- name: Compute release asset checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
find . -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| xargs -0 sha256sum | sed 's#\./##' > SHA256SUMS.txt
|
||||
```
|
||||
und `release-assets/SHA256SUMS.txt` in die `artifacts:`-Liste (`:783-791`) aufnehmen. Die bestehende Reihenfolge (Release zuerst, dann Website-Build, `:771-825`) bleibt korrekt — die Summen liegen zum Zeitpunkt des Website-Builds bereits am Release. `versionInfo.js` lädt die Datei **nur für das neueste Stable-Release** und baut daraus eine `filename → sha256`-Map; das ist ein Download-URL-Abruf und belastet das API-Ratelimit nicht.
|
||||
- `signatureState` aus einer statischen Tabelle je packageType (`macos-dmg → apple-notarized`, `flatpak → gpg-repo`, Rest `unsigned`). In Stufe 3 wird `windows-msi` nur dann auf `authenticode` gesetzt, wenn ein signiertes Asset tatsächlich existiert — der Feed darf nicht lügen, wenn der SignPath-Schritt übersprungen wurde.
|
||||
|
||||
**Robustheit:** Weil der Server unabhängig vom Release-Workflow baut, kann er einen Feed erzeugen, bevor `SHA256SUMS.txt` am Release hängt. Fehlt `<sha256>`, gilt das Artefakt als **nicht automatisch ladbar** — der Client öffnet nur die Downloadseite. Ein Artefakt ohne Prüfsumme wird nie geladen und nie gestartet. Die bestehende `try/catch`-Klammer in `versionInfo.js` bricht den Website-Build bei einem unvollständigen Feed ab; die neue Sektion darf **nicht** so scharf sein — bei Problemen wird `<updatePackages>` weggelassen, der Rest des Feeds bleibt gültig.
|
||||
|
||||
**Mitzuziehen:**
|
||||
- `website/scripts/validate-version-info.js` — bekannter `packageType`; `sha256` matcht `^[0-9a-f]{64}$`; `downloadUrl` beginnt mit `https://github.com/praktimarc/kst4contest/releases/download/`; `fileSizeBytes` numerisch > 0. **Zusätzlich neu und wichtig:** `<changeLog>` muss weiterhin exakt die 7 Kinder in der Altreihenfolge haben — das nagelt die Alt-Client-Kompatibilität fest.
|
||||
- `website/test/version-info.test.js` — Klassifikator, SHA256SUMS-Parsing, „Release ohne Assets".
|
||||
|
||||
**Warnung, nicht Teil dieser Umsetzung:** `.github/workflows/test-publish-version-xml.yml` (ungetrackt, nur `workflow_dispatch`) erzeugt mit einem eigenen Python-Generator einen konkurrierenden Feed **ohne** `<semanticVersion>` und lädt ihn per WebDAV hoch. Würde er ausgelöst, fielen alle Clients auf den kaputten Double-Vergleich (`1.41.1` → `1.411`) zurück.
|
||||
|
||||
### 1c. Prüfdienst neu bauen
|
||||
|
||||
Neues Package `kst4contest.service.update` (konsistent zum vorhandenen `kst4contest/service/path/`).
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| `model/PackageType.java` | Enum + `feedId()`, `fromFeedId()` (null/unbekannt → `UNKNOWN`, wirft nie), `updateStrategy()` |
|
||||
| `model/UpdatePackageInfo.java` | Transport-DTO, **explizite Klasse, kein Record** (AGENTS.md) |
|
||||
| `model/UpdateCheckResult.java` | Ergebnis-DTO inkl. `resultSource` (`NETWORK`/`CACHE`/`NONE`) |
|
||||
| `service/update/UpdateFeedParser.java` | **Reiner Parser**, kein IO, kein Netz |
|
||||
| `service/update/UpdateFeedClient.java` | **Nur HTTP**, injizierbarer `HttpClient` + Timeouts |
|
||||
| `service/update/UpdateCheckService.java` | Orchestrierung, injizierbare Abhängigkeiten + `Clock` |
|
||||
| `service/update/RuntimePackageTypeResolver.java` | Installationsart, Test-Konstruktor für Property/Env/Pfad |
|
||||
|
||||
Muster: `OpenMeteoTerrainProfileProvider.java:76-88` (injizierbarer Client, konfigurierbare Timeouts), `TerrainPackageDownloader.java:107-145` (Temp-Datei → Statusprüfung → `Files.move`).
|
||||
|
||||
Verhalten:
|
||||
|
||||
- Aufruf **asynchron im Daemon-Thread, erst nach `primaryStage.show()`** — nie vorher. `ChatController.java:2937-2941` entfällt.
|
||||
- Ergebnis über eine `ObjectProperty<UpdateCheckResult>` im `ChatController`, gesetzt via `Platform.runLater`. Muster: `lastUiReminderEvent` (`ChatController.java:4236-4243`). Der Worker fasst **keine** `ObservableList` an.
|
||||
- Versionsvergleich über `VersionUtils.compareStableVersions`. **Achtung:** `parseVersion` wirft bei leerem oder nicht-numerischem Input (`IllegalArgumentException`/`NumberFormatException`) — der Dienst muss das fangen und auf den Double-Pfad zurückfallen. Heute ist das in der View (`:9266-9278`) nicht abgesichert.
|
||||
- Ausschließlich Stable. Fehlende Verbindung erzeugt **keine** Meldung, nur einen Logeintrag über `java.util.logging` statt `System.out`. Höchstens eine Meldung je Sitzung. Bei Netzfehler Rückfall auf die zuvor gecachte Datei.
|
||||
|
||||
**Positionales Parsing entschärfen:** Primärpfad namensbasiert (`readChildText(element, "changedVersionNumber")` …), Ergebnis weiterhin als `String[7]` in derselben Reihenfolge, damit der TreeView-Aufbau im Update-Fenster unverändert bleibt. Kompatibilitätspfad für uralte Feeds behält den positionalen Walk, aber mit `if (counter >= entry.length) break;`. Präfixe (`"Date: "`, `"Desc: "`) exakt erhalten. Bei `<bug>` ist `aChangeLogEntry[2]` heute uninitialisiert → explizit `""`.
|
||||
|
||||
`UpdateChecker.java` wird durch den Dienst ersetzt.
|
||||
|
||||
### 1d. Hinweis-UI
|
||||
|
||||
Wiederverwendet wird das etablierte Muster `initSkedWarnIndicatorButton()` (`Kst4ContestApplication.java:5790-5807`) und `initBandUpgradeIndicatorButton()` (`:5965`): versteckt per Default, `managedProperty().bind(visibleProperty())`, eingehängt in `flwpne_StatusBar` (Aufbau `:7158`, `setTop` `:7161`, Indicator-Buttons `:7163`, `:7166`, `:7189`).
|
||||
|
||||
Abweichung: **nicht** `setMouseTransparent(true)` — der Button ist klickbar und öffnet ein `ContextMenu`.
|
||||
|
||||
- Text: `KST4Contest 1.x.y is available`
|
||||
- Aktionen: Änderungen anzeigen · Update herunterladen (Stufe 1: deaktiviert bzw. „Release-Seite öffnen") · für diese Sitzung schließen
|
||||
- **Contest-Regel:** `visible = updateAvailable && !dismissedForSession && Verbindungszustand != ONLINE`. `onConnectionStateChanged(...)` (`:12566`) ruft zusätzlich `refreshUpdateHintVisibility()` im vorhandenen `Platform.runLater`-Block — der Hinweis verschwindet beim Login in den Chat und kommt beim Trennen zurück.
|
||||
- FX-Thread-Guard-Idiom aus `:5833` übernehmen.
|
||||
- **Neuer Menüpunkt** im Info-Menü (Info-Menü, `helpMenu.getItems().addAll(...)` `:5774`): „Check for updates…" und „Show update details…". Damit existiert erstmals ein manueller Re-Check.
|
||||
- Das bestehende `stage_updateStage` wird aus `start()` in eine eigene, **lazy** aufgerufene Methode herausgezogen; gespeicherte Fenstergröße (`getGUIstage_updateStage_SceneSizeHW`, `:9253-9260`) bleibt. Das Auto-`show()` (`:9280`) entfällt, `setAlwaysOnTop(true)` (`:9162`) wird entfernt — ein Always-on-Top-Fenster über einem laufenden Contest ist genau das, was das Ziel verbietet.
|
||||
|
||||
### 1e. Versionsdrift absichern
|
||||
|
||||
`pom.xml:9` und `ApplicationConstants.APPLICATION_CURRENT_VERSION` werden **von Hand gepflegt, ohne jede Prüfung**. Für einen Mechanismus, der genau diese Zeichenkette vergleicht, ist das die riskanteste Stelle. Analog zu `packaging/AddModules.java --verify-pom` (an `validate` gebunden, `pom.xml:219-240`) einen `--verify-version`-Wächter ergänzen. Kein Auto-Bump.
|
||||
|
||||
`docs/PROJECT_CONTEXT.md:198` beschreibt zusätzlich die Kodierung von `APPLICATION_CURRENTVERSIONNUMBER` (`1.43.1` → `1.431`) — beim Bump mitführen, solange das Legacy-Feld existiert. Auf 1.50 stehen die Werte in `ApplicationConstants.java:23` und `:30`.
|
||||
|
||||
### 1f. `--app-version` nachziehen
|
||||
|
||||
Außer im macOS-Skript (`build-signed-dmg.sh:75`) setzt **kein** jpackage-Aufruf `--app-version`. DEB und RPM tragen dadurch intern die Vorgabeversion `1.0`; ein `apt install ./KST4Contest-v1.45.0.deb` wäre für dpkg **kein Upgrade**. Vor Stufe 3 zwingend, sinnvollerweise überall — dieselbe `sed`-Ableitung wie `build-signed-dmg.sh:59-65`. Nebenwirkung: bestehende DEB/RPM-Installationen springen einmalig von `1.0` auf die echte Version.
|
||||
|
||||
---
|
||||
|
||||
## Stufe 2 — Gesicherter Download
|
||||
|
||||
Erst nach „Update herunterladen".
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| `service/update/UpdateDownloadPolicy.java` | Reine Validierung, kein IO |
|
||||
| `service/update/UpdateDownloadService.java` | Download + Verifikation |
|
||||
| `service/update/UpdateStorage.java` | `~/.praktiKST/updates/`, Aufräumen verwaister Dateien |
|
||||
| `model/UpdateDownloadResult.java` | Ergebnis-DTO |
|
||||
|
||||
**Ablauf** (Muster `TerrainPackageDownloader.java:93-165`):
|
||||
|
||||
1. Download nach `<fileName>.download`;
|
||||
2. Prüfung von Dateigröße **und** SHA-256 (`computeSha256`, `:175-187`, 8-KiB-Puffer + `HexFormat`);
|
||||
3. `Files.move` erst nach erfolgreicher Prüfung;
|
||||
4. bei Abbruch oder falscher Prüfsumme temporäre Datei löschen, verständliche Meldung;
|
||||
5. **niemals** ein ungeprüftes Artefakt starten.
|
||||
|
||||
Empfehlung: `BodyHandlers.ofInputStream()` + `DigestInputStream` statt `ofFile` — ein Durchlauf statt zwei, und ein Fortschrittsbalken ist bei 150-MB-Dateien Pflicht. Short-circuit, wenn die Zieldatei bereits mit passendem Hash existiert.
|
||||
|
||||
**Sicherheitsschranke — nicht optional:** SHA-256 aus dem Feed beweist nur, dass die Datei zum Feed passt. Da der Feed über einen selbst gebauten Host ausgeliefert wird, ist eine **Host-Allowlist** (`github.com`, `objects.githubusercontent.com`) die eigentliche Schutzschicht: selbst ein manipulierter Feed kann keine beliebige URL unterschieben. `UpdateDownloadPolicy` prüft zusätzlich: Schema `https`; `sha256` matcht `^[0-9a-fA-F]{64}$`; `fileName` ohne `/`, `\`, `..`; `fileSizeBytes` > 0 und < 1 GiB; ausreichend Plattenplatz; `packageType` erlaubt Download.
|
||||
|
||||
Stufe 2 führt **nichts** aus. Nach Erfolg: „Jetzt beenden und aktualisieren" / „Später installieren". Flatpak erhält gar keinen Download-Pfad, sondern den Hinweistext aus `<hintCommand>`.
|
||||
|
||||
---
|
||||
|
||||
## Stufe 3 — Plattformausführung, Windows-Installer, Signatur
|
||||
|
||||
### 3a. Windows-Installer
|
||||
|
||||
`--type msi` mit **dauerhaft konstanter** `--win-upgrade-uuid`, dazu `--win-menu --win-shortcut --win-dir-chooser`. `choco install wixtoolset` steht bereits in beiden Workflows (`tagged-release.yml:33-35`, `nightly-artifacts.yml:49-51`) und wird bisher **nicht genutzt** — die Voraussetzung ist vorhanden. Der ZIP-Build bleibt bestehen.
|
||||
|
||||
Die GUID muss dokumentiert werden (z. B. `packaging/windows/README.md`) — ändert sie sich, sind MSI-Upgrades dauerhaft kaputt. Asset in die `artifacts:`-Liste und in den SHA256SUMS-Schritt aufnehmen.
|
||||
|
||||
### 3b. Ausführung je Installationsart
|
||||
|
||||
| Installation | Verhalten |
|
||||
|---|---|
|
||||
| `windows-msi` | `msiexec /i "<datei>" /qb` detached, danach beenden |
|
||||
| `windows-zip` | Keine Ausführung. Installer laden, Ordner öffnen, Benutzerdaten bleiben. **Hinweis nötig**, dass der alte ZIP-Ordner bestehen bleibt und künftig der Installer-Eintrag zu starten ist — sonst startet der Nutzer weiter die alte `praktiKST.exe` und sieht nie wieder ein Update |
|
||||
| `macos-dmg` | `open "<dmg>"`, danach beenden; der Nutzer zieht selbst. Ein laufendes `.app` kann sich ohne separates Updater-Binary nicht ersetzen |
|
||||
| `linux-appimage` | Neues AppImage neben `$APPIMAGE` ablegen, `chmod +x`, Ordner öffnen, beenden. Kein In-Place-Overwrite einer laufenden Datei |
|
||||
| `flatpak` | Nur Verweis auf `flatpak update de.x08.KST4Contest`; keine Datei im Paket anfassen |
|
||||
| `linux-deb/rpm/arch` | `xdg-open "<datei>"` → GNOME Software / Discover / gdebi übernehmen inkl. Rechteabfrage. **Bewusst kein `pkexec`/`sudo`** aus der Anwendung heraus |
|
||||
| AUR | Verweis auf den vorhandenen AUR-Helper |
|
||||
| `UNKNOWN` | Ausschließlich Downloadseite öffnen |
|
||||
|
||||
Kein eigener Update-Starter. Der kontrollierte Ausstieg: Verbindung prüfen und ggf. bestätigen lassen → ON4KST sauber trennen → Layout/Preferences flushen → DB schließen → Installer starten → `Platform.exit()` + `System.exit(0)`. Kein `Runtime.halt`. Der vorhandene Weg über `Kst4ContestApplication.stop()` (`disconnect("CLOSEALL")` + `System.exit(0)`) bleibt die Basis.
|
||||
|
||||
### 3c. SignPath
|
||||
|
||||
Eigener Step im MSI-Job, gegated über das Vorhandensein des Secrets. Fehlt es (bis zur Foundation-Freigabe), läuft die Pipeline unverändert durch und liefert ein unsigniertes MSI. Signiertes Ergebnis unter eigenem Namen hochladen; `versionInfo.js` setzt `signatureState` nur, wenn dieses Asset existiert.
|
||||
|
||||
**Vorbedingung für die Abhängigkeitsprüfung:** `module-info.java:14-15` deklariert `requires org.junit.jupiter.api;` und `requires org.mockito;`, weil Testklassen unter `src/main/java/` liegen (u. a. `controller/On4KstProtocolTest.java`, `controller/On4KstSocketThreadTest.java`, `controller/ReadUDPByWintestThreadTest.java`, `test/MockKstServer.java`, `view/map/mapTest.java`). `pom.xml` deklariert `junit-jupiter-api` und `mockito-core` deshalb mit `<scope>compile</scope>`, und JUnit, Mockito, ByteBuddy und Objenesis landen im ausgelieferten Runtime-Image — im Flatpak in `lib/app/` nachgewiesen. Ausgerechnet ByteBuddy/Mockito (Bytecode-Manipulation, Agent) in einem signierten Produktionsbinary ist für so eine Prüfung ungünstig. Als **eigener, abgegrenzter Commit vor dem Antrag** bereinigen: Klassen nach `src/test/java/`, `requires` entfernen, Scopes zurück auf `test`.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
Alle neuen Tests unter `src/test/java/kst4contest/service/update/` bzw. `.../model/`, Fixtures unter `src/test/resources/update/`. **Nichts Neues unter `src/main/java`** — die dort liegenden Testklassen sind Altlast, keine Vorlage.
|
||||
|
||||
| Testklasse | Assertions |
|
||||
|---|---|
|
||||
| `UpdateFeedParserTest` | Legacy-Feed 1.44 ohne `<updatePackages>` parst korrekt; erweiterter Feed, Reihenfolge irrelevant; `<changeLog>` mit 8 Kindern → **keine** `ArrayIndexOutOfBoundsException`; kaputtes XML → leeres Ergebnis statt Exception; XXE-Payload wird nicht expandiert; `sha256` in Großbuchstaben wird normalisiert |
|
||||
| `UpdateFeedClientTest` | 200 → Zieldatei ersetzt; 404/500 → Temp gelöscht und **vorhandene Zieldatei unverändert** (die konkrete Regression gegenüber `UpdateChecker:54-71`); Timeout → `success=false`, kein Throw; leerer Body verworfen |
|
||||
| `UpdateCheckServiceTest` | 1.44.0/1.44.0 → kein Update; /1.45.0 → Update; /1.4.4 → kein Update; /1.44.1 → Update; kaputte `semanticVersion` → Double-Fallback statt Exception; Netz down + Cache → `CACHE`; ohne Cache → `NONE`; Paketauswahl je Typ; `UNKNOWN` → kein Paket; injizierte `Clock` für den Prüfrhythmus |
|
||||
| `RuntimePackageTypeResolverTest` | Property gewinnt; unbekannter Wert → `UNKNOWN`; `/.flatpak-info` → `FLATPAK` **auch gegen** widersprechende Property; `APPIMAGE` → AppImage; alles `null` → kein NPE |
|
||||
| `UpdateDownloadPolicyTest` | `http://` abgelehnt; fremder Host abgelehnt; fehlende/zu kurze/nicht-hex `sha256` abgelehnt; `fileName` mit `../` abgelehnt; Größe ≤ 0 abgelehnt; `FLATPAK`/`UNKNOWN` abgelehnt |
|
||||
| `UpdateDownloadServiceTest` | Ohne Netz über `com.sun.net.httpserver.HttpServer` auf Port 0 (JDK-only, keine neue Dependency): Hash-Mismatch → Temp gelöscht, Ziel **nicht** angelegt; Match → Bytes identisch; Non-2xx → Temp gelöscht; bereits gültige Datei → kein HTTP-Call; Progress monoton bis `totalBytes`; Abbruch → Temp gelöscht |
|
||||
| `UpdateLauncher*Test` (Stufe 3) | Injizierter `ProcessStarter` fängt die Kommandozeile: MSI → `[msiexec, /i, <abs>, /qb]`; deb/rpm/arch → `[xdg-open, <abs>]`; dmg → `[open, <abs>]`; **kein Kommando enthält `sudo` oder `pkexec`** (explizite Assertion); `FLATPAK`/`UNKNOWN`/`WINDOWS_ZIP` → `ProcessStarter` nie aufgerufen |
|
||||
| `website/test/version-info.test.js` | Klassifikator für alle Namensmuster; SHA256SUMS-Parsing inkl. Doppelspace; Release ohne Assets → Feed bleibt valide; erzeugter Feed hat weiterhin exakt 7 `<changeLog>`-Kinder |
|
||||
|
||||
Versionsvergleich: bestehende `VersionUtils`-Abdeckung um nicht-numerische Eingaben ergänzen.
|
||||
|
||||
---
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Nach jeder Stufe eine gezielte Einordnung, kein Voll-Audit (`AGENTS.md`):
|
||||
|
||||
- `github_docs/de-Installation.md` und `github_docs/en-Installation.md` — Windows-Installer, Verhältnis zum ZIP, Update-Weg je Paketformat, Flatpak-Hinweis. Beide Sprachversionen semantisch gleichziehen.
|
||||
- Neuer Abschnitt zur Updatefunktion (Hinweis, Zustimmung, Contest-Regel, Verhalten je Installationsart) — DE und EN.
|
||||
- `docs/PROJECT_CONTEXT.md` — Abschnitt „Website / Deployment Relationship" (`:192-200`) um Feed-Format, Paketkennzeichnung, Host-Allowlist und die Entscheidung gegen ein Updater-Binary ergänzen.
|
||||
- `website/src/features/` prüfen, ob die Funktion dort dargestellt werden soll.
|
||||
- Screenshots des Hauptfensters veralten durch den Hinweisbereich — als veraltet markieren, nicht erfinden.
|
||||
|
||||
## Verifikation
|
||||
|
||||
1. `./mvnw -B test` — **Surefire-Berichte einzeln lesen**, nicht am Exit-Code messen (`AGENTS.md`: Testfehler brechen den Build nicht ab). Hinweis: auf Host-JDK 26 schlagen 8 Mockito-Tests umgebungsbedingt fehl; mit Temurin 21 prüfen.
|
||||
2. `cd website && npm test && npm run build && npm run validate:version-info` gegen einen erweiterten Beispiel-Feed.
|
||||
3. **Abwärtskompatibilität:** erweiterten Feed gegen den **alten** `UpdateChecker` laufen lassen und bestätigen, dass `<latestVersion>` und `<changeLog>` unverändert gelesen werden.
|
||||
4. **Startverhalten:** Anwendung mit nicht erreichbarer Feed-URL starten — die UI muss ohne Verzögerung und ohne Meldung hochkommen.
|
||||
5. **Contest-Regel:** mit ON4KST verbunden erscheint kein Hinweis; nach dem Trennen erscheint er.
|
||||
6. **Paketkennzeichnung:** je Artefakt einmal starten und die erkannte Installationsart prüfen. Der Flatpak lässt sich lokal testen — die Sandbox ist netzwerkseitig transparent (verifiziert).
|
||||
7. **Stufe 2:** Download mit absichtlich falscher Prüfsumme — es darf keine Zieldatei entstehen und nichts gestartet werden.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
1. **Zweite Feed-Quelle?** Wenn der serverseitige Build hinterherhinkt, sehen Clients einen veralteten Feed. Soll der Client hilfsweise `https://github.com/praktimarc/kst4contest/releases/latest/download/kst4ContestVersionInfo.xml` verwenden?
|
||||
2. **`test-publish-version-xml.yml`** bleibt vorerst unverändert, ist aber scharf (Feed ohne `<semanticVersion>`). Bei der späteren Umstellung auf GitHub Actions zusammenführen oder entfernen.
|
||||
3. **`--app-version` überall setzen** ändert die Paketversion bestehender DEB/RPM-Installationen einmalig von `1.0` auf die echte. Akzeptabel?
|
||||
4. **`UpdateChecker.java`** löschen oder als deprecated Fassade behalten? (`module-info.java` exportiert `kst4contest.controller`.)
|
||||
5. **MSI vs. ZIP** — soll MSI der empfohlene Windows-Weg werden? Beide Installationen können parallel existieren; ein MSI-Upgrade findet eine ZIP-Installation nicht.
|
||||
6. **Opt-out-Preference?** Soll `ChatPreferences` ein „Check for updates on start" bekommen? Der Abruf verrät IP und Version an den Feed-Host.
|
||||
7. **Prüfrhythmus** — einmal pro Start, oder höchstens alle 24 h über einen Zeitstempel in `~/.praktiKST/`?
|
||||
8. **Beta-Nutzer** — `1.46.0-beta` gegen Stable `1.45.0` ergibt „kein Update", weil `compareStableVersions` das Suffix abschneidet. Gewollt?
|
||||
9. **Signaturzustand** darf nur automatisch aus dem Build stammen, sonst driftet er.
|
||||
10. **Vertrauensmodell** — soll der Feed mittelfristig selbst signiert werden (Detached-Signatur + eingebauter Public Key)?
|
||||
11. **ZIP-Migration** — der Hinweistext für den Wechsel ZIP → Installer muss formuliert und in beiden Sprachversionen dokumentiert werden.
|
||||
+93
-4
@@ -1,6 +1,6 @@
|
||||
# KST4Contest Project Context
|
||||
|
||||
Last reviewed: 2026-08-27
|
||||
Last reviewed: 2026-09-03
|
||||
|
||||
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.
|
||||
|
||||
@@ -21,8 +21,8 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m
|
||||
|
||||
### Chat identity
|
||||
|
||||
- Full callsign variants can be distinct chat-member identities.
|
||||
- Category is part of chat identity.
|
||||
- For remote chat participants, the complete visible callsign plus category forms the chat-member identity.
|
||||
- Full callsign variants can therefore be distinct chat-member identities.
|
||||
- Base-call normalization is permitted only for explicitly base-call-wide functions.
|
||||
- Worked status is shared across suffix variants of the same base call.
|
||||
- Monitoring a variant such as `DN9APW-2` or `DN9APW-70` intentionally monitors the base call `DN9APW`.
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -49,6 +50,38 @@ 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.
|
||||
|
||||
## Configuration and Layout Persistence
|
||||
|
||||
- Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. 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.
|
||||
|
||||
## Operator Profiles and Per-Profile Persistence
|
||||
|
||||
- One operator profile owns one `preferences.xml` and one worked-station database. Everything else under `~/.praktiKST/` stays global: CSS, audio files, DEM and terrain packages, the error log and the version-info feed.
|
||||
- The **root profile** is the historic flat installation: `preferences.xml` and `praktiKST.db` directly below the application directory. It is never moved, and it always uses the common station database, because that database is the installation's own.
|
||||
- Additional profiles live under `profiles/<profileId>/`. `profileId` is a stable, file-system-safe slug assigned once; renaming a profile changes only its display name and never moves a directory.
|
||||
- The registry `profiles.xml` is created lazily. An installation that has only the flat layout gets no registry and no `profiles/` directory; the root profile is synthesised in memory. Startup with no or exactly one profile therefore asks nothing and writes nothing, and a downgrade to an older release is a no-op.
|
||||
- The registry stores `profileId`, `displayName`, `rootProfile` and `sharedWorkedDatabase`, never a path. Both file names are derived in `OperatorProfilePaths` alone, so a stored path cannot drift apart from the flag that produced it.
|
||||
- `sharedWorkedDatabase` resolves to a path, not to a schema change: a sharing profile points at the flat `praktiKST.db`, an owning profile at its own file. There is no owner column, and no SQL statement in `DBController` knows about profiles.
|
||||
- A profile database is created **empty**. The bundled `/praktiKST.db` resource carries several thousand foreign callsigns and `user_version = 0`; seeding an additional profile from it would show a new operator foreign data and trigger the full callsign-normalization rebuild. Only the root installation is seeded from the resource. `preferences.xml` of a new profile *is* seeded from `/praktiKSTpreferences.xml`, because its defaults are what a first installation gets.
|
||||
- Worked-state semantics are unchanged and now apply per database file: normalized base callsign as key, worked state shared across suffix variants, three-day expiry, manual reset.
|
||||
- `stn_loginCallSign` and `stn_loginCallSignRaw` default to empty. The preferences reader treats an empty element as "not set" and falls back to the field default, so a non-empty default would make a profile created without credentials come up carrying a compiled-in callsign.
|
||||
- Passwords remain plaintext per profile. Profiles separate configuration; they are explicitly not an access-control boundary. This is documented in both manuals.
|
||||
|
||||
### Runtime profile switching
|
||||
|
||||
- A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state.
|
||||
- `Platform.setImplicitExit(false)` is required, because closing every window during a switch would otherwise end the process. All exits therefore run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close handler; JavaFX calls `stop()` only on the instance it launched itself.
|
||||
- `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the map tile proxy, the station map bridge listeners and its coalescing animation, both view timers and every owned stage. Several of these were real leaks before; they only became visible once a second runtime could exist.
|
||||
- `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill.
|
||||
- The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed.
|
||||
|
||||
## External Interfaces
|
||||
|
||||
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
|
||||
@@ -65,10 +98,59 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
- The Simplelogfile interpreter reads the selected text file after connection startup and then once per minute using a fixed built-in callsign pattern.
|
||||
- Simplelogfile callsigns are normalized to base callsigns and set only the global Worked state for every active variant. They do not create per-band Worked or grid-square state.
|
||||
- Simplelogfile-derived Worked state is not persisted in SQLite. The selected file is the durable source and is read again in each application session.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
||||
- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
|
||||
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
|
||||
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
|
||||
|
||||
### ON4KST session and authentication
|
||||
|
||||
- One KST4Contest connection authenticates one ON4KST TCP session with one local login callsign and one password.
|
||||
- The TCP session uses one common locator for both categories; the locator is not part of authentication.
|
||||
- The primary category is part of the initial login. A distinct second category is added to the same session through ON4KST Single Sign-on; it must not create a second TCP connection or local login.
|
||||
- **Name in Chat** is a visible category-specific name field, not a login callsign or message destination. Private messages to the local station are addressed to the local login callsign.
|
||||
- The visible **Name in Chat** field, message context, QRG and beacon configuration remain category-specific.
|
||||
|
||||
### ON4KST session liveness
|
||||
|
||||
- After 90 seconds without inbound data, the application keeps the established empty CRLF heartbeat.
|
||||
- At about 180 seconds of inbound idle time, the TCP session sends one `RDXQ|<main chat id>|` probe. The probe state belongs to the session, so a two-category login still sends only one probe per idle phase.
|
||||
- At about 180 seconds of inbound idle time, the TCP session sends one `RDXQ|<main chat id>|` probe. The probe state belongs to the session, so a two-category session still sends only one probe per idle phase.
|
||||
- Any subsequent inbound server frame confirms the probe. `DXQ` is accepted as the expected internal response and is not published as chat content.
|
||||
- If no inbound frame arrives by about 210 seconds, the existing reconnect flow remains responsible for replacing the session.
|
||||
- Probe diagnostics contain the session id, main category, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
|
||||
@@ -78,6 +160,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
- 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.
|
||||
- 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.
|
||||
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
|
||||
|
||||
@@ -111,6 +195,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.
|
||||
@@ -136,6 +224,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.
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 🇬🇧 [English version](en-AirScout-Integration) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
AirScout (von DL2ALF) ist ein Programm zur Erkennung von Flugzeugen für den Aircraft-Scatter-Betrieb. KST4Contest ist eng mit AirScout integriert und zeigt reflektierbare Flugzeuge direkt in der Benutzerliste an.
|
||||
AirScout (von DL2ALF) berechnet Aircraft-Scatter-Gelegenheiten anhand aktueller Flugzeugpositionen. KST4Contest übernimmt diese Ergebnisse und zeigt für die jeweilige Gegenstation geeignete Flugzeuge direkt in der Benutzerliste an.
|
||||
|
||||
> **Aircraft Scatter** ermöglicht sehr weitreichende Verbindungen auf VHF und höher – auch für Stationen mit geringer Höhe über NN oder ungünstigen topografischen Verhältnissen.
|
||||
|
||||
@@ -15,9 +15,9 @@ Download von AirScout:
|
||||
|
||||
---
|
||||
|
||||
## Flugzeugdaten-Feeds (ADSB)
|
||||
## Flugzeugdaten-Feeds (ADS-B)
|
||||
|
||||
Öffentliche Flugzeugdaten-Feeds im Internet sind oft unzuverlässig und begrenzt nutzbar. Eine empfohlene Alternative bietet **OV3T (Thomas)** mit einem dedizierten ADSB-Feed-Dienst:
|
||||
Öffentliche Flugzeugdaten-Feeds im Internet sind oft unzuverlässig und nur eingeschränkt nutzbar. Eine empfohlene Alternative bietet **OV3T (Thomas)** mit einem dedizierten ADS-B-Feed-Dienst:
|
||||
|
||||
- https://airscatter.dk/
|
||||
- https://www.facebook.com/groups/825093981868542
|
||||
@@ -28,7 +28,7 @@ Für diesen Dienst ist ein Account erforderlich. Bitte eine Spende für Thomas i
|
||||
|
||||
## AirScout einrichten
|
||||
|
||||
### Schritt 1: ADSB-Feed in AirScout konfigurieren
|
||||
### Schritt 1: ADS-B-Feed in AirScout konfigurieren
|
||||
|
||||
1. AirScout starten.
|
||||
2. In den AirScout-Einstellungen den OV3T-Feed-Account eintragen (Benutzername, Passwort, URL).
|
||||
@@ -85,32 +85,40 @@ Andernfalls kann es zu fehlerhaften AP-Daten kommen.
|
||||
|
||||
## AP-Spalte in der Benutzerliste
|
||||
|
||||
Nach der Einrichtung erscheint in der Benutzerliste eine **AP-Spalte** mit bis zu zwei reflektierbaren Flugzeugen pro Station.
|
||||
Nach der Einrichtung erscheint in der Benutzerliste eine **AP-Spalte**. Sie zeigt für jede Station die Ankunftszeit und das von AirScout berechnete Reflexionspotenzial der ersten beiden geeigneten Flugzeuge.
|
||||
|
||||
Beispiel-Darstellung:
|
||||
|
||||
| Station | AP-Info |
|
||||
|---|---|
|
||||
| DF9QX | 2 Planes: 0 min / 0 min, je 100% |
|
||||
| F5DYD | 2 Planes: 14 min / 31 min, je 50% |
|
||||
| DF9QX | 0 (100 %) / 0 (100 %) |
|
||||
| F5DYD | 14 (50 %) / 31 (50 %) |
|
||||
|
||||
Die Zahl vor der Klammer gibt die verbleibenden Minuten bis zur berechneten Gelegenheit an. Die Prozentzahl beschreibt das von AirScout gemeldete Reflexionspotenzial. Sie ist keine QSO-Wahrscheinlichkeit.
|
||||
|
||||
Die AP-Informationen sind auch im **Privatnachrichten-Fenster** verfügbar.
|
||||
|
||||
Die Prozentzahl gibt das Reflexionspotenzial an (Größe des Flugzeugs, Höhe, Entfernung).
|
||||
## Einfluss auf Priority Score und Timeline
|
||||
|
||||
Mindestens ein von AirScout als erreichbar gemeldetes Flugzeug erhöht den Priority Score der Station. Eine unmittelbar bevorstehende Gelegenheit in null, einer oder zwei Minuten wird zusätzlich zeitabhängig gewichtet. AirScout ist dabei nur ein Faktor neben Worked-Status, verfügbaren Bändern, QRB, Antennenrichtung, Chat-Aktivität und Skeds.
|
||||
|
||||
Die AP- und Sked-Timeline verwendet den Priority Score zur Auswahl interessanter Stationen und ordnet die nächste geeignete Aircraft-Scatter-Gelegenheit zeitlich ein. Das Reflexionspotenzial bestimmt zusätzlich die Darstellung des AP-Symbols. Die Timeline bleibt eine Vorschau; sie garantiert kein QSO.
|
||||
|
||||
---
|
||||
|
||||
## AP-Variablen in Nachrichten
|
||||
|
||||
Die Flugzeugdaten können direkt in Nachrichten eingefügt werden:
|
||||
Die Flugzeugdaten der ausgewählten Station können direkt in Shortcuts, Snippets und andere stationsbezogene Nachrichten eingefügt werden:
|
||||
|
||||
- `FIRSTAP` → z. B. `a very big AP in 1 min`
|
||||
- `SECONDAP` → z. B. `Next big AP in 9 min`
|
||||
|
||||
Details: [Makros und Variablen](de-Makros-und-Variablen#variablen)
|
||||
|
||||
Da die Werte eine ausgewählte Gegenstation benötigen, stehen `FIRSTAP` und `SECONDAP` nicht als globale Beacon-Variablen zur Verfügung.
|
||||
|
||||
---
|
||||
|
||||
## „Show Path in AirScout"-Button
|
||||
|
||||
In der Benutzerliste gibt es einen Button mit einem Pfeil, der die Richtung (QTF) zur ausgewählten Station anzeigt. Ein Klick maximiert AirScout und zeigt den Pfad mit reflektierbaren Flugzeugen zum ausgewählten Gesprächspartner.
|
||||
In der Benutzerliste gibt es einen Button mit einem Pfeil, der die Richtung (QTF) zur ausgewählten Station anzeigt. Ein Klick maximiert das externe AirScout-Fenster und lässt dort den Pfad zur ausgewählten Gegenstation mit den berechneten Aircraft-Scatter-Gelegenheiten anzeigen. Die Schaltfläche startet keine eigene Gelände- oder Ausbreitungsberechnung in KST4Contest.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Verbinden mit dem Chat
|
||||
|
||||
Vor dem ersten Verbindungsaufbau müssen im Einstellungsfenster mindestens Rufzeichen, Passwort, Locator und primäre Chat-Kategorie konfiguriert werden. Soll zusätzlich eine zweite Kategorie verwendet werden, muss auch deren Login aktiviert und vollständig eingerichtet sein.
|
||||
Vor dem ersten Verbindungsaufbau müssen im Einstellungsfenster das lokale Login-Rufzeichen und Passwort, der gemeinsame Locator der TCP-Sitzung sowie die primäre Chat-Kategorie konfiguriert werden. Soll zusätzlich eine zweite Kategorie verwendet werden, wird sie dort aktiviert und ausgewählt. KST4Contest meldet sich einmal an und ergänzt die zweite Kategorie per Single Sign-on innerhalb derselben TCP-Sitzung.
|
||||
|
||||
Die Verbindung kann auf zwei Wegen aufgebaut werden:
|
||||
|
||||
@@ -54,11 +54,20 @@ Beide Hinweise blinken ungefähr zwölf Sekunden und verschwinden anschließend
|
||||
|
||||
### PM-Fenster (oben links)
|
||||
|
||||
Das PM-Fenster zeigt die an die eigenen Chat-Logins gerichteten Privatnachrichten und die zugehörigen ausgehenden Antworten.
|
||||
Das PM-Fenster zeigt die an das lokale Login-Rufzeichen adressierten Privatnachrichten und die zugehörigen ausgehenden Antworten.
|
||||
|
||||
Nicht selbst gesendete Nachrichten erscheinen dort zusätzlich, wenn ihr Text ohne Beachtung der Groß- und Kleinschreibung das konfigurierte eigene Login-Rufzeichen enthält. Das gilt für öffentliche Nachrichten an `ALL` ebenso wie für gerichtete Nachrichten zwischen anderen Chatteilnehmern. Gerade im zweiten Fall lässt sich dieses PM-Catching flapsig als **„Lästererkennung“** bezeichnen.
|
||||
|
||||
Der tatsächliche Empfänger, der Nachrichtentext, die Chat-Kategorie und das Routing bleiben unverändert. Die Nachricht erhält lediglich eine zusätzliche Darstellung im PM-Fenster.
|
||||
|
||||
Ist das [QSO-Monitoring](de-Funktionen#qso-monitoring-ab-v131) aktiviert, erscheinen dort zusätzlich die erfassten Nachrichten der überwachten Basisrufzeichen. Diese Einträge erhalten eine `Sniffed:`-Kennzeichnung mit dem vollständigen sichtbaren Absender und Empfänger.
|
||||
|
||||
Neue Nachrichten werden zunächst auffällig dargestellt und wechseln anschließend schrittweise zur normalen Tabellenfarbe. Die farbliche Hervorhebung dient nur als zeitlicher Hinweis; sie verändert weder Inhalt noch Routing der Nachricht.
|
||||
Neue, nicht selbst gesendete Zeilen durchlaufen sechs grüne Altersstufen und kehren nach fünf Minuten zur normalen Tabellenfarbe zurück. Eigene Nachrichten behalten ihre separate Hervorhebung. Die farbliche Darstellung ist nur ein zeitlicher Hinweis; sie verändert weder Inhalt noch Routing der Nachricht.
|
||||
|
||||
Die Auswahl einer eingehenden Zeile bereitet eine Antwort an den Absender vor. Bei einer eigenen ausgehenden Nachricht wird stattdessen der ursprüngliche Empfänger als Nachrichtenziel wiederhergestellt. Caught- und Monitoring-Zeilen lösen keine PM-Audioausgabe aus.
|
||||
|
||||
Altersstufen: [Farbige PM-Zeilen](de-Funktionen#farbige-pm-zeilen-ab-v125). Erkennung und Grenzen: [PM-Abfang](de-Funktionen#pm-abfang-catching-personal-messages-ab-v11).
|
||||
|
||||
### Benutzerliste (Chat Members)
|
||||
|
||||
Die zentrale Tabelle aller aktuell aktiven Chat-Nutzer. Spalten (je nach Konfiguration):
|
||||
@@ -143,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.
|
||||
|
||||
@@ -318,7 +335,11 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
|
||||
3. aktualisiert den **Further Info**-Bereich und
|
||||
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:
|
||||
|
||||
@@ -423,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:
|
||||
|
||||
@@ -469,13 +490,13 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+105
-1
@@ -8,6 +8,107 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
|
||||
|
||||
---
|
||||
|
||||
## v1.50 (in Entwicklung)
|
||||
|
||||
**Operator-Profile**
|
||||
|
||||
Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/praktimarc/kst4contest/issues/57) umgesetzt.
|
||||
|
||||
### Neu
|
||||
|
||||
- **Operator-Profile:** Jedes Profil hat seine eigene `preferences.xml` und damit eigene fachliche Einstellungen und einen eigenen Layoutstand. Verwaltet werden Profile im neuen Reiter **Profiles** des Einstellungsfensters: anlegen, duplizieren, umbenennen, löschen und aktivieren.
|
||||
|
||||
- **Gemeinsame oder eigene gearbeitete Stationen:** Pro Profil wird entschieden, ob es eine eigene Worked-Datenbank bekommt oder die gemeinsame Stationsdatenbank benutzt. Eine Multi-OP-Station mit einem einzigen Stationslog teilt den Worked-Status, zwei OMs mit verschiedenen Rufzeichen an einem Rechner trennen ihn.
|
||||
|
||||
- **Profilwahl beim Start:** Mit nur einem Profil fragt KST4Contest beim Start nichts und verhält sich unverändert. Ab zwei Profilen erscheint eine kleine Auswahl mit vorausgewähltem letzten Profil; Enter oder Doppelklick starten sofort. Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl.
|
||||
|
||||
- **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
|
||||
|
||||
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter.
|
||||
|
||||
### Hinweise zur Aktualisierung
|
||||
|
||||
- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert.
|
||||
- Eine Profil-Registry entsteht erst beim Anlegen des zweiten Profils. Wer nur ein Profil benutzt, merkt von der Änderung nichts.
|
||||
- Eine Rückkehr zu einer älteren KST4Contest-Version bleibt möglich; sie findet ihre Dateien unverändert vor.
|
||||
- Passwörter stehen weiterhin im Klartext in der `preferences.xml` des jeweiligen Profils. Profile trennen die Konfiguration, sie sind kein Zugriffsschutz.
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
@@ -34,6 +135,8 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
|
||||
|
||||
- **Manuelle QTF-Eingabe:** Die aktuelle Antennenrichtung kann auch ohne PSTRotator direkt in KST4Contest geändert werden.
|
||||
|
||||
- **Nachrichtenvariable `MYQTF`:** Die aktuelle Antennenrichtung kann als numerischer Winkel in Grad in Shortcuts, Snippets und andere unterstützte Nachrichtentexte eingesetzt werden.
|
||||
|
||||
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
|
||||
|
||||
- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
|
||||
@@ -80,6 +183,8 @@ 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.
|
||||
|
||||
- **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.
|
||||
|
||||
### Behoben
|
||||
@@ -411,7 +516,6 @@ Erste öffentlich veröffentlichte Version. Grundfunktionen:
|
||||
|
||||
## Geplante Features
|
||||
|
||||
- `MYQTF`-Variable (eigene Antennenrichtung als Text)
|
||||
- ~~Lebensdauer für den Worked-Status (automatisches Zurücksetzen)~~ ✅ **Umgesetzt in v1.40** (3-Tage-Lebensdauer, kein manuelles Zurücksetzen mehr nötig)
|
||||
- Filterung des „Cluster & QSO der anderen"-Fensters auf eigenes QTF
|
||||
- Weitere Topografie-basierte Berechnungen für die Richtungswarnung
|
||||
|
||||
@@ -67,6 +67,8 @@ Aktiviere nur die Schnittstellen, die tatsächlich verwendet und vorher getestet
|
||||
|
||||
Ein Contest ist ein ungünstiger Zeitpunkt, um gleichzeitig die Funkbedingungen und eine erstmals aktivierte Netzwerkschnittstelle zu untersuchen.
|
||||
|
||||
Beim Simplelogfile sollte vor jedem Contest geprüft werden, ob das Logprogramm die aktuelle Contestdatei an den in KST4Contest ausgewählten Pfad schreibt. Ein Test-QSO muss innerhalb einer Minute als global gearbeitet erscheinen. KST4Contest setzt die aus dieser Datei abgeleiteten Worked-Markierungen beim Contestwechsel nicht automatisch zurück.
|
||||
|
||||
---
|
||||
|
||||
## Grundablauf während des Contests
|
||||
@@ -89,7 +91,7 @@ KST4Contest hält die benötigten Informationen zwischen diesen Schritten zusamm
|
||||
|
||||
## CQ-Betrieb
|
||||
|
||||
Bei einer weitgehend festen CQ-QRG sollten `MYQRG` beziehungsweise `SECONDQRG` den tatsächlich verwendeten Frequenzen entsprechen. Eine aktivierte TRX-Synchronisation kann `MYQRG` automatisch aktualisieren. Ohne automatische Quelle muss der Wert von Hand gepflegt werden.
|
||||
Bei einer weitgehend festen CQ-QRG sollten `MYQRG` beziehungsweise `SECONDQRG` den tatsächlich verwendeten Frequenzen entsprechen. Eine aktivierte und tatsächlich liefernde TRX-Synchronisation kann `MYQRG` automatisch aktualisieren. Die Aktivierung allein reicht nicht: Vor dem Contest sollte mit einer realen Frequenzänderung geprüft werden, ob gültige Pakete ankommen. Ohne funktionierende automatische Quelle muss der Wert von Hand gepflegt werden.
|
||||
|
||||
Der Beacon kann die aktuelle QRG, den Locator und die Antennenrichtung regelmäßig im Chat veröffentlichen. Seine Variablen werden bei jedem Sendedurchlauf erneut ausgewertet.
|
||||
|
||||
@@ -246,6 +248,19 @@ G1YBB verwendet die Richtungsanzeige besonders konsequent. Grün markierte Stati
|
||||
|
||||
KST4Contest automatisiert dabei nicht das QSO. Der Vorteil besteht darin, dass QRG, Richtung, Aircraft-Scatter-Informationen und weitere Bewertungsdaten bereits vorliegen, wenn die Gelegenheit entsteht. Die verbleibende Aufgabe ist eine schnelle betriebliche Entscheidung.
|
||||
|
||||
Eine weitere, besonders konsequente Betriebsweise nutzt die Stationskarte als geografische Arbeitsliste:
|
||||
|
||||
1. Der **wkd**-Filter blendet bereits gearbeitete Rufzeichen aus der Benutzerliste und damit auch aus der Karte aus.
|
||||
2. G1YBB wählt eine interessante Station auf der Karte aus.
|
||||
3. **Trigger cluster spot** übergibt die bekannte QRG an Minos.
|
||||
4. Ein Klick auf den Spot wechselt in Minos auf diese QRG.
|
||||
5. Nach dem QSO wird der Kontakt geloggt.
|
||||
6. Die Log-Synchronisation aktualisiert den Worked-Status; der Filter entfernt die Station anschließend aus Benutzerliste und Karte.
|
||||
|
||||
Die Karte wird so zu einer räumlichen Liste der noch abzuarbeitenden Stationen. Dieser Ablauf ist optional. Er setzt eine zuverlässig eingerichtete Log-Synchronisation und DX-Cluster-Verbindung voraus und ist vor allem für Operatoren interessant, die den Contest bewusst auf diese geografische Weise strukturieren möchten.
|
||||
|
||||
[G1YBB zeigt diesen Workflow im Video.](https://www.youtube.com/watch?v=lMQZMiSHlUI)
|
||||
|
||||
---
|
||||
|
||||
## Optionale Schnittstellen im Workflow
|
||||
|
||||
@@ -163,7 +163,7 @@ Die Schaltfläche **Send test spot** erzeugt folgenden Testeintrag:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 des konfigurierten Fallback-Bandes
|
||||
```
|
||||
|
||||
@@ -191,8 +191,18 @@ Ein Spot enthält:
|
||||
- den Locator,
|
||||
- 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.
|
||||
|
||||
Ein kompakter Kommentar mit AirScout-Information sieht beispielsweise so aus:
|
||||
|
||||
```text
|
||||
JO51HK AP 1m/100%;4m/75%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
@@ -242,5 +270,6 @@ Die Schnittstelle wurde mit folgenden Logprogrammen verwendet:
|
||||
|
||||
- UCXLog
|
||||
- N1MM+
|
||||
- Minos
|
||||
|
||||
Weitere Logger können funktionieren, wenn sie eine normale TCP-Verbindung zu einem DX-Cluster-Server unterstützen.
|
||||
|
||||
@@ -23,6 +23,10 @@ Angenommen, Station A schreibt eine gerichtete Nachricht an Station B:
|
||||
|
||||
Ein eingetragener Öffnungswinkel von `70°` ergibt damit einen angenommenen Korridor von jeweils `35°` links und rechts der Richtung von Station A zu Station B.
|
||||
|
||||

|
||||
|
||||
Im dargestellten Beispiel verläuft der grüne Korridor von F5FEN in Richtung DM5M. Sendet F5FEN eine gerichtete Nachricht an DM5M, prüft KST4Contest, ob die eigene Station innerhalb dieses Korridors liegt. Bei einer Antwort von DM5M an F5FEN wird die Richtung neu berechnet: Dann beginnt der blaue Korridor bei DM5M und zeigt in Gegenrichtung auf F5FEN. Bewertet wird also bei jeder Nachricht die angenommene Antennenrichtung des jeweiligen Absenders.
|
||||
|
||||
| Beispiel | Ergebnis |
|
||||
|---|---|
|
||||
| Richtung A → B: `120°`, Richtung A → eigene Station: `145°` | Winkeldifferenz `25°`: Richtungsgelegenheit erkannt |
|
||||
@@ -80,13 +84,15 @@ KST4Contest wertet deshalb den Text jeder öffentlichen und gerichteten Chat-Nac
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
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?
|
||||
|
||||
KST4Contest verwendet folgende Reihenfolge:
|
||||
@@ -132,7 +138,7 @@ Diese Trennung ist notwendig. Ein bereits gearbeitetes Rufzeichen kann auf einem
|
||||
|
||||
Die [Log-Synchronisation](de-Log-Synchronisation) übernimmt neue QSOs aus dem Logprogramm. Welche Informationen dabei zur Verfügung stehen, hängt von der verwendeten Schnittstelle ab:
|
||||
|
||||
- Der dateibasierte Simplelogfile-Interpreter erkennt nur das Rufzeichen. Er kann deshalb lediglich den globalen Worked-Status setzen.
|
||||
- Der dateibasierte Simplelogfile-Interpreter liest die ausgewählte Textdatei einmal pro Minute mit einem festen Rufzeichenmuster. Treffer werden auf das Basisrufzeichen normalisiert und setzen für alle aktiven Varianten lediglich den globalen Worked-Status.
|
||||
- Die QSO-UDP-Schnittstellen und der Win-Test-Netzwerk-Listener können zusätzlich das Band übernehmen.
|
||||
- Enthält das Logpaket einen gültigen Locator, speichert KST4Contest außerdem das gearbeitete vierstellige Großfeld für dieses Band.
|
||||
|
||||
@@ -201,7 +207,9 @@ Im Klartext: Ein erkannter Hinweis bedeutet „wahrscheinlich auf diesem Band ak
|
||||
|
||||
### Speicherung und Lebensdauer
|
||||
|
||||
Worked-, NOT-QRV- und Großfeldinformationen werden in der internen SQLite-Datenbank gespeichert und beim nächsten Start wieder geladen. Die Einträge laufen nach drei Tagen automatisch ab. Ein Reset vor jedem Contest ist deshalb normalerweise nicht erforderlich.
|
||||
Worked-, NOT-QRV- und Großfeldinformationen aus den Netzwerkschnittstellen sowie manuelle Markierungen werden in der internen SQLite-Datenbank gespeichert und beim nächsten Start wieder geladen. Die Einträge laufen nach drei Tagen automatisch ab. Ein Reset vor jedem Contest ist deshalb normalerweise nicht erforderlich.
|
||||
|
||||
Simplelogfile-Treffer sind davon ausgenommen. Sie werden nicht in SQLite persistiert, sondern bei jedem Programmlauf aus der ausgewählten Datei abgeleitet. Die Datei ist damit die dauerhafte Quelle. Der Interpreter entfernt während der laufenden Sitzung keine bereits gesetzte Worked-Markierung und setzt den Zustand beim Wechsel zu einem neuen Contest nicht automatisch zurück. Ein manueller Datenbank-Reset verändert oder leert die Datei ebenfalls nicht. Darin enthaltene Rufzeichen werden bei der nächsten Auswertung innerhalb einer Minute erneut als gearbeitet markiert.
|
||||
|
||||
Ein manueller Reset unter **Workedstn database** entfernt sämtliche Worked-Markierungen, NOT-QRV-Tags und gespeicherten Worked-Großfelder. Die bekannten Rufzeichenzeilen bleiben dabei in der Datenbank erhalten. Einzelheiten: [Worked Station Database Settings](de-Konfiguration#worked-station-database-settings-gearbeitete-stationen-datenbank).
|
||||
|
||||
@@ -233,7 +241,7 @@ Die Filter oberhalb der Benutzerliste greifen auf dieselben Informationen zurüc
|
||||
|
||||
Mehrere aktivierte Filter werden gemeinsam angewendet. Eine Station bleibt nur sichtbar, wenn sie alle gewählten Bedingungen erfüllt. Die Filter reagieren unmittelbar auf neue Logeinträge und geänderte NOT-QRV-Markierungen.
|
||||
|
||||
Bedienung und Aufbau der Filterleiste: [Benutzeroberfläche – Filter](de-Benutzeroberflaeche#filter).
|
||||
Bedienung und Aufbau der Filterleiste: [Benutzeroberfläche – Filter](de-Benutzeroberflaeche#filter-und-reachability-steuerung).
|
||||
|
||||
---
|
||||
|
||||
@@ -259,7 +267,7 @@ Eigene Nachrichten erhalten weiterhin eine separate Hervorhebung und verwenden n
|
||||
|
||||
---
|
||||
|
||||
## PM-Abfang (Catching Personal Messages)
|
||||
## PM-Abfang (Catching Personal Messages, ab v1.1)
|
||||
|
||||
Manche Nutzer senden Direktnachrichten versehentlich öffentlich, z. B.:
|
||||
|
||||
@@ -267,7 +275,17 @@ Manche Nutzer senden Direktnachrichten versehentlich öffentlich, z. B.:
|
||||
(DM5M) pse ur qrg
|
||||
```
|
||||
|
||||
KST4Contest erkennt solche Nachrichten, die das eigene Rufzeichen enthalten, und sortiert sie automatisch in die **Privatnachrichten-Tabelle** ein. So gehen keine Nachrichten verloren.
|
||||
KST4Contest sucht im Text aller nicht selbst gesendeten Nachrichten ohne Beachtung der Groß- und Kleinschreibung nach dem konfigurierten eigenen Login-Rufzeichen. Ein Treffer erscheint zusätzlich in der **Privatnachrichten-Tabelle**. Das gilt sowohl für öffentliche Nachrichten an `ALL` als auch für gerichtete Nachrichten zwischen anderen Chatteilnehmern.
|
||||
|
||||
Gerade bei einer fremden gerichteten Nachricht wirkt das etwas wie **„Lästererkennung“**. Das ist eine flapsige Nebenbezeichnung, kein formaler Funktionsname.
|
||||
|
||||
Die ursprüngliche Nachricht bleibt unverändert: Der tatsächliche Empfänger bleibt `ALL` beziehungsweise der andere Chatteilnehmer; auch Text, Chat-Kategorie und Routing bleiben erhalten. PM-Catching ergänzt nur eine weitere Ansicht derselben Nachricht.
|
||||
|
||||
Die Textsuche versteht keine Absicht. Ein Schreibfehler oder ein verkürztes Rufzeichen wird nicht erkannt. Umgekehrt kann eine bloße Erwähnung des vollständig geschriebenen Rufzeichens einen Treffer erzeugen, obwohl keine Antwort erwartet wird.
|
||||
|
||||
Wird eine eingehende Zeile in der PM-Tabelle ausgewählt, bereitet KST4Contest eine Antwort an ihren Absender vor. Bei einer eigenen ausgehenden Nachricht wird stattdessen der ursprüngliche Empfänger als Ziel wiederhergestellt. Die Auswahl versendet noch keine Nachricht.
|
||||
|
||||
Auch abgefangene Zeilen verwenden die normale Altersanzeige der PM-Tabelle. Sie lösen jedoch weder den einfachen PM-Hinweiston noch die CW- oder Sprachausgabe für ein eingehendes Rufzeichen aus. Dasselbe gilt für Nachrichten, die durch das QSO-Monitoring zusätzlich in der PM-Tabelle erscheinen.
|
||||
|
||||
---
|
||||
|
||||
@@ -294,7 +312,7 @@ Konfiguration, erkannte QRG-Anfragen und genaue Kategorienzuordnung: [Konfigurat
|
||||
|
||||
## Multi-Channel-Login (ab v1.26)
|
||||
|
||||
Gleichzeitiger Login in **zwei Chat-Kategorien** (z. B. 144 MHz und 432 MHz). Beide Chats werden parallel überwacht.
|
||||
KST4Contest meldet sich mit einem lokalen Login-Rufzeichen und Passwort einmal bei ON4KST an. Ein gemeinsamer Locator gilt für die gesamte TCP-Sitzung. Eine zweite Chat-Kategorie (z. B. 144 MHz und 432 MHz) wird per Single Sign-on innerhalb derselben Sitzung ergänzt. Beide Kategorien werden parallel überwacht; das sichtbare kategoriebasierte Namensfeld, der Nachrichtenkontext, die QRG und der Beacon bleiben je Kategorie getrennt.
|
||||
|
||||
---
|
||||
|
||||
@@ -308,7 +326,7 @@ Die grüne Altersskala der Privatnachrichten bleibt in beiden Darstellungen erha
|
||||
|
||||
## Opposite Station Multi-Callsign Login-Tagging (ab v1.26)
|
||||
|
||||
Unterstützung für Stationen, die mit mehreren Rufzeichen gleichzeitig im Chat aktiv sind (z. B. Expedition-Setups).
|
||||
Unterstützung für Gegenstationen, die mit mehreren vollständigen sichtbaren Rufzeichenvarianten gleichzeitig im Chat aktiv sind (z. B. Expedition-Setups). Vollständiges Rufzeichen und Chat-Kategorie bleiben getrennte Teilnehmeridentitäten; Worked-, Band- und Prioritätsdaten werden über das Basisrufzeichen gemeinsam ausgewertet.
|
||||
|
||||
---
|
||||
|
||||
@@ -424,7 +442,7 @@ Sniffed: (DN9APW-70 > 9A0BB-23) pse sked 19:30
|
||||
|
||||
Das ist ein wichtiger Unterschied. Das Monitoring fasst die Varianten zusammen, damit keine Nachricht übersehen wird. Das Nachrichtenrouting fasst sie nicht zusammen, weil andernfalls nicht mehr eindeutig wäre, welcher Login tatsächlich angesprochen wurde.
|
||||
|
||||
Das Basisrufzeichen wird außerdem unabhängig von der Chat-Kategorie ausgewertet. Ist KST4Contest gleichzeitig mit zwei Kategorien verbunden, gilt derselbe Monitoring-Eintrag für beide Nachrichtenströme. Die Kategorie der einzelnen Nachricht bleibt dabei erhalten.
|
||||
Das Basisrufzeichen wird außerdem unabhängig von der Chat-Kategorie ausgewertet. Sind in derselben ON4KST-Sitzung zwei Kategorien aktiv, gilt derselbe Monitoring-Eintrag für beide Nachrichtenströme. Die Kategorie der einzelnen Nachricht bleibt dabei erhalten.
|
||||
|
||||
Erfasst werden:
|
||||
|
||||
@@ -507,7 +525,7 @@ UDP selbst bestätigt außerdem keine Paketzustellung. Bleibt die QTF unverände
|
||||
|
||||
Im Klartext: KST4Contest liefert die Zielrichtung und verarbeitet die gemeldete Position. Die mechanische Realität bleibt Aufgabe des Rotators – und gelegentlich der Blick aus dem Fenster.
|
||||
|
||||
Konfiguration und Portbelegung: [Konfiguration – PSTRotator-Einstellungen](de-Konfiguration#pstrotator-einstellungen-ab-v131-vollstaendig-konfigurierbar-ab-v140).
|
||||
Konfiguration und Portbelegung: [Konfiguration – PSTRotator-Einstellungen](de-Konfiguration#pstrotator-einstellungen-ab-v131-vollständig-konfigurierbar-ab-v140).
|
||||
|
||||
---
|
||||
|
||||
@@ -590,7 +608,7 @@ Die Bewertung wird für das normalisierte Basisrufzeichen vorgenommen. Ein Sked
|
||||
|
||||
### Wie werden mehrere SSIDs und Chat-Kategorien behandelt?
|
||||
|
||||
Aktive Rufzeichen wie `9A0BB-2`, `9A0BB-70`, `9A0BB-23` und `9A0BB-13` bleiben getrennte Chatmember. Dadurch können Nachrichten weiterhin an das vollständige Rufzeichen und die richtige Chat-Kategorie adressiert werden.
|
||||
Aktive vollständige sichtbare Rufzeichenvarianten wie `9A0BB-2`, `9A0BB-70`, `9A0BB-23` und `9A0BB-13` bleiben getrennte Chatmember. Dadurch können Nachrichten weiterhin an das vollständige Rufzeichen und die richtige Chat-Kategorie adressiert werden.
|
||||
|
||||
Worked-, Band-, NOT-QRV- und Score-Informationen beziehen sich dagegen auf das gemeinsame Basisrufzeichen `9A0BB`. Der Score wird deshalb einmal berechnet und auf alle aktiven Varianten übertragen. Die Benutzerliste kann mehrere getrennte Zeilen mit demselben Score enthalten; in der Prioritätsliste erscheint das Basisrufzeichen nur einmal.
|
||||
|
||||
@@ -689,7 +707,7 @@ Die Timeline ist eine Vorschau. AirScout-Daten können sich ändern, und ein ein
|
||||
|
||||
KST4Contest kann wiederkehrende CQ-Nachrichten in den öffentlichen Chat senden. Der Beacon ist für längeres CQ-Rufen auf einer festen Frequenz gedacht: Die eigene QRG wird regelmäßig veröffentlicht, ohne dass derselbe Text von Hand wiederholt werden muss.
|
||||
|
||||
Beide Chat-Kategorien verwenden ein gemeinsames Intervall, besitzen aber jeweils einen eigenen Aktivierungsschalter und Nachrichtentext. Der zweite Beacon wird nur gesendet, wenn auch der zweite Chat aktiviert und verbunden ist.
|
||||
Beide Chat-Kategorien verwenden ein gemeinsames Intervall, besitzen aber jeweils einen eigenen Aktivierungsschalter und Nachrichtentext. Der zweite Beacon wird nur gesendet, wenn die zweite Kategorie in derselben ON4KST-Sitzung aktiviert und vollständig synchronisiert ist.
|
||||
|
||||
Globale Variablen wie `MYQRG`, `SECONDQRG`, `MYLOCATOR` oder `MYQTF` werden unmittelbar vor jeder Aussendung neu aufgelöst. Eine zwischenzeitlich vom Logprogramm aktualisierte QRG kann dadurch bereits im nächsten Beacon erscheinen.
|
||||
|
||||
@@ -704,7 +722,7 @@ Konfiguration, Timer-Verhalten und verfügbare Variablen: [Konfiguration – Bea
|
||||
|
||||
## Simplelogfile
|
||||
|
||||
Details: [Log-Synchronisation](de-Log-Synchronisation#methode-1-universal-file-based-callsign-interpreter-simplelogfile).
|
||||
Der Simplelogfile-Interpreter liest einmal pro Minute Rufzeichen aus einer ausgewählten Textdatei. Fehlt die Datei, legt KST4Contest sie an und zeigt einen nicht blockierenden Hinweis mit Pfad, Funktionstest und Contestprüfung. Details: [Log-Synchronisation](de-Log-Synchronisation#methode-1-universal-file-based-callsign-interpreter-simplelogfile).
|
||||
|
||||
---
|
||||
|
||||
@@ -813,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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
> **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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,14 +14,15 @@ Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist de
|
||||
|
||||
### Login und Chat-Kategorien
|
||||
|
||||
Hier werden die Zugangsdaten für den ON4KST-Chat eingetragen (Rufzeichen und Passwort).
|
||||
Zudem wird die **primäre Chat-Kategorie** (z. B. IARU Region 1 VHF/Microwave) ausgewählt.
|
||||
Hier werden das lokale Login-Rufzeichen und das Passwort für den ON4KST-Chat eingetragen. Zudem wird die **primäre Chat-Kategorie** (z. B. IARU Region 1 VHF/Microwave) ausgewählt.
|
||||
|
||||
Mit der Option für einen **zweiten Chat** (Multi-Channel-Login) kann man sich gleichzeitig in eine weitere Kategorie (z. B. UHF/SHF) einloggen. Beide Chats werden dann parallel überwacht. Hier kann optional auch ein abweichender Login-Name für den zweiten Chat vergeben werden (nützlich für Opposite Station Multi-Callsign Logging).
|
||||
Mit der Option für einen **zweiten Chat** (Multi-Channel-Login) wird eine weitere Kategorie (z. B. UHF/SHF) per Single Sign-on in derselben ON4KST-TCP-Sitzung aktiviert. KST4Contest öffnet dafür keine zweite TCP-Verbindung und verwendet weder ein zweites lokales Login-Rufzeichen noch ein zweites Passwort.
|
||||
|
||||
**Name in Chat 2** konfiguriert ausschließlich das sichtbare, kategoriebasierte Namensfeld der zweiten Kategorie. Es ist weder ein zweites Login noch ein Nachrichtenempfänger. Das sichtbare Namensfeld, der Nachrichtenkontext, die QRG und der Beacon bleiben für beide Kategorien getrennt. **Opposite Station Multi-Callsign Login-Tagging** bezieht sich dagegen ausschließlich auf Gegenstationen, die mit mehreren vollständigen sichtbaren Rufzeichenvarianten im Chat erscheinen.
|
||||
|
||||
### Rufzeichen und Locator
|
||||
|
||||
Eigenes Rufzeichen und Maidenhead-Locator (6-stellig, z. B. `JN49IJ`) eintragen. Diese Werte werden für Distanz- und Richtungsberechnungen benötigt.
|
||||
Eigenes Rufzeichen und Maidenhead-Locator (6-stellig, z. B. `JN49IJ`) eintragen. Der Locator ist kein Bestandteil der Authentifizierung. ON4KST verwendet ihn gemeinsam für beide Kategorien der einen TCP-Sitzung; KST4Contest benötigt ihn außerdem für Distanz- und Richtungsberechnungen.
|
||||
|
||||
### Aktivierte Bänder
|
||||
|
||||
@@ -108,7 +109,9 @@ Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu mar
|
||||
|
||||
### Universal File Based Callsign Interpreter (Simplelogfile)
|
||||
|
||||
Interpretiert beliebige Log-Dateien per Regex nach Rufzeichen-Mustern. Keine Bandinformation möglich. Geeignet als Fallback oder für nicht direkt unterstützte Logprogramme.
|
||||
Liest die ausgewählte Textdatei einmal pro Minute mit einem fest eingebauten Rufzeichenmuster. Die Treffer werden auf Basisrufzeichen normalisiert und setzen für alle aktiven Varianten nur den globalen Worked-Status. Band- und Locatorinformationen können daraus nicht abgeleitet werden. Die Funktion eignet sich als Fallback für Logprogramme ohne unterstützte Netzwerkschnittstelle. Vor der Verwendung sollte deshalb geprüft werden, ob das eingesetzte Logprogramm bereits eine der unterstützten Netzwerkschnittstellen bereitstellt.
|
||||
|
||||
Existiert die ausgewählte Datei noch nicht, legt KST4Contest sie an und zeigt einmalig den vollständigen Pfad sowie Hinweise zum Funktions- und Contesttest. Die Datei ist selbst die dauerhafte Quelle; aus ihr abgeleitete Worked-Markierungen werden nicht in SQLite gespeichert und nicht automatisch für einen neuen Contest zurückgesetzt.
|
||||
|
||||
### Netzwerk-Listener für QSO-UDP-Broadcast
|
||||
|
||||
@@ -134,7 +137,7 @@ Die TRX-Synchronisation übernimmt die aktuelle Frequenz aus dem Logprogramm und
|
||||
| **Win-Test STATUS** | `Win-Test STATUS QRG Sync` | Verarbeitet die Haupt- oder Pass-Frequenz aus nativen Win-Test-`STATUS`-Paketen. Der Win-Test-Listener verwendet seinen separat konfigurierten Port, standardmäßig `9871`. |
|
||||
| **Manuelle Eingabe** | Beide automatischen QRG-Quellen deaktivieren | Die eigene QRG kann im Hauptfenster von Hand eingetragen werden. |
|
||||
|
||||
Der allgemeine Listener ist für Logprogramme vorgesehen, die kompatible `RadioInfo`-Pakete senden. Dazu gehören – abhängig von deren jeweiliger Konfiguration – UCXLog, N1MM+, QARTest und DXLog.net. QSO- und `RadioInfo`-Pakete verwenden denselben unter **Log sync** konfigurierten Port. Dort wird jedoch getrennt festgelegt, ob KST4Contest QSO-Informationen, TRX-Informationen oder beide Paketarten verarbeitet.
|
||||
Der allgemeine Listener ist für Logprogramme vorgesehen, die kompatible `RadioInfo`-Pakete senden. Dazu gehören – abhängig von deren jeweiliger Konfiguration – UCXLog, N1MM+, QARTest und DXLog.net. QSO- und `RadioInfo`-Pakete verwenden denselben unter **Log sync** konfigurierten Port. Dort wird jedoch getrennt festgelegt, ob KST4Contest QSO-Informationen, TRX-Informationen oder beide Paketarten verarbeitet. Eine automatische QRG-Quelle muss aktiviert sein und tatsächlich gültige Pakete liefern; die Aktivierung allein aktualisiert `MYQRG` nicht.
|
||||
|
||||
Wird der gemeinsame UDP-Port geändert, muss KST4Contest neu gestartet werden. Eine reine Änderung der Checkboxen wird dagegen sofort berücksichtigt.
|
||||
|
||||
@@ -144,7 +147,7 @@ Beide automatischen Quellen aktualisieren ausschließlich `MYQRG`. Das ist die e
|
||||
|
||||
Bei aktiviertem zweiten Chat bleibt dessen QRG davon unabhängig. Sie wird nicht aus den empfangenen TRX-Paketen abgeleitet und steht als `SECONDQRG` zur Verfügung. Dadurch kann beispielsweise die erste Kategorie automatisch der Frequenz des Logprogramms folgen, während für die zweite Kategorie eine eigene QRG von Hand eingetragen wird.
|
||||
|
||||
Sobald mindestens eine automatische QRG-Quelle aktiviert ist, wird das QRG-Feld der ersten Kategorie im Hauptfenster an den empfangenen Wert gebunden. Eine manuelle Eingabe in dieses Feld ist wieder möglich, wenn sowohl der allgemeine RadioInfo-Listener als auch die Win-Test-STATUS-Synchronisation deaktiviert sind.
|
||||
Sobald mindestens eine automatische QRG-Quelle aktiviert ist und einen gültigen Wert liefert, folgt das QRG-Feld der ersten Kategorie im Hauptfenster der empfangenen Frequenz. Bleiben die erwarteten Pakete aus, sollte die Schnittstelle vor dem Contest geprüft werden. Eine manuelle Eingabe in dieses Feld ist möglich, wenn sowohl der allgemeine RadioInfo-Listener als auch die Win-Test-STATUS-Synchronisation deaktiviert sind.
|
||||
|
||||
### Haupt- oder Pass-Frequenz aus Win-Test
|
||||
|
||||
@@ -332,6 +335,8 @@ Die drei Audiofunktionen arbeiten unabhängig voneinander:
|
||||
|
||||
CW- und Sprachausgabe können gleichzeitig aktiviert werden. Das ist technisch möglich, im Contest aber nicht zwingend hilfreich. In der Praxis sollte nur die Ausgabe eingeschaltet werden, die im eigenen Stationsbetrieb tatsächlich wahrgenommen werden kann, ohne den Operator dauerhaft zu beschäftigen.
|
||||
|
||||
Die PM-bezogenen Audioausgaben reagieren nur auf Nachrichten, die tatsächlich an das eigene Login-Rufzeichen gerichtet sind. Öffentliche Nachrichten, die durch PM-Catching zusätzlich in der PM-Tabelle erscheinen, und dort eingeblendete Monitoring-Nachrichten bleiben akustisch still.
|
||||
|
||||
### Fallback-Band für relative QRG-Erkennung
|
||||
|
||||
Das Dropdown **Fallback band for relative QRG detection** legt fest, welches Band KST4Contest verwendet, wenn eine relative QRG keinem aktuellen Stationskontext zugeordnet werden kann.
|
||||
@@ -374,12 +379,14 @@ Folgende Einstellungen und Schaltflächen gehören zur lokalen DX-Cluster-Ausgab
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
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`.
|
||||
|
||||
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 Test funktioniert nur, wenn
|
||||
@@ -574,7 +581,7 @@ KST4Contest verwendet einen gemeinsamen Timer für beide Chat-Kategorien. Aktivi
|
||||
- **Beacon message** enthält den öffentlichen Nachrichtentext dieser Kategorie.
|
||||
- **Shared beacon interval** legt das gemeinsame Intervall für beide Kategorien fest.
|
||||
|
||||
Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn auch der zweite Chat aktiviert und verbunden ist.
|
||||
Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn die zweite Kategorie in derselben ON4KST-Sitzung aktiviert und vollständig synchronisiert ist.
|
||||
|
||||
### Intervall und Timer-Verhalten
|
||||
|
||||
@@ -857,6 +864,8 @@ Die interne SQLite-Datenbank speichert die contestbezogenen Zustände unabhängi
|
||||
- manuell gesetzte NOT-QRV-Tags pro Band und
|
||||
- gearbeitete vierstellige Großfelder pro Band.
|
||||
|
||||
Eine Ausnahme bilden Worked-Markierungen des Simplelogfile-Interpreters. Sie werden nur aus der ausgewählten Datei in den laufenden Zustand übernommen und nicht in SQLite persistiert. Die Datei ist die dauerhafte Quelle und besitzt keinen automatischen Contest-Reset. Ein Datenbank-Reset verändert oder leert sie nicht; enthaltene Rufzeichen werden bei der nächsten periodischen Auswertung erneut als gearbeitet markiert.
|
||||
|
||||
Als Schlüssel wird das normalisierte Rufzeichen ohne sichtbare Chat-Klammern oder Kategorieformatierung verwendet. Dadurch können aktive Varianten desselben Rufzeichens konsistent ausgewertet werden.
|
||||
|
||||
Worked- und NOT-QRV-Informationen laufen drei Tage nach ihrer letzten Änderung automatisch ab. Gespeicherte Großfelder laufen drei Tage nach dem zugehörigen Logeintrag ab. Ein manuelles Zurücksetzen vor jedem Contest ist deshalb normalerweise nicht erforderlich.
|
||||
@@ -869,6 +878,61 @@ Anzeige und Herleitung: [Gearbeitete Rufzeichen, neue Bänder und neue Großfeld
|
||||
|
||||
---
|
||||
|
||||
## Operator-Profile (ab v1.50)
|
||||
|
||||
Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locators und Layouts. Ein Operator-Profil bündelt genau das: **jedes Profil hat seine eigene `preferences.xml` und damit seine eigenen fachlichen Einstellungen und seinen eigenen Layoutstand.**
|
||||
|
||||
### Wo die Profile liegen
|
||||
|
||||
| Profil | Einstellungen | Gearbeitete Stationen |
|
||||
|---|---|---|
|
||||
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
|
||||
| weitere Profile | `~/.praktiKST/profiles/<Profil-ID>/preferences.xml` | je nach Einstellung gemeinsam oder `~/.praktiKST/profiles/<Profil-ID>/praktiKST.db` |
|
||||
|
||||
Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`.
|
||||
|
||||
Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen.
|
||||
|
||||
Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor.
|
||||
|
||||
### Gemeinsame oder eigene gearbeitete Stationen
|
||||
|
||||
Beim Anlegen eines Profils wird entschieden, woher dessen Worked-, NOT-QRV- und Großfeld-Daten kommen:
|
||||
|
||||
- **Eigene gearbeitete Stationen** (Vorgabe): Das Profil bekommt eine eigene, zunächst leere Datenbank. Sinnvoll, wenn sich zwei OMs mit verschiedenen Rufzeichen einen Rechner teilen.
|
||||
- **Gemeinsame Stationsdatenbank**: Das Profil benutzt `~/.praktiKST/praktiKST.db`, also dieselbe Datenbank wie das Profil **Default**. Das ist der Fall der **Multi-OP-Station**: es gibt nur ein Stationslog, also soll auch der Worked-Status für alle Operateure derselbe sein.
|
||||
|
||||
Ein Wechsel zwischen beiden Einstellungen verschiebt keine Daten. Bereits gesammelte Worked-Daten bleiben dort liegen, wo sie entstanden sind. Der Drei-Tage-Ablauf und die Reset-Schaltfläche wirken jeweils auf die Datenbank des gerade aktiven Profils.
|
||||
|
||||
### Profile verwalten
|
||||
|
||||
Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art der Worked-Daten und letzter Benutzung.
|
||||
|
||||
- **New profile...** legt ein Profil an. Es startet **ohne Rufzeichen und ohne Passwort**; beides wird anschließend im Reiter **Station** eingetragen.
|
||||
- **Duplicate...** übernimmt die komplette Konfiguration des gewählten Profils — Antenne, Locator, Layout, Beacon, Integrationen — **außer Rufzeichen und Passwort**. Gearbeitete Stationen werden nie mitkopiert.
|
||||
- **Rename...** ändert nur den angezeigten Namen. Verzeichnisse und Dateien bleiben unberührt.
|
||||
- **Delete...** entfernt das Profilverzeichnis endgültig. Das Profil **Default** und das gerade aktive Profil lassen sich nicht löschen. Bei einem Profil mit gemeinsamer Stationsdatenbank bleibt diese unangetastet.
|
||||
- **Change worked stations...** schaltet zwischen gemeinsamer und eigener Datenbank um.
|
||||
- **Switch to selected profile...** wechselt das Profil im laufenden Betrieb.
|
||||
|
||||
### Profilwahl beim Start
|
||||
|
||||
- Solange nur **ein** Profil existiert, fragt KST4Contest beim Start **nichts** und startet wie bisher. Es wird auch keine Profil-Registry angelegt. Erst das Anlegen des zweiten Profils erzeugt `~/.praktiKST/profiles.xml`.
|
||||
- Ab **zwei** Profilen erscheint beim Start eine kleine Auswahl. Das zuletzt benutzte Profil ist vorausgewählt, **Enter** oder ein Doppelklick starten sofort.
|
||||
- Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl und startet direkt das genannte Profil. Erlaubt sind die Profil-ID und der angezeigte Name, Groß- und Kleinschreibung spielen keine Rolle. Ein unbekannter Name führt zu einem Hinweis und danach zur normalen Auswahl — der Start wird nie verweigert.
|
||||
|
||||
### Profil im laufenden Betrieb wechseln
|
||||
|
||||
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
|
||||
|
||||
Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen.
|
||||
|
||||
### Hinweis zu Passwörtern
|
||||
|
||||
Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des jeweiligen Profils. Auf einem gemeinsam genutzten Rechner kann jeder, der Zugriff auf das Benutzerkonto hat, die Passwörter aller Profile lesen. Profile trennen die Konfiguration, sie sind **kein** Zugriffsschutz.
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode (ab v1.26)
|
||||
|
||||
Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **Windows → Use default mode design** wird wieder das normale helle Farbschema geladen.
|
||||
@@ -877,8 +941,11 @@ Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **W
|
||||
|
||||
## 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`)
|
||||
- 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.
|
||||
|
||||
@@ -10,13 +10,15 @@ KST4Contest übernimmt gearbeitete Stationen aus dem Logprogramm und stellt dara
|
||||
|
||||
## Methode 1: Universal File Based Callsign Interpreter (Simplelogfile)
|
||||
|
||||
KST4Contest liest eine Logdatei und sucht mit einem konfigurierbaren regulären Ausdruck nach Rufzeichen. Die Datei wird ausschließlich gelesen und nicht verändert. Auch binäre Logdateien können verwendet werden; nicht als Text interpretierbare Inhalte werden übersprungen.
|
||||
KST4Contest liest die ausgewählte Textdatei einmal pro Minute und sucht darin mit einem fest eingebauten regulären Ausdruck nach Rufzeichen. Jedes gefundene Rufzeichen wird auf sein Basisrufzeichen normalisiert. Der globale Worked-Status gilt dadurch für alle derzeit aktiven Chat-Varianten dieses Rufzeichens.
|
||||
|
||||
Der Vorteil liegt in der breiten Kompatibilität: Die Funktion benötigt keine besondere Netzwerkschnittstelle des Logprogramms.
|
||||
|
||||
Die Grenze ist ebenso eindeutig: Aus einem reinen Rufzeichentreffer lassen sich weder Band noch Locator zuverlässig ableiten. Der Simplelogfile-Interpreter kann deshalb nur den globalen Worked-Status setzen. Er erzeugt keine bandbezogene `X`-Markierung, kein Worked-Großfeld und keine belastbare Grundlage für den Band-Upgrade-Hinweis nach einem Logeintrag.
|
||||
|
||||
Den Pfad der Logdatei und den regulären Ausdruck im Reiter **Log sync** eintragen. Für bandbezogene Auswertungen sollte nach Möglichkeit eine der Netzwerkschnittstellen verwendet werden.
|
||||
Den Pfad der Textdatei im Reiter **Log sync** auswählen. Fehlt die Datei, legt KST4Contest sie an und zeigt einmalig einen nicht blockierenden Hinweis mit dem Pfad und den nächsten Prüfschritten an. Lese- oder Erstellungsfehler werden protokolliert; die minütliche Auswertung läuft beim nächsten Termin weiter. Für bandbezogene Auswertungen sollte nach Möglichkeit eine der Netzwerkschnittstellen verwendet werden.
|
||||
|
||||
Der aus dem Simplelogfile abgeleitete Worked-Status wird nicht in der internen SQLite-Datenbank gespeichert. Die ausgewählte Datei ist die dauerhafte Quelle und wird auch nach einem Neustart erneut eingelesen. Der Interpreter setzt nur positive Worked-Markierungen; er entfernt während der laufenden Programmsitzung keine bereits gesetzte Markierung und führt beim Wechsel zu einem neuen Contest keinen automatischen Reset durch. Auch ein manueller Datenbank-Reset ändert oder leert das Simplelogfile nicht. Darin enthaltene Rufzeichen werden bei der nächsten Auswertung innerhalb einer Minute erneut als gearbeitet markiert. Vor jedem Contest sollte deshalb geprüft werden, ob das Logprogramm genau die aktuelle Contestdatei beschreibt.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,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.
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
@@ -152,7 +172,11 @@ Im Reiter **TRX sync**:
|
||||
- `Use pass frequency from Win-Test STATUS`
|
||||
- `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)
|
||||
|
||||
@@ -163,7 +187,7 @@ Neben der QSO-Synchronisation übertragen UCXLog und andere Programme auch die *
|
||||
|
||||

|
||||
|
||||
**Ergebnis**: Die eigene QRG muss im Chat nie mehr manuell eingegeben werden – ein Klick auf den MYQRG-Button oder die Verwendung der Variable im Beacon genügt.
|
||||
**Ergebnis**: Eine aktivierte Schnittstelle aktualisiert `MYQRG`, sobald sie tatsächlich gültige Frequenzpakete liefert. Das Aktivieren allein erzeugt noch keine QRG. Kommen keine passenden Pakete an, muss die Schnittstelle geprüft oder die QRG nach dem Deaktivieren beider automatischen Quellen manuell gepflegt werden.
|
||||
|
||||
**Quellen für die eigene QRG (MYQRG):**
|
||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP-Port 12060
|
||||
@@ -191,7 +215,7 @@ Für DM5M-typische Setups (2 Radios, 2 Computer, eine KST4Contest-Instanz oder z
|
||||
|
||||
## Interne Datenbank
|
||||
|
||||
KST4Contest speichert Worked-, NOT-QRV- und Großfeldinformationen in einer eigenen SQLite-Datenbank. Sie ist von der Datenbank des Logprogramms unabhängig.
|
||||
KST4Contest speichert Worked-, NOT-QRV- und Großfeldinformationen aus den Netzwerkschnittstellen sowie manuelle Markierungen in einer eigenen SQLite-Datenbank. Sie ist von der Datenbank des Logprogramms unabhängig. Simplelogfile-Treffer sind davon ausgenommen und werden bei jeder Programmsitzung aus der ausgewählten Datei neu abgeleitet.
|
||||
|
||||
Die Datenquellen liefern unterschiedlich genaue Informationen:
|
||||
|
||||
@@ -201,6 +225,6 @@ Die Datenquellen liefern unterschiedlich genaue Informationen:
|
||||
| QSO-UDP-Listener | ja | ja, wenn im Paket enthalten | ja, wenn Band und Locator enthalten sind |
|
||||
| Win-Test-Netzwerk-Listener | ja | ja | ja, wenn ein Locator enthalten ist |
|
||||
|
||||
Die Daten werden beim Programmstart wieder geladen und bei neuen Logeinträgen während des Betriebs aktualisiert. Sie laufen nach drei Tagen automatisch ab. Ein Reset vor jedem Contest ist daher normalerweise nicht erforderlich.
|
||||
Die in SQLite gespeicherten Daten werden beim Programmstart wieder geladen und bei neuen Logeinträgen während des Betriebs aktualisiert. Sie laufen nach drei Tagen automatisch ab. Ein Reset vor jedem Contest ist daher normalerweise nicht erforderlich. Für den Simplelogfile-Interpreter gilt diese Lebensdauer nicht: Seine Datei bleibt die dauerhafte Quelle und wird durch einen Datenbank-Reset weder geändert noch geleert. Enthaltene Rufzeichen setzt die nächste periodische Auswertung erneut auf den globalen Worked-Status.
|
||||
|
||||
Ein vollständiger manueller Reset entfernt Worked-Markierungen, NOT-QRV-Tags und Worked-Großfelder gemeinsam. Weitere Einzelheiten: [Worked Station Database Settings](de-Konfiguration#worked-station-database-settings-gearbeitete-stationen-datenbank).
|
||||
Ein vollständiger manueller Reset entfernt Worked-Markierungen, NOT-QRV-Tags und Worked-Großfelder gemeinsam. Weitere Einzelheiten: [Worked Station Database Settings](de-Konfiguration#worked-station-database-settings-gearbeitete-stationen-datenbank).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 571 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
> 🇬🇧 You are reading the English version | 🇩🇪 [Deutsche Version](de-AirScout-Integration)
|
||||
|
||||
AirScout (by DL2ALF) is a program for detecting aircraft for aircraft scatter operation. KST4Contest is tightly integrated with AirScout and shows reflectable aircraft directly in the user list.
|
||||
AirScout (by DL2ALF) calculates aircraft-scatter opportunities from current aircraft positions. KST4Contest receives these results and displays aircraft suitable for the path to each remote station directly in the user list.
|
||||
|
||||
> **Aircraft Scatter** enables very long-distance communication on VHF and higher – even for stations with low altitude above sea level or unfavourable topographic conditions.
|
||||
|
||||
@@ -15,9 +15,9 @@ Download AirScout from:
|
||||
|
||||
---
|
||||
|
||||
## Aircraft Data Feeds (ADSB)
|
||||
## Aircraft Data Feeds (ADS-B)
|
||||
|
||||
Public aircraft data feeds on the internet are often unreliable and limited in use. A recommended alternative is the dedicated ADSB feed service provided by **OV3T (Thomas)**:
|
||||
Public aircraft data feeds on the internet are often unreliable and of limited use. A recommended alternative is the dedicated ADS-B feed service provided by **OV3T (Thomas)**:
|
||||
|
||||
- https://airscatter.dk/
|
||||
- https://www.facebook.com/groups/825093981868542
|
||||
@@ -28,7 +28,7 @@ An account is required for this service. Please consider donating to Thomas –
|
||||
|
||||
## Setting Up AirScout
|
||||
|
||||
### Step 1: Configure the ADSB Feed in AirScout
|
||||
### Step 1: Configure the ADS-B Feed in AirScout
|
||||
|
||||
1. Start AirScout.
|
||||
2. Enter your OV3T feed account details (username, password, URL) in the AirScout settings.
|
||||
@@ -85,32 +85,40 @@ Otherwise, it may result in incorrect AP data.
|
||||
|
||||
## AP Column in the User List
|
||||
|
||||
After setup, an **AP column** appears in the user list showing up to two reflectable aircraft per station.
|
||||
After setup, an **AP column** appears in the user list. For each station, it shows the arrival time and the AirScout reflection potential of the first two suitable aircraft.
|
||||
|
||||
Example display:
|
||||
|
||||
| Station | AP Info |
|
||||
|---|---|
|
||||
| DF9QX | 2 Planes: 0 min / 0 min, 100% each |
|
||||
| F5DYD | 2 Planes: 14 min / 31 min, 50% each |
|
||||
| DF9QX | 0 (100%) / 0 (100%) |
|
||||
| F5DYD | 14 (50%) / 31 (50%) |
|
||||
|
||||
The number before the brackets is the number of minutes remaining until the calculated opportunity. The percentage is the reflection potential reported by AirScout. It is not a QSO probability.
|
||||
|
||||
AP information is also available in the **private messages window**.
|
||||
|
||||
The percentage indicates the reflection potential (aircraft size, altitude, distance).
|
||||
## Effect on the Priority Score and Timeline
|
||||
|
||||
At least one aircraft reported as reachable by AirScout raises the station's Priority Score. An opportunity expected in zero, one or two minutes receives additional time-dependent weighting. AirScout is only one factor alongside Worked status, available bands, QRB, antenna direction, chat activity and skeds.
|
||||
|
||||
The AP and sked timeline uses the Priority Score to select interesting stations and places the next suitable aircraft-scatter opportunity on the time axis. The reflection potential also controls how the AP marker is displayed. The timeline remains a preview and does not guarantee a QSO.
|
||||
|
||||
---
|
||||
|
||||
## AP Variables in Messages
|
||||
|
||||
Aircraft data can be inserted directly into messages:
|
||||
Aircraft data for the selected station can be inserted directly into shortcuts, snippets and other station-specific messages:
|
||||
|
||||
- `FIRSTAP` → e.g. `a very big AP in 1 min`
|
||||
- `SECONDAP` → e.g. `Next big AP in 9 min`
|
||||
|
||||
Details: [Macros and Variables](en-Macros-and-Variables#variables)
|
||||
|
||||
Because these values require a selected remote station, `FIRSTAP` and `SECONDAP` are not available as global beacon variables.
|
||||
|
||||
---
|
||||
|
||||
## "Show Path in AirScout" Button
|
||||
|
||||
In the user list there is a button with an arrow showing the direction (QTF) to the selected station. Clicking it maximises AirScout and shows the path with reflectable aircraft to the selected contact.
|
||||
In the user list there is a button with an arrow showing the direction (QTF) to the selected station. Clicking it maximises the external AirScout window and displays the path to the selected remote station together with the calculated aircraft-scatter opportunities. The button does not start a separate terrain or propagation calculation in KST4Contest.
|
||||
|
||||
+105
-1
@@ -8,6 +8,107 @@ Published Stable versions and their application packages are available under [Gi
|
||||
|
||||
---
|
||||
|
||||
## v1.50 (in development)
|
||||
|
||||
**Operator profiles**
|
||||
|
||||
Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/praktimarc/kst4contest/issues/57).
|
||||
|
||||
### New
|
||||
|
||||
- **Operator profiles:** every profile has its own `preferences.xml`, and therefore its own settings and its own window layout. Profiles are managed on the new **Profiles** tab of the settings window: create, duplicate, rename, delete and activate.
|
||||
|
||||
- **Shared or own worked stations:** each profile decides whether it gets its own worked database or uses the common station database. A multi operator station with a single station log shares the worked state; two operators with different callsigns on one computer keep it apart.
|
||||
|
||||
- **Choosing a profile at startup:** with only one profile, KST4Contest asks nothing at startup and behaves exactly as before. From two profiles on, a small selection appears with the last used profile preselected; Enter or a double click start immediately. The `--profile=<name>` argument skips the selection.
|
||||
|
||||
- **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
|
||||
|
||||
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.
|
||||
- A profile registry only appears when the second profile is created. Anyone using a single profile will not notice the change.
|
||||
- Going back to an older KST4Contest release stays possible; it finds its files unchanged.
|
||||
- Passwords are still stored in clear text in each profile's `preferences.xml`. Profiles separate configuration; they are not an access control mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
@@ -34,6 +135,8 @@ v1.42 brings several previously separate calculations together. Band information
|
||||
|
||||
- **Manual QTF input:** The current antenna direction can be changed directly in KST4Contest when PSTRotator is not being used.
|
||||
|
||||
- **`MYQTF` message variable:** The current antenna direction can be inserted as a numeric angle in degrees into shortcuts, snippets and other supported message texts.
|
||||
|
||||
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
|
||||
|
||||
- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
|
||||
@@ -80,6 +183,8 @@ 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.
|
||||
|
||||
- **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.
|
||||
|
||||
### Fixed
|
||||
@@ -410,7 +515,6 @@ First publicly released version. Core features:
|
||||
|
||||
## Planned Features
|
||||
|
||||
- `MYQTF` variable (own antenna direction as text)
|
||||
- ~~Lifetime for worked status (automatic reset)~~ ✅ **Implemented in v1.40** (3-day lifetime, no manual reset needed anymore)
|
||||
- Filtering the "Cluster & QSO of others" window to own QTF
|
||||
- Further topography-based calculations for direction warnings
|
||||
|
||||
@@ -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).
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,14 +14,15 @@ After the first start, the **settings window** opens – this is the central sta
|
||||
|
||||
### Login and Chat Categories
|
||||
|
||||
Enter your ON4KST chat credentials here (callsign and password).
|
||||
Also, select the **primary chat category** (e.g., IARU Region 1 VHF/Microwave).
|
||||
Enter the one local login callsign and password used for the ON4KST chat. Also select the **primary chat category** (e.g. IARU Region 1 VHF/Microwave).
|
||||
|
||||
With the option for a **second chat** (Multi-Channel Login), you can log in to another category simultaneously (e.g., UHF/SHF). Both chats will then be monitored in parallel. You can optionally specify a different login name for the second chat (useful for Opposite Station Multi-Callsign Logging).
|
||||
The **second chat** option (Multi-Channel Login) adds another category (e.g. UHF/SHF) to the same ON4KST TCP session through Single Sign-on. KST4Contest does not open a second TCP connection and does not use another local login callsign or password.
|
||||
|
||||
**Name in Chat 2** configures only the visible, category-specific name field for the second category. It is neither another login nor a message destination. The visible name field, message context, QRG and beacon remain separate for each category. **Opposite Station Multi-Callsign Login Tagging**, by contrast, refers exclusively to remote stations which appear in the chat under several complete visible callsign variants.
|
||||
|
||||
### Callsign and Locator
|
||||
|
||||
Enter your own callsign and Maidenhead locator (6 characters, e.g., `JN49IJ`). These values are needed for distance and direction calculations.
|
||||
Enter your own callsign and Maidenhead locator (6 characters, e.g. `JN49IJ`). The locator is not part of authentication. ON4KST shares it between both categories in the one TCP session, and KST4Contest also uses it for distance and direction calculations.
|
||||
|
||||
### Active Bands
|
||||
|
||||
@@ -127,13 +128,15 @@ The **Log sync** tab selects the sources from which KST4Contest imports worked s
|
||||
| **General QSO UDP listener** | QSO packets from UCXLog, QARTest, N1MM+ and DXLog.net | global and per-band Worked status plus worked grid square where both band and locator are transmitted |
|
||||
| **Win-Test network listener** | native Win-Test network packets | global and per-band Worked status, locator information and, depending on the settings, QRG synchronisation and sked handover |
|
||||
|
||||
The file-based interpreter is mainly useful when the logging application provides no supported network interface. A callsign match alone, however, contains neither a reliable band nor a locator. Use one of the network listeners wherever possible if per-band information is required.
|
||||
The file-based interpreter reads the selected text file once per minute using a fixed callsign pattern. Matches are normalised to base callsigns and set only the global Worked status for all active variants. It is mainly useful when the logging application provides no supported network interface. A callsign match alone contains neither a reliable band nor a locator, so use one of the network listeners wherever possible if per-band information is required.
|
||||
|
||||
If the selected file does not exist, KST4Contest creates it and displays its full path together with checks for initial setup and the next contest. The file itself is the durable source; Worked marks derived from it are not stored in SQLite and are not reset automatically for a new contest.
|
||||
|
||||
The general QSO UDP listener is the recommended interface for UCXLog, QARTest, N1MM+ and DXLog.net. QSO and `RadioInfo` packets use the same configurable UDP port; the default is `12060`. Separate options in **Log sync** and **TRX sync** determine whether the received QSO and frequency information is processed.
|
||||
|
||||
Win-Test uses its own network protocol and therefore has a separate listener. Its default port is `9871`. If this port is changed while the listener is enabled, KST4Contest restarts the Win-Test listener on the new port. After changing the shared UDP port `12060`, KST4Contest must instead be restarted completely.
|
||||
|
||||
All enabled input paths may be used in parallel. Their Worked information is merged into the same internal database; identical reports do not create separate Worked states. KST4Contest must be running when a QSO is saved unless the logging application can resend the existing log.
|
||||
All enabled input paths may be used in parallel and identical reports do not create separate Worked states. Network-derived Worked information is stored in the internal database. Simplelogfile marks remain runtime state derived from the selected file. Before using Simplelogfile, check whether the logging application already provides one of the supported network interfaces. KST4Contest must be running when a network QSO is transmitted unless the logging application can resend the existing log.
|
||||
|
||||
Configuration of the individual logging applications, band and locator handling, and the Win-Test sked handover are described under [Log Synchronisation](en-Log-Sync).
|
||||
|
||||
@@ -153,7 +156,7 @@ TRX synchronisation imports the current frequency from the logging application a
|
||||
| **Win-Test STATUS** | `Win-Test STATUS QRG Sync` | Processes the main or pass frequency from native Win-Test `STATUS` packets. The Win-Test listener uses its separately configured port, which defaults to `9871`. |
|
||||
| **Manual entry** | Disable both automatic QRG sources | The local QRG can be entered manually in the main window. |
|
||||
|
||||
The general listener is intended for logging applications which transmit compatible `RadioInfo` packets. Depending on their individual configuration, this includes UCXLog, N1MM+, QARTest and DXLog.net. QSO and `RadioInfo` packets use the same port configured under **Log sync**, but separate options determine whether KST4Contest processes QSO information, TRX information or both packet types.
|
||||
The general listener is intended for logging applications which transmit compatible `RadioInfo` packets. Depending on their individual configuration, this includes UCXLog, N1MM+, QARTest and DXLog.net. QSO and `RadioInfo` packets use the same port configured under **Log sync**, but separate options determine whether KST4Contest processes QSO information, TRX information or both packet types. An automatic QRG source must be enabled and actually supply valid packets; enabling it alone does not update `MYQRG`.
|
||||
|
||||
Restart KST4Contest after changing the shared UDP port. Changes to the two QRG-sync checkboxes take effect immediately.
|
||||
|
||||
@@ -163,7 +166,7 @@ Both automatic sources update `MYQRG` only. This is the local QRG of the first o
|
||||
|
||||
If a second chat is enabled, its QRG remains independent. It is not derived from incoming TRX packets and is available through `SECONDQRG`. The first category can therefore follow the logging application's frequency automatically while a separate QRG is entered manually for the second category.
|
||||
|
||||
As soon as at least one automatic QRG source is enabled, the first category's QRG field in the main window is bound to the received value. Manual entry becomes available again when both the general RadioInfo listener and Win-Test STATUS synchronisation are disabled.
|
||||
When at least one automatic QRG source is enabled and supplies a valid value, the first category's QRG field in the main window follows the received frequency. If the expected packets do not arrive, verify the interface before the contest. Manual entry is available when both the general RadioInfo listener and Win-Test STATUS synchronisation are disabled.
|
||||
|
||||
### Main or Pass Frequency from Win-Test
|
||||
|
||||
@@ -373,11 +376,15 @@ AirScout setup, aircraft display and the meaning of the returned AP information
|
||||
|
||||

|
||||
|
||||
Three notification types are available:
|
||||
The three audio functions work independently:
|
||||
|
||||
1. **Simple sounds**: TADA sound for incoming messages, tick for sked direction detection, etc.
|
||||
2. **CW announcement**: The callsign of a station sending a private message is output as a CW signal.
|
||||
3. **Phonetic announcement**: The callsign is pronounced phonetically.
|
||||
1. **Simple sounds**: Plays short cues for new private messages, detected directional opportunities, sked reminders and `BAND+` hints.
|
||||
2. **CW announcement**: Spells the sender's callsign in CW for a new private message.
|
||||
3. **Phonetic announcement**: Speaks the sender's callsign phonetically for a new private message.
|
||||
|
||||
Each function can be enabled separately. CW and phonetic output can also be active at the same time.
|
||||
|
||||
PM-related audio output is triggered only by messages actually directed to the local login callsign. Messages shown additionally through PM Catching and messages added through QSO Monitoring remain silent.
|
||||
|
||||
### Fallback Band for Relative QRG Detection
|
||||
|
||||
@@ -421,12 +428,14 @@ The following settings and controls belong to the local DX cluster output:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 on the selected fallback band
|
||||
```
|
||||
|
||||
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 test works only if
|
||||
@@ -572,7 +581,7 @@ KST4Contest uses one shared timer for both chat categories. Each category nevert
|
||||
- **Beacon message** contains the public message for that category.
|
||||
- **Shared beacon interval** sets the common interval used by both categories.
|
||||
|
||||
When both beacons are enabled, they are sent one after the other in their respective categories during the same timer run. The second beacon is only considered while the second chat is enabled and connected.
|
||||
When both beacons are enabled, they are sent one after the other in their respective categories during the same timer run. The second beacon is only considered while the second category is enabled and fully synchronised in the same ON4KST session.
|
||||
|
||||
### Interval and timer behaviour
|
||||
|
||||
@@ -912,6 +921,8 @@ The internal SQLite database stores contest-related state independently of the l
|
||||
- manually assigned NOT-QRV marks per band, and
|
||||
- worked four-character grid squares per band.
|
||||
|
||||
Worked marks from the Simplelogfile interpreter are the exception. They are copied from the selected file into runtime state only and are not persisted in SQLite. The file is the durable source and has no automatic contest reset. A database reset does not change or empty it; callsigns contained in the file are marked as worked again during the next periodic evaluation.
|
||||
|
||||
The normalised callsign, without visible chat brackets or category formatting, is used as the key. This allows active variants of the same callsign to be evaluated consistently.
|
||||
|
||||
Worked and NOT-QRV information expires automatically three days after its most recent change. Stored grid squares expire three days after the corresponding log entry. A manual reset before every contest is therefore normally unnecessary.
|
||||
@@ -924,6 +935,61 @@ Display and derivation: [Worked Callsigns, New Bands and New Grid Squares](en-Fe
|
||||
|
||||
---
|
||||
|
||||
## Operator Profiles (from v1.50)
|
||||
|
||||
Several operators sharing one computer need different callsigns, locators and layouts. An operator profile bundles exactly that: **every profile has its own `preferences.xml`, and therefore its own settings and its own window layout.**
|
||||
|
||||
### Where the profiles live
|
||||
|
||||
| Profile | Settings | Worked stations |
|
||||
|---|---|---|
|
||||
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
|
||||
| additional profiles | `~/.praktiKST/profiles/<profile ID>/preferences.xml` | shared, or `~/.praktiKST/profiles/<profile ID>/praktiKST.db` |
|
||||
|
||||
On Windows the same files live below `%USERPROFILE%\.praktiKST\`.
|
||||
|
||||
Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information.
|
||||
|
||||
The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged.
|
||||
|
||||
### Shared or own worked stations
|
||||
|
||||
When a profile is created you decide where its worked, NOT-QRV and grid data come from:
|
||||
|
||||
- **Own worked stations** (default): the profile gets its own, initially empty database. This is what two operators with different callsigns sharing a private computer want.
|
||||
- **Common station database**: the profile uses `~/.praktiKST/praktiKST.db`, the same database as the **Default** profile. This is the **multi operator station** case: there is only one station log, so the worked state should be the same for every operator.
|
||||
|
||||
Switching between the two settings moves no data. Worked data already collected stays where it was created. The three day expiry and the reset button always act on the database of the currently active profile.
|
||||
|
||||
### Managing profiles
|
||||
|
||||
The **Profiles** tab in the settings window lists all profiles with their name, the kind of worked data they use and when they were last used.
|
||||
|
||||
- **New profile...** creates a profile. It starts **without callsign and password**; both are entered afterwards on the **Station** tab.
|
||||
- **Duplicate...** copies the complete configuration of the selected profile - antenna, locator, layout, beacon, integrations - **except callsign and password**. Worked stations are never copied.
|
||||
- **Rename...** changes the displayed name only. Folders and files are untouched.
|
||||
- **Delete...** removes the profile folder permanently. The **Default** profile and the currently active profile cannot be deleted. For a profile using the common station database, that database is left untouched.
|
||||
- **Change worked stations...** switches between the common and an own database.
|
||||
- **Switch to selected profile...** changes the profile while the application is running.
|
||||
|
||||
### Choosing a profile at startup
|
||||
|
||||
- As long as only **one** profile exists, KST4Contest asks **nothing** at startup and starts exactly as before. No profile registry is created either; only creating the second profile writes `~/.praktiKST/profiles.xml`.
|
||||
- From **two** profiles on, a small selection appears at startup. The last used profile is preselected, and **Enter** or a double click start immediately.
|
||||
- The `--profile=<name>` argument skips the selection and starts the named profile directly. Both the profile ID and the displayed name are accepted, case insensitively. An unknown name produces a note and then the normal selection - startup is never refused.
|
||||
|
||||
### Switching profiles while running
|
||||
|
||||
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
|
||||
|
||||
Once more than one profile exists, the main window title also shows the profile name.
|
||||
|
||||
### A note about passwords
|
||||
|
||||
As before, the ON4KST password is stored in clear text in the `preferences.xml` of each profile. On a shared computer, anyone with access to the user account can read the passwords of all profiles. Profiles separate configuration; they are **not** an access control mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode (from v1.26)
|
||||
|
||||
Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to restore the normal light colour scheme.
|
||||
@@ -932,8 +998,11 @@ Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows →
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
|
||||
@@ -67,6 +67,8 @@ Enable only interfaces which are actually required and have already been tested:
|
||||
|
||||
A contest is not an ideal time to investigate radio conditions and a newly enabled network interface at the same time.
|
||||
|
||||
Before each contest, verify that the logging application writes the current contest file to the path selected for the Simplelogfile. A test QSO should appear as globally worked within one minute. KST4Contest does not reset Worked marks derived from this file automatically when the contest changes.
|
||||
|
||||
---
|
||||
|
||||
## Basic Contest Cycle
|
||||
@@ -89,7 +91,7 @@ KST4Contest keeps the required information together between these steps. Changin
|
||||
|
||||
## CQ Operation
|
||||
|
||||
During operation on a mainly fixed CQ frequency, `MYQRG` and `SECONDQRG` should match the frequencies actually in use. Enabled TRX synchronisation can update `MYQRG` automatically. Without an automatic source, the value must be maintained manually.
|
||||
During operation on a mainly fixed CQ frequency, `MYQRG` and `SECONDQRG` should match the frequencies actually in use. TRX synchronisation can update `MYQRG` automatically only when it is enabled and actually supplies valid packets. Before the contest, verify this with a real frequency change. Without a working automatic source, the value must be maintained manually.
|
||||
|
||||
The beacon can publish the current QRG, locator and antenna direction in the chat at regular intervals. Its variables are evaluated again for every transmission.
|
||||
|
||||
@@ -246,6 +248,19 @@ G1YBB uses the directional indication particularly consistently. Stations highli
|
||||
|
||||
KST4Contest does not automate the QSO. Its advantage is that QRG, direction, aircraft scatter information and other evaluation data are already available when the opportunity appears. The remaining task is a quick operating decision.
|
||||
|
||||
Another particularly consistent operating method uses the station map as a geographical worklist:
|
||||
|
||||
1. The **wkd** filter removes already worked callsigns from the user list and therefore from the map.
|
||||
2. G1YBB selects an interesting station on the map.
|
||||
3. **Trigger cluster spot** passes the known QRG to Minos.
|
||||
4. Selecting the spot moves Minos to that QRG.
|
||||
5. The completed QSO is logged.
|
||||
6. Log synchronisation updates the Worked state, and the filter removes the station from both the user list and the map.
|
||||
|
||||
The map consequently becomes a spatial list of the stations still to be worked. This workflow is optional. It requires reliable log synchronisation and a working DX Cluster connection and is mainly useful for operators who deliberately want to organise the contest in this geographical way.
|
||||
|
||||
[Watch G1YBB demonstrate this workflow.](https://www.youtube.com/watch?v=lMQZMiSHlUI)
|
||||
|
||||
---
|
||||
|
||||
## Optional Interfaces in the Workflow
|
||||
|
||||
@@ -163,7 +163,7 @@ The **Send test spot** button creates the following test entry:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: Testing DXC-Spot: Congrats, you donated $100!
|
||||
Comment: DXC test: You donated $100!
|
||||
Frequency: .300 on the configured fallback band
|
||||
```
|
||||
|
||||
@@ -191,12 +191,16 @@ A spot contains:
|
||||
- the locator; and
|
||||
- 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.
|
||||
|
||||
An automatic comment with AirScout information may look like this:
|
||||
|
||||
```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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
@@ -248,6 +270,7 @@ The interface has been used with:
|
||||
|
||||
- UCXLog
|
||||
- N1MM+
|
||||
- Minos
|
||||
|
||||
Other loggers may work if they support a normal TCP connection to a DX Cluster server and accept conventional `DX de ...` spot lines.
|
||||
|
||||
|
||||
+36
-14
@@ -24,6 +24,10 @@ Assume that station A sends a directed message to station B:
|
||||
|
||||
A configured beamwidth of `70°` therefore produces an assumed corridor of `35°` on either side of the direction from station A to station B.
|
||||
|
||||

|
||||
|
||||
In the example, the green corridor runs from F5FEN towards DM5M. When F5FEN sends a directed message to DM5M, KST4Contest checks whether the local station lies inside this corridor. A reply from DM5M to F5FEN is calculated again in the opposite direction: the blue corridor then starts at DM5M and points towards F5FEN. Each message therefore evaluates the assumed antenna direction of its sender.
|
||||
|
||||
| Example | Result |
|
||||
|---|---|
|
||||
| Direction A → B: `120°`, direction A → local station: `145°` | Angular difference `25°`: directional opportunity detected |
|
||||
@@ -80,13 +84,15 @@ KST4Contest therefore evaluates the text of every public and directed chat messa
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
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?
|
||||
|
||||
KST4Contest uses the following order:
|
||||
@@ -133,7 +139,7 @@ This distinction matters. A callsign already worked on one band may still be use
|
||||
|
||||
[Log Synchronisation](en-Log-Sync) imports new QSOs from the logging application. The amount of information available depends on the interface being used:
|
||||
|
||||
- The file-based Simplelogfile interpreter detects callsigns only. It can therefore set only the global Worked status.
|
||||
- The file-based Simplelogfile interpreter reads the selected text file once per minute using a fixed callsign pattern. Matches are normalised to the base callsign and set only the global Worked status for all active variants.
|
||||
- The QSO UDP interfaces and the Win-Test network listener can also provide the band.
|
||||
- If the log packet contains a valid locator, KST4Contest additionally stores the worked four-character grid square for that band.
|
||||
|
||||
@@ -202,7 +208,9 @@ In plain terms: an automatically detected hint means "probably active on this ba
|
||||
|
||||
### Storage and lifetime
|
||||
|
||||
Worked, NOT-QRV and worked-grid information is stored in the internal SQLite database and restored on the next start. Entries expire automatically after three days, so a reset before every contest is normally unnecessary.
|
||||
Worked, NOT-QRV and worked-grid information received from network interfaces, together with manual marks, is stored in the internal SQLite database and restored on the next start. Entries expire automatically after three days, so a reset before every contest is normally unnecessary.
|
||||
|
||||
Simplelogfile matches are excluded. They are not persisted in SQLite but are derived from the selected file during each application session. The file is therefore the durable source. The interpreter does not remove an existing Worked mark during the current session and does not reset the state automatically for a new contest. A manual database reset does not change or empty the file. Callsigns contained in it are marked as worked again when the file is next evaluated within one minute.
|
||||
|
||||
A manual reset under **Workedstn database** removes all Worked marks, NOT-QRV marks and stored worked grid squares. The known callsign rows remain in the database. See [Worked Station Database Settings](en-Configuration#worked-station-database-settings) for details.
|
||||
|
||||
@@ -232,7 +240,7 @@ The filters above the user list use the same information as the Worked columns:
|
||||
|
||||
Several active filters are applied together. A station remains visible only if it satisfies every selected condition. The filters react immediately to new log entries and changed NOT-QRV marks.
|
||||
|
||||
Operation and layout of the filter bar: [User Interface – Filters](en-User-Interface#filters).
|
||||
Operation and layout of the filter bar: [User Interface – Filters](en-User-Interface#filters-and-reachability-controls).
|
||||
|
||||
---
|
||||
|
||||
@@ -258,7 +266,7 @@ Messages sent by the local station retain their separate highlight and do not us
|
||||
|
||||
---
|
||||
|
||||
## PM Catching
|
||||
## PM Catching (from v1.1)
|
||||
|
||||
Some users accidentally post direct messages publicly, e.g.:
|
||||
|
||||
@@ -266,7 +274,17 @@ Some users accidentally post direct messages publicly, e.g.:
|
||||
(DM5M) pse ur qrg
|
||||
```
|
||||
|
||||
KST4Contest detects such messages that contain your own callsign and automatically sorts them into the **private messages table**. No messages are missed this way.
|
||||
KST4Contest searches the text of every message not sent by the local station for the configured local login callsign without distinguishing upper- and lower-case letters. A match also appears in the **private messages table**. This applies to public messages addressed to `ALL` and to directed messages between other chat participants.
|
||||
|
||||
A directed message between two other stations is where the slightly flippant description **“gossip detection”** fits best. It is a nickname, not the formal name of the function.
|
||||
|
||||
The original message remains unchanged: its actual receiver remains `ALL` or the other chat participant; its text, chat category and routing are retained as well. PM Catching merely adds another view of the same message.
|
||||
|
||||
The text search cannot infer intention. A typing error or shortened callsign is not recognised. Conversely, a simple mention of the complete callsign can produce a match even when no reply was expected.
|
||||
|
||||
Selecting an incoming row in the PM table prepares a reply to its sender. For an outgoing message from the local station, the original receiver is restored as the target instead. Selecting the row does not send the message.
|
||||
|
||||
Caught rows use the normal PM-table age display. They do not trigger the simple PM sound, CW callsign output or phonetic callsign output. The same applies to messages shown additionally through QSO Monitoring.
|
||||
|
||||
---
|
||||
|
||||
@@ -293,7 +311,7 @@ Configuration, recognised QRG requests and category handling: [Configuration –
|
||||
|
||||
## Multi-Channel Login (from v1.26)
|
||||
|
||||
Simultaneous login to **two chat categories** (e.g. 144 MHz and 432 MHz). Both chats are monitored in parallel.
|
||||
KST4Contest signs in to ON4KST once with one local login callsign and password. One locator is shared by the entire TCP session. A second chat category (e.g. 144 MHz and 432 MHz) is added through Single Sign-on within the same session. Both categories are monitored in parallel, while the visible category-specific name field, message context, QRG and beacon remain separate for each category.
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +325,7 @@ The green private-message age scale remains available in both designs. Text colo
|
||||
|
||||
## Opposite Station Multi-Callsign Login Tagging (from v1.26)
|
||||
|
||||
Support for stations that are active in the chat with multiple callsigns simultaneously (e.g. expedition setups).
|
||||
Support for remote stations which are active in the chat under several complete visible callsign variants at the same time (e.g. expedition setups). The complete callsign and chat category remain separate participant identities, while Worked, band and priority data are shared through the base callsign.
|
||||
|
||||
---
|
||||
|
||||
@@ -423,7 +441,7 @@ Sniffed: (DN9APW-70 > 9A0BB-23) pse sked 19:30
|
||||
|
||||
This distinction is intentional. Monitoring aggregates the variants so that their communication remains visible. Message routing does not aggregate them because the intended login would otherwise become ambiguous.
|
||||
|
||||
The base callsign is evaluated across both connected chat categories. The category of each individual message remains unchanged.
|
||||
The base callsign is evaluated across both chat categories in the shared ON4KST session. The category of each individual message remains unchanged.
|
||||
|
||||
The monitoring view includes:
|
||||
|
||||
@@ -588,7 +606,7 @@ The score is calculated for the normalised base callsign. A sked entered for an
|
||||
|
||||
### How are multiple suffixes and chat categories handled?
|
||||
|
||||
Active callsigns such as `9A0BB-2`, `9A0BB-70`, `9A0BB-23` and `9A0BB-13` remain separate chat members. Messages can therefore still be addressed to the complete callsign in the correct chat category.
|
||||
Active complete visible callsign variants such as `9A0BB-2`, `9A0BB-70`, `9A0BB-23` and `9A0BB-13` remain separate chat members. Messages can therefore still be addressed to the complete callsign in the correct chat category.
|
||||
|
||||
Worked, band, NOT-QRV and score information belongs to the common base callsign `9A0BB`. The score is calculated once and projected to all active variants. The user list may consequently contain several separate rows with the same score, while the priority list contains only one entry for the base station.
|
||||
|
||||
@@ -689,7 +707,7 @@ The timeline is a preview. AirScout data can change, and a stored sked guarantee
|
||||
|
||||
KST4Contest can send recurring CQ messages to the public chat. The beacon is intended for longer periods of calling CQ on a fixed frequency: it publishes the local QRG regularly without requiring the operator to enter the same text again.
|
||||
|
||||
Both chat categories use one shared interval, but each category has its own enable setting and message template. The second beacon is only sent while the second chat is enabled and connected.
|
||||
Both chat categories use one shared interval, but each category has its own enable setting and message template. The second beacon is only sent while the second category is enabled and fully synchronised in the shared ON4KST session.
|
||||
|
||||
Global variables such as `MYQRG`, `SECONDQRG`, `MYLOCATOR` and `MYQTF` are resolved immediately before every transmission. A QRG updated by the logging software can therefore appear in the next beacon.
|
||||
|
||||
@@ -704,7 +722,7 @@ Configuration, timer behaviour and available variables: [Configuration – Beaco
|
||||
|
||||
## Simplelogfile
|
||||
|
||||
File-based log evaluation using regex. Details: [Log Synchronisation](en-Log-Sync#method-1-universal-file-based-callsign-interpreter-simplelogfile).
|
||||
The Simplelogfile interpreter reads callsigns from a selected text file once per minute. If the file is missing, KST4Contest creates it and displays a non-blocking notice with its path and the initial setup and contest checks. Details: [Log Synchronisation](en-Log-Sync#method-1-universal-file-based-callsign-interpreter-simplelogfile).
|
||||
---
|
||||
|
||||
## Global Message Views
|
||||
@@ -802,12 +820,16 @@ Marker colours provide a compact status indication:
|
||||
|---|---|
|
||||
| Blue | Normal station marker |
|
||||
| Yellow | The callsign has already been worked on at least one band |
|
||||
| Green | The station is inside the current antenna sector and is relevant as a directional candidate |
|
||||
| Green | A directional opportunity has been derived for the station from directed chat messages |
|
||||
| Orange | Currently selected station |
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -10,17 +10,19 @@ KST4Contest imports worked stations from the logging application and derives the
|
||||
|
||||
## Method 1: Universal File Based Callsign Interpreter (Simplelogfile)
|
||||
|
||||
KST4Contest reads a log file and searches it for callsigns using a configurable regular expression. The file is read only and is never modified. Binary log files can also be used; content which cannot be interpreted as text is skipped.
|
||||
KST4Contest reads the selected text file once per minute and searches it for callsigns using a fixed built-in regular expression. Each match is normalised to its base callsign, so the global Worked status applies to every currently active chat variant of that callsign.
|
||||
|
||||
The advantage is broad compatibility: no dedicated network interface is required from the logging application.
|
||||
|
||||
The limitation is equally clear. A callsign match alone provides neither a reliable band nor a locator. The Simplelogfile interpreter can therefore set only the global Worked status. It does not create a per-band `X`, a worked-grid record or a reliable basis for the band-upgrade hint after a log entry.
|
||||
|
||||
Configure the log-file path and regular expression in the **Log sync** tab. Use one of the network interfaces where possible if band-specific information is required.
|
||||
Select the text-file path in the **Log sync** tab. If the file is missing, KST4Contest creates it and displays a one-time, non-blocking notice with the path and the checks to perform next. Read or creation errors are logged; the scheduled task continues with its next one-minute pass. Use one of the network interfaces where possible if band-specific information is required.
|
||||
|
||||
Worked status derived from the Simplelogfile is not stored in the internal SQLite database. The selected file is the durable source and is read again after every restart. The interpreter only adds positive Worked marks; it does not remove an existing mark during the current application session and does not reset automatically for a new contest. A manual database reset does not change or empty the Simplelogfile either. Callsigns contained in it are marked as worked again when the file is next evaluated within one minute. Before each contest, verify that the logging application is writing the current contest log to this exact file.
|
||||
|
||||
---
|
||||
|
||||
# Method 2: Network Listener for QSO UDP Packets – Recommended
|
||||
## Method 2: Network Listener for QSO UDP Packets – Recommended
|
||||
|
||||
UCXLog, QARTest, N1MM+ and DXLog.net can transmit a UDP packet when a QSO is saved. KST4Contest receives these packets on port `12060` by default and imports the callsign together with any band and locator information they contain.
|
||||
|
||||
@@ -100,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.
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
@@ -152,7 +172,11 @@ In the **TRX sync** tab:
|
||||
- `Use pass frequency from Win-Test STATUS`
|
||||
- `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)
|
||||
|
||||
@@ -164,7 +188,7 @@ In addition to QSO synchronisation, UCXLog and other programs also transmit the
|
||||
|
||||

|
||||
|
||||
**Result**: Your own QRG never needs to be typed manually in the chat – clicking the MYQRG button or using the variable in the beacon is sufficient.
|
||||
**Result**: An enabled interface updates `MYQRG` when it actually supplies valid frequency packets. Enabling an interface does not create a QRG on its own. If no suitable packets arrive, check the interface or disable both automatic sources and maintain the QRG manually.
|
||||
|
||||
**Sources for your own QRG (MYQRG):**
|
||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP port 12060
|
||||
@@ -192,7 +216,7 @@ For DM5M-style setups (2 radios, 2 computers, one KST4Contest instance or two se
|
||||
|
||||
## Internal Database
|
||||
|
||||
KST4Contest stores Worked, NOT-QRV and worked-grid information in its own SQLite database. This database is independent of the logging application's database.
|
||||
KST4Contest stores Worked, NOT-QRV and worked-grid information received from network interfaces, together with manual marks, in its own SQLite database. This database is independent of the logging application's database. Simplelogfile matches are excluded and are derived again from the selected file in each application session.
|
||||
|
||||
The input sources provide different levels of detail:
|
||||
|
||||
@@ -202,6 +226,6 @@ The input sources provide different levels of detail:
|
||||
| QSO UDP listener | yes | yes, if included in the packet | yes, if both band and locator are available |
|
||||
| Win-Test network listener | yes | yes | yes, if a locator is available |
|
||||
|
||||
The information is restored when KST4Contest starts and updated during operation when new log entries arrive. It expires automatically after three days, so a reset before every contest is normally unnecessary.
|
||||
The information stored in SQLite is restored when KST4Contest starts and updated during operation when new log entries arrive. It expires automatically after three days, so a reset before every contest is normally unnecessary. This lifetime does not apply to the Simplelogfile interpreter: its file remains the durable source and is neither changed nor emptied by a database reset. Callsigns contained in it set the global Worked status again during the next periodic evaluation.
|
||||
|
||||
A complete manual reset removes Worked marks, NOT-QRV marks and worked grid squares together. See [Worked Station Database Settings](en-Configuration#worked-station-database-settings) for details.
|
||||
A complete manual reset removes Worked marks, NOT-QRV marks and worked grid squares together. See [Worked Station Database Settings](en-Configuration#worked-station-database-settings) for details.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Connecting to the Chat
|
||||
|
||||
Before connecting for the first time, configure at least the callsign, password, locator and primary chat category in the settings window. If a second category is required, its login must also be enabled and configured completely.
|
||||
Before connecting for the first time, configure the local login callsign and password, the locator shared by the TCP session, and the primary chat category in the settings window. If a second category is required, enable and select it there. KST4Contest signs in once and adds the second category through Single Sign-on within the same TCP session.
|
||||
|
||||
The connection can be started in two ways:
|
||||
|
||||
@@ -54,11 +54,19 @@ Both indicators flash for approximately twelve seconds and then disappear. Their
|
||||
|
||||
### PM Window (top left)
|
||||
|
||||
The PM window shows private messages addressed to the local chat logins and the corresponding outgoing replies.
|
||||
The PM window shows private messages addressed to the local login callsign and the corresponding outgoing replies.
|
||||
|
||||
Messages not sent by the local station are also shown when their text contains the configured local login callsign, ignoring letter case. This applies to public messages addressed to `ALL` and to directed messages between other chat participants. The informal description **“gossip detection”** is particularly apt for the latter case.
|
||||
|
||||
The actual receiver, message text, chat category and routing remain unchanged. The message merely gains an additional representation in the PM window.
|
||||
|
||||
If [QSO Monitoring](en-Features#qso-sniffer-from-v131) is enabled, it additionally shows captured messages involving the monitored base callsigns. These entries receive a `Sniffed:` prefix containing the complete visible sender and receiver callsigns.
|
||||
|
||||
New messages are initially highlighted and then gradually return to the normal table colour. This highlighting only indicates the age of the message; it does not change its content or routing.
|
||||
New rows not sent by the local station pass through six green age levels and return to the normal table colour after five minutes. Messages sent by the local station retain their separate highlight. The colour only indicates message age; it does not change content or routing.
|
||||
|
||||
Selecting an incoming row prepares a reply to the sender. For an outgoing message from the local station, the original receiver is restored as the message target instead. Caught and monitored rows do not trigger PM audio output.
|
||||
|
||||
Age levels: [Coloured PM Rows](en-Features#coloured-pm-rows-from-v125). Recognition and limitations: [PM Catching](en-Features#pm-catching-from-v11).
|
||||
|
||||
### User List (Chat Members)
|
||||
|
||||
@@ -146,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.
|
||||
|
||||
@@ -323,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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -428,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:
|
||||
|
||||
@@ -474,13 +494,13 @@ Functions which communicate with the server are available only after the ON4KST
|
||||
|
||||
## 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 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
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
|
||||
pkgver = 1.42.0
|
||||
pkgver = 1.44.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
@@ -10,7 +10,7 @@ pkgbase = kst4contest-bin
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest
|
||||
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
|
||||
sha256sums = 3d8ac19c9f9d3ab0bdaf0ea621de0aa18c64f442c8776d28bfad607522aaf02b
|
||||
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 = 62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0
|
||||
|
||||
pkgname = kst4contest-bin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-bin
|
||||
pkgver=1.42.0
|
||||
pkgver=1.44.0
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
|
||||
arch=('x86_64')
|
||||
@@ -10,7 +10,7 @@ depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
provides=('kst4contest')
|
||||
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")
|
||||
sha256sums=('3d8ac19c9f9d3ab0bdaf0ea621de0aa18c64f442c8776d28bfad607522aaf02b')
|
||||
sha256sums=('62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0')
|
||||
|
||||
package() {
|
||||
cp -a "${srcdir}/usr" "${pkgdir}/"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = kst4contest-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
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-git
|
||||
pkgver=1.42.0.r256.g8aadbb9
|
||||
pkgver=1.44.0.r299.g08d65a0e
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = kst4contest
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
|
||||
pkgver = 1.42.0
|
||||
pkgver = 1.44.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
@@ -12,7 +12,7 @@ pkgbase = kst4contest
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest-bin
|
||||
conflicts = kst4contest-git
|
||||
source = kst4contest-1.42.0.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.42.0.tar.gz
|
||||
sha256sums = bd396387b8de41aac706458d5ebf64140ab3e83b8a7c710b66aa802bd48e804e
|
||||
source = kst4contest-1.44.0.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.44.0.tar.gz
|
||||
sha256sums = e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914
|
||||
|
||||
pkgname = kst4contest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest
|
||||
pkgver=1.42.0
|
||||
pkgver=1.44.0
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
|
||||
arch=('x86_64')
|
||||
@@ -11,7 +11,7 @@ makedepends=('java-environment=21' 'maven')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest-bin' 'kst4contest-git')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('bd396387b8de41aac706458d5ebf64140ab3e83b8a7c710b66aa802bd48e804e')
|
||||
sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/kst4contest-${pkgver}"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>de.x08</groupId>
|
||||
<artifactId>praktiKST</artifactId>
|
||||
<version>1.42.0-nightly</version>
|
||||
<version>1.50.0-nightly</version>
|
||||
|
||||
<name>praktiKST</name>
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ public class ApplicationConstants {
|
||||
/**
|
||||
* 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.50.0";
|
||||
|
||||
/**
|
||||
* Legacy numeric representation used only while older update feeds and
|
||||
* application versions still exist.
|
||||
*/
|
||||
@Deprecated
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.42;
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.50;
|
||||
|
||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
|
||||
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
/**
|
||||
* Holds the operator profile the current runtime works with.
|
||||
*
|
||||
* <p>The state is deliberately static, which is safe here for reasons that did not apply
|
||||
* to the former static database connection: the value is immutable, it holds no live
|
||||
* resource, and it is set on the JavaFX Application Thread before anything reads it -
|
||||
* during startup, and again during a profile switch after the previous runtime has been
|
||||
* shut down completely.</p>
|
||||
*/
|
||||
public final class ActiveOperatorProfile {
|
||||
|
||||
private static volatile OperatorProfileSelection currentSelection;
|
||||
|
||||
private ActiveOperatorProfile() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active profile selection.
|
||||
*
|
||||
* @return the active selection, or null when startup has not resolved one yet
|
||||
*/
|
||||
public static OperatorProfileSelection get() {
|
||||
return currentSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active profile selection.
|
||||
*
|
||||
* @param selection selection to activate
|
||||
*/
|
||||
public static void set(final OperatorProfileSelection selection) {
|
||||
currentSelection = selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a profile has already been resolved for this runtime.
|
||||
*
|
||||
* @return true if a selection is present
|
||||
*/
|
||||
public static boolean isInitialized() {
|
||||
return currentSelection != null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package kst4contest.controller;
|
||||
import java.net.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.Selector;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -1082,9 +1083,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
rotatorClient.stop();
|
||||
rotatorClient = null;
|
||||
}
|
||||
|
||||
releaseBackgroundExecutors();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background executors that live as long as this controller.
|
||||
*
|
||||
* <p>These are not bound to one ON4KST session, so disconnecting leaves them running
|
||||
* on purpose. When the controller itself is discarded they have to go, otherwise a
|
||||
* discarded controller stays reachable through its own threads.</p>
|
||||
*/
|
||||
private void releaseBackgroundExecutors() {
|
||||
|
||||
on4KstConnectionManager.shutdown();
|
||||
skedReminderService.shutdown();
|
||||
|
||||
if (reachabilityService != null) {
|
||||
reachabilityService.shutdown();
|
||||
}
|
||||
|
||||
if (pendingRotatorRetry != null) {
|
||||
pendingRotatorRetry.cancel(false);
|
||||
pendingRotatorRetry = null;
|
||||
}
|
||||
|
||||
rotatorCommandScheduler.shutdownNow();
|
||||
}
|
||||
|
||||
private void cancelTimer(Timer timer) {
|
||||
if (timer != null) {
|
||||
timer.cancel();
|
||||
@@ -1203,10 +1230,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return;
|
||||
}
|
||||
|
||||
InetAddress broadcastAddress = InetAddress.getByName(
|
||||
chatPreferences
|
||||
.getLogsynch_wintestNetworkBroadcastAddress()
|
||||
);
|
||||
InetAddress broadcastAddress =
|
||||
winTestAddressResolver.resolveBroadcastAddress(
|
||||
chatPreferences
|
||||
.getLogsynch_wintestNetworkBroadcastAddress()
|
||||
);
|
||||
|
||||
int port =
|
||||
chatPreferences.getLogsynch_wintestNetworkPort();
|
||||
@@ -1641,11 +1669,26 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// private String hostname = "109.90.0.130";
|
||||
private String hostname;
|
||||
// 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 ReadUDPbyUCXMessageThread readUDPbyUCXThread;
|
||||
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 ReadThread readThread;
|
||||
private InputReaderThread consoleReader;
|
||||
@@ -1847,7 +1890,7 @@ private ObservableList<String>
|
||||
* assumption which one is the current run frequency. Therefore the legacy
|
||||
* frequency property is initialized only when exactly one explicit QRG exists.</p>
|
||||
*/
|
||||
private void initializeFrequencyFromStationNameIfUnambiguous(
|
||||
/* package */ void initializeFrequencyFromStationNameIfUnambiguous(
|
||||
ChatMember member
|
||||
) {
|
||||
if (member == null) {
|
||||
@@ -1941,6 +1984,8 @@ private ObservableList<String>
|
||||
int categoryNumber = category.getCategoryNumber();
|
||||
List<ChatMember> safeMembers = completeMembers == null
|
||||
? List.of() : new ArrayList<>(completeMembers);
|
||||
Map<String, ChatMember> workedDataFromDatabase =
|
||||
loadWorkedStateForInitialUserList(safeMembers);
|
||||
for (ChatMember member : safeMembers) {
|
||||
initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||
}
|
||||
@@ -1965,10 +2010,48 @@ private ObservableList<String>
|
||||
&& member.getChatCategory() != null
|
||||
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
|
||||
lst_chatMemberList.addAll(safeMembers);
|
||||
if (workedDataFromDatabase != null) {
|
||||
getLst_DBBasedWkdCallSignList().setAll(
|
||||
workedDataFromDatabase.values());
|
||||
}
|
||||
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
|
||||
* TableView backing list from MessageBusManagementThread.
|
||||
@@ -2016,6 +2099,177 @@ private ObservableList<String>
|
||||
return matchingMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a QSO received from an external logger to all active callsign
|
||||
* variants. Active member objects back JavaFX views, so their state is changed
|
||||
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated
|
||||
* afterwards and therefore sees the new Worked state.
|
||||
*
|
||||
* @param loggedQso validated external QSO
|
||||
*/
|
||||
public void applyExternalLoggedQso(ExternalLoggedQso loggedQso) {
|
||||
if (loggedQso == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
runOnFxThread(() -> {
|
||||
int updatedMembers = markExternalLoggedQsoMembers(
|
||||
activeChatMembersByCallAndCategory.values(), loggedQso);
|
||||
if (updatedMembers == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireUserListUpdate("External logger Worked state updated");
|
||||
try {
|
||||
onExternalLogEntryReceived(loggedQso.getCallSign());
|
||||
} catch (Exception exception) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Band-upgrade hint failed after external logger update for "
|
||||
+ loggedQso.getCallSign(), exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static int markExternalLoggedQsoMembers(
|
||||
Collection<ChatMember> members,
|
||||
ExternalLoggedQso loggedQso
|
||||
) {
|
||||
if (members == null || loggedQso == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
String workedBaseCall = ChatMember.normalizeCallSignToBaseCallSign(
|
||||
loggedQso.getCallSign());
|
||||
if (workedBaseCall == null || workedBaseCall.isBlank()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updatedMembers = 0;
|
||||
for (ChatMember member : members) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCall = member.getCallSignRaw() != null
|
||||
? member.getCallSignRaw() : member.getCallSign();
|
||||
String memberBaseCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
|
||||
if (memberBaseCall != null && memberBaseCall.equalsIgnoreCase(workedBaseCall)) {
|
||||
loggedQso.applyToActiveMember(member);
|
||||
updatedMembers++;
|
||||
}
|
||||
}
|
||||
return updatedMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the global Worked state from the Simplelogfile to every active
|
||||
* callsign variant with the same base callsign. UI-backed state is changed only
|
||||
* on the JavaFX Application Thread.
|
||||
*
|
||||
* @param workedBaseCalls normalized callsigns detected in the selected file
|
||||
*/
|
||||
public void applySimpleLogWorkedBaseCalls(Set<String> workedBaseCalls) {
|
||||
Set<String> normalizedWorkedBaseCalls = normalizeWorkedBaseCalls(workedBaseCalls);
|
||||
if (normalizedWorkedBaseCalls.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
runOnFxThread(() -> {
|
||||
int changedMembers = markSimpleLogWorkedMembers(
|
||||
activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls);
|
||||
int changedClusterMessages = markSimpleLogWorkedClusterMessages(
|
||||
lst_clusterMemberList, normalizedWorkedBaseCalls);
|
||||
|
||||
if (changedMembers > 0) {
|
||||
fireUserListUpdate("Simplelogfile Worked status updated");
|
||||
}
|
||||
|
||||
LOGGER.log(Level.FINE,
|
||||
"Simplelogfile marked {0} active members and {1} cluster messages as worked.",
|
||||
new Object[] { changedMembers, changedClusterMessages });
|
||||
});
|
||||
}
|
||||
|
||||
static int markSimpleLogWorkedMembers(
|
||||
Collection<ChatMember> members,
|
||||
Set<String> workedBaseCalls
|
||||
) {
|
||||
if (members == null || workedBaseCalls == null || workedBaseCalls.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (ChatMember member : members) {
|
||||
if (member == null || member.isWorked()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCall = member.getCallSignRaw() != null
|
||||
? member.getCallSignRaw() : member.getCallSign();
|
||||
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
|
||||
if (baseCall != null && workedBaseCalls.contains(baseCall.toUpperCase(Locale.ROOT))) {
|
||||
member.setWorked(true);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static int markSimpleLogWorkedClusterMessages(
|
||||
Collection<ClusterMessage> clusterMessages,
|
||||
Set<String> workedBaseCalls
|
||||
) {
|
||||
if (clusterMessages == null || workedBaseCalls == null || workedBaseCalls.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (ClusterMessage clusterMessage : clusterMessages) {
|
||||
if (clusterMessage == null || clusterMessage.isReceiverWkd()
|
||||
|| clusterMessage.getReceiver() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ChatMember receiver = clusterMessage.getReceiver();
|
||||
String receiverCall = receiver.getCallSignRaw() != null
|
||||
? receiver.getCallSignRaw() : receiver.getCallSign();
|
||||
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(receiverCall);
|
||||
if (baseCall != null && workedBaseCalls.contains(baseCall.toUpperCase(Locale.ROOT))) {
|
||||
clusterMessage.setReceiverWkd(true);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static Set<String> normalizeWorkedBaseCalls(Set<String> workedBaseCalls) {
|
||||
if (workedBaseCalls == null || workedBaseCalls.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
Set<String> normalizedCalls = new HashSet<>();
|
||||
for (String callSign : workedBaseCalls) {
|
||||
String baseCall = ChatMember.normalizeCallSignToBaseCallSign(callSign);
|
||||
if (baseCall != null && !baseCall.isBlank()) {
|
||||
normalizedCalls.add(baseCall.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(normalizedCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the UI after a missing Simplelogfile was created successfully.
|
||||
*
|
||||
* @param filePath absolute path of the new file
|
||||
*/
|
||||
public void notifySimpleLogFileCreated(Path filePath) {
|
||||
if (filePath == null || statusListener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
runOnFxThread(() -> statusListener.onSimpleLogFileCreated(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the band-specific NOT-QRV state to every active category variant of the
|
||||
* same base callsign. The database already uses callSignRaw as its key; applying
|
||||
@@ -2638,9 +2892,35 @@ private ObservableList<String>
|
||||
* @param setOwnChatMemberObject
|
||||
*/
|
||||
public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) {
|
||||
this(setOwnChatMemberObject,
|
||||
listener,
|
||||
ChatPreferences.PREFERENCES_FILE,
|
||||
DBController.DATABASE_FILE,
|
||||
true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a chat controller bound to the files of one operator profile.
|
||||
*
|
||||
* <p>Both file names are resolved below the application directory. This is the only
|
||||
* place where the active operator profile enters the controller; everything below
|
||||
* works on the resulting {@link ChatPreferences} and {@link DBController} instances
|
||||
* without knowing about profiles at all.</p>
|
||||
*
|
||||
* @param setOwnChatMemberObject chat member object representing the local station
|
||||
* @param listener callback for thread status updates
|
||||
* @param preferencesRelativeFileName preferences file name relative to the application directory
|
||||
* @param workedDatabaseRelativeFileName worked-station database file name relative to the application directory
|
||||
* @param seedWorkedDatabaseFromResource true to seed a missing database from the bundled template
|
||||
*/
|
||||
public ChatController(ChatMember setOwnChatMemberObject,
|
||||
StatusUpdateListener listener,
|
||||
String preferencesRelativeFileName,
|
||||
String workedDatabaseRelativeFileName,
|
||||
boolean seedWorkedDatabaseFromResource) {
|
||||
super();
|
||||
|
||||
chatPreferences = new ChatPreferences();
|
||||
chatPreferences = new ChatPreferences(preferencesRelativeFileName);
|
||||
chatPreferences.readPreferencesFromXmlFile();
|
||||
// this.statusListener = listener;
|
||||
lstNotify_QSOSniffer_sniffedCallSignList =
|
||||
@@ -2702,7 +2982,7 @@ private ObservableList<String>
|
||||
}
|
||||
});
|
||||
|
||||
dbHandler = new DBController();
|
||||
dbHandler = new DBController(workedDatabaseRelativeFileName, seedWorkedDatabaseFromResource);
|
||||
reachabilityService = new ReachabilityService(this);
|
||||
rebuildWorkedGrossFieldCacheFromDatabase();
|
||||
|
||||
@@ -3682,15 +3962,6 @@ private ObservableList<String>
|
||||
|
||||
}
|
||||
|
||||
new Timer().schedule(new TimerTask() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("fetchWorkedFromDBTimer");
|
||||
refreshWorkedStateAndDatabaseListFromDatabase();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// new Timer().schedule(new TimerTask() {
|
||||
// HashMap<String, ChatMember> getWorkedDataFromDb;
|
||||
//
|
||||
@@ -3794,23 +4065,34 @@ private ObservableList<String>
|
||||
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
helper_applyWorkedAndQrvStateFromDatabase(finalWorkedDataFromDatabase);
|
||||
applyWorkedAndQrvStateFromDatabase(
|
||||
activeChatMembersByCallAndCategory.values(),
|
||||
finalWorkedDataFromDatabase);
|
||||
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values());
|
||||
fireUserListUpdate("Worked database state refreshed");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the worked and not-QRV state from the database snapshot to all active
|
||||
* chatmember objects that are currently visible in the live chat list.
|
||||
* Applies the worked and not-QRV state from a database snapshot to chat members.
|
||||
* 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
|
||||
*/
|
||||
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();) {
|
||||
|
||||
ChatMember activeChatMember = (ChatMember) iterator.next();
|
||||
for (ChatMember activeChatMember : chatMembers) {
|
||||
if (activeChatMember == null) {
|
||||
continue;
|
||||
}
|
||||
ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw());
|
||||
|
||||
if (storedChatMemberState == null) {
|
||||
@@ -4098,4 +4380,4 @@ private ObservableList<String>
|
||||
|
||||
return "Sniffed: (" + senderCall + " > " + receiverCall + ") " + msgText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
@@ -52,34 +56,128 @@ public class DBController {
|
||||
*/
|
||||
private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;
|
||||
|
||||
private static final DBController dbcontroller = new DBController();
|
||||
private static Connection connection;
|
||||
private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
/**
|
||||
* Lazily created controller for the root installation database. It is created on
|
||||
* first use only, because an eagerly created instance would open a database file
|
||||
* before the application knows which operator profile is active.
|
||||
*/
|
||||
private static volatile DBController defaultInstance;
|
||||
|
||||
private Connection connection;
|
||||
|
||||
/**
|
||||
* File name of this database relative to the application directory, for example
|
||||
* "praktiKST.db" or "profiles/OP2/praktiKST.db".
|
||||
*/
|
||||
private final String databaseRelativeFileName;
|
||||
|
||||
/**
|
||||
* Absolute path of the database file, resolved once during construction.
|
||||
*/
|
||||
private final String databaseFilePath;
|
||||
|
||||
/**
|
||||
* True if a missing database file should be seeded from the shipped template.
|
||||
*/
|
||||
private final boolean seedFromResource;
|
||||
|
||||
/**
|
||||
* Shutdown hook of this instance. It is remembered so it can be deregistered when
|
||||
* the connection is closed. Without that, every operator profile switch would leave
|
||||
* another hook behind that keeps a dead connection alive until the process ends.
|
||||
*/
|
||||
private Thread databaseShutdownHook;
|
||||
|
||||
/**
|
||||
* Remembers the last timestamp at which the expiration cleanup had been executed.
|
||||
*/
|
||||
private long lastExpirationCleanupExecutionEpochMs = 0L;
|
||||
|
||||
/**
|
||||
* Creates a controller for the worked-station database of the root installation.
|
||||
*/
|
||||
public DBController() {
|
||||
initDBConnection();
|
||||
}
|
||||
|
||||
public static DBController getInstance() {
|
||||
return dbcontroller;
|
||||
this(DATABASE_FILE, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection if it is still open.
|
||||
* Creates a controller for the worked-station database of one operator profile.
|
||||
*
|
||||
* @param databaseRelativeFileName file name relative to the application directory
|
||||
* @param seedFromResource true to copy the shipped template database when the file
|
||||
* does not exist yet, false to create an empty database and
|
||||
* let the schema creation build all required tables
|
||||
*/
|
||||
public DBController(final String databaseRelativeFileName, final boolean seedFromResource) {
|
||||
this.databaseRelativeFileName =
|
||||
Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName");
|
||||
this.seedFromResource = seedFromResource;
|
||||
this.databaseFilePath = ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
databaseRelativeFileName
|
||||
);
|
||||
|
||||
initDBConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a controller for the root installation database, creating it on first use.
|
||||
*
|
||||
* @return the shared controller for the root installation database
|
||||
*/
|
||||
public static synchronized DBController getInstance() {
|
||||
|
||||
if (defaultInstance == null) {
|
||||
defaultInstance = new DBController();
|
||||
}
|
||||
|
||||
return defaultInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path of the database file this controller works on.
|
||||
*
|
||||
* @return absolute database file path
|
||||
*/
|
||||
public String getDatabaseFilePath() {
|
||||
return databaseFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection if it is still open and deregisters the shutdown
|
||||
* hook of this instance.
|
||||
*/
|
||||
public synchronized void closeDBConnection() {
|
||||
|
||||
closeConnectionQuietly();
|
||||
|
||||
if (databaseShutdownHook != null) {
|
||||
try {
|
||||
Runtime.getRuntime().removeShutdownHook(databaseShutdownHook);
|
||||
} catch (IllegalStateException shutdownAlreadyInProgress) {
|
||||
// Expected while the JVM is shutting down; the hook is running anyway.
|
||||
}
|
||||
|
||||
databaseShutdownHook = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the connection without touching the shutdown hook. This is also the body of
|
||||
* the shutdown hook itself.
|
||||
*/
|
||||
private synchronized void closeConnectionQuietly() {
|
||||
|
||||
try {
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
connection.close();
|
||||
System.out.println("Connection to Database closed: " + databaseFilePath);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
connection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,22 +189,17 @@ public class DBController {
|
||||
System.out.println("DBH: initiate new db connection");
|
||||
|
||||
try {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DATABASE_RESOURCE,
|
||||
DATABASE_FILE
|
||||
);
|
||||
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
prepareDatabaseFile();
|
||||
|
||||
System.out.println("Creating Connection to Database...");
|
||||
|
||||
DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);
|
||||
connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath);
|
||||
|
||||
System.out.println("[DBH, Info]: Path = " + DB_PATH);
|
||||
System.out.println("[DBH, Info]: Path = " + databaseFilePath);
|
||||
|
||||
if (!connection.isClosed()) {
|
||||
System.out.println("...Connection established");
|
||||
@@ -115,25 +208,50 @@ public class DBController {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread() {
|
||||
public void run() {
|
||||
try {
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
connection.close();
|
||||
|
||||
if (connection.isClosed()) {
|
||||
System.out.println("Connection to Database closed");
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
databaseShutdownHook = new Thread(this::closeConnectionQuietly,
|
||||
"DBController-shutdown-" + databaseRelativeFileName);
|
||||
Runtime.getRuntime().addShutdownHook(databaseShutdownHook);
|
||||
|
||||
ensureChatMemberTableCompatibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the database file can be opened.
|
||||
*
|
||||
* <p>The database of the root installation is seeded from the shipped template so
|
||||
* existing installations keep their historic content. A database that belongs to an
|
||||
* additional operator profile is created empty on purpose: the shipped template
|
||||
* carries several thousand foreign callsigns and an outdated schema version, which
|
||||
* would present a new operator with foreign data and trigger the full callsign
|
||||
* normalization rebuild. The required tables are created by
|
||||
* {@link #ensureChatMemberTableCompatibility()} in both cases.</p>
|
||||
*/
|
||||
private synchronized void prepareDatabaseFile() {
|
||||
|
||||
if (seedFromResource) {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DATABASE_RESOURCE,
|
||||
databaseRelativeFileName
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Path parentDirectory = Path.of(databaseFilePath).getParent();
|
||||
|
||||
if (parentDirectory == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createDirectories(parentDirectory);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(
|
||||
"[DBH, ERROR:] Could not create database directory " + parentDirectory, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the ChatMember table exists, that all required columns are
|
||||
* available for newer software versions, that existing old callsign keys are
|
||||
|
||||
@@ -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(
|
||||
ChatMember chatMember
|
||||
) {
|
||||
final byte[] clusterPayload;
|
||||
final String clusterMessage;
|
||||
|
||||
try {
|
||||
@@ -162,24 +163,27 @@ public class DXClusterThreadPooledServer implements Runnable {
|
||||
.getNotify_optionalFrequencyPrefix()
|
||||
);
|
||||
|
||||
clusterMessage =
|
||||
"DX de "
|
||||
+ chatController
|
||||
clusterPayload = DXClusterSpotFormatter.formatPayload(
|
||||
chatController
|
||||
.getChatPreferences()
|
||||
.getNotify_DXCSrv_SpottersCallSign()
|
||||
.getValue()
|
||||
+ ": "
|
||||
+ frequency
|
||||
+ " "
|
||||
+ chatMember.getCallSign().toUpperCase()
|
||||
+ " "
|
||||
+ chatMember.getQra().toUpperCase()
|
||||
+ " "
|
||||
+ new Utils4KST()
|
||||
.getValue(),
|
||||
frequency,
|
||||
chatMember.getCallSign(),
|
||||
chatMember.getQra(),
|
||||
new Utils4KST()
|
||||
.time_generateCurrenthhmmZTimeStringForClusterMessage()
|
||||
+ ((char) 7)
|
||||
+ ((char) 7)
|
||||
+ "\r\n";
|
||||
);
|
||||
clusterMessage = new String(
|
||||
clusterPayload,
|
||||
StandardCharsets.US_ASCII
|
||||
);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"DX Cluster spot rejected: " + exception.getMessage()
|
||||
);
|
||||
return false;
|
||||
} catch (Exception exception) {
|
||||
LOGGER.log(
|
||||
Level.SEVERE,
|
||||
@@ -204,11 +208,7 @@ public class DXClusterThreadPooledServer implements Runnable {
|
||||
|
||||
try {
|
||||
OutputStream output = socket.getOutputStream();
|
||||
output.write(
|
||||
clusterMessage.getBytes(
|
||||
StandardCharsets.US_ASCII
|
||||
)
|
||||
);
|
||||
output.write(clusterPayload);
|
||||
output.flush();
|
||||
deliveredClients++;
|
||||
} catch (IOException exception) {
|
||||
@@ -360,4 +360,4 @@ class DXClusterServerWorkerRunnable implements Runnable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
/**
|
||||
* Validated QSO state shared by external logger listeners and the controller.
|
||||
*/
|
||||
public final class ExternalLoggedQso {
|
||||
private final String callSign;
|
||||
private final LoggedQsoBand band;
|
||||
private final String locator;
|
||||
private final String source;
|
||||
|
||||
private ExternalLoggedQso(
|
||||
String callSign,
|
||||
LoggedQsoBand band,
|
||||
String locator,
|
||||
String source
|
||||
) {
|
||||
this.callSign = callSign;
|
||||
this.band = band;
|
||||
this.locator = locator;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a QSO only when the logger supplied a usable callsign. Missing band
|
||||
* and locator values remain unavailable and never receive guessed defaults.
|
||||
*
|
||||
* @param rawCallSign callsign received from the logger
|
||||
* @param band normalized band or {@code null}
|
||||
* @param rawLocator locator received from the logger or {@code null}
|
||||
* @param source logger name used for persistence diagnostics
|
||||
* @return validated QSO, or an empty value for a missing callsign
|
||||
*/
|
||||
public static Optional<ExternalLoggedQso> create(
|
||||
String rawCallSign,
|
||||
LoggedQsoBand band,
|
||||
String rawLocator,
|
||||
String source
|
||||
) {
|
||||
if (rawCallSign == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String normalizedCallSign = rawCallSign.trim();
|
||||
if (normalizedCallSign.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalizedLocator = WorkedGrossFieldCache.extractLocator6(rawLocator);
|
||||
return Optional.of(new ExternalLoggedQso(
|
||||
normalizedCallSign,
|
||||
band,
|
||||
normalizedLocator,
|
||||
source
|
||||
));
|
||||
}
|
||||
|
||||
public String getCallSign() {
|
||||
return callSign;
|
||||
}
|
||||
|
||||
public LoggedQsoBand getBand() {
|
||||
return band;
|
||||
}
|
||||
|
||||
public String getLocator() {
|
||||
return locator;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the database representation of this QSO.
|
||||
*
|
||||
* @return chat-member state with global and optional band Worked flags
|
||||
*/
|
||||
public ChatMember toWorkedChatMember() {
|
||||
ChatMember workedCall = new ChatMember();
|
||||
workedCall.setCallSign(callSign);
|
||||
workedCall.setWorked(true);
|
||||
if (band != null) {
|
||||
band.applyWorkedFlag(workedCall);
|
||||
}
|
||||
if (locator != null) {
|
||||
workedCall.setQra(locator);
|
||||
}
|
||||
return workedCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies this QSO to one active callsign variant.
|
||||
*
|
||||
* @param member active variant with the same base callsign
|
||||
*/
|
||||
public void applyToActiveMember(ChatMember member) {
|
||||
if (member == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
member.setWorked(true);
|
||||
if (band != null) {
|
||||
band.applyWorkedFlag(member);
|
||||
}
|
||||
if (locator != null
|
||||
&& (member.getQra() == null
|
||||
|| member.getQra().isBlank()
|
||||
|| "unknown".equalsIgnoreCase(member.getQra()))) {
|
||||
member.setQra(locator);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
/**
|
||||
* Normalized band of a QSO received from an external logger.
|
||||
*
|
||||
* <p>Logger protocols use different representations for the same amateur-radio
|
||||
* band. UCXLog-compatible XML may contain a frequency-like number or a metre /
|
||||
* centimetre label, while Win-Test uses its own numeric band IDs. Resolving the
|
||||
* raw value once prevents the Worked flag and gross-field update from interpreting
|
||||
* the same packet differently.</p>
|
||||
*/
|
||||
public enum LoggedQsoBand {
|
||||
BAND_50(Band.B_50),
|
||||
BAND_70(Band.B_70),
|
||||
BAND_144(Band.B_144),
|
||||
BAND_432(Band.B_432),
|
||||
BAND_1296(Band.B_1296),
|
||||
BAND_2320(Band.B_2320),
|
||||
BAND_3400(Band.B_3400),
|
||||
BAND_5760(Band.B_5760),
|
||||
BAND_10G(Band.B_10G),
|
||||
BAND_24G(Band.B_24G),
|
||||
BAND_47G(null),
|
||||
BAND_76G(null);
|
||||
|
||||
private final Band projectBand;
|
||||
|
||||
LoggedQsoBand(Band projectBand) {
|
||||
this.projectBand = projectBand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves aliases used by UCXLog, N1MM+, DXLog.net, QARTest and compatible
|
||||
* loggers. The input is trimmed and case-normalized exactly once here.
|
||||
*
|
||||
* @param rawBand band value received in the XML packet
|
||||
* @return normalized band, or {@code null} for a missing or unknown value
|
||||
*/
|
||||
public static LoggedQsoBand fromLoggerValue(String rawBand) {
|
||||
if (rawBand == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedBand = rawBand.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalizedBand) {
|
||||
case "50", "6m" -> BAND_50;
|
||||
case "70", "4m" -> BAND_70;
|
||||
case "144", "2m" -> BAND_144;
|
||||
case "432", "70cm" -> BAND_432;
|
||||
case "1240", "1296", "23cm" -> BAND_1296;
|
||||
case "2300", "2320", "13cm" -> BAND_2320;
|
||||
case "3400", "9cm" -> BAND_3400;
|
||||
case "5600", "5760", "6cm" -> BAND_5760;
|
||||
case "10g", "10368", "3cm" -> BAND_10G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the established Win-Test ADDQSO band IDs. IDs 22 and 23 retain
|
||||
* their existing Worked flags even though the project Band enum has no 47 GHz
|
||||
* or 76 GHz value for gross-field storage.
|
||||
*
|
||||
* @param rawBandId Win-Test band ID
|
||||
* @return normalized band, or {@code null} for a missing or unknown ID
|
||||
*/
|
||||
public static LoggedQsoBand fromWinTestBandId(String rawBandId) {
|
||||
if (rawBandId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedBandId = rawBandId.trim();
|
||||
return switch (normalizedBandId) {
|
||||
case "10" -> BAND_50;
|
||||
case "11" -> BAND_70;
|
||||
case "12" -> BAND_144;
|
||||
case "14" -> BAND_432;
|
||||
case "16" -> BAND_1296;
|
||||
case "17" -> BAND_2320;
|
||||
case "18" -> BAND_3400;
|
||||
case "19" -> BAND_5760;
|
||||
case "20" -> BAND_10G;
|
||||
case "21" -> BAND_24G;
|
||||
case "22" -> BAND_47G;
|
||||
case "23" -> BAND_76G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the common Band value used by worked-grid persistence.
|
||||
*
|
||||
* @return project band, or {@code null} where no matching Band value exists
|
||||
*/
|
||||
public Band getProjectBand() {
|
||||
return projectBand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the established per-band Worked flag to a chat member.
|
||||
*
|
||||
* @param member worked station or active callsign variant
|
||||
*/
|
||||
public void applyWorkedFlag(ChatMember member) {
|
||||
if (member == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this) {
|
||||
case BAND_50 -> member.setWorked50(true);
|
||||
case BAND_70 -> member.setWorked70(true);
|
||||
case BAND_144 -> member.setWorked144(true);
|
||||
case BAND_432 -> member.setWorked432(true);
|
||||
case BAND_1296 -> member.setWorked1240(true);
|
||||
case BAND_2320 -> member.setWorked2300(true);
|
||||
case BAND_3400 -> member.setWorked3400(true);
|
||||
case BAND_5760 -> member.setWorked5600(true);
|
||||
case BAND_10G -> member.setWorked10G(true);
|
||||
case BAND_24G -> member.setWorked24G(true);
|
||||
case BAND_47G -> member.setWorked47G(true);
|
||||
case BAND_76G -> member.setWorked76G(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
/*
|
||||
* 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 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.
|
||||
*/
|
||||
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])"
|
||||
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
||||
);
|
||||
@@ -279,7 +279,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
* @param message message whose text is inspected
|
||||
* @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) {
|
||||
return;
|
||||
}
|
||||
@@ -1847,14 +1847,14 @@ public class MessageBusManagementThread extends Thread {
|
||||
* @param sender station for which the DX Cluster spot is generated
|
||||
* @return locator with up to two optional AP entries
|
||||
*/
|
||||
private String buildDxClusterSpotComment(ChatMember sender) {
|
||||
static String buildDxClusterSpotComment(ChatMember sender) {
|
||||
if (sender == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String locator = sender.getQra() == null
|
||||
? ""
|
||||
: sender.getQra().trim();
|
||||
: sender.getQra().trim().toUpperCase(Locale.ROOT);
|
||||
|
||||
AirPlaneReflectionInfo reflectionInfo =
|
||||
sender.getAirPlaneReflectInfo();
|
||||
@@ -1882,7 +1882,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
|
||||
aircraftComments.add(
|
||||
aircraft.getArrivingDurationMinutes()
|
||||
+ "min, "
|
||||
+ "m/"
|
||||
+ aircraft.getPotential()
|
||||
+ "%"
|
||||
);
|
||||
@@ -1893,11 +1893,11 @@ public class MessageBusManagementThread extends Thread {
|
||||
}
|
||||
|
||||
String apComment =
|
||||
"AP: " + String.join("; ", aircraftComments);
|
||||
"AP " + String.join(";", aircraftComments);
|
||||
|
||||
return locator.isEmpty()
|
||||
? apComment
|
||||
: locator + " , " + apComment;
|
||||
: locator + " " + apComment;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,6 +139,20 @@ final class On4KstConnectionManager {
|
||||
scheduler.execute(() -> openConnection(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the supervisor thread of this manager for good.
|
||||
*
|
||||
* <p>{@link #stopByUser()} only ends the current ON4KST session; the periodic
|
||||
* session monitor keeps running. That is correct while the application lives, but a
|
||||
* manager belonging to a discarded runtime must release its thread, otherwise every
|
||||
* operator profile switch would leave another supervisor behind holding the dead
|
||||
* controller.</p>
|
||||
*/
|
||||
void shutdown() {
|
||||
scheduler.shutdownNow();
|
||||
LOGGER.fine("ON4KST connection supervisor shut down");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current session and invalidates every scheduled callback or reconnect
|
||||
* belonging to it.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Creates, renames, duplicates and removes operator profiles.
|
||||
*
|
||||
* <p>Kept free of user interface code so the behaviour can be tested without a JavaFX
|
||||
* runtime. All methods work on the registry and on the files below the application
|
||||
* directory.</p>
|
||||
*/
|
||||
public class OperatorProfileManagementService {
|
||||
|
||||
private static final Logger LOGGER =
|
||||
Logger.getLogger(OperatorProfileManagementService.class.getName());
|
||||
|
||||
private final OperatorProfileStore profileStore;
|
||||
|
||||
public OperatorProfileManagementService() {
|
||||
this(new OperatorProfileStore());
|
||||
}
|
||||
|
||||
public OperatorProfileManagementService(final OperatorProfileStore profileStore) {
|
||||
this.profileStore = profileStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all profiles, including the implicit root profile of a plain installation.
|
||||
*
|
||||
* @return the known profiles, never empty
|
||||
*/
|
||||
public List<OperatorProfile> listProfiles() {
|
||||
|
||||
List<OperatorProfile> knownProfiles = profileStore.loadProfiles();
|
||||
|
||||
if (knownProfiles.isEmpty()) {
|
||||
knownProfiles = new ArrayList<>();
|
||||
knownProfiles.add(profileStore.buildImplicitRootProfile());
|
||||
}
|
||||
|
||||
return knownProfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new operator profile with its own preferences file.
|
||||
*
|
||||
* <p>Creating the first additional profile is also the moment the registry appears:
|
||||
* the root profile is written alongside, so both are selectable afterwards.</p>
|
||||
*
|
||||
* @param displayName name entered by the operator
|
||||
* @param sharedWorkedDatabase true to use the common station worked database
|
||||
* @return the created profile, or null when it could not be stored
|
||||
*/
|
||||
public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) {
|
||||
|
||||
List<OperatorProfile> knownProfiles = listProfiles();
|
||||
Set<String> takenProfileIds = new LinkedHashSet<>();
|
||||
|
||||
for (OperatorProfile existingProfile : knownProfiles) {
|
||||
takenProfileIds.add(existingProfile.getProfileId());
|
||||
}
|
||||
|
||||
OperatorProfile createdProfile = new OperatorProfile(
|
||||
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
|
||||
displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(),
|
||||
false,
|
||||
sharedWorkedDatabase);
|
||||
|
||||
knownProfiles.add(createdProfile);
|
||||
|
||||
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
createPreferencesFile(createdProfile, null);
|
||||
|
||||
return createdProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of an existing profile.
|
||||
*
|
||||
* <p>The preferences are taken over completely except for the login credentials:
|
||||
* callsign and password are cleared on purpose, because a duplicate is meant for
|
||||
* another operator. Antenna, locator, layout and integration settings are exactly
|
||||
* what the operator does not want to enter twice.</p>
|
||||
*
|
||||
* <p>The worked-station database is never copied.</p>
|
||||
*
|
||||
* @param sourceProfile profile to copy
|
||||
* @param displayName name of the new profile
|
||||
* @return the created profile, or null when it could not be stored
|
||||
*/
|
||||
public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) {
|
||||
|
||||
if (sourceProfile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<OperatorProfile> knownProfiles = listProfiles();
|
||||
Set<String> takenProfileIds = new LinkedHashSet<>();
|
||||
|
||||
for (OperatorProfile existingProfile : knownProfiles) {
|
||||
takenProfileIds.add(existingProfile.getProfileId());
|
||||
}
|
||||
|
||||
OperatorProfile createdProfile = new OperatorProfile(
|
||||
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
|
||||
displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(),
|
||||
false,
|
||||
sourceProfile.isSharedWorkedDatabase());
|
||||
|
||||
knownProfiles.add(createdProfile);
|
||||
|
||||
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
createPreferencesFile(createdProfile, sourceProfile);
|
||||
|
||||
return createdProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the visible name of a profile. The identifier and all paths stay as they are.
|
||||
*
|
||||
* @param profile profile to rename
|
||||
* @param newDisplayName new name
|
||||
* @return true if the registry was updated
|
||||
*/
|
||||
public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) {
|
||||
|
||||
if (profile == null || newDisplayName == null || newDisplayName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<OperatorProfile> knownProfiles = listProfiles();
|
||||
|
||||
for (OperatorProfile currentProfile : knownProfiles) {
|
||||
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
|
||||
currentProfile.setDisplayName(newDisplayName.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches a profile between the common station database and its own one.
|
||||
*
|
||||
* @param profile profile to change
|
||||
* @param sharedWorkedDatabase true to use the common station worked database
|
||||
* @return true if the registry was updated
|
||||
*/
|
||||
public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) {
|
||||
|
||||
if (profile == null || profile.isRootProfile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<OperatorProfile> knownProfiles = listProfiles();
|
||||
|
||||
for (OperatorProfile currentProfile : knownProfiles) {
|
||||
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
|
||||
currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase);
|
||||
}
|
||||
}
|
||||
|
||||
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a profile and its directory.
|
||||
*
|
||||
* <p>The root profile can never be removed, because its files are the installation
|
||||
* itself. A profile using the common station database keeps that database untouched;
|
||||
* only its own directory is deleted.</p>
|
||||
*
|
||||
* @param profile profile to remove
|
||||
* @return true if the profile was removed
|
||||
*/
|
||||
public boolean deleteProfile(final OperatorProfile profile) {
|
||||
|
||||
if (profile == null || profile.isRootProfile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<OperatorProfile> remainingProfiles = new ArrayList<>();
|
||||
|
||||
for (OperatorProfile currentProfile : listProfiles()) {
|
||||
if (!currentProfile.getProfileId().equals(profile.getProfileId())) {
|
||||
remainingProfiles.add(currentProfile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!profileStore.saveProfiles(remainingProfiles,
|
||||
profileStore.loadLastUsedProfileId().orElse(null))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deleteProfileDirectory(profile);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute directory of a profile.
|
||||
*
|
||||
* @param profile profile to resolve
|
||||
* @return absolute profile directory
|
||||
*/
|
||||
public String getProfileDirectory(final OperatorProfile profile) {
|
||||
return ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
OperatorProfilePaths.profileRelativeDirectory(profile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the preferences file of a new profile.
|
||||
*
|
||||
* <p>The file is either seeded from the bundled template or copied from the source
|
||||
* profile. In both cases the login credentials are cleared, so a new profile never
|
||||
* carries another operator's callsign or password.</p>
|
||||
*
|
||||
* @param createdProfile profile that needs a preferences file
|
||||
* @param sourceProfile profile to copy the preferences from, or null for the template
|
||||
*/
|
||||
private void createPreferencesFile(final OperatorProfile createdProfile,
|
||||
final OperatorProfile sourceProfile) {
|
||||
|
||||
String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile);
|
||||
|
||||
if (sourceProfile != null) {
|
||||
copyPreferencesFile(
|
||||
OperatorProfilePaths.preferencesRelativeFileName(sourceProfile),
|
||||
createdRelativeFileName);
|
||||
}
|
||||
|
||||
// Seeds from the bundled template when nothing was copied, and always resolves
|
||||
// the preferences of the new profile.
|
||||
ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName);
|
||||
createdPreferences.readPreferencesFromXmlFile();
|
||||
|
||||
createdPreferences.setStn_loginCallSign("");
|
||||
createdPreferences.setStn_loginPassword("");
|
||||
|
||||
createdPreferences.writePreferencesToXmlFile();
|
||||
}
|
||||
|
||||
private void copyPreferencesFile(final String sourceRelativeFileName,
|
||||
final String targetRelativeFileName) {
|
||||
|
||||
Path sourcePath = Path.of(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName));
|
||||
Path targetPath = Path.of(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME, targetRelativeFileName));
|
||||
|
||||
if (!Files.isRegularFile(sourcePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path targetDirectory = targetPath.getParent();
|
||||
|
||||
if (targetDirectory != null) {
|
||||
Files.createDirectories(targetDirectory);
|
||||
}
|
||||
|
||||
Files.copy(sourcePath, targetPath);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Could not copy the preferences of the source profile, using the defaults instead", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteProfileDirectory(final OperatorProfile profile) {
|
||||
|
||||
Path profileDirectory = Path.of(getProfileDirectory(profile));
|
||||
|
||||
if (!Files.isDirectory(profileDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Stream<Path> containedPaths = Files.walk(profileDirectory)) {
|
||||
List<Path> deepestFirst = containedPaths
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.toList();
|
||||
|
||||
for (Path currentPath : deepestFirst) {
|
||||
Files.deleteIfExists(currentPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Could not remove the directory of the deleted operator profile", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Derives the file names of an operator profile.
|
||||
*
|
||||
* <p>This is the single place that knows how a profile maps onto files. The registry
|
||||
* stores only the shared/own flag, never a path, so the two can never drift apart.</p>
|
||||
*/
|
||||
public final class OperatorProfilePaths {
|
||||
|
||||
/**
|
||||
* Directory below the application directory that holds the additional profiles.
|
||||
*/
|
||||
public static final String PROFILES_DIRECTORY = "profiles";
|
||||
|
||||
/**
|
||||
* Identifier of the profile that uses the historic flat installation layout.
|
||||
*/
|
||||
public static final String ROOT_PROFILE_ID = "default";
|
||||
|
||||
/**
|
||||
* Maximum length of a generated profile identifier.
|
||||
*/
|
||||
private static final int MAX_PROFILE_ID_LENGTH = 32;
|
||||
|
||||
private OperatorProfilePaths() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the profile descriptor of the historic flat installation.
|
||||
*
|
||||
* @param displayName name to show for the root profile
|
||||
* @return the root profile descriptor
|
||||
*/
|
||||
public static OperatorProfile buildRootProfile(final String displayName) {
|
||||
return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the profile directory relative to the application directory.
|
||||
*
|
||||
* @param profile profile to resolve
|
||||
* @return relative directory name
|
||||
*/
|
||||
public static String profileRelativeDirectory(final OperatorProfile profile) {
|
||||
return PROFILES_DIRECTORY + "/" + profile.getProfileId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferences file name relative to the application directory.
|
||||
*
|
||||
* @param profile profile to resolve
|
||||
* @return relative preferences file name
|
||||
*/
|
||||
public static String preferencesRelativeFileName(final OperatorProfile profile) {
|
||||
|
||||
if (profile.isRootProfile()) {
|
||||
return ChatPreferences.PREFERENCES_FILE;
|
||||
}
|
||||
|
||||
return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the worked-station database file name relative to the application directory.
|
||||
*
|
||||
* <p>A profile using the common station database always resolves to the historic flat
|
||||
* file, which is what a multi operator station wants: the existing contest state stays
|
||||
* the shared one.</p>
|
||||
*
|
||||
* @param profile profile to resolve
|
||||
* @return relative database file name
|
||||
*/
|
||||
public static String workedDatabaseRelativeFileName(final OperatorProfile profile) {
|
||||
|
||||
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
|
||||
return DBController.DATABASE_FILE;
|
||||
}
|
||||
|
||||
return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a profile descriptor into the runtime selection used during startup.
|
||||
*
|
||||
* @param profile profile to resolve
|
||||
* @return resolved selection
|
||||
*/
|
||||
public static OperatorProfileSelection resolve(final OperatorProfile profile) {
|
||||
|
||||
boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase();
|
||||
|
||||
return new OperatorProfileSelection(
|
||||
profile,
|
||||
preferencesRelativeFileName(profile),
|
||||
workedDatabaseRelativeFileName(profile),
|
||||
usesSharedStationDatabase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable, file system safe identifier from a display name.
|
||||
*
|
||||
* <p>The identifier becomes a directory name and is never changed afterwards, so a
|
||||
* later rename of the profile does not move any file.</p>
|
||||
*
|
||||
* @param displayName name entered by the operator
|
||||
* @param takenProfileIds identifiers that are already in use
|
||||
* @return an identifier that is not yet taken
|
||||
*/
|
||||
public static String toProfileId(final String displayName, final Collection<String> takenProfileIds) {
|
||||
|
||||
StringBuilder sanitized = new StringBuilder();
|
||||
|
||||
if (displayName != null) {
|
||||
String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT));
|
||||
|
||||
for (char currentCharacter : foldedDisplayName.toCharArray()) {
|
||||
boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z')
|
||||
|| (currentCharacter >= '0' && currentCharacter <= '9')
|
||||
|| currentCharacter == '-';
|
||||
|
||||
if (isAcceptable) {
|
||||
sanitized.append(currentCharacter);
|
||||
} else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') {
|
||||
sanitized.append('_');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') {
|
||||
sanitized.setLength(sanitized.length() - 1);
|
||||
}
|
||||
|
||||
if (sanitized.length() > MAX_PROFILE_ID_LENGTH) {
|
||||
sanitized.setLength(MAX_PROFILE_ID_LENGTH);
|
||||
}
|
||||
|
||||
String candidate = sanitized.toString();
|
||||
|
||||
if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
|
||||
candidate = "OP";
|
||||
}
|
||||
|
||||
if (!isProfileIdTaken(candidate, takenProfileIds)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
int suffix = 2;
|
||||
|
||||
while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
return candidate + "_" + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds German umlauts so a name like "Muller" written with an umlaut still produces a
|
||||
* readable identifier instead of a placeholder character.
|
||||
*
|
||||
* @param upperCaseText already upper-cased text
|
||||
* @return text with umlauts replaced by their base letters
|
||||
*/
|
||||
private static String foldGermanUmlauts(final String upperCaseText) {
|
||||
return upperCaseText
|
||||
.replace("\u00C4", "A")
|
||||
.replace("\u00D6", "O")
|
||||
.replace("\u00DC", "U")
|
||||
.replace("\u00DF", "SS");
|
||||
}
|
||||
|
||||
private static boolean isProfileIdTaken(final String candidate, final Collection<String> takenProfileIds) {
|
||||
|
||||
if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (takenProfileIds == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (String takenProfileId : takenProfileIds) {
|
||||
if (candidate.equalsIgnoreCase(takenProfileId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Reads and writes the operator profile registry.
|
||||
*
|
||||
* <p>The registry file is created lazily. As long as an installation has only the
|
||||
* historic flat layout, no registry exists and none is written, so a single operator
|
||||
* installation behaves exactly as before. The file appears when the second profile is
|
||||
* created; at that moment the root profile is materialised as well.</p>
|
||||
*
|
||||
* <p>A missing, unreadable or malformed registry is never fatal. It is logged and
|
||||
* treated like an installation without additional profiles.</p>
|
||||
*/
|
||||
public class OperatorProfileStore {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName());
|
||||
|
||||
/**
|
||||
* Name of the registry file inside the application directory.
|
||||
*/
|
||||
public static final String PROFILES_REGISTRY_FILE = "profiles.xml";
|
||||
|
||||
private static final String TAG_ROOT = "praktiKSTProfiles";
|
||||
private static final String TAG_REGISTRY_VERSION = "registryVersion";
|
||||
private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId";
|
||||
private static final String TAG_PROFILE = "profile";
|
||||
private static final String TAG_PROFILE_ID = "profileId";
|
||||
private static final String TAG_DISPLAY_NAME = "displayName";
|
||||
private static final String TAG_ROOT_PROFILE = "rootProfile";
|
||||
private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase";
|
||||
private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs";
|
||||
|
||||
private static final int REGISTRY_VERSION = 1;
|
||||
|
||||
private final String registryFilePath;
|
||||
|
||||
/**
|
||||
* Creates a store working on the registry of the current installation.
|
||||
*/
|
||||
public OperatorProfileStore() {
|
||||
this(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a store working on an explicit registry file.
|
||||
*
|
||||
* @param registryFilePath absolute path of the registry file
|
||||
*/
|
||||
public OperatorProfileStore(final String registryFilePath) {
|
||||
this.registryFilePath = registryFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a registry file exists at all.
|
||||
*
|
||||
* @return true if the installation already has more than the historic flat layout
|
||||
*/
|
||||
public boolean isRegistryPresent() {
|
||||
return new File(registryFilePath).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the in-memory descriptor of the historic flat installation.
|
||||
*
|
||||
* <p>Nothing is written. This keeps a single operator installation untouched.</p>
|
||||
*
|
||||
* @return the implicit root profile
|
||||
*/
|
||||
public OperatorProfile buildImplicitRootProfile() {
|
||||
return OperatorProfilePaths.buildRootProfile("Default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all stored profiles.
|
||||
*
|
||||
* @return the stored profiles, or an empty list when no usable registry exists
|
||||
*/
|
||||
public List<OperatorProfile> loadProfiles() {
|
||||
|
||||
List<OperatorProfile> loadedProfiles = new ArrayList<>();
|
||||
Document document = readRegistryDocument();
|
||||
|
||||
if (document == null) {
|
||||
return loadedProfiles;
|
||||
}
|
||||
|
||||
NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE);
|
||||
|
||||
for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) {
|
||||
Node currentNode = profileNodes.item(profileIndex);
|
||||
|
||||
if (currentNode.getNodeType() != Node.ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Element profileElement = (Element) currentNode;
|
||||
String profileId = readText(profileElement, TAG_PROFILE_ID);
|
||||
|
||||
if (profileId == null || profileId.isBlank()) {
|
||||
LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier");
|
||||
continue;
|
||||
}
|
||||
|
||||
OperatorProfile loadedProfile = new OperatorProfile();
|
||||
loadedProfile.setProfileId(profileId.trim());
|
||||
loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME));
|
||||
loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false));
|
||||
loadedProfile.setSharedWorkedDatabase(
|
||||
readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true));
|
||||
loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS));
|
||||
|
||||
if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) {
|
||||
loadedProfile.setDisplayName(loadedProfile.getProfileId());
|
||||
}
|
||||
|
||||
// The root profile always uses the common station database, because its
|
||||
// database is the historic flat file itself.
|
||||
if (loadedProfile.isRootProfile()) {
|
||||
loadedProfile.setSharedWorkedDatabase(true);
|
||||
}
|
||||
|
||||
loadedProfiles.add(loadedProfile);
|
||||
}
|
||||
|
||||
return loadedProfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the identifier of the profile that was activated last.
|
||||
*
|
||||
* @return the identifier, or empty when unknown
|
||||
*/
|
||||
public Optional<String> loadLastUsedProfileId() {
|
||||
|
||||
Document document = readRegistryDocument();
|
||||
|
||||
if (document == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Element rootElement = document.getDocumentElement();
|
||||
|
||||
if (rootElement == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID);
|
||||
|
||||
if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(lastUsedProfileId.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the complete registry.
|
||||
*
|
||||
* @param profiles profiles to store
|
||||
* @param lastUsedProfileId identifier of the profile that was activated last, may be null
|
||||
* @return true if the registry was written
|
||||
*/
|
||||
public boolean saveProfiles(final List<OperatorProfile> profiles, final String lastUsedProfileId) {
|
||||
|
||||
try {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
||||
Document document = documentBuilder.newDocument();
|
||||
|
||||
Element rootElement = document.createElement(TAG_ROOT);
|
||||
document.appendChild(rootElement);
|
||||
|
||||
appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION));
|
||||
|
||||
if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) {
|
||||
appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId);
|
||||
}
|
||||
|
||||
for (OperatorProfile currentProfile : profiles) {
|
||||
Element profileElement = document.createElement(TAG_PROFILE);
|
||||
rootElement.appendChild(profileElement);
|
||||
|
||||
appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId());
|
||||
appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName());
|
||||
appendTextElement(document, profileElement, TAG_ROOT_PROFILE,
|
||||
String.valueOf(currentProfile.isRootProfile()));
|
||||
appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE,
|
||||
String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase()));
|
||||
appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS,
|
||||
String.valueOf(currentProfile.getLastUsedEpochMs()));
|
||||
}
|
||||
|
||||
return writeDocumentAtomically(document);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a profile has been activated.
|
||||
*
|
||||
* <p>Does nothing when no registry exists, so a single operator installation is not
|
||||
* turned into a multi profile installation by merely starting the application.</p>
|
||||
*
|
||||
* @param profileId identifier of the activated profile
|
||||
* @return true if the registry was updated
|
||||
*/
|
||||
public boolean recordLastUsed(final String profileId) {
|
||||
|
||||
if (!isRegistryPresent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<OperatorProfile> storedProfiles = loadProfiles();
|
||||
|
||||
if (storedProfiles.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (OperatorProfile currentProfile : storedProfiles) {
|
||||
if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) {
|
||||
currentProfile.setLastUsedEpochMs(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
return saveProfiles(storedProfiles, profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path of the registry file.
|
||||
*
|
||||
* @return absolute registry path
|
||||
*/
|
||||
public String getRegistryFilePath() {
|
||||
return registryFilePath;
|
||||
}
|
||||
|
||||
private Document readRegistryDocument() {
|
||||
|
||||
File registryFile = new File(registryFilePath);
|
||||
|
||||
if (!registryFile.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
return documentBuilderFactory.newDocumentBuilder().parse(registryFile);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Could not read the operator profile registry, continuing without additional profiles", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String readText(final Element parentElement, final String tagName) {
|
||||
|
||||
NodeList matchingNodes = parentElement.getElementsByTagName(tagName);
|
||||
|
||||
if (matchingNodes.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matchingNodes.item(0).getTextContent();
|
||||
}
|
||||
|
||||
private static boolean readBoolean(final Element parentElement,
|
||||
final String tagName,
|
||||
final boolean defaultValue) {
|
||||
|
||||
String rawValue = readText(parentElement, tagName);
|
||||
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Boolean.parseBoolean(rawValue.trim());
|
||||
}
|
||||
|
||||
private static long readLong(final Element parentElement, final String tagName) {
|
||||
|
||||
String rawValue = readText(parentElement, tagName);
|
||||
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
try {
|
||||
return Long.parseLong(rawValue.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendTextElement(final Document document,
|
||||
final Element parentElement,
|
||||
final String tagName,
|
||||
final String textContent) {
|
||||
|
||||
Element createdElement = document.createElement(tagName);
|
||||
createdElement.setTextContent(textContent == null ? "" : textContent);
|
||||
parentElement.appendChild(createdElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the registry through a temporary file so a crash can never leave a
|
||||
* half-written registry behind. This mirrors the established preferences writer.
|
||||
*
|
||||
* @param document document to write
|
||||
* @return true if the registry file was replaced
|
||||
*/
|
||||
private boolean writeDocumentAtomically(final Document document) {
|
||||
|
||||
Path targetPath = Path.of(registryFilePath).toAbsolutePath();
|
||||
Path parentDirectory = targetPath.getParent();
|
||||
|
||||
if (parentDirectory == null) {
|
||||
LOGGER.log(Level.SEVERE,
|
||||
"The operator profile registry path has no directory: {0}", registryFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createDirectories(parentDirectory);
|
||||
|
||||
// The temporary file has to live next to the target so the final move can be
|
||||
// atomic; both must be on the same file system.
|
||||
Path temporaryPath = Files.createTempFile(
|
||||
parentDirectory, PROFILES_REGISTRY_FILE, ".tmp");
|
||||
|
||||
Transformer transformer = TransformerFactory.newInstance().newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
|
||||
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
|
||||
|
||||
try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
|
||||
transformer.transform(new DOMSource(document), new StreamResult(outputStream));
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(temporaryPath, targetPath,
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException atomicMoveUnsupported) {
|
||||
Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package kst4contest.controller;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
import kst4contest.view.GuiUtils;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
@@ -15,8 +13,9 @@ import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -33,18 +32,40 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
|
||||
private static final int BUFFER_SIZE = 4096;
|
||||
|
||||
private final Map<Integer, String> receivedQsos = new ConcurrentHashMap<>();
|
||||
private long lastPacketTime = 0;
|
||||
|
||||
private String myStation = "DO5AMF";
|
||||
|
||||
private String targetStation = "";
|
||||
private String stationID = "";
|
||||
private int lastKnownQso = 0;
|
||||
|
||||
private ThreadStatusCallback callBackToController;
|
||||
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) {
|
||||
|
||||
@@ -53,6 +74,21 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn
|
||||
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
|
||||
@@ -87,35 +123,113 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
|
||||
while (running) {
|
||||
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);
|
||||
String msg = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim();
|
||||
processWinTestMessage(msg);
|
||||
processWinTestDatagram(
|
||||
packet.getData(), packet.getLength(), packet.getAddress());
|
||||
} catch (SocketTimeoutException e) {
|
||||
// checkForMissingQsos();
|
||||
logSyncService.tick();
|
||||
reportSyncStateIfChanged();
|
||||
} catch (IOException e) {
|
||||
//TODO: here is something to catch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processWinTestMessage(String msg) {
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
|
||||
if (msg == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPacketTime = System.currentTimeMillis();
|
||||
|
||||
if (msg.startsWith("HELLO:")) { //Client Signon of wintest
|
||||
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
|
||||
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);
|
||||
} catch (Exception e) {
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true);
|
||||
@@ -125,8 +239,19 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
} else if (msg.startsWith("STATUS")) {
|
||||
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
|
||||
// parseIHave(msg); //TODO
|
||||
parseIHave(packet);
|
||||
}
|
||||
|
||||
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
||||
@@ -140,6 +265,130 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
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:
|
||||
* "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985"
|
||||
@@ -348,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 {
|
||||
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
|
||||
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
|
||||
@@ -368,33 +608,83 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
|
||||
|
||||
/**
|
||||
* Resolves the project Band enum from Win-Test band IDs.
|
||||
* Extracts the unchanged Win-Test band ID from the unquoted ADDQSO fields.
|
||||
*
|
||||
* <p>Only bands that exist in the current Band enum are returned. 50/70 MHz are
|
||||
* still represented as worked flags in ChatMember, but they are not part of the
|
||||
* current Reachability/New-Locator band enum.</p>
|
||||
*
|
||||
* @param bandId Win-Test band id from ADDQSO
|
||||
* @return matching Band or null
|
||||
* @param message complete ADDQSO packet
|
||||
* @return raw band ID or an empty value when the field is missing
|
||||
*/
|
||||
private Band helper_resolveBandFromWinTestBandId(String bandId) {
|
||||
if (bandId == null) {
|
||||
static String extractBandIdFromWinTestAddQso(String message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String[] quotedParts = message.split("\"");
|
||||
if (quotedParts.length <= 6) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String unquotedFields = quotedParts[6].trim();
|
||||
if (unquotedFields.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String[] packetFields = unquotedFields.split("\\s+");
|
||||
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;
|
||||
}
|
||||
|
||||
return switch (bandId.trim()) {
|
||||
case "10" -> Band.B_50;
|
||||
case "11" -> Band.B_70;
|
||||
case "12" -> Band.B_144;
|
||||
case "14" -> Band.B_432;
|
||||
case "16" -> Band.B_1296;
|
||||
case "17" -> Band.B_2320;
|
||||
case "18" -> Band.B_3400;
|
||||
case "19" -> Band.B_5760;
|
||||
case "20" -> Band.B_10G;
|
||||
case "21" -> Band.B_24G;
|
||||
default -> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,112 +739,53 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
* @param msg
|
||||
*/
|
||||
private void parseAddQso(String msg) {
|
||||
|
||||
|
||||
ChatMember modifyThat = null;
|
||||
|
||||
try {
|
||||
// int qsoNumber = extractQsoNumber(msg);
|
||||
// receivedQsos.put(qsoNumber, msg);
|
||||
// lastKnownQso = Math.max(lastKnownQso, qsoNumber);
|
||||
String callSignCatched = msg.split("\"") [7];
|
||||
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 callSign = quotedParts.length > 7 ? quotedParts[7] : "";
|
||||
String rawBandId = extractBandIdFromWinTestAddQso(msg);
|
||||
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
|
||||
|
||||
ChatMember workedCall = new ChatMember();
|
||||
workedCall.setCallSign(callSignCatched);
|
||||
workedCall.setWorked(true); //its worked at this place, for sure!
|
||||
|
||||
if (locatorFromLogger != null) {
|
||||
workedCall.setQra(locatorFromLogger);
|
||||
LoggedQsoBand loggedBand = LoggedQsoBand.fromWinTestBandId(rawBandId);
|
||||
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
|
||||
callSign, loggedBand, locatorFromLogger, "WINTEST").orElse(null);
|
||||
if (loggedQso == null) {
|
||||
System.out.println("[WinTestUDPRcvr: warning] ADDQSO without usable callsign ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
|
||||
|
||||
String bandId;
|
||||
bandId = msg.split("\"")[6].split(" ")[4].trim();
|
||||
|
||||
Band workedBand = helper_resolveBandFromWinTestBandId(bandId);
|
||||
switch (bandId) {
|
||||
case "10" -> workedCall.setWorked50(true);
|
||||
case "11" -> workedCall.setWorked70(true);
|
||||
case "12" -> workedCall.setWorked144(true);
|
||||
case "14" -> workedCall.setWorked432(true);
|
||||
case "16" -> workedCall.setWorked1240(true);
|
||||
case "17" -> workedCall.setWorked2300(true);
|
||||
case "18" -> workedCall.setWorked3400(true);
|
||||
case "19" -> workedCall.setWorked5600(true);
|
||||
case "20" -> workedCall.setWorked10G(true);
|
||||
case "21" -> workedCall.setWorked24G(true);
|
||||
case "22" -> workedCall.setWorked47G(true);
|
||||
case "23" -> workedCall.setWorked76G(true);
|
||||
default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId);
|
||||
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 (workedBand != null && locatorFromLogger != null) {
|
||||
this.client.registerWorkedGrossField(workedBand, locatorFromLogger, workedCall, "WINTEST");
|
||||
if (loggedBand == null && !rawBandId.isEmpty()) {
|
||||
System.out.println("[WinTestUDPRcvr: warning] Unknown band ID: " + rawBandId);
|
||||
}
|
||||
|
||||
if (!markTheseChattersAsWorked.isEmpty()) {
|
||||
//Worked call is part of the current chatmember list
|
||||
|
||||
for (int index : markTheseChattersAsWorked) {
|
||||
//iterate through the logged in chatmembers callsigns and set the worked markers
|
||||
modifyThat = client.getLst_chatMemberList().get(index);
|
||||
|
||||
modifyThat.setWorked(true); //worked its for sure
|
||||
|
||||
if (locatorFromLogger != null
|
||||
&& (modifyThat.getQra() == null
|
||||
|| modifyThat.getQra().isBlank()
|
||||
|| "unknown".equalsIgnoreCase(modifyThat.getQra()))) {
|
||||
modifyThat.setQra(locatorFromLogger);
|
||||
}
|
||||
|
||||
if (workedCall.isWorked50()) {
|
||||
modifyThat.setWorked50(true);
|
||||
} else if (workedCall.isWorked70()) {
|
||||
modifyThat.setWorked70(true);
|
||||
} else if (workedCall.isWorked144()) {
|
||||
modifyThat.setWorked144(true);
|
||||
} else if (workedCall.isWorked432()) {
|
||||
modifyThat.setWorked432(true);
|
||||
} else if (workedCall.isWorked1240()) {
|
||||
modifyThat.setWorked1240(true);
|
||||
} else if (workedCall.isWorked2300()) {
|
||||
modifyThat.setWorked2300(true);
|
||||
} else if (workedCall.isWorked3400()) {
|
||||
modifyThat.setWorked3400(true);
|
||||
} else if (workedCall.isWorked5600()) {
|
||||
modifyThat.setWorked5600(true);
|
||||
} else if (workedCall.isWorked10G()) {
|
||||
modifyThat.setWorked10G(true);
|
||||
} else if (workedCall.isWorked24G()) {
|
||||
modifyThat.setWorked24G(true);
|
||||
} else if (workedCall.isWorked47G()) {
|
||||
modifyThat.setWorked47G(true);
|
||||
} else if (workedCall.isWorked76G()) {
|
||||
modifyThat.setWorked76G(true);
|
||||
} else {
|
||||
System.out.println("[WinTestUDPRcvr: warning] found no new worked-flag for this band: " + workedCall.getCallSignRaw() + bandId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
|
||||
|
||||
// trigger band-upgrade hint after log entry (Win-Test)
|
||||
try {
|
||||
client.onExternalLogEntryReceived(workedCall.getCallSignRaw());
|
||||
} catch (Exception e) {
|
||||
System.out.println("[WinTestUDPRcvr, warning]: band-upgrade hint failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
} catch (Exception IllegalStateException) {
|
||||
//do nothing, as it works...
|
||||
}
|
||||
ChatMember workedCall = loggedQso.toWorkedChatMember();
|
||||
if (loggedBand != null
|
||||
&& loggedBand.getProjectBand() != null
|
||||
&& locatorFromLogger != null) {
|
||||
this.client.registerWorkedGrossField(
|
||||
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
|
||||
}
|
||||
|
||||
client.applyExternalLoggedQso(loggedQso);
|
||||
|
||||
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
|
||||
// This will update the worked info on a worked chatmember. DBHandler will
|
||||
@@ -587,17 +818,9 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
|
||||
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
|
||||
|
||||
if (modifyThat != null) {
|
||||
bufwrtrRawMSGOut.write("\n" + modifyThat.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
} else {
|
||||
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
}
|
||||
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
|
||||
System.out.println("[WinTest, Info: Marking Chatmember as worked: " + workedCall.toString());
|
||||
|
||||
@@ -3,7 +3,6 @@ package kst4contest.controller;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
@@ -13,7 +12,6 @@ import javax.xml.parsers.ParserConfigurationException;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
import kst4contest.view.GuiUtils;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
@@ -132,54 +130,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the project Band enum from logger band values.
|
||||
*
|
||||
* @param band logger band text
|
||||
* @return matching Band or null
|
||||
*/
|
||||
private Band helper_resolveBandFromLoggerBand(String band) {
|
||||
if (band == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (band.trim()) {
|
||||
case "50":
|
||||
case "6m":
|
||||
return Band.B_50;
|
||||
case "70":
|
||||
case "4m":
|
||||
return Band.B_70;
|
||||
case "144":
|
||||
case "2m":
|
||||
return Band.B_144;
|
||||
case "432":
|
||||
case "70cm":
|
||||
return Band.B_432;
|
||||
case "1240":
|
||||
case "1296":
|
||||
case "23cm":
|
||||
return Band.B_1296;
|
||||
case "2300":
|
||||
case "2320":
|
||||
case "13cm":
|
||||
return Band.B_2320;
|
||||
case "3400":
|
||||
case "9cm":
|
||||
return Band.B_3400;
|
||||
case "5600":
|
||||
case "5760":
|
||||
case "6cm":
|
||||
return Band.B_5760;
|
||||
case "10G":
|
||||
case "10368":
|
||||
case "3cm":
|
||||
return Band.B_10G;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void run() {
|
||||
|
||||
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
|
||||
@@ -295,8 +245,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
|
||||
|
||||
ChatMember modifyThat = null;
|
||||
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false);
|
||||
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
@@ -342,95 +290,27 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
Element element = (Element) node;
|
||||
|
||||
String call = element.getElementsByTagName("call").item(0).getTextContent();
|
||||
String band = helper_getOptionalElementText(element, "band");
|
||||
String call = helper_getOptionalElementText(element, "call");
|
||||
String rawBand = helper_getOptionalElementText(element, "band");
|
||||
String gridSquare = helper_resolveLocatorFromContactInfo(element);
|
||||
String points = helper_getOptionalElementText(element, "points");
|
||||
LoggedQsoBand loggedBand = LoggedQsoBand.fromLoggerValue(rawBand);
|
||||
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
|
||||
call, loggedBand, gridSquare, "UCXLOG").orElse(null);
|
||||
if (loggedQso == null) {
|
||||
System.out.println("[ReadUDPFromUCX, warning]: QSO packet without usable callsign ignored");
|
||||
continue;
|
||||
}
|
||||
|
||||
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
|
||||
+ "call: " + call + " / " + band + " ----> " + points + " POINTS");
|
||||
+ "call: " + call + " / " + rawBand + " ----> " + points + " POINTS");
|
||||
|
||||
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
|
||||
|
||||
ChatMember workedCall = new ChatMember();
|
||||
workedCall.setCallSign(call);
|
||||
workedCall.setWorked(true);
|
||||
|
||||
if (gridSquare != null) {
|
||||
workedCall.setQra(gridSquare);
|
||||
}
|
||||
|
||||
Band workedBand = helper_resolveBandFromLoggerBand(band);
|
||||
|
||||
switch (band) {
|
||||
case "50":
|
||||
case "6m":
|
||||
{
|
||||
workedCall.setWorked50(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "70":
|
||||
case "4m":
|
||||
{
|
||||
workedCall.setWorked70(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "144":
|
||||
case "2m": //minos contest logger
|
||||
{
|
||||
workedCall.setWorked144(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "432":
|
||||
case "70cm":
|
||||
{
|
||||
workedCall.setWorked432(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "1240": //ucxlog style
|
||||
case "1296": //used for n1mm / Dxlog
|
||||
case "23cm": //minos contest logger
|
||||
{
|
||||
workedCall.setWorked1240(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "2300":
|
||||
case "13cm":
|
||||
{
|
||||
workedCall.setWorked2300(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "3400":
|
||||
case "9cm":
|
||||
{
|
||||
workedCall.setWorked3400(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "5600":
|
||||
case "6cm":
|
||||
{
|
||||
workedCall.setWorked5600(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case "10G":
|
||||
case "3cm":
|
||||
{
|
||||
workedCall.setWorked10G(true);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
|
||||
break;
|
||||
ChatMember workedCall = loggedQso.toWorkedChatMember();
|
||||
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
|
||||
if (loggedBand == null && !rawBand.isEmpty()) {
|
||||
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -444,60 +324,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
|
||||
|
||||
ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
|
||||
|
||||
if (markTheseChattersAsWorked.isEmpty()) {
|
||||
//Worked call is not part of the chatmember list
|
||||
} else {
|
||||
for (int index : markTheseChattersAsWorked) {
|
||||
modifyThat = client.getLst_chatMemberList().get(index);
|
||||
|
||||
modifyThat.setWorked(true);
|
||||
|
||||
if (workedCall.isWorked50()) {
|
||||
modifyThat.setWorked50(true);
|
||||
|
||||
} else if (workedCall.isWorked70()) {
|
||||
modifyThat.setWorked70(true);
|
||||
|
||||
} else if (workedCall.isWorked144()) {
|
||||
modifyThat.setWorked144(true);
|
||||
|
||||
} else if (workedCall.isWorked432()) {
|
||||
modifyThat.setWorked432(true);
|
||||
|
||||
} else if (workedCall.isWorked1240()) {
|
||||
modifyThat.setWorked1240(true);
|
||||
|
||||
} else if (workedCall.isWorked2300()) {
|
||||
modifyThat.setWorked2300(true);
|
||||
|
||||
} else if (workedCall.isWorked3400()) {
|
||||
modifyThat.setWorked3400(true);
|
||||
|
||||
} else if (workedCall.isWorked5600()) {
|
||||
modifyThat.setWorked5600(true);
|
||||
|
||||
} else if (workedCall.isWorked10G()) {
|
||||
modifyThat.setWorked10G(true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
GuiUtils.triggerGUIFilteredChatMemberListChange(this.client);
|
||||
// BEGIN PATCH: trigger band-upgrade hint after log entry (UCXLog)
|
||||
try {
|
||||
client.onExternalLogEntryReceived(workedCall.getCallSignRaw());
|
||||
} catch (Exception e) {
|
||||
System.out.println("[UCXUDPRcvr, warning]: band-upgrade hint failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
|
||||
} catch (Exception IllegalStateException) {
|
||||
//do nothing, as it works...
|
||||
}
|
||||
}
|
||||
client.applyExternalLoggedQso(loggedQso);
|
||||
|
||||
/**
|
||||
* old mechanic to markup worked stations in the chatmember table
|
||||
@@ -574,7 +401,8 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
}
|
||||
|
||||
if (workedBand != null && gridSquare != null) {
|
||||
this.client.registerWorkedGrossField(workedBand, gridSquare, workedCall, "UCXLOG");
|
||||
this.client.registerWorkedGrossField(
|
||||
workedBand, gridSquare, workedCall, loggedQso.getSource());
|
||||
}
|
||||
|
||||
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
|
||||
@@ -609,17 +437,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
|
||||
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
|
||||
|
||||
if (modifyThat != null) {
|
||||
bufwrtrRawMSGOut.write("\n" + modifyThat.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
} else {
|
||||
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
}
|
||||
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
|
||||
bufwrtrRawMSGOut.flush();
|
||||
bufwrtrRawMSGOut.close();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -771,4 +591,4 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,24 @@ public final class SkedReminderService {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels every armed reminder and stops the scheduler thread.
|
||||
*
|
||||
* <p>Called when the runtime that owns this service is torn down, so a discarded
|
||||
* runtime does not keep a thread and pending reminders alive.</p>
|
||||
*/
|
||||
public void shutdown() {
|
||||
|
||||
for (List<ScheduledFuture<?>> remindersOfOneCall : scheduledByCallRaw.values()) {
|
||||
for (ScheduledFuture<?> armedReminder : remindersOfOneCall) {
|
||||
armedReminder.cancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
scheduledByCallRaw.clear();
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms reminders for one sked. Existing reminders for this call are cancelled.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
public interface StatusUpdateListener {
|
||||
@@ -33,4 +35,13 @@ public interface StatusUpdateListener {
|
||||
// Optional for non-UI listeners.
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* Called after KST4Contest successfully creates a missing Simplelogfile.
|
||||
*
|
||||
* @param filePath absolute path of the newly created file
|
||||
*/
|
||||
default void onSimpleLogFileCreated(Path filePath) {
|
||||
// Optional for non-UI listeners.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.HashMap;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -14,73 +15,50 @@ import kst4contest.model.ChatMember;
|
||||
|
||||
public class UCXLogFileToHashsetParser {
|
||||
|
||||
public BufferedReader fileReader;
|
||||
// private final String PTRN_CallSign = "(([a-zA-Z]{1,2}[\\d{1}]?\\/)?(\\d{1}[a-zA-Z][\\d{1}][a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)|(([a-zA-Z0-9]{1,2}[\\d{1}]?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"; //OLD, S51AR for example will not work
|
||||
private final String PTRN_CallSign = "(([a-zA-Z]{1,2}[\\d]{1}?\\/)?(\\d{1}[a-zA-Z][\\d]{1}[a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)|(([a-zA-Z0-9]{1,2}[\\d]{1}?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)|([A-Z]\\d{2}[A-Z]{1,3})";
|
||||
|
||||
private static final Pattern CALL_SIGN_PATTERN = Pattern.compile(
|
||||
"(([a-zA-Z]{1,2}[\\d]{1}?\\/)?(\\d{1}[a-zA-Z][\\d]{1}[a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)"
|
||||
+ "|(([a-zA-Z0-9]{1,2}[\\d]{1}?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"
|
||||
+ "|([A-Z]\\d{2}[A-Z]{1,3})");
|
||||
|
||||
private final Path logFile;
|
||||
|
||||
public UCXLogFileToHashsetParser(String filePathAndName) {
|
||||
|
||||
try {
|
||||
fileReader = new BufferedReader(new InputStreamReader(new FileInputStream(new File(filePathAndName))));
|
||||
|
||||
} catch (FileNotFoundException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
this.logFile = Path.of(filePathAndName);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if a line of the ucxlog-Logfile inhibits a Callsign<br/>
|
||||
* <b>returns ChatMember = null, if no frequency found</b>
|
||||
*
|
||||
* @param chatMessage
|
||||
*/
|
||||
private ChatMember checkIfLineInhibitsCallSign(String line) {
|
||||
|
||||
Pattern pattern = Pattern.compile(PTRN_CallSign);
|
||||
Matcher matcher = pattern.matcher(line);
|
||||
|
||||
String matchedString = "";
|
||||
private String findLastCallSign(String line) {
|
||||
Matcher matcher = CALL_SIGN_PATTERN.matcher(line);
|
||||
String matchedCallSign = "";
|
||||
|
||||
while (matcher.find()) {
|
||||
|
||||
matchedString = matcher.group();
|
||||
// System.out.println("[UCXLogFile:] Processed worked Callsign from file: " + matchedString);
|
||||
|
||||
matchedCallSign = matcher.group();
|
||||
}
|
||||
|
||||
ChatMember newChatMember = new ChatMember();
|
||||
|
||||
newChatMember.setCallSign(matchedString.toUpperCase());
|
||||
|
||||
return newChatMember;
|
||||
|
||||
return matchedCallSign.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ucxlog-live-file (full qualified path given by constructor
|
||||
* argument), looks by regex for callsigns and builds a hashmap with only one
|
||||
* entry by callsign
|
||||
* Parses the selected log file and returns every detected station as a
|
||||
* normalized base callsign. The reader is closed after each pass so the logging
|
||||
* application can continue replacing or rotating the file.
|
||||
*
|
||||
* @return unique normalized base callsigns found in the file
|
||||
* @throws IOException if the file cannot be read
|
||||
*/
|
||||
public HashMap<String, String> parse() throws IOException {
|
||||
public Set<String> parse() throws IOException {
|
||||
Set<String> workedBaseCalls = new HashSet<>();
|
||||
|
||||
HashMap<String, String> chatMemberMap = new HashMap();
|
||||
|
||||
String line;
|
||||
while ((line = fileReader.readLine()) != null) {
|
||||
// System.out.println("raw: " + line);
|
||||
ChatMember temp = checkIfLineInhibitsCallSign(line);
|
||||
|
||||
if (temp.getCallSign() != "") {
|
||||
chatMemberMap.put(temp.getCallSign(), temp.getCallSign());
|
||||
try (BufferedReader fileReader = Files.newBufferedReader(logFile, Charset.defaultCharset())) {
|
||||
String line;
|
||||
while ((line = fileReader.readLine()) != null) {
|
||||
String matchedCallSign = findLastCallSign(line);
|
||||
String baseCallSign = ChatMember.normalizeCallSignToBaseCallSign(matchedCallSign);
|
||||
if (baseCallSign != null && !baseCallSign.isBlank()) {
|
||||
workedBaseCalls.add(baseCallSign.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
// System.out.println(chatMemberMap.size());
|
||||
return chatMemberMap;
|
||||
|
||||
return workedBaseCalls;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,325 +1,100 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ClusterMessage;
|
||||
import kst4contest.view.GuiUtils;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class UserActualizationTask extends TimerTask {
|
||||
|
||||
private ChatController client;
|
||||
private static final Logger LOGGER = Logger.getLogger(UserActualizationTask.class.getName());
|
||||
|
||||
private final ChatController client;
|
||||
|
||||
public UserActualizationTask(ChatController client) {
|
||||
|
||||
this.client = client;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
Thread.currentThread().setName("UserActualizationTask");
|
||||
|
||||
/*
|
||||
* File-based log synchronization is optional. Do not create, open or parse the
|
||||
* configured file while the feature is disabled.
|
||||
*/
|
||||
try {
|
||||
updateWorkedCallSignsFromFile();
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Unexpected failure while updating Worked callsigns from the Simplelogfile; "
|
||||
+ "the periodic task will continue.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateWorkedCallSignsFromFile() {
|
||||
if (!client.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System.out.println("[Useract: ] Thread runned now");
|
||||
|
||||
// System.out.println("***********************Useract started");
|
||||
|
||||
/**
|
||||
* ******************************************since here: old mechanic for
|
||||
* marking worked stations by .ucx-file
|
||||
*/
|
||||
|
||||
HashMap<String, String> fetchedWorkedSet = new HashMap<>();
|
||||
// HashMap<String, String> fetchedWorkedSetUdpBckup = new HashMap<>();
|
||||
|
||||
File f = new File(this.client.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly());
|
||||
if (!f.exists() && !f.isDirectory()) {
|
||||
try {
|
||||
f.createNewFile();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
String configuredFileName = client.getChatPreferences()
|
||||
.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly();
|
||||
if (configuredFileName == null || configuredFileName.isBlank()) {
|
||||
LOGGER.warning("Cannot read the Simplelogfile because no file is selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
UCXLogFileToHashsetParser getWorkedCallsignsOfUCXLogFile = new UCXLogFileToHashsetParser(
|
||||
this.client.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly());
|
||||
final Path logFile;
|
||||
try {
|
||||
logFile = Path.of(configuredFileName).toAbsolutePath().normalize();
|
||||
} catch (InvalidPathException exception) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Cannot use the configured Simplelogfile path: " + configuredFileName,
|
||||
exception);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean created = createMissingLogFile(logFile);
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fetchedWorkedSet = getWorkedCallsignsOfUCXLogFile.parse();
|
||||
|
||||
System.out.println("USERACT: fetchedWorkedSet size: " + fetchedWorkedSet.size());
|
||||
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
Set<String> workedBaseCalls = new UCXLogFileToHashsetParser(logFile.toString()).parse();
|
||||
client.applySimpleLogWorkedBaseCalls(workedBaseCalls);
|
||||
LOGGER.log(Level.FINE,
|
||||
"Read {0} unique base callsigns from Simplelogfile {1}.",
|
||||
new Object[] { workedBaseCalls.size(), logFile });
|
||||
} catch (IOException exception) {
|
||||
LOGGER.log(Level.WARNING, "Cannot read Simplelogfile " + logFile, exception);
|
||||
}
|
||||
|
||||
ObservableList<ChatMember> praktiKSTActiveUserList = this.client.getLst_chatMemberList();
|
||||
|
||||
for (Iterator iterator = praktiKSTActiveUserList.iterator(); iterator.hasNext();) {
|
||||
ChatMember chatMember = (ChatMember) iterator.next();
|
||||
|
||||
// System.out.println(chatMember.getCallSign());
|
||||
// System.out.println("USERACT active user list entries " + praktiKSTActiveUserList.size());
|
||||
|
||||
if (fetchedWorkedSet.containsKey(chatMember.getCallSign())) {
|
||||
chatMember.setWorked(true);
|
||||
System.out.println("[USERACT, info:] marking Chatuser " + chatMember.getCallSign()
|
||||
+ " as worked, based on READONLY-Logfile.");
|
||||
}
|
||||
|
||||
// if (fetchedWorkedSetUdpBckup.containsKey(chatMember.getCallSign())) {
|
||||
// chatMember.setWorked(true);
|
||||
// System.out.println("[USERACT, info:] marking Chatuser " + chatMember.getCallSign() + " as worked, based on UDPLsnBackup-Logfile.");
|
||||
// }
|
||||
// GuiUtils.triggerGUIFilteredChatMemberListChange(this.client); //todo: quick and dirty gui fix
|
||||
if (created) {
|
||||
client.notifySimpleLogFileCreated(logFile);
|
||||
}
|
||||
|
||||
ObservableList<ClusterMessage> praktiKSTClusterList = this.client.getLst_clusterMemberList();
|
||||
|
||||
for (Iterator iterator = praktiKSTClusterList.iterator(); iterator.hasNext();) {
|
||||
ClusterMessage clusterMessage = (ClusterMessage) iterator.next();
|
||||
|
||||
if (fetchedWorkedSet.containsKey(clusterMessage.getReceiver().getCallSign())) {
|
||||
clusterMessage.setReceiverWkd(true);
|
||||
System.out.println("[USERACT, info:] marking Clusterspotted "
|
||||
+ clusterMessage.getReceiver().getCallSign() + " as worked.");
|
||||
}
|
||||
|
||||
// if (fetchedWorkedSetUdpBckup.containsKey(clusterMessage.getReceiver().getCallSign())) {
|
||||
// clusterMessage.setReceiverWkd(true);
|
||||
// System.out.println("[USERACT, info:] marking Clusterspotted "
|
||||
// + clusterMessage.getReceiver().getCallSign() + " as worked.");
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* ******************************************end here: old mechanic for marking
|
||||
* worked stations by .ucx-file
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* ******************************************since here: new mechanic for
|
||||
* marking worked stations by udp/adif based information
|
||||
*/
|
||||
// HashMap<String, String> fetchedWorkedMap = new HashMap<>();
|
||||
//
|
||||
// fetchedWorkedMap = this.client.getMap_ucxLogInfoWorkedCalls();
|
||||
|
||||
// ObservableList<ChatMember> praktiKSTActiveUserList1 = this.client.getLst_chatMemberList();
|
||||
//
|
||||
// for (Iterator iterator = praktiKSTActiveUserList.iterator(); iterator.hasNext();) {
|
||||
// ChatMember chatMember = (ChatMember) iterator.next();
|
||||
//
|
||||
// if (fetchedWorkedMap.containsKey(chatMember.getCallSign())) {
|
||||
// chatMember.setWorked(true);
|
||||
// System.out.println("[USERACT, info:] marking Chatuser " + chatMember.getCallSign() + " as worked based on UDP Log Info Collector.");
|
||||
// }
|
||||
// }
|
||||
|
||||
// ObservableList<ClusterMessage> praktiKSTClusterList1 = this.client.getLst_clusterMemberList();
|
||||
//
|
||||
// for (Iterator iterator = praktiKSTClusterList.iterator(); iterator.hasNext();) {
|
||||
// ClusterMessage clusterMessage = (ClusterMessage) iterator.next();
|
||||
//
|
||||
// if (fetchedWorkedMap.containsKey(clusterMessage.getReceiver().getCallSign())) {
|
||||
// clusterMessage.setReceiverWkd(true);
|
||||
// System.out.println("[USERACT, info:] marking Clusterspotted "
|
||||
// + clusterMessage.getReceiver().getCallSign() + " as worked based on UDP Log Info Collector.");
|
||||
// }
|
||||
//
|
||||
// }
|
||||
|
||||
//
|
||||
// UCXLogFileToHashsetParser getWorkedCallsignsOfUCXLogFile = new UCXLogFileToHashsetParser(
|
||||
// "C:\\UcxLog\\Logs\\DO5AMF\\DVU322_I.UCX");
|
||||
// try {
|
||||
// fetchedWorkedSet = getWorkedCallsignsOfUCXLogFile.parse();
|
||||
// } catch (IOException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
//
|
||||
// ObservableList<ChatMember> praktiKSTActiveUserList = this.client.getLst_chatMemberList();
|
||||
//
|
||||
// for (Iterator iterator = praktiKSTActiveUserList.iterator(); iterator.hasNext();) {
|
||||
// ChatMember chatMember = (ChatMember) iterator.next();
|
||||
//
|
||||
// if (fetchedWorkedSet.containsKey(chatMember.getCallSign())) {
|
||||
// chatMember.setWorked(true);
|
||||
// System.out.println("[USERACT, info:] marking Chatuser " + chatMember.getCallSign() + " as worked.");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// ObservableList<ClusterMessage> praktiKSTClusterList = this.client.getLst_clusterMemberList();
|
||||
//
|
||||
// for (Iterator iterator = praktiKSTClusterList.iterator(); iterator.hasNext();) {
|
||||
// ClusterMessage clusterMessage = (ClusterMessage) iterator.next();
|
||||
//
|
||||
// if (fetchedWorkedSet.containsKey(clusterMessage.getReceiver().getCallSign())) {
|
||||
// clusterMessage.setReceiverWkd(true);
|
||||
// System.out.println("[USERACT, info:] marking Clusterspotted "
|
||||
// + clusterMessage.getReceiver().getCallSign() + " as worked.");
|
||||
// }
|
||||
//
|
||||
// }
|
||||
|
||||
/**
|
||||
* ******************************************end here: new mechanic for marking
|
||||
* worked stations by udp/adif based information
|
||||
*
|
||||
*/
|
||||
|
||||
// System.out.println("[UserActualizationtask:] Userlist actualization will be performed now. "
|
||||
// + LocalDateTime.ofInstant(Instant.ofEpochMilli(scheduledExecutionTime()),
|
||||
// ZoneId.systemDefault()));
|
||||
|
||||
// ChatMessage actualizeUserMsg = new ChatMessage();
|
||||
// actualizeUserMsg.setDirectedToServer(true);
|
||||
// actualizeUserMsg.setMessage("/show users");
|
||||
|
||||
// client.getMessageTXBus().add(actualizeUserMsg);
|
||||
|
||||
// Enumeration<String> e = this.client.getChatMemberTable().keys();
|
||||
//
|
||||
// while (e.hasMoreElements()) {
|
||||
// String key = e.nextElement();
|
||||
//
|
||||
// System.out.println(this.client.getChatMemberTable().get(key).getCallSign() + ", "
|
||||
// + this.client.getChatMemberTable().get(key).getQra() + ": "
|
||||
// + this.client.getChatMemberTable().get(key).getFrequency());
|
||||
//
|
||||
// }
|
||||
|
||||
// System.out.println("[UserAct]: Show the Cluster with known frequencies now: ");
|
||||
//
|
||||
// Enumeration<String> e2 = this.client.getdXClusterMemberTable().keys();
|
||||
//
|
||||
// while (e2.hasMoreElements()) {
|
||||
// String key = e2.nextElement();
|
||||
//
|
||||
// System.out.println(this.client.getdXClusterMemberTable().get(key).getCallSign() + ", "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getQra() + ": "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getFrequency());
|
||||
//
|
||||
// }
|
||||
|
||||
// for (int i = 0; i < 100; i++) {
|
||||
//
|
||||
// System.out.print("\n");
|
||||
// }
|
||||
|
||||
/**
|
||||
* keeepalive start
|
||||
*/
|
||||
// ChatMessage keepAliveMSG = new ChatMessage();
|
||||
// keepAliveMSG.setMessageText("\r");
|
||||
// keepAliveMSG.setMessageDirectedToServer(true);
|
||||
//
|
||||
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [UserAct]: Sending keepalive: "
|
||||
// + keepAliveMSG.getMessageText());
|
||||
// /**
|
||||
// * Sending keepalive
|
||||
// */
|
||||
// this.client.getMessageTXBus().add(keepAliveMSG);
|
||||
|
||||
/**
|
||||
* keeepalive end
|
||||
*/
|
||||
|
||||
// System.out.println("[UserAct]: Show the Userlist with known frequencies sorted now: ");
|
||||
|
||||
// ObservableList<ChatMember> userlist = this.client.getLst_chatMemberList();
|
||||
|
||||
// for (Iterator iterator = userlist.iterator(); iterator.hasNext();) {
|
||||
// ChatMember chatMember = (ChatMember) iterator.next();
|
||||
// System.out.println("[Useract] Entry " + this.client.getLst_chatMemberList().indexOf(chatMember) + ": " + chatMember.getCallSign());
|
||||
// }
|
||||
|
||||
//
|
||||
// String chatMembers ="";
|
||||
|
||||
// SortedSet<String> keys = new TreeSet<>(this.client.getChatMemberTable().keySet());
|
||||
// for (String key : keys) {
|
||||
//
|
||||
// chatMembers += this.client.getChatMemberTable().get(key).getCallSign() + ", "
|
||||
// + this.client.getChatMemberTable().get(key).getName() + " in "
|
||||
// + this.client.getChatMemberTable().get(key).getQra() + " @ QRG: "
|
||||
// + this.client.getChatMemberTable().get(key).getFrequency() + "\n";
|
||||
//
|
||||
// System.out.println(this.client.getChatMemberTable().get(key).getCallSign() + ", "
|
||||
// + this.client.getChatMemberTable().get(key).getName() + " in "
|
||||
// + this.client.getChatMemberTable().get(key).getQra() + " @ QRG: "
|
||||
// + this.client.getChatMemberTable().get(key).getFrequency());
|
||||
// }
|
||||
|
||||
// System.out.println("\n[UserAct]: Show the Clusterlist with known frequencies sorted now: ");
|
||||
|
||||
// String dxcMembers ="";
|
||||
|
||||
// SortedSet<String> keys2 = new TreeSet<>(this.client.getdXClusterMemberTable().keySet());
|
||||
// for (String key : keys2) {
|
||||
// System.out.println(this.client.getdXClusterMemberTable().get(key).getCallSign() + " in "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getQra() + " @ QRG: "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getFrequency());
|
||||
//
|
||||
// dxcMembers += this.client.getdXClusterMemberTable().get(key).getCallSign() + " in "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getQra() + " @ QRG: "
|
||||
// + this.client.getdXClusterMemberTable().get(key).getFrequency();
|
||||
//
|
||||
// }
|
||||
|
||||
// File userListLogger = new File(new Utils4KST().time_generateCurrentMMddString() + "_praktiKST_userlist.txt");
|
||||
//
|
||||
// FileWriter fileWriterRAWChatMSGOut = null;
|
||||
//
|
||||
// try {
|
||||
// fileWriterRAWChatMSGOut = new FileWriter(userListLogger, true);
|
||||
// } catch (IOException e1) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e1.printStackTrace();
|
||||
// }
|
||||
//
|
||||
// BufferedWriter bufwrtrRawMSGOut;
|
||||
//
|
||||
// bufwrtrRawMSGOut = new BufferedWriter(fileWriterRAWChatMSGOut);
|
||||
|
||||
// System.out.println("#######################################" + chatMembers);
|
||||
// try {
|
||||
// bufwrtrRawMSGOut.write(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " " +this.client.getChatMemberTable().size() + " Chatmembers:\n" + chatMembers+ "\n");
|
||||
// bufwrtrRawMSGOut.write(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " " + this.client.getdXClusterMemberTable().size() + " Clusterentries:\n" + dxcMembers + "\n");
|
||||
|
||||
// bufwrtrRawMSGOut.flush();
|
||||
|
||||
// } catch (IOException e) {
|
||||
// // TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
|
||||
// try {
|
||||
// bufwrtrRawMSGOut.close();
|
||||
// } catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
}
|
||||
|
||||
private boolean createMissingLogFile(Path logFile) {
|
||||
if (Files.exists(logFile)) {
|
||||
if (!Files.isRegularFile(logFile)) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"The selected Simplelogfile path is not a regular file: {0}",
|
||||
logFile);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createFile(logFile);
|
||||
LOGGER.log(Level.INFO, "Created missing Simplelogfile {0}.", logFile);
|
||||
return true;
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
return false;
|
||||
} catch (IOException | SecurityException exception) {
|
||||
LOGGER.log(Level.WARNING, "Cannot create Simplelogfile " + logFile, exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/** Win-Test message types relevant for SKED management. */
|
||||
/** Win-Test message types sent by KST4Contest. */
|
||||
public enum MessageType {
|
||||
/** Requests a range of QSOs of one Win-Test log for log synchronization. */
|
||||
NEEDQSO,
|
||||
LOCKSKED,
|
||||
UNLOCKSKED,
|
||||
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.
|
||||
*
|
||||
* <p>This parser deliberately handles only complete frequencies such as
|
||||
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
|
||||
* bare values such as "210" require additional message context and remain the
|
||||
* responsibility of the chat-message parser.</p>
|
||||
* 144.300, 432.357, 10368.100 or their compact digit-only forms. Relative
|
||||
* forms such as ".210" or ambiguous bare values such as "210" require
|
||||
* additional message context and remain the responsibility of the
|
||||
* chat-message parser.</p>
|
||||
*/
|
||||
public final class FrequencyTextParser {
|
||||
|
||||
@@ -26,14 +27,18 @@ public final class FrequencyTextParser {
|
||||
* 432,357
|
||||
* 10368.100
|
||||
* 144.300.03
|
||||
* 144300
|
||||
* 10368100
|
||||
*
|
||||
* At least two digits are required before the decimal separator. This
|
||||
* intentionally prevents "1.2" from being interpreted as a frequency.
|
||||
* At least two digits are required before a decimal separator. Compact
|
||||
* 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(
|
||||
"(?<![A-Z0-9])"
|
||||
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
|
||||
+ "(?!\\d)",
|
||||
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
|
||||
+ "(?![A-Z0-9])",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
@@ -93,12 +98,12 @@ public final class FrequencyTextParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized =
|
||||
normalizeFrequencyString(
|
||||
rawFrequency
|
||||
.trim()
|
||||
.replace(',', '.')
|
||||
);
|
||||
final String trimmedFrequency = rawFrequency.trim();
|
||||
final String normalized = trimmedFrequency.matches("\\d{5,8}")
|
||||
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
|
||||
+ "."
|
||||
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
|
||||
: normalizeFrequencyString(trimmedFrequency.replace(',', '.'));
|
||||
|
||||
try {
|
||||
double frequencyMHz =
|
||||
@@ -188,4 +193,4 @@ public final class FrequencyTextParser {
|
||||
return sourceText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
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.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
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.
|
||||
*/
|
||||
// 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.
|
||||
private static final String TAG_CONFIG_VERSION = "configVersion";
|
||||
@@ -74,7 +79,26 @@ public class ChatPreferences {
|
||||
* TODO: delete this from the kst4contest.view/Main.java!
|
||||
*/
|
||||
public ChatPreferences() {
|
||||
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE);
|
||||
this(PREFERENCES_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates preferences bound to one operator profile.
|
||||
*
|
||||
* <p>The file name is resolved below the application directory, so both
|
||||
* "preferences.xml" for the root installation and "profiles/OP2/preferences.xml"
|
||||
* for an additional operator profile are valid. A missing file is seeded from the
|
||||
* bundled template, which gives a new profile the same clean defaults a first-ever
|
||||
* installation gets.</p>
|
||||
*
|
||||
* @param applicationRelativeFileName preferences file name relative to the application directory
|
||||
*/
|
||||
public ChatPreferences(final String applicationRelativeFileName) {
|
||||
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, applicationRelativeFileName);
|
||||
this.storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
applicationRelativeFileName
|
||||
);
|
||||
|
||||
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
|
||||
|
||||
@@ -143,7 +167,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 storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, PREFERENCES_FILE);
|
||||
String chatState; // working variable only for use by primarystage (title bar)
|
||||
@@ -163,8 +187,14 @@ public class ChatPreferences {
|
||||
int stn_pstRotatorPort = 12000;
|
||||
|
||||
boolean stn_loginAFKState = false; //always start as here
|
||||
String stn_loginCallSign = "do5amf";
|
||||
String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2
|
||||
/*
|
||||
* The login credentials default to empty on purpose. A missing or empty value in
|
||||
* preferences.xml means "not configured yet", and falling back to a real callsign
|
||||
* would let an operator transmit under someone else's call. This matters for every
|
||||
* additional operator profile, whose preferences are created without credentials.
|
||||
*/
|
||||
String stn_loginCallSign = "";
|
||||
String stn_loginCallSignRaw = ""; //for example: do5amf instead of logincallsign do5amf-2
|
||||
String stn_loginPassword = "";
|
||||
String stn_loginNameMainCat = "KST4Contest";
|
||||
String stn_loginNameSecondCat = "KST4ContestSHF";
|
||||
@@ -341,6 +371,12 @@ public class ChatPreferences {
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
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 +681,39 @@ public class ChatPreferences {
|
||||
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() {
|
||||
return guiOptions_defaultFilterNothing;
|
||||
}
|
||||
@@ -1389,7 +1458,7 @@ public class ChatPreferences {
|
||||
*
|
||||
* @return true if the file writing was successful, else false
|
||||
*/
|
||||
public boolean writePreferencesToXmlFile() {
|
||||
public synchronized boolean writePreferencesToXmlFile() {
|
||||
|
||||
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
|
||||
try {
|
||||
@@ -2085,27 +2154,25 @@ public class ChatPreferences {
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapPathAnalysisVisible);
|
||||
|
||||
Element GUIstationMapClusteringEnabled = doc.createElement("GUIstationMapClusteringEnabled");
|
||||
GUIstationMapClusteringEnabled.setTextContent(
|
||||
String.valueOf(this.isGUIstationMapClusteringEnabled())
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapClusteringEnabled);
|
||||
|
||||
appendTableColumnWidths(doc, guiOptions);
|
||||
|
||||
/****************************************************************************************
|
||||
****************************** now write this XML! *************************************
|
||||
****************************************************************************************/
|
||||
|
||||
writeXml(doc, System.out);
|
||||
|
||||
// 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();
|
||||
}
|
||||
writeDocumentAtomically(doc);
|
||||
|
||||
|
||||
} catch (ParserConfigurationException | TransformerException e1) {
|
||||
} catch (ParserConfigurationException | TransformerException | IOException e1) {
|
||||
// TODO Auto-generated catch block
|
||||
e1.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -2117,6 +2184,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
|
||||
private static void writeXml(Document doc, OutputStream output) throws TransformerException {
|
||||
|
||||
@@ -2824,6 +2998,7 @@ public class ChatPreferences {
|
||||
* case read GUI options
|
||||
*
|
||||
***********************************************/
|
||||
this.setGUIstationMapClusteringEnabled(true);
|
||||
list = doc.getElementsByTagName("guiOptions");
|
||||
if (list.getLength() != 0) {
|
||||
|
||||
@@ -2862,6 +3037,17 @@ public class ChatPreferences {
|
||||
"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
|
||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||
if (s1 != null) {
|
||||
@@ -2920,6 +3106,22 @@ public class ChatPreferences {
|
||||
if (s5 != null) {
|
||||
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 +3302,63 @@ public class ChatPreferences {
|
||||
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})
|
||||
* or {@code defaultValue} if the tag does not exist or is empty.
|
||||
@@ -3139,6 +3398,20 @@ public class ChatPreferences {
|
||||
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) {
|
||||
String v = getText(parent, null, tagNames);
|
||||
if (v == null) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Descriptor of one operator profile.
|
||||
*
|
||||
* <p>A profile always owns its own preferences file. Whether it also owns its own
|
||||
* worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi
|
||||
* operator contest station keeps one common log and therefore shares the database,
|
||||
* while two operators sharing a private computer usually want their worked data kept
|
||||
* apart.</p>
|
||||
*
|
||||
* <p>The descriptor deliberately carries no file paths. They are derived in exactly one
|
||||
* place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never
|
||||
* drift apart from the flag that produced it.</p>
|
||||
*/
|
||||
public class OperatorProfile {
|
||||
|
||||
/**
|
||||
* Stable identifier of the profile. It is assigned once and never changes, so
|
||||
* renaming a profile never moves a directory.
|
||||
*/
|
||||
private String profileId;
|
||||
|
||||
/**
|
||||
* Name shown in the profile picker and in the settings window.
|
||||
*/
|
||||
private String displayName;
|
||||
|
||||
/**
|
||||
* True for the profile that uses the historic flat installation layout directly.
|
||||
*/
|
||||
private boolean rootProfile;
|
||||
|
||||
/**
|
||||
* True if this profile uses the common station worked-station database.
|
||||
*/
|
||||
private boolean sharedWorkedDatabase;
|
||||
|
||||
/**
|
||||
* Timestamp of the last activation, used to preselect an entry in the picker.
|
||||
*/
|
||||
private long lastUsedEpochMs;
|
||||
|
||||
public OperatorProfile() {
|
||||
// Default constructor for stepwise construction while reading the registry.
|
||||
}
|
||||
|
||||
public OperatorProfile(final String profileId,
|
||||
final String displayName,
|
||||
final boolean rootProfile,
|
||||
final boolean sharedWorkedDatabase) {
|
||||
this.profileId = profileId;
|
||||
this.displayName = displayName;
|
||||
this.rootProfile = rootProfile;
|
||||
this.sharedWorkedDatabase = sharedWorkedDatabase;
|
||||
}
|
||||
|
||||
public String getProfileId() {
|
||||
return profileId;
|
||||
}
|
||||
|
||||
public void setProfileId(final String profileId) {
|
||||
this.profileId = profileId;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(final String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public boolean isRootProfile() {
|
||||
return rootProfile;
|
||||
}
|
||||
|
||||
public void setRootProfile(final boolean rootProfile) {
|
||||
this.rootProfile = rootProfile;
|
||||
}
|
||||
|
||||
public boolean isSharedWorkedDatabase() {
|
||||
return sharedWorkedDatabase;
|
||||
}
|
||||
|
||||
public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) {
|
||||
this.sharedWorkedDatabase = sharedWorkedDatabase;
|
||||
}
|
||||
|
||||
public long getLastUsedEpochMs() {
|
||||
return lastUsedEpochMs;
|
||||
}
|
||||
|
||||
public void setLastUsedEpochMs(final long lastUsedEpochMs) {
|
||||
this.lastUsedEpochMs = lastUsedEpochMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object other) {
|
||||
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(other instanceof OperatorProfile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Objects.equals(profileId, ((OperatorProfile) other).profileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display name so the descriptor can be shown in a list control directly.
|
||||
*
|
||||
* @return the display name, or the profile id when no name was set
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
return String.valueOf(profileId);
|
||||
}
|
||||
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Resolved runtime view of the active operator profile.
|
||||
*
|
||||
* <p>This is the only profile information the rest of the application needs: two file
|
||||
* names relative to the application directory plus the flag whether a missing
|
||||
* worked-station database may be seeded from the bundled template. Everything else is
|
||||
* derived from the descriptor.</p>
|
||||
*/
|
||||
public class OperatorProfileSelection {
|
||||
|
||||
private final OperatorProfile profile;
|
||||
private final String preferencesRelativeFileName;
|
||||
private final String workedDatabaseRelativeFileName;
|
||||
private final boolean seedWorkedDatabaseFromResource;
|
||||
|
||||
public OperatorProfileSelection(final OperatorProfile profile,
|
||||
final String preferencesRelativeFileName,
|
||||
final String workedDatabaseRelativeFileName,
|
||||
final boolean seedWorkedDatabaseFromResource) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.preferencesRelativeFileName =
|
||||
Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName");
|
||||
this.workedDatabaseRelativeFileName =
|
||||
Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName");
|
||||
this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource;
|
||||
}
|
||||
|
||||
public OperatorProfile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getPreferencesRelativeFileName() {
|
||||
return preferencesRelativeFileName;
|
||||
}
|
||||
|
||||
public String getWorkedDatabaseRelativeFileName() {
|
||||
return workedDatabaseRelativeFileName;
|
||||
}
|
||||
|
||||
public boolean isSeedWorkedDatabaseFromResource() {
|
||||
return seedWorkedDatabaseFromResource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute preferences path, for display in the settings window.
|
||||
*
|
||||
* @return absolute path of the preferences file
|
||||
*/
|
||||
public String getPreferencesAbsolutePath() {
|
||||
return ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute worked-station database path, for display in the settings window.
|
||||
*
|
||||
* @return absolute path of the worked-station database
|
||||
*/
|
||||
public String getWorkedDatabaseAbsolutePath() {
|
||||
return ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.ActiveOperatorProfile;
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.controller.OperatorProfileStore;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of the running application.
|
||||
*
|
||||
* <p>Switching the operator profile tears the current runtime down completely and builds
|
||||
* a fresh one in the same process. Rebinding the existing windows is not an option: the
|
||||
* user interface is built from the chat controller outwards, with several hundred
|
||||
* references to the active preferences, and many controls are instance fields created
|
||||
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
|
||||
* here because the class keeps no mutable static state.</p>
|
||||
*/
|
||||
public final class ApplicationRuntimeLauncher {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
|
||||
|
||||
private static Kst4ContestApplication currentRuntime;
|
||||
|
||||
private ApplicationRuntimeLauncher() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the runtime that is currently live.
|
||||
*
|
||||
* @param runtime the running application instance
|
||||
*/
|
||||
public static void setCurrent(final Kst4ContestApplication runtime) {
|
||||
currentRuntime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the runtime that is currently live.
|
||||
*
|
||||
* @return the running application instance, or null before the first startup
|
||||
*/
|
||||
public static Kst4ContestApplication getCurrent() {
|
||||
return currentRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the application down.
|
||||
*
|
||||
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
|
||||
* after a profile switch has to release the resources explicitly.</p>
|
||||
*/
|
||||
public static void exitApplication() {
|
||||
|
||||
if (currentRuntime != null) {
|
||||
currentRuntime.shutdownRuntime();
|
||||
}
|
||||
|
||||
Platform.exit();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the running runtime with one bound to another operator profile.
|
||||
*
|
||||
* @param targetProfile profile to activate
|
||||
* @return true if the new runtime was built
|
||||
*/
|
||||
public static boolean switchProfile(final OperatorProfile targetProfile) {
|
||||
|
||||
if (targetProfile == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
|
||||
|
||||
if (currentRuntime != null) {
|
||||
currentRuntime.shutdownRuntime();
|
||||
}
|
||||
|
||||
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
|
||||
|
||||
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
|
||||
|
||||
try {
|
||||
nextRuntime.start(new Stage());
|
||||
} catch (Exception e) {
|
||||
// The previous runtime is already gone, so there is nothing left to return to.
|
||||
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
|
||||
Kst4ContestApplication.alertWindowEvent(
|
||||
"The operator profile could not be started: " + e.getMessage()
|
||||
+ "\n\nKST4Contest has to be closed.");
|
||||
Platform.exit();
|
||||
System.exit(1);
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrent(nextRuntime);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Command line options of the application.
|
||||
*
|
||||
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
|
||||
* the parameters of the {@code Application} instance it launched itself, so an instance
|
||||
* created during a profile switch would see no parameters at all. Parsing once at
|
||||
* startup and remembering the result avoids that entirely.</p>
|
||||
*/
|
||||
public class CommandLineOptions {
|
||||
|
||||
/**
|
||||
* Command line switch selecting the operator profile to start with.
|
||||
*/
|
||||
public static final String PROFILE_ARGUMENT = "--profile";
|
||||
|
||||
/**
|
||||
* System property used as an alternative to the command line switch.
|
||||
*/
|
||||
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
|
||||
|
||||
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
|
||||
|
||||
private final String requestedProfileName;
|
||||
|
||||
public CommandLineOptions(final String requestedProfileName) {
|
||||
this.requestedProfileName = requestedProfileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw application arguments.
|
||||
*
|
||||
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
|
||||
* keep an operator out of the application shortly before a contest.</p>
|
||||
*
|
||||
* @param rawArguments raw arguments, may be null
|
||||
* @return the parsed options
|
||||
*/
|
||||
public static CommandLineOptions parse(final List<String> rawArguments) {
|
||||
|
||||
String requestedProfileName = null;
|
||||
|
||||
if (rawArguments != null) {
|
||||
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
|
||||
String currentArgument = rawArguments.get(argumentIndex);
|
||||
|
||||
if (currentArgument == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
|
||||
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
|
||||
} else if (PROFILE_ARGUMENT.equals(currentArgument)
|
||||
&& argumentIndex + 1 < rawArguments.size()) {
|
||||
requestedProfileName = rawArguments.get(argumentIndex + 1);
|
||||
argumentIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedProfileName == null || requestedProfileName.isBlank()) {
|
||||
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
|
||||
}
|
||||
|
||||
if (requestedProfileName != null && requestedProfileName.isBlank()) {
|
||||
requestedProfileName = null;
|
||||
}
|
||||
|
||||
return new CommandLineOptions(
|
||||
requestedProfileName == null ? null : requestedProfileName.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the parsed options for the lifetime of the process.
|
||||
*
|
||||
* @param options options to remember
|
||||
*/
|
||||
public static void remember(final CommandLineOptions options) {
|
||||
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the options parsed at application startup.
|
||||
*
|
||||
* @return the remembered options, never null
|
||||
*/
|
||||
public static CommandLineOptions remembered() {
|
||||
return rememberedOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the operator profile requested on the command line.
|
||||
*
|
||||
* @return the requested profile name, or null when none was given
|
||||
*/
|
||||
public String getRequestedProfileName() {
|
||||
return requestedProfileName;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,107 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.controller.OperatorProfileStore;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decides which operator profile the application starts with.
|
||||
*
|
||||
* <p>The class contains no user interface code so the decision can be tested headless.
|
||||
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
|
||||
* problem worth telling the operator about is reported through
|
||||
* {@link #getStartupWarning()} instead of being shown here.</p>
|
||||
*
|
||||
* <p>The most important property of this logic is what it does <em>not</em> do: an
|
||||
* installation with no or exactly one profile is resolved without asking anything and
|
||||
* without touching a single file, so a single operator start stays exactly as fast and
|
||||
* as quiet as it was before profiles existed.</p>
|
||||
*/
|
||||
public class OperatorProfileBootstrap {
|
||||
|
||||
private String startupWarning;
|
||||
|
||||
/**
|
||||
* Resolves the operator profile to start with.
|
||||
*
|
||||
* @param store registry to read the profiles from
|
||||
* @param commandLineOptions parsed command line options
|
||||
* @param choiceRequester requester used when the operator has to choose
|
||||
* @return the resolved selection, or null when the operator chose to quit
|
||||
*/
|
||||
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
|
||||
final CommandLineOptions commandLineOptions,
|
||||
final OperatorProfileChoiceRequester choiceRequester) {
|
||||
|
||||
startupWarning = null;
|
||||
|
||||
List<OperatorProfile> availableProfiles = store.loadProfiles();
|
||||
String requestedProfileName = commandLineOptions == null
|
||||
? null
|
||||
: commandLineOptions.getRequestedProfileName();
|
||||
|
||||
if (requestedProfileName != null) {
|
||||
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
|
||||
|
||||
if (requestedProfile != null) {
|
||||
return OperatorProfilePaths.resolve(requestedProfile);
|
||||
}
|
||||
|
||||
startupWarning = "The operator profile \"" + requestedProfileName
|
||||
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
|
||||
}
|
||||
|
||||
if (availableProfiles.isEmpty()) {
|
||||
// No registry at all: the historic flat installation is the only profile.
|
||||
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
|
||||
}
|
||||
|
||||
if (availableProfiles.size() == 1) {
|
||||
return OperatorProfilePaths.resolve(availableProfiles.get(0));
|
||||
}
|
||||
|
||||
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
|
||||
Optional<OperatorProfile> chosenProfile =
|
||||
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
|
||||
|
||||
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a message that should be shown to the operator after startup.
|
||||
*
|
||||
* @return the warning text, or null when startup was unremarkable
|
||||
*/
|
||||
public String getStartupWarning() {
|
||||
return startupWarning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a profile by identifier or display name, ignoring case.
|
||||
*
|
||||
* @param availableProfiles profiles to search
|
||||
* @param requestedName identifier or display name entered by the operator
|
||||
* @return the matching profile, or null
|
||||
*/
|
||||
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
|
||||
final String requestedName) {
|
||||
|
||||
for (OperatorProfile currentProfile : availableProfiles) {
|
||||
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
|
||||
return currentProfile;
|
||||
}
|
||||
}
|
||||
|
||||
for (OperatorProfile currentProfile : availableProfiles) {
|
||||
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
|
||||
return currentProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.OperatorProfile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Asks the operator which profile to start with.
|
||||
*
|
||||
* <p>The startup logic depends on this interface rather than on a dialog, so the
|
||||
* decision which profile to use can be tested without a JavaFX runtime.</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface OperatorProfileChoiceRequester {
|
||||
|
||||
/**
|
||||
* Requests a profile choice.
|
||||
*
|
||||
* @param selectableProfiles profiles to choose from, never empty
|
||||
* @param preselectedProfileId identifier to preselect, may be null
|
||||
* @return the chosen profile, or empty when the operator wants to quit
|
||||
*/
|
||||
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
|
||||
String preselectedProfileId);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.OperatorProfile;
|
||||
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Asks the operator which profile to start with.
|
||||
*
|
||||
* <p>The dialog is shown only when more than one profile exists. It is intentionally
|
||||
* minimal, because it stands between the operator and a contest: the last used profile
|
||||
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
|
||||
*/
|
||||
public final class OperatorProfilePickerDialog {
|
||||
|
||||
private OperatorProfilePickerDialog() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the picker and waits for the operator's choice.
|
||||
*
|
||||
* @param selectableProfiles profiles to choose from
|
||||
* @param preselectedProfileId identifier of the profile to preselect, may be null
|
||||
* @return the chosen profile, or empty when the operator wants to quit
|
||||
*/
|
||||
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
|
||||
final String preselectedProfileId) {
|
||||
|
||||
Stage dialogStage = new Stage();
|
||||
GuiUtils.applyApplicationIcon(dialogStage);
|
||||
dialogStage.initModality(Modality.APPLICATION_MODAL);
|
||||
dialogStage.setTitle("Select operator profile");
|
||||
|
||||
ListView<OperatorProfile> profileListView = new ListView<>();
|
||||
profileListView.getItems().addAll(selectableProfiles);
|
||||
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
|
||||
VBox.setVgrow(profileListView, Priority.ALWAYS);
|
||||
|
||||
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
|
||||
|
||||
OperatorProfile[] chosenProfile = new OperatorProfile[1];
|
||||
|
||||
Button startButton = new Button("Start");
|
||||
startButton.setDefaultButton(true);
|
||||
startButton.setOnAction(event -> {
|
||||
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
|
||||
dialogStage.close();
|
||||
});
|
||||
|
||||
Button quitButton = new Button("Quit");
|
||||
quitButton.setCancelButton(true);
|
||||
quitButton.setOnAction(event -> {
|
||||
chosenProfile[0] = null;
|
||||
dialogStage.close();
|
||||
});
|
||||
|
||||
profileListView.setOnMouseClicked(event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
|
||||
startButton.fire();
|
||||
}
|
||||
});
|
||||
|
||||
profileListView.setOnKeyPressed(event -> {
|
||||
if (event.getCode() == KeyCode.ENTER) {
|
||||
startButton.fire();
|
||||
}
|
||||
});
|
||||
|
||||
HBox buttonRow = new HBox(10, startButton, quitButton);
|
||||
buttonRow.setPadding(new Insets(10, 0, 0, 0));
|
||||
|
||||
VBox dialogContent = new VBox(8,
|
||||
new Label("More than one operator profile is configured."),
|
||||
profileListView,
|
||||
buttonRow);
|
||||
dialogContent.setPadding(new Insets(15));
|
||||
|
||||
dialogStage.setScene(new Scene(dialogContent, 380, 280));
|
||||
profileListView.requestFocus();
|
||||
dialogStage.showAndWait();
|
||||
|
||||
return Optional.ofNullable(chosenProfile[0]);
|
||||
}
|
||||
|
||||
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
|
||||
final List<OperatorProfile> selectableProfiles,
|
||||
final String preselectedProfileId) {
|
||||
|
||||
int profileIndexToSelect = 0;
|
||||
|
||||
if (preselectedProfileId != null) {
|
||||
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
|
||||
if (preselectedProfileId.equalsIgnoreCase(
|
||||
selectableProfiles.get(profileIndex).getProfileId())) {
|
||||
profileIndexToSelect = profileIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profileListView.getSelectionModel().select(profileIndexToSelect);
|
||||
profileListView.scrollTo(profileIndexToSelect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a profile with its name and the kind of worked data it uses.
|
||||
*/
|
||||
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
|
||||
|
||||
@Override
|
||||
protected void updateItem(final OperatorProfile profile, final boolean empty) {
|
||||
|
||||
super.updateItem(profile, empty);
|
||||
|
||||
if (empty || profile == null) {
|
||||
setText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
|
||||
? "shared station worked database"
|
||||
: "own worked database";
|
||||
|
||||
setText(profile.getDisplayName() + "\n" + workedDataDescription);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.ActiveOperatorProfile;
|
||||
import kst4contest.controller.OperatorProfileManagementService;
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.RadioButton;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.TextInputDialog;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Pair;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Settings tab that manages the operator profiles.
|
||||
*
|
||||
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
|
||||
* own worked stations is chosen per profile, because a multi operator contest station
|
||||
* shares one log while two operators on a private computer usually do not.</p>
|
||||
*/
|
||||
public class OperatorProfileSettingsPane extends VBox {
|
||||
|
||||
private static final DateTimeFormatter LAST_USED_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
|
||||
|
||||
private final OperatorProfileManagementService managementService;
|
||||
private final Consumer<OperatorProfile> profileActivationRequest;
|
||||
|
||||
private final TableView<OperatorProfile> profileTable = new TableView<>();
|
||||
private final Label activeProfileLabel = new Label();
|
||||
private final Label preferencesPathLabel = new Label();
|
||||
private final Label workedDatabasePathLabel = new Label();
|
||||
|
||||
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
|
||||
final Consumer<OperatorProfile> profileActivationRequest) {
|
||||
|
||||
this.managementService = managementService;
|
||||
this.profileActivationRequest = profileActivationRequest;
|
||||
|
||||
setSpacing(10);
|
||||
setPadding(new Insets(15));
|
||||
|
||||
getChildren().addAll(
|
||||
buildActiveProfileHeader(),
|
||||
buildProfileTable(),
|
||||
buildButtonRows(),
|
||||
buildExplanationLabel());
|
||||
|
||||
refreshActiveProfileHeader();
|
||||
refreshProfileTable();
|
||||
}
|
||||
|
||||
private GridPane buildActiveProfileHeader() {
|
||||
|
||||
GridPane headerGrid = new GridPane();
|
||||
headerGrid.setHgap(10);
|
||||
headerGrid.setVgap(4);
|
||||
|
||||
headerGrid.add(new Label("Active profile:"), 0, 0);
|
||||
headerGrid.add(activeProfileLabel, 1, 0);
|
||||
headerGrid.add(new Label("Settings file:"), 0, 1);
|
||||
headerGrid.add(preferencesPathLabel, 1, 1);
|
||||
headerGrid.add(new Label("Worked stations:"), 0, 2);
|
||||
headerGrid.add(workedDatabasePathLabel, 1, 2);
|
||||
|
||||
return headerGrid;
|
||||
}
|
||||
|
||||
private TableView<OperatorProfile> buildProfileTable() {
|
||||
|
||||
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
|
||||
nameColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(cellData.getValue().getDisplayName()));
|
||||
nameColumn.setPrefWidth(200);
|
||||
|
||||
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
|
||||
workedDataColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
|
||||
workedDataColumn.setPrefWidth(200);
|
||||
|
||||
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
|
||||
lastUsedColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
|
||||
lastUsedColumn.setPrefWidth(140);
|
||||
|
||||
profileTable.getColumns().add(nameColumn);
|
||||
profileTable.getColumns().add(workedDataColumn);
|
||||
profileTable.getColumns().add(lastUsedColumn);
|
||||
profileTable.setPlaceholder(new Label("No operator profile configured."));
|
||||
|
||||
VBox.setVgrow(profileTable, Priority.ALWAYS);
|
||||
|
||||
return profileTable;
|
||||
}
|
||||
|
||||
private VBox buildButtonRows() {
|
||||
|
||||
Button newProfileButton = new Button("New profile...");
|
||||
newProfileButton.setOnAction(event -> createProfile());
|
||||
|
||||
Button duplicateProfileButton = new Button("Duplicate...");
|
||||
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
|
||||
|
||||
Button renameProfileButton = new Button("Rename...");
|
||||
renameProfileButton.setOnAction(event -> renameSelectedProfile());
|
||||
|
||||
Button deleteProfileButton = new Button("Delete...");
|
||||
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
|
||||
|
||||
Button changeWorkedDataButton = new Button("Change worked stations...");
|
||||
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
|
||||
|
||||
Button switchProfileButton = new Button("Switch to selected profile...");
|
||||
switchProfileButton.setOnAction(event -> activateSelectedProfile());
|
||||
|
||||
HBox managementRow = new HBox(8,
|
||||
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
|
||||
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
|
||||
|
||||
return new VBox(8, managementRow, activationRow);
|
||||
}
|
||||
|
||||
private Label buildExplanationLabel() {
|
||||
|
||||
Label explanation = new Label(
|
||||
"Each profile has its own settings and window layout. A profile can either share the "
|
||||
+ "common station worked stations, which is what a multi operator station wants, "
|
||||
+ "or keep its own. Duplicating a profile copies everything except callsign and "
|
||||
+ "password, and never copies worked stations.");
|
||||
explanation.setWrapText(true);
|
||||
|
||||
return explanation;
|
||||
}
|
||||
|
||||
private void refreshActiveProfileHeader() {
|
||||
|
||||
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
|
||||
|
||||
if (activeProfile == null) {
|
||||
activeProfileLabel.setText("unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
|
||||
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
|
||||
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
|
||||
}
|
||||
|
||||
private void refreshProfileTable() {
|
||||
|
||||
List<OperatorProfile> knownProfiles = managementService.listProfiles();
|
||||
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
|
||||
|
||||
profileTable.getItems().setAll(knownProfiles);
|
||||
|
||||
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
|
||||
profileTable.getSelectionModel().select(previouslySelected);
|
||||
} else if (!knownProfiles.isEmpty()) {
|
||||
profileTable.getSelectionModel().select(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void createProfile() {
|
||||
|
||||
Optional<Pair<String, Boolean>> enteredProfile =
|
||||
showProfileCreationDialog("New operator profile", "");
|
||||
|
||||
if (enteredProfile.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile(
|
||||
enteredProfile.get().getKey(), enteredProfile.get().getValue());
|
||||
|
||||
if (createdProfile == null) {
|
||||
showError("The profile could not be created. The profile registry could not be written.");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
profileTable.getSelectionModel().select(createdProfile);
|
||||
|
||||
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
|
||||
+ "callsign and password. Enter them on the Station tab after switching to it.");
|
||||
}
|
||||
|
||||
private void duplicateSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TextInputDialog nameDialog =
|
||||
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
|
||||
nameDialog.setTitle("Duplicate operator profile");
|
||||
nameDialog.setHeaderText("Name of the new profile");
|
||||
nameDialog.setContentText(
|
||||
"Everything is copied except callsign and password. Worked stations are never copied.");
|
||||
|
||||
Optional<String> enteredName = nameDialog.showAndWait();
|
||||
|
||||
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatorProfile duplicatedProfile =
|
||||
managementService.duplicateProfile(selectedProfile, enteredName.get());
|
||||
|
||||
if (duplicatedProfile == null) {
|
||||
showError("The profile could not be duplicated.");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
profileTable.getSelectionModel().select(duplicatedProfile);
|
||||
}
|
||||
|
||||
private void renameSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
|
||||
nameDialog.setTitle("Rename operator profile");
|
||||
nameDialog.setHeaderText("New name of the profile");
|
||||
nameDialog.setContentText("Files and folders of the profile are not touched.");
|
||||
|
||||
Optional<String> enteredName = nameDialog.showAndWait();
|
||||
|
||||
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
managementService.renameProfile(selectedProfile, enteredName.get());
|
||||
refreshProfileTable();
|
||||
refreshActiveProfileHeader();
|
||||
}
|
||||
|
||||
private void deleteSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProfile.isRootProfile()) {
|
||||
showError("The default profile uses the files of the installation itself "
|
||||
+ "and cannot be deleted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showError("The profile currently in use cannot be deleted. Switch to another "
|
||||
+ "profile first.");
|
||||
return;
|
||||
}
|
||||
|
||||
Alert confirmation = new Alert(AlertType.CONFIRMATION);
|
||||
confirmation.setTitle("Delete operator profile");
|
||||
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
|
||||
confirmation.setContentText(
|
||||
"The following folder is removed permanently:\n"
|
||||
+ managementService.getProfileDirectory(selectedProfile)
|
||||
+ "\n\n"
|
||||
+ (selectedProfile.isSharedWorkedDatabase()
|
||||
? "The common station worked stations are not touched."
|
||||
: "The worked stations of this profile are deleted as well."));
|
||||
|
||||
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
|
||||
|
||||
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!managementService.deleteProfile(selectedProfile)) {
|
||||
showError("The profile could not be deleted.");
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
}
|
||||
|
||||
private void changeWorkedDataModeOfSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProfile.isRootProfile()) {
|
||||
showError("The default profile always uses the common station worked stations, "
|
||||
+ "because that database is the one of the installation itself.");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
|
||||
|
||||
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
|
||||
refreshProfileTable();
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showInformation("The change takes effect after switching to this profile again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void activateSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showInformation("This profile is already active.");
|
||||
return;
|
||||
}
|
||||
|
||||
profileActivationRequest.accept(selectedProfile);
|
||||
}
|
||||
|
||||
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
|
||||
final String initialName) {
|
||||
|
||||
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
|
||||
creationDialog.setTitle(title);
|
||||
creationDialog.setHeaderText("Name and worked stations of the new profile");
|
||||
|
||||
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
|
||||
|
||||
TextField nameField = new TextField(initialName);
|
||||
nameField.setPromptText("for example DN9APW");
|
||||
|
||||
ToggleGroup workedDataGroup = new ToggleGroup();
|
||||
|
||||
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
|
||||
ownDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
ownDatabaseOption.setSelected(true);
|
||||
|
||||
RadioButton sharedDatabaseOption =
|
||||
new RadioButton("Share the common station worked stations (multi operator station)");
|
||||
sharedDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
VBox dialogContent = new VBox(8,
|
||||
new Label("Profile name"),
|
||||
nameField,
|
||||
new Label("Worked stations"),
|
||||
ownDatabaseOption,
|
||||
sharedDatabaseOption);
|
||||
dialogContent.setPadding(new Insets(10));
|
||||
|
||||
creationDialog.getDialogPane().setContent(dialogContent);
|
||||
|
||||
creationDialog.setResultConverter(pressedButton -> {
|
||||
if (pressedButton != createButton || nameField.getText().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
|
||||
});
|
||||
|
||||
return creationDialog.showAndWait();
|
||||
}
|
||||
|
||||
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
|
||||
|
||||
Dialog<Boolean> modeDialog = new Dialog<>();
|
||||
modeDialog.setTitle("Worked stations");
|
||||
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
|
||||
|
||||
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
|
||||
|
||||
ToggleGroup workedDataGroup = new ToggleGroup();
|
||||
|
||||
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
|
||||
ownDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
RadioButton sharedDatabaseOption =
|
||||
new RadioButton("Share the common station worked stations (multi operator station)");
|
||||
sharedDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
|
||||
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
|
||||
|
||||
Label pathHint = new Label("Switching does not move any data. Worked stations already "
|
||||
+ "collected under the other setting stay where they are.");
|
||||
pathHint.setWrapText(true);
|
||||
|
||||
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
|
||||
dialogContent.setPadding(new Insets(10));
|
||||
|
||||
modeDialog.getDialogPane().setContent(dialogContent);
|
||||
modeDialog.setResultConverter(pressedButton ->
|
||||
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
|
||||
|
||||
return modeDialog.showAndWait();
|
||||
}
|
||||
|
||||
private OperatorProfile requireSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
showInformation("Select a profile in the table first.");
|
||||
}
|
||||
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
private static boolean isActiveProfile(final OperatorProfile profile) {
|
||||
|
||||
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
|
||||
|
||||
return activeProfile != null
|
||||
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
|
||||
}
|
||||
|
||||
private static String describeWorkedDataMode(final OperatorProfile profile) {
|
||||
|
||||
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
|
||||
return "common station database";
|
||||
}
|
||||
|
||||
return "own database";
|
||||
}
|
||||
|
||||
private static String describeLastUsed(final OperatorProfile profile) {
|
||||
|
||||
if (profile.getLastUsedEpochMs() <= 0L) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
|
||||
}
|
||||
|
||||
private static void showInformation(final String message) {
|
||||
Alert information = new Alert(AlertType.INFORMATION);
|
||||
information.setTitle("Operator profiles");
|
||||
information.setContentText(message);
|
||||
information.showAndWait();
|
||||
}
|
||||
|
||||
private static void showError(final String message) {
|
||||
Alert error = new Alert(AlertType.ERROR);
|
||||
error.setTitle("Operator profiles");
|
||||
error.setContentText(message);
|
||||
error.showAndWait();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* - JavaScript errors are forwarded to Java through javaMapBridge
|
||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||
* - setStationClusteringEnabled(boolean) re-renders the existing station data
|
||||
*
|
||||
* Important:
|
||||
* This version intentionally uses integer Leaflet zoom levels again.
|
||||
@@ -356,6 +357,7 @@ public final class MapHtmlResources {
|
||||
*/
|
||||
let stationData = [];
|
||||
let stationsByCallsignRaw = {};
|
||||
let stationClusteringEnabled = true;
|
||||
|
||||
let clustersById = {};
|
||||
let clusterSequence = 0;
|
||||
@@ -810,13 +812,19 @@ public final class MapHtmlResources {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||
if (!stationClusteringEnabled
|
||||
|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||
renderAllStationsIndividually();
|
||||
} else {
|
||||
renderClusteredStations();
|
||||
}
|
||||
}
|
||||
|
||||
function setStationClusteringEnabled(enabled) {
|
||||
stationClusteringEnabled = Boolean(enabled);
|
||||
renderStationMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zooms into a cluster.
|
||||
*
|
||||
@@ -1271,6 +1279,7 @@ public final class MapHtmlResources {
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setStationClusteringEnabled: setStationClusteringEnabled,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
@@ -1284,4 +1293,4 @@ public final class MapHtmlResources {
|
||||
</html>
|
||||
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,17 @@ import java.util.Locale;
|
||||
/**
|
||||
* Small helper service for preparing and filling the local offline DEM directory.
|
||||
*
|
||||
* <p>This is intentionally the first practical step before adding a real network
|
||||
* downloader:
|
||||
* <p>This service only prepares local data for a possible future offline terrain
|
||||
* provider:
|
||||
* <ul>
|
||||
* <li>create a known default Copernicus DEM root directory below .praktiKST</li>
|
||||
* <li>copy manually selected local *_DEM.tif files into that directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The active terrain provider already scans the configured root directory
|
||||
* recursively. Therefore it is sufficient to import valid Copernicus DGED
|
||||
* GeoTIFF files into one dedicated folder tree.</p>
|
||||
* <p>Importing files does not activate an offline provider or change the terrain
|
||||
* calculation chain. The active provider remains Open-Meteo with Copernicus GLO-90
|
||||
* data. Valid Copernicus GLO-30 DGED GeoTIFF files are only copied into the prepared
|
||||
* directory tree.</p>
|
||||
*/
|
||||
public final class OfflineDemImportService {
|
||||
|
||||
@@ -229,4 +230,4 @@ public final class OfflineDemImportService {
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package kst4contest.view.map;
|
||||
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.util.Duration;
|
||||
@@ -54,6 +55,16 @@ public final class StationMapBridge {
|
||||
|
||||
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
|
||||
|
||||
/*
|
||||
* The listeners are kept so install() can be undone. Without that, the coalescing
|
||||
* animation and the registered listeners would keep a discarded runtime reachable
|
||||
* after an operator profile switch.
|
||||
*/
|
||||
private ListChangeListener<ChatMember> chatMemberListListener;
|
||||
private ChangeListener<ChatMember> selectedChatMemberListener;
|
||||
private ChangeListener<Number> antennaDirectionListener;
|
||||
private ListChangeListener<Predicate<ChatMember>> filterPredicateListener;
|
||||
|
||||
public StationMapBridge(ChatController chatController,
|
||||
TableView<ChatMember> chatMemberTable,
|
||||
StationMapView stationMapView,
|
||||
@@ -81,25 +92,59 @@ public final class StationMapBridge {
|
||||
|
||||
stationMapView.setOnResetView(this::handleMapReset);
|
||||
|
||||
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
||||
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
||||
);
|
||||
chatMemberListListener = change -> scheduleRefresh();
|
||||
chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener);
|
||||
|
||||
chatController.getScoreService().selectedChatMemberProperty().addListener(
|
||||
(obs, oldValue, newValue) -> requestImmediateRefresh()
|
||||
);
|
||||
selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh();
|
||||
chatController.getScoreService().selectedChatMemberProperty()
|
||||
.addListener(selectedChatMemberListener);
|
||||
|
||||
chatController.getChatPreferences().getActualQTF().addListener(
|
||||
(obs, oldValue, newValue) -> scheduleRefresh()
|
||||
);
|
||||
antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh();
|
||||
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
|
||||
|
||||
chatController.getLst_chatMemberListFilterPredicates().addListener(
|
||||
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
|
||||
);
|
||||
filterPredicateListener = change -> requestImmediateRefresh();
|
||||
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
|
||||
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything {@link #install()} registered and stops the coalescing timer.
|
||||
*
|
||||
* <p>Needed when the runtime owning this bridge is discarded, for example during an
|
||||
* operator profile switch. A running {@link PauseTransition} would otherwise keep
|
||||
* firing into a dead user interface.</p>
|
||||
*/
|
||||
public void uninstall() {
|
||||
|
||||
refreshCoalescer.stop();
|
||||
|
||||
stationMapView.setOnCallsignRawSelected(null);
|
||||
stationMapView.setOnTriggerClusterSpot(null);
|
||||
stationMapView.setOnResetView(null);
|
||||
|
||||
if (chatMemberListListener != null) {
|
||||
chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener);
|
||||
chatMemberListListener = null;
|
||||
}
|
||||
|
||||
if (selectedChatMemberListener != null) {
|
||||
chatController.getScoreService().selectedChatMemberProperty()
|
||||
.removeListener(selectedChatMemberListener);
|
||||
selectedChatMemberListener = null;
|
||||
}
|
||||
|
||||
if (antennaDirectionListener != null) {
|
||||
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
|
||||
antennaDirectionListener = null;
|
||||
}
|
||||
|
||||
if (filterPredicateListener != null) {
|
||||
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
|
||||
filterPredicateListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMapReset() {
|
||||
Runnable resetAction = () -> {
|
||||
/*
|
||||
|
||||
@@ -59,6 +59,7 @@ public final class StationMapView {
|
||||
private final Label detailPathModeValue = new Label("-");
|
||||
|
||||
private final ChatPreferences chatPreferences;
|
||||
private final Runnable layoutSaveRequester;
|
||||
|
||||
private final Stage stage = new Stage();
|
||||
private final WebView webView = new WebView();
|
||||
@@ -89,6 +90,9 @@ public final class StationMapView {
|
||||
|
||||
private final Button resetViewButton = new Button("Reset view");
|
||||
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;
|
||||
|
||||
@@ -180,7 +184,12 @@ public final class StationMapView {
|
||||
|
||||
|
||||
public StationMapView(ChatPreferences chatPreferences) {
|
||||
this(chatPreferences, () -> { });
|
||||
}
|
||||
|
||||
public StationMapView(ChatPreferences chatPreferences, Runnable layoutSaveRequester) {
|
||||
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
|
||||
this.layoutSaveRequester = Objects.requireNonNull(layoutSaveRequester, "layoutSaveRequester");
|
||||
GuiUtils.applyApplicationIcon(stage);
|
||||
|
||||
try {
|
||||
@@ -235,6 +244,25 @@ public final class StationMapView {
|
||||
stage.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases every resource this map window owns.
|
||||
*
|
||||
* <p>The tile proxy is a local server socket with its own thread pool. It used to
|
||||
* live until the process ended, which was harmless while the map existed exactly
|
||||
* once per process. A runtime that is discarded, for example during an operator
|
||||
* profile switch, has to hand it back.</p>
|
||||
*/
|
||||
public void dispose() {
|
||||
|
||||
if (tileProxyServer != null) {
|
||||
tileProxyServer.stop();
|
||||
tileProxyServer = null;
|
||||
}
|
||||
|
||||
webEngine.load(null);
|
||||
stage.close();
|
||||
}
|
||||
|
||||
public boolean isShowing() {
|
||||
return stage.isShowing();
|
||||
}
|
||||
@@ -327,6 +355,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.setTooltip(pathAnalysisVisibilityTooltip);
|
||||
pathAnalysisVisibilityButton.setOnAction(event ->
|
||||
@@ -461,17 +504,25 @@ public final class StationMapView {
|
||||
stage.setY(pos[1]);
|
||||
}
|
||||
|
||||
stage.widthProperty().addListener((obs, oldValue, newValue) ->
|
||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue());
|
||||
stage.widthProperty().addListener((obs, oldValue, newValue) -> {
|
||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue();
|
||||
layoutSaveRequester.run();
|
||||
});
|
||||
|
||||
stage.heightProperty().addListener((obs, oldValue, newValue) ->
|
||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue());
|
||||
stage.heightProperty().addListener((obs, oldValue, newValue) -> {
|
||||
chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue();
|
||||
layoutSaveRequester.run();
|
||||
});
|
||||
|
||||
stage.xProperty().addListener((obs, oldValue, newValue) ->
|
||||
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue());
|
||||
stage.xProperty().addListener((obs, oldValue, newValue) -> {
|
||||
chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue();
|
||||
layoutSaveRequester.run();
|
||||
});
|
||||
|
||||
stage.yProperty().addListener((obs, oldValue, newValue) ->
|
||||
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue());
|
||||
stage.yProperty().addListener((obs, oldValue, newValue) -> {
|
||||
chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue();
|
||||
layoutSaveRequester.run();
|
||||
});
|
||||
|
||||
stage.setOnShown(event -> Platform.runLater(() -> {
|
||||
webView.requestFocus();
|
||||
@@ -521,6 +572,7 @@ public final class StationMapView {
|
||||
statusLabel,
|
||||
triggerClusterSpotButton,
|
||||
resetViewButton,
|
||||
stationClusteringCheckBox,
|
||||
pathAnalysisHiddenHintLabel,
|
||||
pathAnalysisVisibilityButton
|
||||
);
|
||||
@@ -794,6 +846,9 @@ public final class StationMapView {
|
||||
window.setMember("javaMapBridge", javaMapBridge);
|
||||
|
||||
executeMapScriptSafely("window.kstMapApi.init();");
|
||||
executeMapScriptSafely(
|
||||
"window.kstMapApi.setStationClusteringEnabled("
|
||||
+ chatPreferences.isGUIstationMapClusteringEnabled() + ");");
|
||||
|
||||
mapReady = true;
|
||||
applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive());
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.DBController;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -15,30 +17,44 @@ import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Persistent terrain profile cache stored in the application's existing SQLite database.
|
||||
* Persistent terrain profile cache, stored globally in its own SQLite database.
|
||||
*
|
||||
* The cache is intentionally owner-bound:
|
||||
* if the configured own callsign or own locator changes, all cached terrain
|
||||
* profiles are cleared automatically.
|
||||
* <p>Terrain profiles are pure geometry derived from two locators and a sample count.
|
||||
* They do not belong to one operator, so the cache is deliberately not part of an
|
||||
* operator profile: at a multi operator station both operators share one location, and
|
||||
* duplicating the cache would double the traffic against an external terrain service.</p>
|
||||
*
|
||||
* <p>Entries are separated by owner identity through the primary key instead. Earlier
|
||||
* versions kept a single owner identity and dropped the whole cache whenever the
|
||||
* configured callsign or locator changed; with several operator profiles that would
|
||||
* discard every computed profile on each switch.</p>
|
||||
*/
|
||||
public final class TerrainProfileCacheRepository {
|
||||
|
||||
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw";
|
||||
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6";
|
||||
/**
|
||||
* File name of the global terrain profile cache below the application directory.
|
||||
*/
|
||||
public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db";
|
||||
|
||||
private final String databasePath;
|
||||
|
||||
public TerrainProfileCacheRepository() {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DBController.DATABASE_RESOURCE,
|
||||
DBController.DATABASE_FILE
|
||||
);
|
||||
|
||||
this.databasePath = ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DBController.DATABASE_FILE
|
||||
TERRAIN_CACHE_DATABASE_FILE
|
||||
);
|
||||
|
||||
// SQLite only creates the database file itself, not the directory holding it.
|
||||
Path applicationDirectory = Path.of(databasePath).getParent();
|
||||
|
||||
if (applicationDirectory != null) {
|
||||
try {
|
||||
Files.createDirectories(applicationDirectory);
|
||||
} catch (IOException exception) {
|
||||
System.err.println("[StationMap] Terrain cache directory could not be created: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
|
||||
@@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository {
|
||||
""");
|
||||
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
|
||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
||||
meta_value TEXT NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner (
|
||||
owner_callsign_raw TEXT NOT NULL,
|
||||
owner_locator6 TEXT NOT NULL,
|
||||
last_used_epoch_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_callsign_raw, owner_locator6)
|
||||
)
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the given owner identity is in use.
|
||||
*
|
||||
* <p>Entries of other owners stay untouched. The cached profiles of an identity are
|
||||
* separated by the primary key already, so a different callsign or locator simply
|
||||
* misses the cache instead of invalidating everybody else's entries.</p>
|
||||
*/
|
||||
private void ensureOwnerIdentity(Connection connection,
|
||||
String currentOwnerCallsignRaw,
|
||||
String currentOwnerLocator6) throws Exception {
|
||||
|
||||
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
|
||||
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
|
||||
|
||||
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
|
||||
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
|
||||
|
||||
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
|
||||
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
|
||||
|
||||
if (callsignChanged || locatorChanged) {
|
||||
clearTerrainCache(connection);
|
||||
}
|
||||
|
||||
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
|
||||
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
|
||||
}
|
||||
|
||||
private void clearTerrainCache(Connection connection) throws Exception {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate("DELETE FROM TerrainProfileCache");
|
||||
}
|
||||
}
|
||||
|
||||
private String readMetaValue(Connection connection, String key) throws Exception {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
|
||||
statement.setString(1, key);
|
||||
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? resultSet.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
|
||||
INSERT INTO TerrainProfileCacheOwner (
|
||||
owner_callsign_raw, owner_locator6, last_used_epoch_ms
|
||||
) VALUES (?, ?, ?)
|
||||
ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET
|
||||
last_used_epoch_ms = excluded.last_used_epoch_ms
|
||||
""")) {
|
||||
statement.setString(1, key);
|
||||
statement.setString(2, value == null ? "" : value);
|
||||
statement.setString(1, normalize(currentOwnerCallsignRaw));
|
||||
statement.setString(2, normalize(currentOwnerLocator6));
|
||||
statement.setLong(3, System.currentTimeMillis());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,51 @@
|
||||
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.List;
|
||||
import java.util.Set;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ClusterMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ChatControllerSimpleLogTest {
|
||||
|
||||
@Test
|
||||
void marksEveryActiveVariantOfWorkedBaseCallsign() {
|
||||
ChatMember categoryTwoVariant = member("9A0BB-2");
|
||||
ChatMember categoryThreeVariant = member("9A0BB-70");
|
||||
ChatMember unrelated = member("DL1ABC");
|
||||
|
||||
int changed = ChatController.markSimpleLogWorkedMembers(
|
||||
List.of(categoryTwoVariant, categoryThreeVariant, unrelated),
|
||||
Set.of("9A0BB"));
|
||||
|
||||
assertEquals(2, changed);
|
||||
assertTrue(categoryTwoVariant.isWorked());
|
||||
assertTrue(categoryThreeVariant.isWorked());
|
||||
assertFalse(unrelated.isWorked());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksClusterReceiverByBaseCallsignAndHandlesIncompleteMessages() {
|
||||
ClusterMessage matching = new ClusterMessage();
|
||||
matching.setReceiver(member("9A0BB-70"));
|
||||
ClusterMessage incomplete = new ClusterMessage();
|
||||
|
||||
int changed = ChatController.markSimpleLogWorkedClusterMessages(
|
||||
List.of(matching, incomplete), Set.of("9A0BB"));
|
||||
|
||||
assertEquals(1, changed);
|
||||
assertTrue(matching.isReceiverWkd());
|
||||
assertFalse(incomplete.isReceiverWkd());
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies that one DBController instance works on exactly one database file, so two
|
||||
* operator profiles can keep independent worked-station data inside the same process.
|
||||
*/
|
||||
class DBControllerProfileDatabaseTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreUserHome() {
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException {
|
||||
|
||||
DBController firstProfileDatabase =
|
||||
new DBController("profiles/OP1/praktiKST.db", false);
|
||||
DBController secondProfileDatabase =
|
||||
new DBController("profiles/OP2/praktiKST.db", false);
|
||||
|
||||
try {
|
||||
// A profile database must not inherit the several thousand callsigns of the
|
||||
// bundled template database.
|
||||
assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
|
||||
assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
|
||||
|
||||
assertNotEquals(firstProfileDatabase.getDatabaseFilePath(),
|
||||
secondProfileDatabase.getDatabaseFilePath());
|
||||
assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath())));
|
||||
assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath())));
|
||||
|
||||
ChatMember workedOnFirstProfile = new ChatMember();
|
||||
workedOnFirstProfile.setCallSign("DL0XYZ");
|
||||
workedOnFirstProfile.setQra("JO51IJ");
|
||||
workedOnFirstProfile.setWorked(true);
|
||||
workedOnFirstProfile.setWorked144(true);
|
||||
|
||||
firstProfileDatabase.storeChatMember(workedOnFirstProfile);
|
||||
|
||||
Map<String, ChatMember> firstProfileContent =
|
||||
firstProfileDatabase.fetchChatMemberWkdDataFromDB();
|
||||
Map<String, ChatMember> secondProfileContent =
|
||||
secondProfileDatabase.fetchChatMemberWkdDataFromDB();
|
||||
|
||||
assertEquals(1, firstProfileContent.size());
|
||||
assertTrue(firstProfileContent.get("DL0XYZ").isWorked144());
|
||||
assertTrue(secondProfileContent.isEmpty(),
|
||||
"A worked station of one profile must not appear in the other profile");
|
||||
} finally {
|
||||
firstProfileDatabase.closeDBConnection();
|
||||
secondProfileDatabase.closeDBConnection();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException {
|
||||
|
||||
DBController sharedStationDatabase = new DBController("praktiKST.db", false);
|
||||
DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false);
|
||||
|
||||
try {
|
||||
ChatMember workedAtTheStation = new ChatMember();
|
||||
workedAtTheStation.setCallSign("DL0ABC");
|
||||
workedAtTheStation.setWorked(true);
|
||||
workedAtTheStation.setWorked432(true);
|
||||
|
||||
sharedStationDatabase.storeChatMember(workedAtTheStation);
|
||||
|
||||
Map<String, ChatMember> seenByTheOtherOperator =
|
||||
sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB();
|
||||
|
||||
assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"),
|
||||
"Operators sharing one station database must see the same worked stations");
|
||||
assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432());
|
||||
} finally {
|
||||
sharedStationDatabase.closeDBConnection();
|
||||
sameSharedDatabaseAgain.closeDBConnection();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheConnectionDeregistersTheShutdownHook() {
|
||||
|
||||
DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false);
|
||||
profileDatabase.closeDBConnection();
|
||||
|
||||
// A second close must stay harmless, and the hook must already be gone.
|
||||
profileDatabase.closeDBConnection();
|
||||
|
||||
assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())),
|
||||
"The database file stays on disk after the connection was closed");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
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 ExternalLoggedQsoTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("loggerBandAliases")
|
||||
void resolvesEveryLoggerBandAlias(String rawValue, LoggedQsoBand expectedBand) {
|
||||
assertEquals(expectedBand, LoggedQsoBand.fromLoggerValue(rawValue));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("winTestBandIds")
|
||||
void resolvesEveryExistingWinTestBandId(String rawValue, LoggedQsoBand expectedBand) {
|
||||
assertEquals(expectedBand, LoggedQsoBand.fromWinTestBandId(rawValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownOrMissingBandRemainsUnavailable() {
|
||||
assertNull(LoggedQsoBand.fromLoggerValue(null));
|
||||
assertNull(LoggedQsoBand.fromLoggerValue(""));
|
||||
assertNull(LoggedQsoBand.fromLoggerValue("unknown"));
|
||||
assertNull(LoggedQsoBand.fromWinTestBandId(null));
|
||||
assertNull(LoggedQsoBand.fromWinTestBandId("99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCallsignIsRejectedBeforeAnyStateIsBuilt() {
|
||||
assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
|
||||
assertTrue(ExternalLoggedQso.create(" ", LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingBandSetsOnlyGlobalWorkedState() {
|
||||
ExternalLoggedQso qso = qso("DL1ABC", null, null);
|
||||
ChatMember workedCall = qso.toWorkedChatMember();
|
||||
|
||||
assertTrue(workedCall.isWorked());
|
||||
assertFalse(workedCall.isWorked50());
|
||||
assertFalse(workedCall.isWorked70());
|
||||
assertFalse(workedCall.isWorked144());
|
||||
assertFalse(workedCall.isWorked2300());
|
||||
assertNull(workedCall.getQra());
|
||||
}
|
||||
|
||||
@Test
|
||||
void winTest50And70MhzIdsSetTheirExistingFlags() {
|
||||
String qso50Packet = winTestAddQsoPacket("10", "DL1ABC");
|
||||
String qso70Packet = winTestAddQsoPacket("11", "DL1ABC");
|
||||
String band50Id = ReadUDPByWintestThread.extractBandIdFromWinTestAddQso(qso50Packet);
|
||||
String band70Id = ReadUDPByWintestThread.extractBandIdFromWinTestAddQso(qso70Packet);
|
||||
ChatMember worked50 = qso("DL1ABC", LoggedQsoBand.fromWinTestBandId(band50Id), null)
|
||||
.toWorkedChatMember();
|
||||
ChatMember worked70 = qso("DL1ABC", LoggedQsoBand.fromWinTestBandId(band70Id), null)
|
||||
.toWorkedChatMember();
|
||||
|
||||
assertEquals("10", band50Id);
|
||||
assertEquals("11", band70Id);
|
||||
assertTrue(worked50.isWorked50());
|
||||
assertTrue(worked70.isWorked70());
|
||||
}
|
||||
|
||||
@Test
|
||||
void winTest47And76GhzFlagsRemainAvailableWithoutInventingProjectBands() {
|
||||
LoggedQsoBand band47 = LoggedQsoBand.fromWinTestBandId("22");
|
||||
LoggedQsoBand band76 = LoggedQsoBand.fromWinTestBandId("23");
|
||||
ChatMember worked47 = qso("DL1ABC", band47, "JO50AA").toWorkedChatMember();
|
||||
ChatMember worked76 = qso("DL1ABC", band76, "JO50AA").toWorkedChatMember();
|
||||
|
||||
assertTrue(worked47.isWorked47G());
|
||||
assertTrue(worked76.isWorked76G());
|
||||
assertNull(band47.getProjectBand());
|
||||
assertNull(band76.getProjectBand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalized2320AliasUpdatesEveryActiveCallsignVariant() {
|
||||
ChatMember firstVariant = member("9A0BB-2");
|
||||
ChatMember secondVariant = member("9A0BB-70");
|
||||
ChatMember unrelated = member("DL1ABC");
|
||||
ExternalLoggedQso qso = qso(
|
||||
"9A0BB", LoggedQsoBand.fromLoggerValue(" 2320 "), "JO50AA");
|
||||
|
||||
int updated = ChatController.markExternalLoggedQsoMembers(
|
||||
List.of(firstVariant, secondVariant, unrelated), qso);
|
||||
|
||||
assertEquals(2, updated);
|
||||
assertTrue(firstVariant.isWorked());
|
||||
assertTrue(firstVariant.isWorked2300());
|
||||
assertEquals("JO50AA", firstVariant.getQra());
|
||||
assertTrue(secondVariant.isWorked());
|
||||
assertTrue(secondVariant.isWorked2300());
|
||||
assertEquals("JO50AA", secondVariant.getQra());
|
||||
assertFalse(unrelated.isWorked());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> loggerBandAliases() {
|
||||
return Stream.of(
|
||||
Arguments.of("50", LoggedQsoBand.BAND_50),
|
||||
Arguments.of("6m", LoggedQsoBand.BAND_50),
|
||||
Arguments.of("70", LoggedQsoBand.BAND_70),
|
||||
Arguments.of("4m", LoggedQsoBand.BAND_70),
|
||||
Arguments.of("144", LoggedQsoBand.BAND_144),
|
||||
Arguments.of("2m", LoggedQsoBand.BAND_144),
|
||||
Arguments.of("432", LoggedQsoBand.BAND_432),
|
||||
Arguments.of("70cm", LoggedQsoBand.BAND_432),
|
||||
Arguments.of("1240", LoggedQsoBand.BAND_1296),
|
||||
Arguments.of("1296", LoggedQsoBand.BAND_1296),
|
||||
Arguments.of("23cm", LoggedQsoBand.BAND_1296),
|
||||
Arguments.of("2300", LoggedQsoBand.BAND_2320),
|
||||
Arguments.of(" 2320 ", LoggedQsoBand.BAND_2320),
|
||||
Arguments.of("13cm", LoggedQsoBand.BAND_2320),
|
||||
Arguments.of("3400", LoggedQsoBand.BAND_3400),
|
||||
Arguments.of("9cm", LoggedQsoBand.BAND_3400),
|
||||
Arguments.of("5600", LoggedQsoBand.BAND_5760),
|
||||
Arguments.of(" 5760 ", LoggedQsoBand.BAND_5760),
|
||||
Arguments.of("6cm", LoggedQsoBand.BAND_5760),
|
||||
Arguments.of("10G", LoggedQsoBand.BAND_10G),
|
||||
Arguments.of(" 10368 ", LoggedQsoBand.BAND_10G),
|
||||
Arguments.of("3cm", LoggedQsoBand.BAND_10G)
|
||||
);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> winTestBandIds() {
|
||||
return Stream.of(
|
||||
Arguments.of(" 10 ", LoggedQsoBand.BAND_50),
|
||||
Arguments.of("11", LoggedQsoBand.BAND_70),
|
||||
Arguments.of("12", LoggedQsoBand.BAND_144),
|
||||
Arguments.of("14", LoggedQsoBand.BAND_432),
|
||||
Arguments.of("16", LoggedQsoBand.BAND_1296),
|
||||
Arguments.of("17", LoggedQsoBand.BAND_2320),
|
||||
Arguments.of("18", LoggedQsoBand.BAND_3400),
|
||||
Arguments.of("19", LoggedQsoBand.BAND_5760),
|
||||
Arguments.of("20", LoggedQsoBand.BAND_10G),
|
||||
Arguments.of("21", LoggedQsoBand.BAND_24G),
|
||||
Arguments.of("22", LoggedQsoBand.BAND_47G),
|
||||
Arguments.of("23", LoggedQsoBand.BAND_76G)
|
||||
);
|
||||
}
|
||||
|
||||
private static ExternalLoggedQso qso(
|
||||
String callSign,
|
||||
LoggedQsoBand band,
|
||||
String locator
|
||||
) {
|
||||
return ExternalLoggedQso.create(callSign, band, locator, "TEST").orElseThrow();
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
return member;
|
||||
}
|
||||
|
||||
private static String winTestAddQsoPacket(String bandId, String callSign) {
|
||||
return "ADDQSO: \"STN1\" \"\" \"STN1\" 1762202297 1440000 0 "
|
||||
+ bandId + " 0 0 0 2 2 \"" + callSign
|
||||
+ "\" \"599\" \"599001\" \"JO51UM\" \"\" \"\" 0 \"\" \"\" \"\" 44510";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExternalLoggerListenerValidationTest {
|
||||
|
||||
@Mock
|
||||
ChatController controller;
|
||||
|
||||
@Mock
|
||||
ChatPreferences preferences;
|
||||
|
||||
@Mock
|
||||
ThreadStatusCallback statusCallback;
|
||||
|
||||
@BeforeEach
|
||||
void configurePreferences() {
|
||||
when(controller.getChatPreferences()).thenReturn(preferences);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ucxLogPacketWithoutCallsignIsDiscardedWithoutStoppingProcessing() {
|
||||
when(preferences.isLogsynch_ucxUDPWkdCallListenerEnabled()).thenReturn(true);
|
||||
ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(
|
||||
12060, controller, statusCallback);
|
||||
String packet = "<?xml version=\"1.0\"?><contactinfo>"
|
||||
+ "<band>144</band><call> </call><gridsquare>JO50AA</gridsquare>"
|
||||
+ "</contactinfo>";
|
||||
|
||||
assertDoesNotThrow(() -> listener.processUCXUDPMessage(packet));
|
||||
|
||||
verify(controller, never()).applyExternalLoggedQso(any());
|
||||
verify(controller, never()).getDbHandler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void winTestPacketWithoutCallsignIsDiscardedWithoutStoppingProcessing() {
|
||||
when(preferences.getStn_loginCallSignRaw()).thenReturn("DL0TEST");
|
||||
when(preferences.getLogsynch_wintestNetworkPort()).thenReturn(9871);
|
||||
ReadUDPByWintestThread listener = new ReadUDPByWintestThread(controller, statusCallback);
|
||||
String packet = "ADDQSO: \"STN1\" \"\" \"STN1\" 1762202297 1440000 0 12 0 0 0 2 2 "
|
||||
+ "\" \" \"599\" \"599001\" \"JO51UM\" \"\" \"\" 0 \"\" \"\" \"\" 44510";
|
||||
|
||||
assertDoesNotThrow(() -> listener.processWinTestMessage(packet));
|
||||
|
||||
verify(controller, never()).applyExternalLoggedQso(any());
|
||||
verify(controller, never()).getDbHandler();
|
||||
}
|
||||
}
|
||||
+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,177 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OperatorProfileManagementServiceTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
private OperatorProfileManagementService managementService;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
managementService = new OperatorProfileManagementService();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreUserHome() {
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPlainInstallationReportsExactlyOneImplicitRootProfile() {
|
||||
|
||||
List<OperatorProfile> knownProfiles = managementService.listProfiles();
|
||||
|
||||
assertEquals(1, knownProfiles.size());
|
||||
assertTrue(knownProfiles.get(0).isRootProfile());
|
||||
assertFalse(Files.exists(applicationFile("profiles.xml")),
|
||||
"Merely listing profiles must not create a registry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingTheSecondProfileMaterialisesTheRegistryIncludingTheRootProfile() {
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
|
||||
|
||||
assertNotNull(createdProfile);
|
||||
assertEquals("DN9APW", createdProfile.getProfileId());
|
||||
assertTrue(Files.exists(applicationFile("profiles.xml")));
|
||||
|
||||
List<OperatorProfile> knownProfiles = managementService.listProfiles();
|
||||
|
||||
assertEquals(2, knownProfiles.size());
|
||||
assertTrue(knownProfiles.get(0).isRootProfile());
|
||||
assertEquals("DN9APW", knownProfiles.get(1).getProfileId());
|
||||
|
||||
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
|
||||
|
||||
// The historic files must stay exactly where an older release expects them.
|
||||
assertFalse(Files.exists(applicationFile("profiles/default")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNewProfileStartsWithoutLoginCredentials() {
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
|
||||
|
||||
ChatPreferences createdPreferences =
|
||||
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(createdProfile));
|
||||
|
||||
assertEquals("", createdPreferences.getStn_loginCallSign());
|
||||
assertEquals("", createdPreferences.getStn_loginPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicatingKeepsTheStationSetupButClearsCallsignAndPassword() {
|
||||
|
||||
OperatorProfile sourceProfile = managementService.createProfile("Source", false);
|
||||
|
||||
ChatPreferences sourcePreferences =
|
||||
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(sourceProfile));
|
||||
sourcePreferences.setStn_loginCallSign("DM5M");
|
||||
sourcePreferences.setStn_loginPassword("secret");
|
||||
sourcePreferences.setStn_loginLocatorMainCat("JO51IJ");
|
||||
sourcePreferences.setStn_antennaBeamWidthDeg(17.5);
|
||||
assertTrue(sourcePreferences.writePreferencesToXmlFile());
|
||||
|
||||
OperatorProfile duplicatedProfile =
|
||||
managementService.duplicateProfile(sourceProfile, "Copy of source");
|
||||
|
||||
assertNotNull(duplicatedProfile);
|
||||
|
||||
ChatPreferences duplicatedPreferences =
|
||||
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(duplicatedProfile));
|
||||
|
||||
// The work worth keeping.
|
||||
assertEquals("JO51IJ", duplicatedPreferences.getStn_loginLocatorMainCat());
|
||||
assertEquals(17.5, duplicatedPreferences.getStn_antennaBeamWidthDeg());
|
||||
|
||||
// The identity that must not be inherited.
|
||||
assertEquals("", duplicatedPreferences.getStn_loginCallSign());
|
||||
assertEquals("", duplicatedPreferences.getStn_loginPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchingBetweenSharedAndOwnWorkedDataChangesOnlyTheDatabasePath() {
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
|
||||
|
||||
assertEquals("profiles/DN9APW/praktiKST.db",
|
||||
OperatorProfilePaths.workedDatabaseRelativeFileName(createdProfile));
|
||||
|
||||
assertTrue(managementService.setSharedWorkedDatabase(createdProfile, true));
|
||||
|
||||
OperatorProfile reloadedProfile = managementService.listProfiles().get(1);
|
||||
|
||||
assertTrue(reloadedProfile.isSharedWorkedDatabase());
|
||||
assertEquals("praktiKST.db",
|
||||
OperatorProfilePaths.workedDatabaseRelativeFileName(reloadedProfile));
|
||||
assertEquals("profiles/DN9APW/preferences.xml",
|
||||
OperatorProfilePaths.preferencesRelativeFileName(reloadedProfile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingRemovesTheProfileDirectoryButNeverTheRootProfile() {
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
|
||||
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
|
||||
|
||||
OperatorProfile rootProfile = managementService.listProfiles().get(0);
|
||||
assertFalse(managementService.deleteProfile(rootProfile),
|
||||
"The root profile is the installation itself and must not be removable");
|
||||
|
||||
assertTrue(managementService.deleteProfile(createdProfile));
|
||||
assertFalse(Files.exists(applicationFile("profiles/DN9APW")));
|
||||
assertEquals(1, managementService.listProfiles().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renamingKeepsTheIdentifierAndTherebyAllPaths() {
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
|
||||
|
||||
assertTrue(managementService.renameProfile(createdProfile, "Philipp portable"));
|
||||
|
||||
OperatorProfile renamedProfile = managementService.listProfiles().get(1);
|
||||
|
||||
assertEquals("Philipp portable", renamedProfile.getDisplayName());
|
||||
assertEquals("DN9APW", renamedProfile.getProfileId());
|
||||
assertEquals("profiles/DN9APW/preferences.xml",
|
||||
OperatorProfilePaths.preferencesRelativeFileName(renamedProfile));
|
||||
}
|
||||
|
||||
private ChatPreferences preferencesAt(final String relativeFileName) {
|
||||
ChatPreferences preferences = new ChatPreferences(relativeFileName);
|
||||
preferences.readPreferencesFromXmlFile();
|
||||
return preferences;
|
||||
}
|
||||
|
||||
private Path applicationFile(final String relativeFileName) {
|
||||
return temporaryHomeDirectory.resolve(".praktiKST").resolve(relativeFileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OperatorProfilePathsTest {
|
||||
|
||||
@Test
|
||||
void rootProfileKeepsTheHistoricFlatFileNames() {
|
||||
|
||||
OperatorProfileSelection resolved =
|
||||
OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default"));
|
||||
|
||||
// This is the downgrade guard: an older KST4Contest release reads exactly these
|
||||
// two files. If this test ever fails, existing installations would silently lose
|
||||
// their configuration and worked data when the operator reverts a version.
|
||||
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
|
||||
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() {
|
||||
|
||||
OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true);
|
||||
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile);
|
||||
|
||||
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
|
||||
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() {
|
||||
|
||||
OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false);
|
||||
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile);
|
||||
|
||||
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
|
||||
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
|
||||
// Seeding would hand a new operator the several thousand callsigns of the
|
||||
// bundled template database.
|
||||
assertFalse(resolved.isSeedWorkedDatabaseFromResource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileIdIsFileSystemSafe() {
|
||||
|
||||
assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of()));
|
||||
assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of()));
|
||||
assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of()));
|
||||
assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of()));
|
||||
assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of()));
|
||||
assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of()));
|
||||
|
||||
String longName = "A".repeat(60);
|
||||
assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() {
|
||||
|
||||
assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW")));
|
||||
assertEquals("DN9APW_3",
|
||||
OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2")));
|
||||
|
||||
// "default" is reserved for the historic flat installation.
|
||||
assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID,
|
||||
OperatorProfilePaths.toProfileId("default", Set.of()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.OperatorProfile;
|
||||
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 java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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 OperatorProfileStoreTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void missingRegistryIsNotAnErrorAndIsNotCreated() {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
assertFalse(store.isRegistryPresent());
|
||||
assertTrue(store.loadProfiles().isEmpty());
|
||||
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
|
||||
|
||||
// A single operator installation must stay untouched by merely starting up.
|
||||
assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void profilesSurviveAWriteReadRoundTrip() {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station");
|
||||
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
|
||||
secondProfile.setLastUsedEpochMs(1757328000000L);
|
||||
|
||||
assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2"));
|
||||
assertTrue(store.isRegistryPresent());
|
||||
|
||||
List<OperatorProfile> restored = store.loadProfiles();
|
||||
|
||||
assertEquals(2, restored.size());
|
||||
assertEquals("default", restored.get(0).getProfileId());
|
||||
assertEquals("DM5M station", restored.get(0).getDisplayName());
|
||||
assertTrue(restored.get(0).isRootProfile());
|
||||
assertTrue(restored.get(0).isSharedWorkedDatabase());
|
||||
|
||||
assertEquals("OP2", restored.get(1).getProfileId());
|
||||
assertEquals("DN9APW", restored.get(1).getDisplayName());
|
||||
assertFalse(restored.get(1).isRootProfile());
|
||||
assertFalse(restored.get(1).isSharedWorkedDatabase());
|
||||
assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs());
|
||||
|
||||
assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicWriteLeavesNoTemporaryFileBehind() throws IOException {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
|
||||
|
||||
try (var directoryEntries = Files.list(temporaryDirectory)) {
|
||||
assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException {
|
||||
|
||||
Path registryFile = temporaryDirectory.resolve("profiles.xml");
|
||||
Files.writeString(registryFile, "<praktiKSTProfiles><profile><profileId>OP2");
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
assertTrue(store.isRegistryPresent());
|
||||
assertTrue(store.loadProfiles().isEmpty());
|
||||
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException {
|
||||
|
||||
Path registryFile = temporaryDirectory.resolve("profiles.xml");
|
||||
Files.writeString(registryFile,
|
||||
"<praktiKSTProfiles>"
|
||||
+ "<profile><displayName>broken</displayName></profile>"
|
||||
+ "<profile><profileId>OP2</profileId><displayName>DN9APW</displayName>"
|
||||
+ "<sharedWorkedDatabase>false</sharedWorkedDatabase></profile>"
|
||||
+ "</praktiKSTProfiles>");
|
||||
|
||||
List<OperatorProfile> restored = storeAt("profiles.xml").loadProfiles();
|
||||
|
||||
assertEquals(1, restored.size());
|
||||
assertEquals("OP2", restored.get(0).getProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordLastUsedDoesNothingWhenNoRegistryExists() {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
assertFalse(store.recordLastUsed("default"));
|
||||
assertFalse(store.isRegistryPresent());
|
||||
}
|
||||
|
||||
private OperatorProfileStore storeAt(final String fileName) {
|
||||
return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserActualizationTaskTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Mock
|
||||
ChatController controller;
|
||||
|
||||
@Mock
|
||||
ChatPreferences preferences;
|
||||
|
||||
@BeforeEach
|
||||
void configureControllerPreferences() {
|
||||
when(controller.getChatPreferences()).thenReturn(preferences);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledInterpreterDoesNotCreateOrReadFile() {
|
||||
when(preferences.isLogsynch_fileBasedWkdCallInterpreterEnabled()).thenReturn(false);
|
||||
|
||||
new UserActualizationTask(controller).run();
|
||||
|
||||
verify(controller, never()).applySimpleLogWorkedBaseCalls(anySet());
|
||||
verify(controller, never()).notifySimpleLogFileCreated(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsMissingFileAndNotifiesOnlyOnce() {
|
||||
Path logFile = temporaryDirectory.resolve("created.log").toAbsolutePath().normalize();
|
||||
when(preferences.isLogsynch_fileBasedWkdCallInterpreterEnabled()).thenReturn(true);
|
||||
when(preferences.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
||||
.thenReturn(logFile.toString());
|
||||
UserActualizationTask task = new UserActualizationTask(controller);
|
||||
|
||||
task.run();
|
||||
task.run();
|
||||
|
||||
assertTrue(Files.isRegularFile(logFile));
|
||||
verify(controller, times(2)).applySimpleLogWorkedBaseCalls(anySet());
|
||||
verify(controller, times(1)).notifySimpleLogFileCreated(logFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readFailureIsContainedAndDoesNotUpdateUiState() {
|
||||
when(preferences.isLogsynch_fileBasedWkdCallInterpreterEnabled()).thenReturn(true);
|
||||
when(preferences.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
||||
.thenReturn(temporaryDirectory.toString());
|
||||
|
||||
assertDoesNotThrow(() -> new UserActualizationTask(controller).run());
|
||||
|
||||
verify(controller, never()).applySimpleLogWorkedBaseCalls(anySet());
|
||||
verify(controller, never()).notifySimpleLogFileCreated(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,48 @@
|
||||
package kst4contest.test;
|
||||
|
||||
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.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import kst4contest.controller.UCXLogFileToHashsetParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
public class TestUCXLogFileToHashsetParser {
|
||||
class TestUCXLogFileToHashsetParser {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
public static void main(String[] args) {
|
||||
// TODO Auto-generated method stub
|
||||
void parsesUniqueWorkedBaseCallsignsWithFixedPattern() throws IOException {
|
||||
Path logFile = temporaryDirectory.resolve("contest.log");
|
||||
Files.writeString(logFile, String.join(System.lineSeparator(),
|
||||
"QSO 001 S53CC 144 MHz",
|
||||
"QSO 002 9A0BB-70 432 MHz",
|
||||
"QSO 003 s53cc repeated",
|
||||
"no station here"));
|
||||
|
||||
UCXLogFileToHashsetParser testTheParser = new UCXLogFileToHashsetParser("C:\\UcxLog\\Logs\\DO5AMF\\DVU322_I.UCX");
|
||||
try {
|
||||
testTheParser.parse();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
Set<String> result = new UCXLogFileToHashsetParser(logFile.toString()).parse();
|
||||
|
||||
assertEquals(Set.of("S53CC", "9A0BB"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesFileAfterEveryParsePass() throws IOException {
|
||||
Path logFile = temporaryDirectory.resolve("replaceable.log");
|
||||
Files.writeString(logFile, "QSO DL1ABC");
|
||||
|
||||
UCXLogFileToHashsetParser parser = new UCXLogFileToHashsetParser(logFile.toString());
|
||||
assertEquals(Set.of("DL1ABC"), parser.parse());
|
||||
|
||||
Path replacement = temporaryDirectory.resolve("replacement.log");
|
||||
Files.move(logFile, replacement);
|
||||
assertFalse(Files.exists(logFile));
|
||||
assertTrue(Files.exists(replacement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class CommandLineOptionsTest {
|
||||
|
||||
@Test
|
||||
void profileArgumentIsAcceptedInBothSpellings() {
|
||||
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName());
|
||||
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName());
|
||||
assertEquals("DM5M Contest",
|
||||
CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() {
|
||||
assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(null).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedArgumentsAreIgnoredInsteadOfFailing() {
|
||||
assertEquals("OP2",
|
||||
CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi"))
|
||||
.getRequestedProfileName());
|
||||
assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rememberedOptionsDefaultToEmptyInsteadOfNull() {
|
||||
CommandLineOptions.remember(null);
|
||||
assertNull(CommandLineOptions.remembered().getRequestedProfileName());
|
||||
|
||||
CommandLineOptions.remember(new CommandLineOptions("OP2"));
|
||||
assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName());
|
||||
|
||||
CommandLineOptions.remember(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.controller.OperatorProfileStore;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OperatorProfileBootstrapTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() {
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileStore store = storeAt();
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
|
||||
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
|
||||
// Nothing may be asked, and nothing may be written.
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertTrue(store.loadProfiles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleProfileStartsWithoutAskingAnything() {
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
String[] observedPreselection = new String[1];
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store,
|
||||
new CommandLineOptions(null),
|
||||
(profiles, preselectedProfileId) -> {
|
||||
pickerInvocations.incrementAndGet();
|
||||
observedPreselection[0] = preselectedProfileId;
|
||||
return Optional.of(profiles.get(1));
|
||||
});
|
||||
|
||||
assertEquals(1, pickerInvocations.get());
|
||||
assertEquals("OP2", observedPreselection[0]);
|
||||
assertNotNull(resolved);
|
||||
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidProfileArgumentSkipsThePicker() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
|
||||
|
||||
OperatorProfileSelection byId = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName());
|
||||
assertNull(bootstrap.getStartupWarning());
|
||||
|
||||
OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null));
|
||||
|
||||
assertEquals(0, pickerInvocations.get());
|
||||
assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
AtomicInteger pickerInvocations = new AtomicInteger();
|
||||
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
|
||||
|
||||
OperatorProfileSelection resolved = bootstrap.resolveAtStartup(
|
||||
store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0));
|
||||
|
||||
assertEquals(1, pickerInvocations.get());
|
||||
assertNotNull(resolved);
|
||||
assertNotNull(bootstrap.getStartupWarning());
|
||||
assertTrue(bootstrap.getStartupWarning().contains("NOPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void quittingInThePickerYieldsNoSelection() {
|
||||
|
||||
OperatorProfileStore store = storeAt();
|
||||
store.saveProfiles(
|
||||
List.of(OperatorProfilePaths.buildRootProfile("Default"),
|
||||
new OperatorProfile("OP2", "DN9APW", false, false)),
|
||||
"default");
|
||||
|
||||
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
|
||||
store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty());
|
||||
|
||||
assertNull(resolved);
|
||||
}
|
||||
|
||||
private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter,
|
||||
final Integer profileIndexToChoose) {
|
||||
return (profiles, preselectedProfileId) -> {
|
||||
invocationCounter.incrementAndGet();
|
||||
|
||||
if (profileIndexToChoose == null) {
|
||||
return Optional.of(profiles.get(0));
|
||||
}
|
||||
|
||||
return Optional.of(profiles.get(profileIndexToChoose));
|
||||
};
|
||||
}
|
||||
|
||||
private OperatorProfileStore storeAt() {
|
||||
return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Guards the property that made the terrain cache useless with several operator
|
||||
* profiles: it used to drop every cached profile whenever the configured callsign or
|
||||
* locator changed.
|
||||
*/
|
||||
class TerrainProfileCacheRepositoryTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
private static final String PROVIDER_ID = "test-provider";
|
||||
private static final int SAMPLE_COUNT = 3;
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreUserHome() {
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
// Another operator profile with a different callsign and locator.
|
||||
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
|
||||
|
||||
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
|
||||
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
|
||||
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||
|
||||
assertTrue(firstOwnerEntry.isPresent(),
|
||||
"Working under a second operator identity must not discard the first one's cache");
|
||||
assertTrue(secondOwnerEntry.isPresent());
|
||||
assertEquals("station-a", firstOwnerEntry.get().sourceName());
|
||||
assertEquals("station-b", secondOwnerEntry.get().sourceName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
|
||||
|
||||
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
|
||||
"A cache miss of one owner must not remove the entries of another");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
|
||||
|
||||
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||
|
||||
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
|
||||
|
||||
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
|
||||
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
|
||||
"The terrain cache must not pull in the worked station database");
|
||||
}
|
||||
|
||||
private static TerrainProfileData profileData(final String sourceName) {
|
||||
return new TerrainProfileData(
|
||||
List.of(
|
||||
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
|
||||
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
|
||||
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
|
||||
sourceName,
|
||||
false);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user